diff --git a/iamapi/controller.go b/iamapi/controller.go index ef4b5086..f224d24b 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -522,3 +522,199 @@ func (c IAMApiController) ListUserPolicies(ctx fiber.Ctx) (*Response, error) { }, }}, nil } + +func (c IAMApiController) CreateRole(ctx fiber.Ctx) (*Response, error) { + roleName, err := iamutil.GetRoleName(ctx, "CreateRole", iamutil.MaxUserNameLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + path, ok := iamutil.RequestParam(ctx, "Path") + if !ok || path == "" { + path = iamutil.DefaultUserPath + } + if err := iamutil.ValidatePath("path", path); err != nil { + return nil, err + } + + assumeRolePolicyDocument, ok := iamutil.RequestParam(ctx, "AssumeRolePolicyDocument") + if !ok || assumeRolePolicyDocument == "" { + debuglogger.Logf("missing required CreateRole parameter: AssumeRolePolicyDocument") + return nil, iamerr.MissingValue("assumeRolePolicyDocument") + } + if err := policy.Validate("assumeRolePolicyDocument", assumeRolePolicyDocument); err != nil { + return nil, err + } + if err := policy.ParseTrust(assumeRolePolicyDocument); err != nil { + return nil, err + } + if len(assumeRolePolicyDocument) > policy.MaxTrustPolicyBytes { + return nil, iamerr.TrustPolicySizeLimitExceeded(policy.MaxTrustPolicyBytes) + } + + description, _ := iamutil.RequestParam(ctx, "Description") + if err := iamutil.ValidateDescription("description", description); err != nil { + return nil, err + } + + maxSessionDuration, err := iamutil.ParseMaxSessionDuration(ctx) + if err != nil { + return nil, err + } + + tags, err := iamutil.ParseTags(ctx) + if err != nil { + return nil, err + } + + for range 3 { + roleID, err := iamutil.GenerateRoleID() + if err != nil { + return nil, err + } + + role := types.Role{ + Path: path, + RoleName: roleName, + RoleID: roleID, + Arn: iamutil.BuildRoleArn(iamutil.DefaultAccountID, path, roleName), + CreateDate: time.Now().UTC().Truncate(time.Second), + AssumeRolePolicyDocument: assumeRolePolicyDocument, + Description: description, + MaxSessionDuration: maxSessionDuration, + Tags: tags, + } + + stored, err := c.store.CreateRole(ctx.Context(), role) + if errors.Is(err, storage.ErrRoleIDAlreadyExists) { + debuglogger.Logf("IAM role ID collision while creating role %q: %v", roleName, err) + continue + } + if err != nil { + debuglogger.Logf("failed to create IAM role %q: %v", roleName, err) + return nil, err + } + + stored.AssumeRolePolicyDocument = iamutil.EncodePolicyDocument(stored.AssumeRolePolicyDocument) + + return &Response{Data: &types.CreateRoleResponse{ + Result: types.CreateRoleResult{Role: stored}, + }}, nil + } + + err = fmt.Errorf("generate IAM role id: exhausted collision retries") + debuglogger.Logf("failed to create IAM role %q: %v", roleName, err) + return nil, err +} + +func (c IAMApiController) GetRole(ctx fiber.Ctx) (*Response, error) { + roleName, err := iamutil.GetRoleName(ctx, "GetRole", iamutil.MaxUserLookupLen, iamerr.MissingParameter("RoleName")) + if err != nil { + return nil, err + } + + role, err := c.store.GetRole(ctx.Context(), roleName) + if err != nil { + debuglogger.Logf("failed to get IAM role %q: %v", roleName, err) + return nil, err + } + + role.AssumeRolePolicyDocument = iamutil.EncodePolicyDocument(role.AssumeRolePolicyDocument) + + return &Response{Data: &types.GetRoleResponse{ + Result: types.GetRoleResult{Role: role}, + }}, nil +} + +func (c IAMApiController) ListRoles(ctx fiber.Ctx) (*Response, error) { + pathPrefix, ok := iamutil.RequestParam(ctx, "PathPrefix") + if !ok || pathPrefix == "" { + pathPrefix = iamutil.DefaultUserPath + } + if err := iamutil.ValidatePathPrefix(pathPrefix); err != nil { + return nil, err + } + + maxItems, err := iamutil.ParseMaxItems(ctx, "ListRoles") + if err != nil { + return nil, err + } + + marker, _ := iamutil.RequestParam(ctx, "Marker") + out, err := c.store.ListRoles(ctx.Context(), storage.ListRolesInput{ + PathPrefix: pathPrefix, + Marker: marker, + MaxItems: maxItems, + }) + if err != nil { + debuglogger.Logf("failed to list IAM roles: %v", err) + return nil, err + } + + roles := make([]types.Role, len(out.Roles)) + for i, role := range out.Roles { + role.AssumeRolePolicyDocument = iamutil.EncodePolicyDocument(role.AssumeRolePolicyDocument) + roles[i] = role + } + + return &Response{Data: &types.ListRolesResponse{ + Result: types.ListRolesResult{ + Roles: types.Roles{Members: roles}, + IsTruncated: out.IsTruncated, + Marker: out.Marker, + }, + }}, nil +} + +func (c IAMApiController) DeleteRole(ctx fiber.Ctx) (*Response, error) { + roleName, err := iamutil.GetRoleName(ctx, "DeleteRole", iamutil.MaxUserLookupLen, iamerr.MissingParameter("RoleName")) + if err != nil { + return nil, err + } + + if err := c.store.DeleteRole(ctx.Context(), roleName); err != nil { + debuglogger.Logf("failed to delete IAM role %q: %v", roleName, err) + return nil, err + } + + return &Response{Data: &types.DeleteRoleResponse{}}, nil +} + +func (c IAMApiController) UpdateAssumeRolePolicy(ctx fiber.Ctx) (*Response, error) { + policyDocument, ok := iamutil.RequestParam(ctx, "PolicyDocument") + if !ok { + debuglogger.Logf("missing required UpdateAssumeRolePolicy parameter: PolicyDocument") + return nil, iamerr.MissingValue("policyDocument") + } + if err := policy.Validate("policyDocument", policyDocument); err != nil { + return nil, err + } + + roleName, err := iamutil.GetRoleName(ctx, "UpdateAssumeRolePolicy", 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 UpdateAssumeRolePolicy: %v", roleName, err) + return nil, err + } + + if err := policy.ParseTrust(policyDocument); err != nil { + return nil, err + } + if len(policyDocument) > policy.MaxTrustPolicyBytes { + return nil, iamerr.TrustPolicySizeLimitExceeded(policy.MaxTrustPolicyBytes) + } + + if _, err := c.store.UpdateAssumeRolePolicy(ctx.Context(), storage.UpdateAssumeRolePolicyInput{ + RoleName: roleName, + PolicyDocument: policyDocument, + }); err != nil { + debuglogger.Logf("failed to update IAM assume role policy for role %q: %v", roleName, err) + return nil, err + } + + return &Response{Data: &types.UpdateAssumeRolePolicyResponse{}}, nil +} diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index 58cf4db8..dd975302 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -30,6 +30,7 @@ import ( ) var userIDPattern = regexp.MustCompile(`^AIDA[A-Z2-7]{17}$`) +var roleIDPattern = regexp.MustCompile(`^AROA[A-Z2-7]{17}$`) func TestIAMApiControllerUserLifecycle(t *testing.T) { server := newIAMControllerTestServer(t) @@ -828,6 +829,478 @@ func TestIAMApiControllerDeleteUserPolicyConflict(t *testing.T) { requireIAMError(t, deleteKeyOnly, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete access keys first.") } +const validTrustPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` + +func TestIAMApiControllerRoleLifecycle(t *testing.T) { + server := newIAMControllerTestServer(t) + + create := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "Path": {"/engineering/"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "Description": {"a test role"}, + "MaxSessionDuration": {"7200"}, + "Tags.member.1.Key": {"env"}, + "Tags.member.1.Value": {"test"}, + }) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateRole status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + createBody := readBody(t, create) + var createOut iamtypes.CreateRoleResponse + unmarshalXML(t, createBody, &createOut) + if createOut.XMLName.Space != "https://iam.amazonaws.com/doc/2010-05-08/" || createOut.XMLName.Local != "CreateRoleResponse" { + t.Fatalf("CreateRole XMLName = %#v", createOut.XMLName) + } + role := createOut.Result.Role + if role.Path != "/engineering/" || role.RoleName != "my-role" { + t.Fatalf("created role = %#v, want path/name", role) + } + if !roleIDPattern.MatchString(role.RoleID) { + t.Fatalf("RoleId = %q, want AWS IAM role id form", role.RoleID) + } + if role.Arn != "arn:aws:iam::000000000000:role/engineering/my-role" { + t.Fatalf("Arn = %q", role.Arn) + } + if role.CreateDate.IsZero() { + t.Fatal("CreateDate is zero") + } + if role.Description != "a test role" { + t.Fatalf("Description = %q", role.Description) + } + if role.MaxSessionDuration != 7200 { + t.Fatalf("MaxSessionDuration = %d, want 7200", role.MaxSessionDuration) + } + wantEncodedPolicy := iamutil.EncodePolicyDocument(validTrustPolicy) + if role.AssumeRolePolicyDocument != wantEncodedPolicy { + t.Fatalf("AssumeRolePolicyDocument = %q, want %q", role.AssumeRolePolicyDocument, wantEncodedPolicy) + } + if role.RoleLastUsed == nil { + t.Fatal("CreateRole RoleLastUsed = nil, want non-nil empty element") + } + if len(role.Tags) != 1 || role.Tags[0].Key != "env" || role.Tags[0].Value != "test" { + t.Fatalf("Tags = %#v", role.Tags) + } + if createOut.ResponseMetadata.RequestID == "" { + t.Fatal("CreateRole missing RequestId") + } + + duplicate := doIAMAction(t, server, url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"MY-ROLE"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }) + requireIAMError(t, duplicate, http.StatusConflict, "Sender", "EntityAlreadyExists", "Role with name MY-ROLE already exists.") + + get := doIAMAction(t, server, url.Values{ + "Action": {"GetRole"}, + "RoleName": {"my-role"}, + }) + if get.StatusCode != http.StatusOK { + t.Fatalf("GetRole status = %d, body=%s", get.StatusCode, readBody(t, get)) + } + var getOut iamtypes.GetRoleResponse + unmarshalXML(t, readBody(t, get), &getOut) + gotRole := getOut.Result.Role + if gotRole.RoleID != role.RoleID || !gotRole.CreateDate.Equal(role.CreateDate) { + t.Fatalf("GetRole identity = %#v, want RoleId/CreateDate preserved from %#v", gotRole, role) + } + if gotRole.RoleLastUsed == nil { + t.Fatal("GetRole RoleLastUsed = nil, want non-nil empty element") + } + if gotRole.AssumeRolePolicyDocument != wantEncodedPolicy { + t.Fatalf("GetRole AssumeRolePolicyDocument = %q, want %q", gotRole.AssumeRolePolicyDocument, wantEncodedPolicy) + } + + list := doIAMAction(t, server, url.Values{ + "Action": {"ListRoles"}, + "PathPrefix": {"/engineering/"}, + }) + if list.StatusCode != http.StatusOK { + t.Fatalf("ListRoles status = %d, body=%s", list.StatusCode, readBody(t, list)) + } + var listOut iamtypes.ListRolesResponse + unmarshalXML(t, readBody(t, list), &listOut) + if len(listOut.Result.Roles.Members) != 1 || listOut.Result.Roles.Members[0].RoleName != "my-role" { + t.Fatalf("ListRoles = %#v, want my-role", listOut.Result.Roles.Members) + } + if listOut.Result.Roles.Members[0].RoleLastUsed != nil { + t.Fatalf("ListRoles RoleLastUsed = %#v, want nil (list/get asymmetry)", listOut.Result.Roles.Members[0].RoleLastUsed) + } + if listOut.Result.Roles.Members[0].AssumeRolePolicyDocument != wantEncodedPolicy { + t.Fatalf("ListRoles AssumeRolePolicyDocument = %q, want %q", listOut.Result.Roles.Members[0].AssumeRolePolicyDocument, wantEncodedPolicy) + } + + const updatedTrustPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}` + update := doIAMAction(t, server, url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyDocument": {updatedTrustPolicy}, + }) + if update.StatusCode != http.StatusOK { + t.Fatalf("UpdateAssumeRolePolicy status = %d, body=%s", update.StatusCode, readBody(t, update)) + } + var updateOut iamtypes.UpdateAssumeRolePolicyResponse + unmarshalXML(t, readBody(t, update), &updateOut) + if updateOut.XMLName.Local != "UpdateAssumeRolePolicyResponse" || updateOut.ResponseMetadata.RequestID == "" { + t.Fatalf("UpdateAssumeRolePolicy output = %#v", updateOut) + } + + oversizedTrustPolicy := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` + updateOversized := doIAMAction(t, server, url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "RoleName": {"my-role"}, + "PolicyDocument": {oversizedTrustPolicy}, + }) + requireIAMError(t, updateOversized, http.StatusConflict, "Sender", "LimitExceeded", "Cannot exceed quota for ACLSizePerRole: 2048") + + getAfterUpdate := doIAMAction(t, server, url.Values{ + "Action": {"GetRole"}, + "RoleName": {"my-role"}, + }) + var getAfterUpdateOut iamtypes.GetRoleResponse + unmarshalXML(t, readBody(t, getAfterUpdate), &getAfterUpdateOut) + wantUpdatedEncoded := iamutil.EncodePolicyDocument(updatedTrustPolicy) + if getAfterUpdateOut.Result.Role.AssumeRolePolicyDocument != wantUpdatedEncoded { + t.Fatalf("GetRole after update AssumeRolePolicyDocument = %q, want %q", getAfterUpdateOut.Result.Role.AssumeRolePolicyDocument, wantUpdatedEncoded) + } + + deleteResp := doIAMAction(t, server, url.Values{ + "Action": {"DeleteRole"}, + "RoleName": {"my-role"}, + }) + if deleteResp.StatusCode != http.StatusOK { + t.Fatalf("DeleteRole status = %d, body=%s", deleteResp.StatusCode, readBody(t, deleteResp)) + } + var deleteOut iamtypes.DeleteRoleResponse + unmarshalXML(t, readBody(t, deleteResp), &deleteOut) + if deleteOut.XMLName.Local != "DeleteRoleResponse" || deleteOut.ResponseMetadata.RequestID == "" { + t.Fatalf("DeleteRole output = %#v", deleteOut) + } + + missing := doIAMAction(t, server, url.Values{ + "Action": {"GetRole"}, + "RoleName": {"my-role"}, + }) + requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", "The role with name my-role cannot be found.") +} + +func TestIAMApiControllerCreateRoleValidationErrors(t *testing.T) { + tests := []struct { + name string + params url.Values + status int + code string + message string + }{ + { + name: "missing role name", + params: url.Values{ + "Action": {"CreateRole"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'roleName' failed to satisfy constraint: Member must not be null", + }, + { + name: "invalid role name", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"bad/name"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for roleName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-", + }, + { + name: "long role name", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {strings.Repeat("a", 65)}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'roleName' failed to satisfy constraint: Member must have length less than or equal to 64", + }, + { + name: "invalid path", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "Path": {"bad"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for path is invalid. It must begin and end with / and contain only alphanumeric characters and/or / characters.", + }, + { + name: "missing assume role policy document", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'assumeRolePolicyDocument' failed to satisfy constraint: Member must not be null", + }, + { + name: "invalid json policy", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {"{invalid"}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "This policy contains invalid Json", + }, + { + name: "policy statement empty", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Could not parse the policy: Statement is empty!", + }, + { + name: "policy missing principal", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:AssumeRole"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Missing required field Principal", + }, + { + name: "policy principal empty object", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{},"Action":"sts:AssumeRole"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Missing required field Principal cannot be empty!", + }, + { + name: "policy action not sts prefixed", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"*"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "AssumeRole policy may only specify STS AssumeRole actions.", + }, + { + name: "policy has resource", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Resource":"*"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Has prohibited field Resource", + }, + { + name: "policy has notresource", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","NotResource":"*"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "AssumeRole policy must not contain resources.", + }, + { + name: "policy allow with notprincipal", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`}, + }, + status: http.StatusBadRequest, + code: "MalformedPolicyDocument", + message: "Allow with NotPrincipal is not allowed.", + }, + { + name: "policy too large", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {strings.Repeat("x", 131073)}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'assumeRolePolicyDocument' failed to satisfy constraint: Member must have length less than or equal to 131072", + }, + { + name: "description invalid charset", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "Description": {"emoji\U0001F600test"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'description' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\u0009\\u000A\\u000D\\u0020-\\u007E\\u00A1-\\u00FF]*", + }, + { + name: "trust policy exceeds ACLSizePerRole quota", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`}, + }, + status: http.StatusConflict, + code: "LimitExceeded", + message: "Cannot exceed quota for ACLSizePerRole: 2048", + }, + { + name: "max session duration not a number", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "MaxSessionDuration": {"not-a-number"}, + }, + status: http.StatusBadRequest, + code: "MalformedInput", + message: "", + }, + { + name: "max session duration too low", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "MaxSessionDuration": {"3599"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value greater than or equal to 3600", + }, + { + name: "max session duration too high", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "MaxSessionDuration": {"43201"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value less than or equal to 43200", + }, + { + name: "duplicate tag key", + params: url.Values{ + "Action": {"CreateRole"}, + "RoleName": {"my-role"}, + "AssumeRolePolicyDocument": {validTrustPolicy}, + "Tags.member.1.Key": {"dup"}, + "Tags.member.1.Value": {"one"}, + "Tags.member.2.Key": {"DUP"}, + "Tags.member.2.Value": {"two"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + resp := doIAMActionPost(t, server, tt.params) + requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message) + }) + } +} + +func TestIAMApiControllerDeleteAndUpdateAssumeRolePolicyErrors(t *testing.T) { + tests := []struct { + name string + params url.Values + status int + code string + message string + }{ + { + name: "get missing role name", + params: url.Values{ + "Action": {"GetRole"}, + }, + status: http.StatusBadRequest, + code: "MissingParameter", + message: "The request must contain the parameter RoleName.", + }, + { + name: "get missing role", + params: url.Values{ + "Action": {"GetRole"}, + "RoleName": {"asdfadsf"}, + }, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name asdfadsf cannot be found.", + }, + { + name: "delete missing role", + params: url.Values{ + "Action": {"DeleteRole"}, + "RoleName": {"asdfadsf"}, + }, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name asdfadsf cannot be found.", + }, + { + name: "update assume role policy missing role", + params: url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "RoleName": {"asdfadsf"}, + "PolicyDocument": {validTrustPolicy}, + }, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name asdfadsf cannot be found.", + }, + { + name: "update assume role policy missing document", + params: url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "RoleName": {"asdfadsf"}, + }, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must not be null", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + resp := doIAMAction(t, server, tt.params) + requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message) + }) + } +} + func newIAMControllerTestServer(t *testing.T) *IAMApiServer { t.Helper() diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index 5c55c6dd..4c919a0e 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -113,7 +113,7 @@ func (e Error) XMLBody(requestID string) []byte { type errorXML struct { Type ErrorType Code string - Message string + Message string `xml:",omitempty"` } var errorCodeResponse = map[ErrorCode]Error{ @@ -345,10 +345,22 @@ func NoSuchEntityAccessKey(accessKeyID string) Error { return newSenderError("NoSuchEntity", fmt.Sprintf("The Access Key with id %s cannot be found", accessKeyID), http.StatusNotFound) } +func EntityAlreadyExistsRole(roleName string) Error { + return newSenderError("EntityAlreadyExists", fmt.Sprintf("Role with name %s already exists.", roleName), http.StatusConflict) +} + +func NoSuchEntityRole(roleName string) Error { + return newSenderError("NoSuchEntity", fmt.Sprintf("The role with name %s cannot be found.", roleName), http.StatusNotFound) +} + func AccessKeysLimitExceeded(maxKeys int) Error { return newSenderError("LimitExceeded", fmt.Sprintf("Cannot exceed quota for AccessKeysPerUser: %d", maxKeys), http.StatusConflict) } +func TrustPolicySizeLimitExceeded(maxBytes int) Error { + return newSenderError("LimitExceeded", fmt.Sprintf("Cannot exceed quota for ACLSizePerRole: %d", maxBytes), http.StatusConflict) +} + func ValidationError(message string) Error { return newSenderError("ValidationError", message, http.StatusBadRequest) } @@ -417,6 +429,22 @@ 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 InvalidDescriptionCharset(field string) Error { + return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\u0009\\u000A\\u000D\\u0020-\\u007E\\u00A1-\\u00FF]*", field)) +} + +func MaxSessionDurationTooLow() Error { + return ValidationError("1 validation error detected: Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value greater than or equal to 3600") +} + +func MaxSessionDurationTooHigh() Error { + return ValidationError("1 validation error detected: Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value less than or equal to 43200") +} + +func MalformedInput() Error { + return newSenderError("MalformedInput", "", http.StatusBadRequest) +} + func MalformedPolicyDocument(message string) Error { return newSenderError("MalformedPolicyDocument", message, http.StatusBadRequest) } diff --git a/iamapi/internal/iamutil/user.go b/iamapi/internal/iamutil/user.go index 68bcb667..24a91189 100644 --- a/iamapi/internal/iamutil/user.go +++ b/iamapi/internal/iamutil/user.go @@ -41,6 +41,15 @@ const ( userIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" maxTagKeyLen = 128 maxTagValLen = 256 + + roleIDPrefix = "AROA" + roleIDRandomLen = 17 + + MaxRoleDescriptionLen = 1000 + + DefaultMaxSessionDuration = 3600 + MinMaxSessionDuration = 3600 + MaxMaxSessionDuration = 43200 ) var ( @@ -82,6 +91,68 @@ func GetUserName(ctx fiber.Ctx, operation string, maxLen int, missingErr error) return userName, nil } +// GetRoleName resolves the RoleName request parameter and validates it +// against maxLen, returning missingErr if the parameter is absent or empty. +func GetRoleName(ctx fiber.Ctx, operation string, maxLen int, missingErr error) (string, error) { + roleName, ok := RequestParam(ctx, "RoleName") + if !ok || roleName == "" { + debuglogger.Logf("missing required %s parameter: RoleName", operation) + return "", missingErr + } + if err := ValidateName("roleName", roleName, maxLen); err != nil { + return "", err + } + + return roleName, nil +} + +// ParseMaxSessionDuration reads the MaxSessionDuration request parameter, +// defaulting to DefaultMaxSessionDuration when absent, and validates it +// falls within [MinMaxSessionDuration, MaxMaxSessionDuration]. +func ParseMaxSessionDuration(ctx fiber.Ctx) (int32, error) { + raw, ok := RequestParam(ctx, "MaxSessionDuration") + if !ok || raw == "" { + return DefaultMaxSessionDuration, nil + } + + parsed, err := strconv.ParseInt(raw, 10, 32) + if err != nil { + debuglogger.Logf("malformed MaxSessionDuration value %q", raw) + return 0, iamerr.MalformedInput() + } + if parsed < MinMaxSessionDuration { + debuglogger.Logf("invalid MaxSessionDuration value %q", raw) + return 0, iamerr.MaxSessionDurationTooLow() + } + if parsed > MaxMaxSessionDuration { + debuglogger.Logf("invalid MaxSessionDuration value %q", raw) + return 0, iamerr.MaxSessionDurationTooHigh() + } + + return int32(parsed), nil +} + +// ValidateDescription checks that the IAM role "Description" fits +// within MaxRoleDescriptionLen and uses the allowed charset — printable +// Latin-1 (excluding 0x7F-0xA0) plus tab/LF/CR +func ValidateDescription(field, desc string) error { + if len(desc) > MaxRoleDescriptionLen { + debuglogger.Logf("IAM role description exceeds maximum length: field=%s length=%d max=%d", field, len(desc), MaxRoleDescriptionLen) + return iamerr.ValueTooLong(field, MaxRoleDescriptionLen) + } + for _, r := range desc { + switch r { + case '\t', '\n', '\r': + continue + } + if r < 0x20 || (r > 0x7E && r < 0xA1) || r > 0xFF { + debuglogger.Logf("invalid IAM role description charset: field=%s", field) + return iamerr.InvalidDescriptionCharset(field) + } + } + return 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"). @@ -198,6 +269,21 @@ func GenerateUserID() (string, error) { return id, nil } +// BuildRoleArn constructs the ARN for an IAM role. +func BuildRoleArn(accountID, path, roleName string) string { + return fmt.Sprintf("arn:aws:iam::%s:role%s%s", accountID, path, roleName) +} + +// GenerateRoleID returns a new cryptographically random IAM role ID in the AROA… format. +func GenerateRoleID() (string, error) { + id, err := generateAWSID(roleIDPrefix, roleIDRandomLen) + if err != nil { + debuglogger.Logf("failed to generate IAM role ID: %v", err) + return "", err + } + return id, nil +} + // generateAWSID builds an AWS-style unique identifier: a fixed prefix // followed by randomLen characters drawn from userIDAlphabet. func generateAWSID(prefix string, randomLen int) (string, error) { diff --git a/iamapi/policy/document.go b/iamapi/policy/document.go index 39a7bc04..50490de2 100644 --- a/iamapi/policy/document.go +++ b/iamapi/policy/document.go @@ -41,6 +41,10 @@ type Statement struct { NotResource StringOrSlice Principal json.RawMessage NotPrincipal json.RawMessage + // Condition is never structurally validated (neither the identity- nor + // trust-policy path models its grammar) — it is only checked for + // presence, by the trust-policy Cognito-provider rule. + Condition json.RawMessage } // UnmarshalJSON accepts Statement as either a single JSON object or an diff --git a/iamapi/policy/trust.go b/iamapi/policy/trust.go new file mode 100644 index 00000000..c86380f2 --- /dev/null +++ b/iamapi/policy/trust.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 policy + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + "github.com/versity/versitygw/iamapi/iamerr" +) + +// trustPrincipalKeys are the only keys IAM accepts inside a trust policy +// statement's Principal object. CanonicalUser is deliberately not accepted +// here (see errTrustInvalidPrincipalKey) since it identifies an S3 canonical +// user id which is the legacy s3 user identifier and is not planned to support +var trustPrincipalKeys = map[string]bool{ + "AWS": true, + "Service": true, + "Federated": true, +} + +const cognitoFederatedProvider = "cognito-identity.amazonaws.com" + +// validServicePrincipals are the only Service principal values the gateway +// recognizes. Real AWS validates Service against its live catalog of +// ~300+ service principals; the gateway only exposes S3, STS, and IAM +// APIs, so those are the only services that could plausibly ever assume a +// role here. +var validServicePrincipals = map[string]bool{ + "s3.amazonaws.com": true, + "sts.amazonaws.com": true, + "iam.amazonaws.com": true, +} + +// MaxTrustPolicyBytes is IAM's ACLSizePerRole quota: a role has exactly one +// trust policy, so unlike inline identity policies (which sum across all of +// a user's/role's named policies) this is a plain length check against the +// single AssumeRolePolicyDocument/PolicyDocument value. +const MaxTrustPolicyBytes = 2048 + +var ( + errTrustInvalidJSON = iamerr.MalformedPolicyDocument("This policy contains invalid Json") + errTrustInvalidVersion = iamerr.MalformedPolicyDocument("The policy must contain a valid version string") + errTrustEmptyStatement = iamerr.MalformedPolicyDocument("Could not parse the policy: Statement is empty!") + errTrustDuplicateSid = iamerr.MalformedPolicyDocument("The Statement Ids in the policy are not unique") + errTrustMissingEffect = iamerr.MalformedPolicyDocument("Missing required field Effect") + errTrustMissingPrincipal = iamerr.MalformedPolicyDocument("Missing required field Principal") + errTrustEmptyPrincipal = iamerr.MalformedPolicyDocument("Missing required field Principal cannot be empty!") + errTrustPrincipalNotObject = iamerr.MalformedPolicyDocument("Principal must be a JSON object.") + errTrustAllowNotPrincipal = iamerr.MalformedPolicyDocument("Allow with NotPrincipal is not allowed.") + errTrustNotPrincipalForbidden = iamerr.MalformedPolicyDocument("AssumeRole policy must not contain NotPrincipal field.") + errTrustMissingAction = iamerr.MalformedPolicyDocument("Missing required field Action") + errTrustNonSTSAction = iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.") + errTrustResourceForbidden = iamerr.MalformedPolicyDocument("Has prohibited field Resource") + errTrustNotResourceForbidden = iamerr.MalformedPolicyDocument("AssumeRole policy must not contain resources.") + errTrustCognitoConditionRequired = iamerr.MalformedPolicyDocument("A condition block must be present for the Cognito provider") + errTrustSyntax = iamerr.MalformedPolicyDocument("Syntax error in policy.") +) + +// ParseTrust parses raw as an IAM role trust-policy document (the value of +// AssumeRolePolicyDocument / UpdateAssumeRolePolicy's PolicyDocument) and +// checks it against trust-policy grammar: Principal is required (the +// opposite of an identity policy), Action/NotAction values must carry the +// "sts:" prefix, and Resource/NotResource are forbidden. +func ParseTrust(raw string) error { + var doc Document + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + return errTrustInvalidJSON + } + return doc.ValidateTrust() +} + +// ValidateTrust checks d against IAM's trust-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.ValidateTrust. +func (d Document) ValidateTrust() error { + if d.Version != "" && d.Version != Version2008 && d.Version != Version2012 { + return errTrustInvalidVersion + } + if len(d.Statement) == 0 { + return errTrustEmptyStatement + } + + seenSids := make(map[string]struct{}, len(d.Statement)) + for _, stmt := range d.Statement { + if err := stmt.ValidateTrust(); err != nil { + return err + } + if stmt.Sid != "" { + if _, ok := seenSids[stmt.Sid]; ok { + return errTrustDuplicateSid + } + seenSids[stmt.Sid] = struct{}{} + } + } + + return nil +} + +// ValidateTrust checks s against IAM trust-policy statement grammar: a +// valid Effect, a required Principal (never NotPrincipal), an Action or +// NotAction with only "sts:"-prefixed values, and no Resource/NotResource. +// Condition is not modeled or validated(not supported at the moment) +func (s Statement) ValidateTrust() error { + switch s.Effect { + case "Allow", "Deny": + case "": + return errTrustMissingEffect + default: + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid effect: %s", s.Effect)) + } + + if len(s.NotPrincipal) > 0 { + if s.Effect == "Allow" { + return errTrustAllowNotPrincipal + } + return errTrustNotPrincipalForbidden + } + if err := s.validateTrustPrincipal(); err != nil { + return err + } + + if len(s.Action) == 0 && len(s.NotAction) == 0 { + return errTrustMissingAction + } + for _, action := range s.Action { + if !strings.HasPrefix(action, "sts:") { + return errTrustNonSTSAction + } + } + for _, action := range s.NotAction { + if !strings.HasPrefix(action, "sts:") { + return errTrustNonSTSAction + } + } + + if len(s.Resource) > 0 { + return errTrustResourceForbidden + } + if len(s.NotResource) > 0 { + return errTrustNotResourceForbidden + } + + return nil +} + +// validateTrustPrincipal checks s.Principal against trust-policy grammar: +// required, a JSON object (not a bare string or array), non-empty, with +// only AWS/Service/Federated keys, plus the Cognito-specific Condition +// requirement. Real AWS additionally validates that AWS/Service values +// resolve to real accounts/services against its live catalog; the gateway +// has no such catalog for AWS account/ARN values and validates those shape +// only. Service values are the exception — they're checked against +// validServicePrincipals, since the gateway only exposes S3, STS, and IAM +// APIs and so only those services could ever assume a role here. +func (s Statement) validateTrustPrincipal() error { + raw := s.Principal + if len(raw) == 0 { + return errTrustMissingPrincipal + } + + var principal map[string]StringOrSlice + if err := json.Unmarshal(raw, &principal); err != nil { + var asString string + if err := json.Unmarshal(raw, &asString); err == nil { + return errTrustPrincipalNotObject + } + return errTrustSyntax + } + + if len(principal) == 0 { + return errTrustEmptyPrincipal + } + + requiresCondition := false + for key, values := range principal { + if !trustPrincipalKeys[key] { + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid principal in policy: %q", key)) + } + if key == "Service" { + for _, v := range values { + if !validServicePrincipals[v] { + return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid principal in policy: %q:%q", strings.ToUpper(key), v)) + } + } + } + if key == "Federated" && slices.Contains(values, cognitoFederatedProvider) { + requiresCondition = true + } + } + + if requiresCondition && len(s.Condition) == 0 { + return errTrustCognitoConditionRequired + } + + return nil +} diff --git a/iamapi/policy/trust_test.go b/iamapi/policy/trust_test.go new file mode 100644 index 00000000..ecc51cf6 --- /dev/null +++ b/iamapi/policy/trust_test.go @@ -0,0 +1,92 @@ +// 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" + "testing" + + "github.com/versity/versitygw/iamapi/iamerr" +) + +// Every case below was verified against a live AWS IAM account, except +// where noted as a deliberate simplification (see IAM_ROLES_IMPLEMENTATION_PLAN.md). +// The "ec2 service (unsupported)" case is one such deliberate deviation: +// real AWS accepts ec2.amazonaws.com, but this gateway only exposes S3, +// STS, and IAM APIs, so it restricts Service principals to those three. +func TestParseTrust(t *testing.T) { + tests := []struct { + name string + doc string + wantErr error // nil means ParseTrust must succeed + }{ + {"valid AWS principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid without version", `{"Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid Service principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid multiple principal type keys together", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*","Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid Federated non-cognito provider", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"bogus.example.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid non-AssumeRole sts action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:TagSession"}]}`, nil}, + {"valid NotAction with sts prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"NotAction":"sts:AssumeRole"}]}`, nil}, + {"valid action array all sts prefixed", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":["sts:AssumeRole","sts:TagSession"]}]}`, nil}, + {"valid multiple unique sids", `{"Version":"2012-10-17","Statement":[{"Sid":"A","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Sid":"B","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, nil}, + + {"invalid json syntax", `{invalid json`, errTrustInvalidJSON}, + {"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustInvalidVersion}, + {"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, errTrustEmptyStatement}, + {"missing statement", `{"Version":"2012-10-17"}`, errTrustEmptyStatement}, + + {"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Invalid effect: Maybe")}, + {"missing effect field", `{"Version":"2012-10-17","Statement":[{"Principal":{"Service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, errTrustMissingEffect}, + + {"missing principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:AssumeRole"}]}`, errTrustMissingPrincipal}, + {"empty principal object", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{},"Action":"sts:AssumeRole"}]}`, errTrustEmptyPrincipal}, + {"principal as bare string", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"sts:AssumeRole"}]}`, errTrustPrincipalNotObject}, + {"principal as array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":["a"],"Action":"sts:AssumeRole"}]}`, errTrustSyntax}, + {"principal has invalid key", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"CanonicalUser":"abc"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "CanonicalUser"`)}, + {"principal has unrecognized service", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"invalid.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "SERVICE":"invalid.amazonaws.com"`)}, + {"principal has ec2 service (unsupported)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "SERVICE":"ec2.amazonaws.com"`)}, + + {"allow with notprincipal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustAllowNotPrincipal}, + {"deny with notprincipal", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustNotPrincipalForbidden}, + + {"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"}}]}`, errTrustMissingAction}, + {"bare wildcard action rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"*"}]}`, errTrustNonSTSAction}, + {"non-sts vendor action rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"s3:GetObject"}]}`, errTrustNonSTSAction}, + {"non-sts notaction rejected even on deny", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"NotAction":"s3:GetObject"}]}`, errTrustNonSTSAction}, + + {"resource forbidden", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Resource":"*"}]}`, errTrustResourceForbidden}, + {"notresource forbidden", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","NotResource":"*"}]}`, errTrustNotResourceForbidden}, + + {"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Sid":"Dup","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, errTrustDuplicateSid}, + + {"cognito federated without condition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, errTrustCognitoConditionRequired}, + {"cognito federated with condition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"cognito-identity.amazonaws.com:aud":"us-east-1:abc"}}}]}`, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ParseTrust(tt.doc) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("ParseTrust() = %v, want nil", err) + } + return + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("ParseTrust() = %v, want %v", err, tt.wantErr) + } + }) + } +} diff --git a/iamapi/router.go b/iamapi/router.go index 4b4baa70..4e6444e9 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -62,6 +62,12 @@ func (r *IAMApiRouter) Init() { "GetUserPolicy": ctrl.GetUserPolicy, "DeleteUserPolicy": ctrl.DeleteUserPolicy, "ListUserPolicies": ctrl.ListUserPolicies, + // Role CRUD + "CreateRole": ctrl.CreateRole, + "GetRole": ctrl.GetRole, + "ListRoles": ctrl.ListRoles, + "DeleteRole": ctrl.DeleteRole, + "UpdateAssumeRolePolicy": ctrl.UpdateAssumeRolePolicy, } actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds)) diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 7eef589a..81a76a61 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -54,12 +54,24 @@ type iamConfig struct { // AccessKeyIndex maps an access key id to the username that owns it, // so GetAccessKeyLastUsed can resolve a key without scanning every user. AccessKeyIndex map[string]string `json:"accessKeyIndex"` + // UserNameIndex maps a lowercased user name to the canonical (as-created) + // stored user name, so lookups can enforce AWS's case-insensitive + // uniqueness while still preserving the original casing in conf.Users's + // key and the stored User.UserName. + UserNameIndex map[string]string `json:"userNameIndex"` + + Roles map[string]types.Role `json:"roles"` + // RoleNameIndex is UserNameIndex's counterpart for roles. + RoleNameIndex map[string]string `json:"roleNameIndex"` } func defaultIAMConfig() iamConfig { return iamConfig{ Users: map[string]types.User{}, AccessKeyIndex: map[string]string{}, + UserNameIndex: map[string]string{}, + Roles: map[string]types.Role{}, + RoleNameIndex: map[string]string{}, } } @@ -70,6 +82,49 @@ func normalizeIAMConfig(conf *iamConfig) { if conf.AccessKeyIndex == nil { conf.AccessKeyIndex = make(map[string]string) } + if conf.UserNameIndex == nil { + conf.UserNameIndex = make(map[string]string) + } + for name := range conf.Users { + key := strings.ToLower(name) + if _, ok := conf.UserNameIndex[key]; !ok { + conf.UserNameIndex[key] = name + } + } + + if conf.Roles == nil { + conf.Roles = make(map[string]types.Role) + } + if conf.RoleNameIndex == nil { + conf.RoleNameIndex = make(map[string]string) + } + for name := range conf.Roles { + key := strings.ToLower(name) + if _, ok := conf.RoleNameIndex[key]; !ok { + conf.RoleNameIndex[key] = name + } + } +} + +// lookupUser resolves name to the canonical stored user name and entry, +// case-insensitively, via conf.UserNameIndex. +func lookupUser(conf iamConfig, name string) (string, types.User, bool) { + canonical, ok := conf.UserNameIndex[strings.ToLower(name)] + if !ok { + return "", types.User{}, false + } + user, ok := conf.Users[canonical] + return canonical, user, ok +} + +// lookupRole is lookupUser's counterpart for roles. +func lookupRole(conf iamConfig, name string) (string, types.Role, bool) { + canonical, ok := conf.RoleNameIndex[strings.ToLower(name)] + if !ok { + return "", types.Role{}, false + } + role, ok := conf.Roles[canonical] + return canonical, role, ok } func (s *InternalStore) CreateUser(_ context.Context, user types.User) (*types.User, error) { @@ -82,7 +137,8 @@ func (s *InternalStore) CreateUser(_ context.Context, user types.User) (*types.U return nil, err } - if _, ok := conf.Users[user.UserName]; ok { + key := strings.ToLower(user.UserName) + if _, ok := conf.UserNameIndex[key]; ok { return nil, iamerr.EntityAlreadyExistsUser(user.UserName) } for _, existing := range conf.Users { @@ -92,6 +148,7 @@ func (s *InternalStore) CreateUser(_ context.Context, user types.User) (*types.U } conf.Users[user.UserName] = user + conf.UserNameIndex[key] = user.UserName return json.Marshal(conf) }); err != nil { return nil, unwrapAPIError(err) @@ -110,7 +167,7 @@ func (s *InternalStore) DeleteUser(_ context.Context, username string) error { return nil, err } - user, ok := conf.Users[username] + canonical, user, ok := lookupUser(conf, username) if !ok { return nil, iamerr.NoSuchEntityUser(username) } @@ -121,7 +178,8 @@ func (s *InternalStore) DeleteUser(_ context.Context, username string) error { return nil, iamerr.GetAPIError(iamerr.ErrDeleteConflict) } - delete(conf.Users, username) + delete(conf.Users, canonical) + delete(conf.UserNameIndex, strings.ToLower(canonical)) return json.Marshal(conf) }) return unwrapAPIError(err) @@ -136,7 +194,7 @@ func (s *InternalStore) GetUser(_ context.Context, username string) (*types.User return nil, err } - user, ok := conf.Users[username] + _, user, ok := lookupUser(conf, username) if !ok { return nil, iamerr.NoSuchEntityUser(username) } @@ -204,7 +262,7 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t return nil, err } - user, ok := conf.Users[input.UserName] + canonical, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -213,8 +271,8 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t if input.NewUserName != "" { finalName = input.NewUserName } - if finalName != input.UserName { - if _, ok := conf.Users[finalName]; ok { + if !strings.EqualFold(finalName, canonical) { + if _, ok := conf.UserNameIndex[strings.ToLower(finalName)]; ok { return nil, iamerr.EntityAlreadyExistsUser(finalName) } } @@ -229,13 +287,15 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t user.Arn = input.NewArn } - if user.UserName != input.UserName { - delete(conf.Users, input.UserName) + if user.UserName != canonical { + delete(conf.Users, canonical) + delete(conf.UserNameIndex, strings.ToLower(canonical)) for _, key := range user.AccessKeys { conf.AccessKeyIndex[key.AccessKeyId] = user.UserName } } conf.Users[user.UserName] = user + conf.UserNameIndex[strings.ToLower(user.UserName)] = user.UserName updated = user return json.Marshal(conf) @@ -257,7 +317,7 @@ func (s *InternalStore) CreateAccessKey(_ context.Context, input CreateAccessKey return nil, err } - user, ok := conf.Users[input.UserName] + canonical, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -274,11 +334,11 @@ func (s *InternalStore) CreateAccessKey(_ context.Context, input CreateAccessKey Status: input.Status, CreateDate: input.CreateDate, }) - conf.Users[input.UserName] = user - conf.AccessKeyIndex[input.AccessKeyID] = input.UserName + conf.Users[canonical] = user + conf.AccessKeyIndex[input.AccessKeyID] = canonical created = types.AccessKey{ - UserName: input.UserName, + UserName: canonical, AccessKeyId: input.AccessKeyID, Status: input.Status, SecretAccessKey: input.SecretAccessKey, @@ -303,7 +363,7 @@ func (s *InternalStore) UpdateAccessKey(_ context.Context, input UpdateAccessKey return nil, err } - user, ok := conf.Users[input.UserName] + canonical, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -320,7 +380,7 @@ func (s *InternalStore) UpdateAccessKey(_ context.Context, input UpdateAccessKey return nil, iamerr.NoSuchEntityAccessKey(input.AccessKeyID) } - conf.Users[input.UserName] = user + conf.Users[canonical] = user return json.Marshal(conf) }) return unwrapAPIError(err) @@ -336,7 +396,7 @@ func (s *InternalStore) DeleteAccessKey(_ context.Context, username, accessKeyID return nil, err } - user, ok := conf.Users[username] + canonical, user, ok := lookupUser(conf, username) if !ok { return nil, iamerr.NoSuchEntityUser(username) } @@ -353,7 +413,7 @@ func (s *InternalStore) DeleteAccessKey(_ context.Context, username, accessKeyID } user.AccessKeys = slices.Delete(user.AccessKeys, idx, idx+1) - conf.Users[username] = user + conf.Users[canonical] = user delete(conf.AccessKeyIndex, accessKeyID) return json.Marshal(conf) @@ -402,7 +462,7 @@ func (s *InternalStore) ListAccessKeys(_ context.Context, input ListAccessKeysIn return nil, err } - user, ok := conf.Users[input.UserName] + canonical, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -410,7 +470,7 @@ func (s *InternalStore) ListAccessKeys(_ context.Context, input ListAccessKeysIn keys := make([]types.AccessKeyMetadata, 0, len(user.AccessKeys)) for _, key := range user.AccessKeys { keys = append(keys, types.AccessKeyMetadata{ - UserName: input.UserName, + UserName: canonical, AccessKeyId: key.AccessKeyId, Status: key.Status, CreateDate: key.CreateDate, @@ -459,7 +519,7 @@ func (s *InternalStore) PutUserPolicy(_ context.Context, input PutUserPolicyInpu return nil, err } - user, ok := conf.Users[input.UserName] + canonical, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -490,7 +550,7 @@ func (s *InternalStore) PutUserPolicy(_ context.Context, input PutUserPolicyInpu }) } - conf.Users[input.UserName] = user + conf.Users[canonical] = user return json.Marshal(conf) }) return unwrapAPIError(err) @@ -505,7 +565,7 @@ func (s *InternalStore) GetUserPolicy(_ context.Context, userName, policyName st return nil, err } - user, ok := conf.Users[userName] + _, user, ok := lookupUser(conf, userName) if !ok { return nil, iamerr.NoSuchEntityUser(userName) } @@ -530,7 +590,7 @@ func (s *InternalStore) DeleteUserPolicy(_ context.Context, userName, policyName return nil, err } - user, ok := conf.Users[userName] + canonical, user, ok := lookupUser(conf, userName) if !ok { return nil, iamerr.NoSuchEntityUser(userName) } @@ -547,7 +607,7 @@ func (s *InternalStore) DeleteUserPolicy(_ context.Context, userName, policyName } user.Policies.Inline = slices.Delete(user.Policies.Inline, idx, idx+1) - conf.Users[userName] = user + conf.Users[canonical] = user return json.Marshal(conf) }) return unwrapAPIError(err) @@ -562,7 +622,7 @@ func (s *InternalStore) ListUserPolicies(_ context.Context, input ListUserPolici return nil, err } - user, ok := conf.Users[input.UserName] + _, user, ok := lookupUser(conf, input.UserName) if !ok { return nil, iamerr.NoSuchEntityUser(input.UserName) } @@ -602,6 +662,160 @@ func (s *InternalStore) ListUserPolicies(_ context.Context, input ListUserPolici return out, nil } +func (s *InternalStore) CreateRole(_ context.Context, role types.Role) (*types.Role, error) { + s.Lock() + defer s.Unlock() + + role.EnsureRoleLastUsed() + + if err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + key := strings.ToLower(role.RoleName) + if _, ok := conf.RoleNameIndex[key]; ok { + return nil, iamerr.EntityAlreadyExistsRole(role.RoleName) + } + for _, existing := range conf.Roles { + if existing.RoleID == role.RoleID { + return nil, ErrRoleIDAlreadyExists + } + } + + conf.Roles[role.RoleName] = role + conf.RoleNameIndex[key] = role.RoleName + return json.Marshal(conf) + }); err != nil { + return nil, unwrapAPIError(err) + } + + return cloneRole(role), nil +} + +func (s *InternalStore) GetRole(_ context.Context, roleName string) (*types.Role, 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) + } + + return cloneRole(role), nil +} + +func (s *InternalStore) ListRoles(_ context.Context, input ListRolesInput) (*ListRolesOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + roles := make([]types.Role, 0, len(conf.Roles)) + for _, role := range conf.Roles { + if input.PathPrefix != "" && !strings.HasPrefix(role.Path, input.PathPrefix) { + continue + } + // ListRoles entries omit RoleLastUsed even though it's persisted — + // matches the documented list/get field asymmetry. + role.RoleLastUsed = nil + roles = append(roles, role) + } + sort.Slice(roles, func(i, j int) bool { + return roles[i].RoleName < roles[j].RoleName + }) + + start := 0 + if input.Marker != "" { + start = len(roles) + for i, role := range roles { + if role.RoleName == input.Marker { + start = i + 1 + break + } + } + } + roles = roles[start:] + + limit := len(roles) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListRolesOutput{ + Roles: make([]types.Role, limit), + } + copy(out.Roles, roles[:limit]) + if limit < len(roles) { + out.IsTruncated = true + out.Marker = out.Roles[limit-1].RoleName + } + + return out, nil +} + +func (s *InternalStore) DeleteRole(_ context.Context, roleName 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) + } + if len(role.Policies.Inline) > 0 { + return nil, iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) + } + + delete(conf.Roles, canonical) + delete(conf.RoleNameIndex, strings.ToLower(canonical)) + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) UpdateAssumeRolePolicy(_ context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) { + s.Lock() + defer s.Unlock() + + var updated types.Role + if 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) + } + + role.AssumeRolePolicyDocument = input.PolicyDocument + conf.Roles[canonical] = role + updated = role + + return json.Marshal(conf) + }); err != nil { + return nil, unwrapAPIError(err) + } + + return cloneRole(updated), nil +} + func cloneUser(user types.User) *types.User { cloned := user cloned.Tags = slices.Clone(user.Tags) @@ -609,3 +823,10 @@ func cloneUser(user types.User) *types.User { cloned.Policies.Inline = slices.Clone(user.Policies.Inline) return &cloned } + +func cloneRole(role types.Role) *types.Role { + cloned := role + cloned.Tags = slices.Clone(role.Tags) + cloned.Policies.Inline = slices.Clone(role.Policies.Inline) + return &cloned +} diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index d799d711..f862d00f 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -36,6 +36,7 @@ const MaxInlinePolicyBytesPerUser = 2048 var ( ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") + ErrRoleIDAlreadyExists = errors.New("iamapi: role id already exists") ) type ListUsersInput struct { @@ -108,6 +109,23 @@ type ListUserPoliciesOutput struct { Marker string } +type ListRolesInput struct { + PathPrefix string + Marker string + MaxItems int32 +} + +type ListRolesOutput struct { + Roles []types.Role + IsTruncated bool + Marker string +} + +type UpdateAssumeRolePolicyInput struct { + RoleName string + PolicyDocument string +} + // Storer is the IAM API storage backend contract. type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) @@ -126,6 +144,12 @@ type Storer interface { 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) + + CreateRole(ctx context.Context, role types.Role) (*types.Role, error) + GetRole(ctx context.Context, roleName string) (*types.Role, error) + ListRoles(ctx context.Context, input ListRolesInput) (*ListRolesOutput, error) + DeleteRole(ctx context.Context, roleName string) error + UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) } func unwrapAPIError(err error) error { diff --git a/iamapi/storage/storer_test.go b/iamapi/storage/storer_test.go index be0ea36d..e54104a0 100644 --- a/iamapi/storage/storer_test.go +++ b/iamapi/storage/storer_test.go @@ -221,3 +221,166 @@ func TestInternalStoreUserCRUDAndPagination(t *testing.T) { t.Fatalf("DeleteUser missing err = %v, want NoSuchEntity", err) } } + +func TestInternalStoreUserNameCaseInsensitive(t *testing.T) { + ctx := context.Background() + store, err := NewInternal(t.TempDir()) + if err != nil { + t.Fatalf("NewInternal: %v", err) + } + + if _, err := store.CreateUser(ctx, types.User{UserName: "alice", UserID: "AIDA11111111111111111"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if _, err := store.CreateUser(ctx, types.User{UserName: "ALICE", UserID: "AIDA22222222222222222"}); !errors.Is(err, iamerr.EntityAlreadyExistsUser("ALICE")) { + t.Fatalf("CreateUser case-variant duplicate err = %v, want EntityAlreadyExists", err) + } + + got, err := store.GetUser(ctx, "ALICE") + if err != nil { + t.Fatalf("GetUser case-insensitive lookup: %v", err) + } + if got.UserName != "alice" { + t.Fatalf("GetUser case-insensitive lookup = %#v, want canonical casing preserved", got) + } + + if err := store.DeleteUser(ctx, "ALICE"); err != nil { + t.Fatalf("DeleteUser case-insensitive lookup: %v", err) + } + if _, err := store.GetUser(ctx, "alice"); !errors.Is(err, iamerr.NoSuchEntityUser("alice")) { + t.Fatalf("GetUser after case-insensitive delete err = %v, want NoSuchEntity", err) + } +} + +func TestInternalStoreRoleCRUDAndPagination(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + store, err := NewInternal(dir) + if err != nil { + t.Fatalf("NewInternal: %v", err) + } + + created := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) + roles := []types.Role{ + { + Path: "/engineering/", + RoleName: "alice-role", + RoleID: "AROA22222222222222222", + Arn: "arn:aws:iam::000000000000:role/engineering/alice-role", + CreateDate: created, + AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, + MaxSessionDuration: 3600, + Tags: []types.Tag{ + {Key: "env", Value: "test"}, + }, + }, + { + Path: "/engineering/platform/", + RoleName: "bob-role", + RoleID: "AROA33333333333333333", + Arn: "arn:aws:iam::000000000000:role/engineering/platform/bob-role", + CreateDate: created.Add(time.Second), + AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, + MaxSessionDuration: 3600, + }, + { + Path: "/ops/", + RoleName: "carol-role", + RoleID: "AROA44444444444444444", + Arn: "arn:aws:iam::000000000000:role/ops/carol-role", + CreateDate: created.Add(2 * time.Second), + AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, + MaxSessionDuration: 3600, + }, + } + for _, role := range roles { + created, err := store.CreateRole(ctx, role) + if err != nil { + t.Fatalf("CreateRole(%s): %v", role.RoleName, err) + } + if created.RoleLastUsed == nil { + t.Fatalf("CreateRole(%s) RoleLastUsed = nil, want non-nil empty element", role.RoleName) + } + } + + if _, err := store.CreateRole(ctx, roles[0]); !errors.Is(err, iamerr.EntityAlreadyExistsRole("alice-role")) { + t.Fatalf("CreateRole duplicate err = %v, want EntityAlreadyExists", err) + } + if _, err := store.CreateRole(ctx, types.Role{RoleName: "ALICE-ROLE", RoleID: "AROA55555555555555555"}); !errors.Is(err, iamerr.EntityAlreadyExistsRole("ALICE-ROLE")) { + t.Fatalf("CreateRole case-variant duplicate err = %v, want EntityAlreadyExists", err) + } + duplicateID := roles[2] + duplicateID.RoleName = "dave-role" + if _, err := store.CreateRole(ctx, duplicateID); !errors.Is(err, ErrRoleIDAlreadyExists) { + t.Fatalf("CreateRole duplicate id err = %v, want ErrRoleIDAlreadyExists", err) + } + + got, err := store.GetRole(ctx, "ALICE-ROLE") + if err != nil { + t.Fatalf("GetRole: %v", err) + } + if got.RoleName != "alice-role" || got.RoleID != roles[0].RoleID { + t.Fatalf("GetRole = %#v, want alice-role with stable id and preserved casing", got) + } + if !reflect.DeepEqual(got.Tags, roles[0].Tags) { + t.Fatalf("GetRole tags = %#v, want %#v", got.Tags, roles[0].Tags) + } + if got.RoleLastUsed == nil { + t.Fatal("GetRole RoleLastUsed = nil, want non-nil empty element") + } + + page1, err := store.ListRoles(ctx, ListRolesInput{PathPrefix: "/engineering/", MaxItems: 1}) + if err != nil { + t.Fatalf("ListRoles page1: %v", err) + } + if len(page1.Roles) != 1 || page1.Roles[0].RoleName != "alice-role" || !page1.IsTruncated || page1.Marker != "alice-role" { + t.Fatalf("page1 = %#v, want truncated alice-role page", page1) + } + if page1.Roles[0].RoleLastUsed != nil { + t.Fatalf("ListRoles RoleLastUsed = %#v, want nil (list/get asymmetry)", page1.Roles[0].RoleLastUsed) + } + + page2, err := store.ListRoles(ctx, ListRolesInput{PathPrefix: "/engineering/", Marker: page1.Marker, MaxItems: 10}) + if err != nil { + t.Fatalf("ListRoles page2: %v", err) + } + if len(page2.Roles) != 1 || page2.Roles[0].RoleName != "bob-role" || page2.IsTruncated { + t.Fatalf("page2 = %#v, want final bob-role page", page2) + } + + updatedRole, err := store.UpdateAssumeRolePolicy(ctx, UpdateAssumeRolePolicyInput{ + RoleName: "alice-role", + PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, + }) + if err != nil { + t.Fatalf("UpdateAssumeRolePolicy: %v", err) + } + if updatedRole.AssumeRolePolicyDocument != `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}` { + t.Fatalf("UpdateAssumeRolePolicy result = %#v", updatedRole) + } + if updatedRole.RoleID != roles[0].RoleID { + t.Fatalf("UpdateAssumeRolePolicy identity changed: %#v", updatedRole) + } + if _, err := store.UpdateAssumeRolePolicy(ctx, UpdateAssumeRolePolicyInput{RoleName: "missing-role", PolicyDocument: "{}"}); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) { + t.Fatalf("UpdateAssumeRolePolicy missing role err = %v, want NoSuchEntity", err) + } + + reopened, err := NewInternal(dir) + if err != nil { + t.Fatalf("reopen NewInternal: %v", err) + } + reopenedRole, err := reopened.GetRole(ctx, "alice-role") + if err != nil { + t.Fatalf("GetRole after reopen: %v", err) + } + if reopenedRole.AssumeRolePolicyDocument != updatedRole.AssumeRolePolicyDocument { + t.Fatalf("reopened AssumeRolePolicyDocument = %q, want %q", reopenedRole.AssumeRolePolicyDocument, updatedRole.AssumeRolePolicyDocument) + } + + if err := reopened.DeleteRole(ctx, "carol-role"); err != nil { + t.Fatalf("DeleteRole: %v", err) + } + if err := reopened.DeleteRole(ctx, "carol-role"); !errors.Is(err, iamerr.NoSuchEntityRole("carol-role")) { + t.Fatalf("DeleteRole missing err = %v, want NoSuchEntity", err) + } +} diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index 48399724..d54c97a2 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -191,7 +191,45 @@ func (s *VaultStore) reAuthIfNeeded(err error) error { return nil } +// findUserKey resolves name to the exact stored KV path segment (the +// original UserName casing used at creation), case-insensitively, by +// listing the users under secretStoragePath and comparing with EqualFold. +// AWS enforces case-insensitive UserName uniqueness but Vault's KV paths +// are plain case-sensitive strings, so a list+compare fallback is needed — +// KV has no native case-insensitive lookup. ok is false both when nothing +// matches and (harmlessly) when the prefix has no children at all. +func (s *VaultStore) findUserKey(name string) (string, bool, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return "", false, nil + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return "", false, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return "", false, nil + } + return "", false, err + } + } + for _, key := range resp.Data.Keys { + if strings.EqualFold(key, name) { + return key, true, nil + } + } + return "", false, nil +} + func (s *VaultStore) CreateUser(_ context.Context, user types.User) (*types.User, error) { + if _, ok, err := s.findUserKey(user.UserName); err != nil { + return nil, err + } else if ok { + return nil, iamerr.EntityAlreadyExistsUser(user.UserName) + } + userMap, err := userToVaultMap(user) if err != nil { return nil, fmt.Errorf("serialize user: %w", err) @@ -239,11 +277,19 @@ func (s *VaultStore) DeleteUser(ctx context.Context, username string) error { if len(user.AccessKeys) > 0 { return iamerr.GetAPIError(iamerr.ErrDeleteConflict) } - return s.deleteByPath(username) + return s.deleteByPath(user.UserName) } func (s *VaultStore) GetUser(_ context.Context, username string) (*types.User, error) { - path := s.secretStoragePath + "/" + username + canonical, ok, err := s.findUserKey(username) + if err != nil { + return nil, err + } + if !ok { + return nil, iamerr.NoSuchEntityUser(username) + } + + path := s.secretStoragePath + "/" + canonical resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { @@ -261,7 +307,7 @@ func (s *VaultStore) GetUser(_ context.Context, username string) (*types.User, e } } - user, err := parseVaultUser(resp.Data.Data, username) + user, err := parseVaultUser(resp.Data.Data, canonical) if err != nil { return nil, err } @@ -340,13 +386,14 @@ func (s *VaultStore) UpdateUser(ctx context.Context, input UpdateUserInput) (*ty if err != nil { return nil, err } + originalName := user.UserName finalName := user.UserName if input.NewUserName != "" { finalName = input.NewUserName } - if finalName != input.UserName { + if !strings.EqualFold(finalName, originalName) { existing, err := s.GetUser(ctx, finalName) if err != nil && !errors.Is(err, iamerr.NoSuchEntityUser(finalName)) { return nil, err @@ -366,12 +413,12 @@ func (s *VaultStore) UpdateUser(ctx context.Context, input UpdateUserInput) (*ty user.Arn = input.NewArn } - if user.UserName != input.UserName { + if user.UserName != originalName { // Create at new path first to detect conflicts before deleting the old entry. if _, err := s.CreateUser(ctx, *user); err != nil { return nil, err } - if err := s.deleteByPath(input.UserName); err != nil { + if err := s.deleteByPath(originalName); err != nil { return nil, err } } else if _, err := s.replaceUser(ctx, *user); err != nil { @@ -689,6 +736,273 @@ func (s *VaultStore) deleteByPath(username string) error { return nil } +// rolesPath is the KV prefix under which roles are stored, kept distinct +// from secretStoragePath (which holds users) so listing one entity kind +// never has to filter out the other's keys. +func (s *VaultStore) rolesPath() string { + return s.secretStoragePath + "/roles" +} + +// findRoleKey is findUserKey's counterpart for roles. +func (s *VaultStore) findRoleKey(name string) (string, bool, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return "", false, nil + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return "", false, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return "", false, nil + } + return "", false, err + } + } + for _, key := range resp.Data.Keys { + if strings.EqualFold(key, name) { + return key, true, nil + } + } + return "", false, nil +} + +func (s *VaultStore) CreateRole(_ context.Context, role types.Role) (*types.Role, error) { + if _, ok, err := s.findRoleKey(role.RoleName); err != nil { + return nil, err + } else if ok { + return nil, iamerr.EntityAlreadyExistsRole(role.RoleName) + } + + role.EnsureRoleLastUsed() + + roleMap, err := roleToVaultMap(role) + if err != nil { + return nil, fmt.Errorf("serialize role: %w", err) + } + + path := s.rolesPath() + "/" + role.RoleName + req := schema.KvV2WriteRequest{ + Data: map[string]any{role.RoleName: roleMap}, + Options: map[string]any{ + "cas": 0, + }, + } + + _, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return nil, iamerr.EntityAlreadyExistsRole(role.RoleName) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + // retry once after re-auth + _, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...) + if err != nil { + if strings.Contains(err.Error(), "check-and-set") { + return nil, iamerr.EntityAlreadyExistsRole(role.RoleName) + } + if vault.IsErrorStatus(err, http.StatusForbidden) { + return nil, fmt.Errorf("vault 403 permission denied on path %q. check KV mount path and policy. original: %w", path, err) + } + return nil, err + } + } + return cloneRole(role), nil +} + +func (s *VaultStore) GetRole(_ context.Context, roleName string) (*types.Role, error) { + canonical, ok, err := s.findRoleKey(roleName) + if err != nil { + return nil, err + } + if !ok { + return nil, iamerr.NoSuchEntityRole(roleName) + } + + path := s.rolesPath() + "/" + canonical + resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityRole(roleName) + } + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return nil, iamerr.NoSuchEntityRole(roleName) + } + return nil, err + } + } + + role, err := parseVaultRole(resp.Data.Data, canonical) + if err != nil { + return nil, err + } + return cloneRole(role), nil +} + +func (s *VaultStore) ListRoles(ctx context.Context, input ListRolesInput) (*ListRolesOutput, error) { + resp, err := s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return &ListRolesOutput{Roles: []types.Role{}}, nil + } + reauthErr := s.reAuthIfNeeded(err) + if reauthErr != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return &ListRolesOutput{Roles: []types.Role{}}, nil + } + return nil, reauthErr + } + resp, err = s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...) + if err != nil { + if vault.IsErrorStatus(err, http.StatusNotFound) { + return &ListRolesOutput{Roles: []types.Role{}}, nil + } + return nil, err + } + } + + roles := make([]types.Role, 0, len(resp.Data.Keys)) + for _, key := range resp.Data.Keys { + role, err := s.GetRole(ctx, key) + if err != nil { + return nil, err + } + if input.PathPrefix != "" && !strings.HasPrefix(role.Path, input.PathPrefix) { + continue + } + // ListRoles entries omit RoleLastUsed even though GetRole (reused + // above to fetch each entry) attaches it — matches the documented + // list/get field asymmetry. + role.RoleLastUsed = nil + roles = append(roles, *role) + } + + sort.Slice(roles, func(i, j int) bool { + return roles[i].RoleName < roles[j].RoleName + }) + + start := 0 + if input.Marker != "" { + start = len(roles) + for i, role := range roles { + if role.RoleName == input.Marker { + start = i + 1 + break + } + } + } + roles = roles[start:] + + limit := len(roles) + if input.MaxItems > 0 && int(input.MaxItems) < limit { + limit = int(input.MaxItems) + } + + out := &ListRolesOutput{ + Roles: make([]types.Role, limit), + } + copy(out.Roles, roles[:limit]) + if limit < len(roles) { + out.IsTruncated = true + out.Marker = out.Roles[limit-1].RoleName + } + + return out, nil +} + +func (s *VaultStore) DeleteRole(ctx context.Context, roleName string) error { + role, err := s.GetRole(ctx, roleName) + if err != nil { + return err + } + if len(role.Policies.Inline) > 0 { + return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies) + } + return s.deleteRoleByPath(role.RoleName) +} + +func (s *VaultStore) UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) { + role, err := s.GetRole(ctx, input.RoleName) + if err != nil { + return nil, err + } + role.AssumeRolePolicyDocument = input.PolicyDocument + + return s.replaceRole(ctx, *role) +} + +// 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) { + if err := s.deleteRoleByPath(role.RoleName); err != nil { + return nil, err + } + return s.CreateRole(ctx, role) +} + +// deleteRoleByPath permanently removes a role secret and all its versions +// without checking for existence first. +func (s *VaultStore) deleteRoleByPath(roleName string) error { + path := s.rolesPath() + "/" + roleName + _, err := s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...) + if err != nil { + if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { + return reauthErr + } + _, err = s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...) + if err != nil { + return err + } + } + return nil +} + +var errInvalidVaultRole = errors.New("invalid role entry in vault secrets engine") + +// roleToVaultMap is userToVaultMap's counterpart for roles. +func roleToVaultMap(role types.Role) (map[string]any, error) { + b, err := json.Marshal(role) + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + return m, nil +} + +// parseVaultRole reconstructs a Role from the raw map[string]any that vault +// returns. The outer key is the role name. +func parseVaultRole(data map[string]any, roleName string) (types.Role, error) { + raw, ok := data[roleName] + if !ok { + return types.Role{}, errInvalidVaultRole + } + roleMap, ok := raw.(map[string]any) + if !ok { + return types.Role{}, errInvalidVaultRole + } + b, err := json.Marshal(roleMap) + if err != nil { + return types.Role{}, fmt.Errorf("re-marshal vault role: %w", err) + } + var role types.Role + if err := json.Unmarshal(b, &role); err != nil { + return types.Role{}, fmt.Errorf("unmarshal vault role: %w", err) + } + return role, nil +} + var errInvalidVaultUser = errors.New("invalid user entry in vault secrets engine") // userToVaultMap round-trips User through JSON to produce a map[string]any diff --git a/iamapi/types/role.go b/iamapi/types/role.go new file mode 100644 index 00000000..fada0857 --- /dev/null +++ b/iamapi/types/role.go @@ -0,0 +1,113 @@ +// 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" +) + +type Role struct { + Path string `xml:",omitempty"` + RoleName string `xml:",omitempty"` + RoleID string `xml:"RoleId"` + Arn string `xml:"Arn"` + CreateDate time.Time `xml:"CreateDate"` + AssumeRolePolicyDocument string `xml:",omitempty"` + Description string `xml:",omitempty"` + MaxSessionDuration int32 `xml:"MaxSessionDuration,omitempty"` + RoleLastUsed *RoleLastUsed + Tags []Tag `xml:"Tags>member,omitempty"` + Policies Policies `xml:"-"` // unused until role inline-policy CRUD exists; see DeleteRole conflict check +} + +type RoleLastUsed struct { + LastUsedDate time.Time `xml:",omitempty"` + Region string `xml:",omitempty"` +} + +// EnsureRoleLastUsed defaults RoleLastUsed to a zero value if unset, +// without clobbering an already-set value. +func (r *Role) EnsureRoleLastUsed() { + if r.RoleLastUsed == nil { + r.RoleLastUsed = &RoleLastUsed{} + } +} + +type CreateRoleResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateRoleResponse"` + Result CreateRoleResult `xml:"CreateRoleResult"` + ResponseMetadata ResponseMetadata +} + +func (r *CreateRoleResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type CreateRoleResult struct { + Role *Role +} + +type GetRoleResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetRoleResponse"` + Result GetRoleResult `xml:"GetRoleResult"` + ResponseMetadata ResponseMetadata +} + +func (r *GetRoleResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type GetRoleResult struct { + Role *Role +} + +type ListRolesResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListRolesResponse"` + Result ListRolesResult `xml:"ListRolesResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListRolesResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListRolesResult struct { + Roles Roles + IsTruncated bool + Marker string `xml:",omitempty"` +} + +type Roles struct { + Members []Role `xml:"member"` +} + +type DeleteRoleResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteRoleResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *DeleteRoleResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type UpdateAssumeRolePolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateAssumeRolePolicyResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *UpdateAssumeRolePolicyResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} diff --git a/runiamtests.sh b/runiamtests.sh index 73dacc5a..e12a6cce 100755 --- a/runiamtests.sh +++ b/runiamtests.sh @@ -152,7 +152,7 @@ fi vault_policy=$(printf '%s\n' \ "path \"$VAULT_MOUNT_PATH/data/$VAULT_SECRET_PATH/*\" { capabilities = [\"create\", \"update\", \"read\"] }" \ "path \"$VAULT_MOUNT_PATH/metadata/$VAULT_SECRET_PATH/\" { capabilities = [\"list\"] }" \ - "path \"$VAULT_MOUNT_PATH/metadata/$VAULT_SECRET_PATH/*\" { capabilities = [\"delete\"] }") + "path \"$VAULT_MOUNT_PATH/metadata/$VAULT_SECRET_PATH/*\" { capabilities = [\"delete\", \"list\"] }") vault_policy_payload=$(jq -nc --arg policy "$vault_policy" '{policy: $policy}') vault_request PUT "sys/policies/acl/$VAULT_POLICY_NAME" "$vault_policy_payload" >/dev/null diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 95d3a520..287bc358 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1127,6 +1127,7 @@ func TestIAMQueryAuth(ts *TestState) { func TestIAMCreateUser(ts *TestState) { ts.Run(IAMCreateUser_user_already_exists) + ts.Run(IAMCreateUser_already_exists_case_insensitive) ts.Run(IAMCreateUser_invalid_user_name) ts.Run(IAMCreateUser_long_user_name) ts.Run(IAMCreateUser_missing_user_name) @@ -1280,6 +1281,68 @@ func TestIAMListUserPolicies(ts *TestState) { ts.Run(IAMListUserPolicies_pagination) } +func TestIAMCreateRole(ts *TestState) { + ts.Run(IAMCreateRole_missing_role_name) + ts.Run(IAMCreateRole_invalid_role_name) + ts.Run(IAMCreateRole_long_role_name) + ts.Run(IAMCreateRole_already_exists) + ts.Run(IAMCreateRole_already_exists_case_insensitive) + ts.Run(IAMCreateRole_invalid_path) + ts.Run(IAMCreateRole_long_path) + ts.Run(IAMCreateRole_missing_assume_role_policy_document) + ts.Run(IAMCreateRole_non_ascii_assume_role_policy_document) + ts.Run(IAMCreateRole_trust_policy_size_limit_exceeded) + ts.Run(IAMCreateRole_description_invalid_charset) + ts.Run(IAMCreateRole_description_too_long) + ts.Run(IAMCreateRole_max_session_duration_invalid_format) + ts.Run(IAMCreateRole_max_session_duration_too_low) + ts.Run(IAMCreateRole_max_session_duration_too_high) + ts.Run(IAMCreateRole_duplicate_tag_keys) + ts.Run(IAMCreateRole_success) + ts.Run(IAMCreateRole_defaults) + ts.Run(IAMCreateRole_trust_policy_document_grammar) +} + +func TestIAMGetRole(ts *TestState) { + ts.Run(IAMGetRole_missing_role_name) + ts.Run(IAMGetRole_invalid_role_name) + ts.Run(IAMGetRole_long_role_name) + ts.Run(IAMGetRole_non_existing_role) + ts.Run(IAMGetRole_success) +} + +func TestIAMListRoles(ts *TestState) { + ts.Run(IAMListRoles_invalid_path_prefix) + ts.Run(IAMListRoles_long_path_prefix) + ts.Run(IAMListRoles_invalid_max_items) + ts.Run(IAMListRoles_invalid_max_items_format) + ts.Run(IAMListRoles_empty_result) + ts.Run(IAMListRoles_success) + ts.Run(IAMListRoles_path_prefix) + ts.Run(IAMListRoles_pagination) + ts.Run(IAMListRoles_path_prefix_pagination) +} + +func TestIAMDeleteRole(ts *TestState) { + ts.Run(IAMDeleteRole_missing_role_name) + ts.Run(IAMDeleteRole_invalid_role_name) + ts.Run(IAMDeleteRole_long_role_name) + ts.Run(IAMDeleteRole_non_existing_role) + ts.Run(IAMDeleteRole_success) +} + +func TestIAMUpdateAssumeRolePolicy(ts *TestState) { + ts.Run(IAMUpdateAssumeRolePolicy_missing_role_name) + ts.Run(IAMUpdateAssumeRolePolicy_missing_policy_document) + ts.Run(IAMUpdateAssumeRolePolicy_invalid_role_name) + ts.Run(IAMUpdateAssumeRolePolicy_long_role_name) + ts.Run(IAMUpdateAssumeRolePolicy_non_existing_role) + ts.Run(IAMUpdateAssumeRolePolicy_non_ascii_policy_document) + ts.Run(IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded) + ts.Run(IAMUpdateAssumeRolePolicy_success) + ts.Run(IAMUpdateAssumeRolePolicy_trust_policy_document_grammar) +} + func TestIAM(ts *TestState) { TestIAMAuth(ts) TestIAMQueryAuth(ts) @@ -1297,6 +1360,11 @@ func TestIAM(ts *TestState) { TestIAMGetUserPolicy(ts) TestIAMDeleteUserPolicy(ts) TestIAMListUserPolicies(ts) + TestIAMCreateRole(ts) + TestIAMGetRole(ts) + TestIAMListRoles(ts) + TestIAMDeleteRole(ts) + TestIAMUpdateAssumeRolePolicy(ts) } func TestAccessControl(ts *TestState) { @@ -1652,6 +1720,7 @@ func GetIntTests() IntTests { "IAMQueryAuth_invalid_sha256_payload_hash_ignored": IAMQueryAuth_invalid_sha256_payload_hash_ignored, "IAMQueryAuth_with_expect_header": IAMQueryAuth_with_expect_header, "IAMCreateUser_user_already_exists": IAMCreateUser_user_already_exists, + "IAMCreateUser_already_exists_case_insensitive": IAMCreateUser_already_exists_case_insensitive, "IAMCreateUser_invalid_user_name": IAMCreateUser_invalid_user_name, "IAMCreateUser_long_user_name": IAMCreateUser_long_user_name, "IAMCreateUser_missing_user_name": IAMCreateUser_missing_user_name, @@ -1764,6 +1833,53 @@ func GetIntTests() IntTests { "IAMListUserPolicies_empty_result": IAMListUserPolicies_empty_result, "IAMListUserPolicies_success": IAMListUserPolicies_success, "IAMListUserPolicies_pagination": IAMListUserPolicies_pagination, + "IAMCreateRole_missing_role_name": IAMCreateRole_missing_role_name, + "IAMCreateRole_invalid_role_name": IAMCreateRole_invalid_role_name, + "IAMCreateRole_long_role_name": IAMCreateRole_long_role_name, + "IAMCreateRole_already_exists": IAMCreateRole_already_exists, + "IAMCreateRole_already_exists_case_insensitive": IAMCreateRole_already_exists_case_insensitive, + "IAMCreateRole_invalid_path": IAMCreateRole_invalid_path, + "IAMCreateRole_long_path": IAMCreateRole_long_path, + "IAMCreateRole_missing_assume_role_policy_document": IAMCreateRole_missing_assume_role_policy_document, + "IAMCreateRole_non_ascii_assume_role_policy_document": IAMCreateRole_non_ascii_assume_role_policy_document, + "IAMCreateRole_trust_policy_size_limit_exceeded": IAMCreateRole_trust_policy_size_limit_exceeded, + "IAMCreateRole_description_invalid_charset": IAMCreateRole_description_invalid_charset, + "IAMCreateRole_description_too_long": IAMCreateRole_description_too_long, + "IAMCreateRole_max_session_duration_invalid_format": IAMCreateRole_max_session_duration_invalid_format, + "IAMCreateRole_max_session_duration_too_low": IAMCreateRole_max_session_duration_too_low, + "IAMCreateRole_max_session_duration_too_high": IAMCreateRole_max_session_duration_too_high, + "IAMCreateRole_duplicate_tag_keys": IAMCreateRole_duplicate_tag_keys, + "IAMCreateRole_success": IAMCreateRole_success, + "IAMCreateRole_defaults": IAMCreateRole_defaults, + "IAMCreateRole_trust_policy_document_grammar": IAMCreateRole_trust_policy_document_grammar, + "IAMGetRole_missing_role_name": IAMGetRole_missing_role_name, + "IAMGetRole_invalid_role_name": IAMGetRole_invalid_role_name, + "IAMGetRole_long_role_name": IAMGetRole_long_role_name, + "IAMGetRole_non_existing_role": IAMGetRole_non_existing_role, + "IAMGetRole_success": IAMGetRole_success, + "IAMListRoles_invalid_path_prefix": IAMListRoles_invalid_path_prefix, + "IAMListRoles_long_path_prefix": IAMListRoles_long_path_prefix, + "IAMListRoles_invalid_max_items": IAMListRoles_invalid_max_items, + "IAMListRoles_invalid_max_items_format": IAMListRoles_invalid_max_items_format, + "IAMListRoles_empty_result": IAMListRoles_empty_result, + "IAMListRoles_success": IAMListRoles_success, + "IAMListRoles_path_prefix": IAMListRoles_path_prefix, + "IAMListRoles_pagination": IAMListRoles_pagination, + "IAMListRoles_path_prefix_pagination": IAMListRoles_path_prefix_pagination, + "IAMDeleteRole_missing_role_name": IAMDeleteRole_missing_role_name, + "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_success": IAMDeleteRole_success, + "IAMUpdateAssumeRolePolicy_missing_role_name": IAMUpdateAssumeRolePolicy_missing_role_name, + "IAMUpdateAssumeRolePolicy_missing_policy_document": IAMUpdateAssumeRolePolicy_missing_policy_document, + "IAMUpdateAssumeRolePolicy_invalid_role_name": IAMUpdateAssumeRolePolicy_invalid_role_name, + "IAMUpdateAssumeRolePolicy_long_role_name": IAMUpdateAssumeRolePolicy_long_role_name, + "IAMUpdateAssumeRolePolicy_non_existing_role": IAMUpdateAssumeRolePolicy_non_existing_role, + "IAMUpdateAssumeRolePolicy_non_ascii_policy_document": IAMUpdateAssumeRolePolicy_non_ascii_policy_document, + "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, "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_create_role.go b/tests/integration/iam_create_role.go new file mode 100644 index 00000000..6bddf576 --- /dev/null +++ b/tests/integration/iam_create_role.go @@ -0,0 +1,433 @@ +// 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" + "regexp" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/policy" +) + +// validTrustPolicyDocument is a minimal role trust policy accepted by +// ParseTrust: any principal may assume the role via sts:AssumeRole. +const validTrustPolicyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` + +var integrationIAMRoleIDPattern = regexp.MustCompile(`^AROA[A-Z2-7]{17}$`) + +func IAMCreateRole_missing_role_name(s *S3Conf) error { + testName := "IAMCreateRole_missing_role_name" + body := []byte(url.Values{ + "Action": {"CreateRole"}, + "Version": {"2010-05-08"}, + "AssumeRolePolicyDocument": {validTrustPolicyDocument}, + }.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 IAMCreateRole_invalid_role_name(s *S3Conf) error { + testName := "IAMCreateRole_invalid_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String("invalid/role"), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("roleName")) + }) +} + +func IAMCreateRole_long_role_name(s *S3Conf) error { + testName := "IAMCreateRole_long_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(strings.Repeat("a", 65)), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 64)) + }) +} + +func IAMCreateRole_already_exists(s *S3Conf) error { + testName := "IAMCreateRole_already_exists" + 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 + } + + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + checkErr := checkIAMApiErr(err, iamerr.EntityAlreadyExistsRole(roleName)) + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateRole_already_exists_case_insensitive(s *S3Conf) error { + testName := "IAMCreateRole_already_exists_case_insensitive" + 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 + } + + upperName := strings.ToUpper(roleName) + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &upperName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + checkErr := checkIAMApiErr(err, iamerr.EntityAlreadyExistsRole(upperName)) + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateRole_invalid_path(s *S3Conf) error { + testName := "IAMCreateRole_invalid_path" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Path: aws.String("invalid"), + }) + return checkIAMApiErr(err, iamerr.InvalidPath("path")) + }) +} + +func IAMCreateRole_long_path(s *S3Conf) error { + testName := "IAMCreateRole_long_path" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Path: aws.String("/" + strings.Repeat("a", 511) + "/"), + }) + return checkIAMApiErr(err, iamerr.PathTooLong("path", 512)) + }) +} + +func IAMCreateRole_missing_assume_role_policy_document(s *S3Conf) error { + testName := "IAMCreateRole_missing_assume_role_policy_document" + body := []byte(url.Values{ + "Action": {"CreateRole"}, + "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("assumeRolePolicyDocument")) + }) +} + +func IAMCreateRole_non_ascii_assume_role_policy_document(s *S3Conf) error { + testName := "IAMCreateRole_non_ascii_assume_role_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String("emoji\U0001F600test"), + }) + return checkIAMApiErr(err, iamerr.InvalidCharset("assumeRolePolicyDocument")) + }) +} + +func IAMCreateRole_trust_policy_size_limit_exceeded(s *S3Conf) error { + testName := "IAMCreateRole_trust_policy_size_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 2000) + `","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(oversized), + }) + return checkIAMApiErr(err, iamerr.TrustPolicySizeLimitExceeded(policy.MaxTrustPolicyBytes)) + }) +} + +func IAMCreateRole_description_invalid_charset(s *S3Conf) error { + testName := "IAMCreateRole_description_invalid_charset" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Description: aws.String("emoji\U0001F600test"), + }) + return checkIAMApiErr(err, iamerr.InvalidDescriptionCharset("description")) + }) +} + +func IAMCreateRole_description_too_long(s *S3Conf) error { + testName := "IAMCreateRole_description_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Description: aws.String(strings.Repeat("a", 1001)), + }) + return checkIAMApiErr(err, iamerr.ValueTooLong("description", 1000)) + }) +} + +func IAMCreateRole_max_session_duration_invalid_format(s *S3Conf) error { + testName := "IAMCreateRole_max_session_duration_invalid_format" + body := []byte(url.Values{ + "Action": {"CreateRole"}, + "Version": {"2010-05-08"}, + "RoleName": {newIAMRoleName()}, + "AssumeRolePolicyDocument": {validTrustPolicyDocument}, + "MaxSessionDuration": {"not-a-number"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MalformedInput()) + }) +} + +func IAMCreateRole_max_session_duration_too_low(s *S3Conf) error { + testName := "IAMCreateRole_max_session_duration_too_low" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + MaxSessionDuration: aws.Int32(3599), + }) + return checkIAMApiErr(err, iamerr.MaxSessionDurationTooLow()) + }) +} + +func IAMCreateRole_max_session_duration_too_high(s *S3Conf) error { + testName := "IAMCreateRole_max_session_duration_too_high" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + MaxSessionDuration: aws.Int32(43201), + }) + return checkIAMApiErr(err, iamerr.MaxSessionDurationTooHigh()) + }) +} + +func IAMCreateRole_duplicate_tag_keys(s *S3Conf) error { + testName := "IAMCreateRole_duplicate_tag_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: aws.String(newIAMRoleName()), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Tags: []iamtypes.Tag{ + {Key: aws.String("key"), Value: aws.String("one")}, + {Key: aws.String("KEY"), Value: aws.String("two")}, + }, + }) + return checkIAMApiErr(err, iamerr.InvalidInput("Duplicate tag keys found. Please note that Tag keys are case insensitive.")) + }) +} + +func IAMCreateRole_success(s *S3Conf) error { + testName := "IAMCreateRole_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + out, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + Path: aws.String("/engineering/"), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Description: aws.String("a test role"), + MaxSessionDuration: aws.Int32(7200), + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("test")}, + }, + }) + if err != nil { + return err + } + + checkErr := checkCreateRoleOutput(out, roleName, "/engineering/", "a test role", 7200, validTrustPolicyDocument, true) + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateRole_defaults(s *S3Conf) error { + testName := "IAMCreateRole_defaults" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + out, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + if err != nil { + return err + } + + checkErr := checkCreateRoleOutput(out, roleName, "/", "", 3600, validTrustPolicyDocument, false) + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMCreateRole_trust_policy_document_grammar(s *S3Conf) error { + testName := "IAMCreateRole_trust_policy_document_grammar" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, tt := range trustPolicyGrammarCases { + if err := checkCreateRoleTrustPolicyCase(client, tt.doc, tt.wantErr); err != nil { + return fmt.Errorf("%s: %w", tt.name, err) + } + } + return nil + }) +} + +// checkCreateRoleTrustPolicyCase verifies doc is accepted/rejected as +// expected when used as a fresh role's AssumeRolePolicyDocument. +func checkCreateRoleTrustPolicyCase(client *iam.Client, doc string, wantErr iamerr.APIError) error { + roleName := newIAMRoleName() + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(doc), + }) + if wantErr == nil { + if err != nil { + return fmt.Errorf("CreateRole: %w", err) + } + return deleteIAMRole(client, roleName) + } + return checkIAMApiErr(err, wantErr) +} + +func createIAMRole(client *iam.Client, input *iam.CreateRoleInput) (*iam.CreateRoleOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.CreateRole(ctx, input) +} + +func newIAMRoleName() string { + return "create-role-" + genRandString(16) +} + +// checkCreateRoleOutput verifies the fields of a CreateRoleOutput-shaped role. +func checkCreateRoleOutput(out *iam.CreateRoleOutput, roleName, path, description string, maxSessionDuration int32, wantDocument string, expectTags bool) error { + if out == nil { + return fmt.Errorf("expected CreateRole output role") + } + requestID, hasRequestID := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata) + return checkRoleFields("CreateRole", out.Role, roleName, path, description, maxSessionDuration, wantDocument, expectTags, requestID, hasRequestID) +} + +func checkRoleFields(operation string, role *iamtypes.Role, roleName, path, description string, maxSessionDuration int32, wantDocument string, expectTags bool, requestID string, hasRequestID bool) error { + if role == nil { + return fmt.Errorf("expected %s output role", operation) + } + if aws.ToString(role.Path) != path { + return fmt.Errorf("expected role path to be %q, instead got %q", path, aws.ToString(role.Path)) + } + if aws.ToString(role.RoleName) != roleName { + return fmt.Errorf("expected role name to be %q, instead got %q", roleName, aws.ToString(role.RoleName)) + } + expectedARN := "arn:aws:iam::000000000000:role" + path + roleName + if aws.ToString(role.Arn) != expectedARN { + return fmt.Errorf("expected role ARN to be %q, instead got %q", expectedARN, aws.ToString(role.Arn)) + } + if !integrationIAMRoleIDPattern.MatchString(aws.ToString(role.RoleId)) { + return fmt.Errorf("expected AWS IAM role id, instead got %q", aws.ToString(role.RoleId)) + } + if role.CreateDate == nil || role.CreateDate.IsZero() { + return fmt.Errorf("expected role create date") + } + if aws.ToString(role.Description) != description { + return fmt.Errorf("expected role description to be %q, instead got %q", description, aws.ToString(role.Description)) + } + if aws.ToInt32(role.MaxSessionDuration) != maxSessionDuration { + return fmt.Errorf("expected role max session duration to be %d, instead got %d", maxSessionDuration, aws.ToInt32(role.MaxSessionDuration)) + } + gotDocument, err := url.QueryUnescape(aws.ToString(role.AssumeRolePolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode assume role policy document %q: %w", aws.ToString(role.AssumeRolePolicyDocument), err) + } + if gotDocument != wantDocument { + return fmt.Errorf("expected assume role policy document %q, instead got %q", wantDocument, gotDocument) + } + if role.RoleLastUsed == nil { + return fmt.Errorf("expected role RoleLastUsed to be non-nil (empty element)") + } + if expectTags { + if len(role.Tags) != 1 || aws.ToString(role.Tags[0].Key) != "env" || aws.ToString(role.Tags[0].Value) != "test" { + return fmt.Errorf("expected role tag env=test, instead got %#v", role.Tags) + } + } else if len(role.Tags) != 0 { + return fmt.Errorf("expected no role tags, instead got %#v", role.Tags) + } + if !hasRequestID || requestID == "" { + return fmt.Errorf("expected %s response request id", operation) + } + + return nil +} diff --git a/tests/integration/iam_create_user.go b/tests/integration/iam_create_user.go index 9f64cae1..ca1f435e 100644 --- a/tests/integration/iam_create_user.go +++ b/tests/integration/iam_create_user.go @@ -47,6 +47,27 @@ func IAMCreateUser_user_already_exists(s *S3Conf) error { }) } +func IAMCreateUser_already_exists_case_insensitive(s *S3Conf) error { + testName := "IAMCreateUser_already_exists_case_insensitive" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{ + UserName: &userName, + }); err != nil { + return err + } + + upperName := strings.ToUpper(userName) + _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &upperName}) + checkErr := checkIAMApiErr(err, iamerr.EntityAlreadyExistsUser(upperName)) + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + func IAMCreateUser_invalid_user_name(s *S3Conf) error { testName := "IAMCreateUser_invalid_user_name" return iamActionHandler(s, testName, func(client *iam.Client) error { diff --git a/tests/integration/iam_delete_role.go b/tests/integration/iam_delete_role.go new file mode 100644 index 00000000..ecb30031 --- /dev/null +++ b/tests/integration/iam_delete_role.go @@ -0,0 +1,95 @@ +// 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" + "net/http" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMDeleteRole_missing_role_name(s *S3Conf) error { + testName := "IAMDeleteRole_missing_role_name" + body := []byte("Action=DeleteRole&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.MissingParameter("RoleName")) + }) +} + +func IAMDeleteRole_invalid_role_name(s *S3Conf) error { + testName := "IAMDeleteRole_invalid_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMRole(client, "invalid/role") + return checkIAMApiErr(err, iamerr.InvalidUserName("roleName")) + }) +} + +func IAMDeleteRole_long_role_name(s *S3Conf) error { + testName := "IAMDeleteRole_long_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + err := deleteIAMRole(client, strings.Repeat("a", 129)) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 128)) + }) +} + +func IAMDeleteRole_non_existing_role(s *S3Conf) error { + testName := "IAMDeleteRole_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + const roleName = "asdfadsf" + err := deleteIAMRole(client, roleName) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMDeleteRole_success(s *S3Conf) error { + testName := "IAMDeleteRole_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 + } + + if err := deleteIAMRole(client, roleName); err != nil { + return err + } + + _, err := getIAMRole(client, roleName) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func deleteIAMRole(client *iam.Client, roleName string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + _, err := client.DeleteRole(ctx, &iam.DeleteRoleInput{RoleName: &roleName}) + return err +} diff --git a/tests/integration/iam_get_role.go b/tests/integration/iam_get_role.go new file mode 100644 index 00000000..4e20525f --- /dev/null +++ b/tests/integration/iam_get_role.go @@ -0,0 +1,122 @@ +// 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" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMGetRole_missing_role_name(s *S3Conf) error { + testName := "IAMGetRole_missing_role_name" + body := []byte("Action=GetRole&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.MissingParameter("RoleName")) + }) +} + +func IAMGetRole_invalid_role_name(s *S3Conf) error { + testName := "IAMGetRole_invalid_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := getIAMRole(client, "invalid/role") + return checkIAMApiErr(err, iamerr.InvalidUserName("roleName")) + }) +} + +func IAMGetRole_long_role_name(s *S3Conf) error { + testName := "IAMGetRole_long_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := getIAMRole(client, strings.Repeat("a", 129)) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 128)) + }) +} + +func IAMGetRole_non_existing_role(s *S3Conf) error { + testName := "IAMGetRole_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + const roleName = "asdfadsf" + _, err := getIAMRole(client, roleName) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMGetRole_success(s *S3Conf) error { + testName := "IAMGetRole_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + Path: aws.String("/engineering/"), + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Description: aws.String("a test role"), + MaxSessionDuration: aws.Int32(7200), + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("test")}, + }, + }); err != nil { + return err + } + + out, err := getIAMRole(client, roleName) + if err != nil { + deleteErr := deleteIAMRole(client, roleName) + if deleteErr != nil { + return fmt.Errorf("get role: %v; delete role: %w", err, deleteErr) + } + return err + } + + checkErr := checkGetRoleOutput(out, roleName, "/engineering/", "a test role", 7200, validTrustPolicyDocument, true) + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func getIAMRole(client *iam.Client, roleName string) (*iam.GetRoleOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.GetRole(ctx, &iam.GetRoleInput{RoleName: &roleName}) +} + +// checkGetRoleOutput verifies the fields of a GetRoleOutput-shaped role. +func checkGetRoleOutput(out *iam.GetRoleOutput, roleName, path, description string, maxSessionDuration int32, wantDocument string, expectTags bool) error { + if out == nil { + return fmt.Errorf("expected GetRole output role") + } + requestID, hasRequestID := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata) + return checkRoleFields("GetRole", out.Role, roleName, path, description, maxSessionDuration, wantDocument, expectTags, requestID, hasRequestID) +} diff --git a/tests/integration/iam_list_roles.go b/tests/integration/iam_list_roles.go new file mode 100644 index 00000000..0849a288 --- /dev/null +++ b/tests/integration/iam_list_roles.go @@ -0,0 +1,375 @@ +// 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" + "errors" + "fmt" + "net/http" + "net/url" + "reflect" + "sort" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMListRoles_invalid_path_prefix(s *S3Conf) error { + testName := "IAMListRoles_invalid_path_prefix" + return iamActionHandler(s, testName, func(client *iam.Client) error { + expected := iamerr.ValidationError("The specified value for pathPrefix is invalid. It must begin with the / character and contain only alphanumeric characters and/or / characters.") + for _, pathPrefix := range []string{"invalid", "/invalid\n"} { + _, err := listIAMRoles(client, &iam.ListRolesInput{PathPrefix: aws.String(pathPrefix)}) + if checkErr := checkIAMApiErr(err, expected); checkErr != nil { + return fmt.Errorf("PathPrefix %q: %w", pathPrefix, checkErr) + } + } + return nil + }) +} + +func IAMListRoles_long_path_prefix(s *S3Conf) error { + testName := "IAMListRoles_long_path_prefix" + return iamActionHandler(s, testName, func(client *iam.Client) error { + pathPrefix := "/" + strings.Repeat("a", 512) + _, err := listIAMRoles(client, &iam.ListRolesInput{PathPrefix: &pathPrefix}) + return checkIAMApiErr(err, iamerr.ValidationError("The specified value for pathPrefix is invalid. It must begin with the / character and contain only alphanumeric characters and/or / characters.")) + }) +} + +func IAMListRoles_invalid_max_items(s *S3Conf) error { + testName := "IAMListRoles_invalid_max_items" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, maxItems := range []int32{-1, 0, 1001} { + _, err := listIAMRoles(client, &iam.ListRolesInput{MaxItems: aws.Int32(maxItems)}) + expected := iamerr.ValidationError(fmt.Sprintf("1 validation error detected: Value '%d' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", maxItems)) + if checkErr := checkIAMApiErr(err, expected); checkErr != nil { + return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr) + } + } + return nil + }) +} + +func IAMListRoles_invalid_max_items_format(s *S3Conf) error { + testName := "IAMListRoles_invalid_max_items_format" + body := []byte(url.Values{ + "Action": {"ListRoles"}, + "Version": {"2010-05-08"}, + "MaxItems": {"not-a-number"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + expected := iamerr.ValidationError("1 validation error detected: Value 'not-a-number' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000") + return checkIAMAuthRequest(s, req, expected) + }) +} + +func IAMListRoles_empty_result(s *S3Conf) error { + testName := "IAMListRoles_empty_result" + return iamActionHandler(s, testName, func(client *iam.Client) error { + pathPrefix := "/list-roles-" + genRandString(16) + "/" + input := &iam.ListRolesInput{PathPrefix: &pathPrefix} + first, err := listIAMRoles(client, input) + if err != nil { + return err + } + second, err := listIAMRoles(client, input) + if err != nil { + return err + } + if err := checkIAMListRolesOutput(first); err != nil { + return err + } + if err := checkIAMListRolesOutput(second); err != nil { + return err + } + if len(first.Roles) != 0 || len(second.Roles) != 0 { + return fmt.Errorf("expected consistent empty results, instead got %v and %v", iamListRoleNames(first.Roles), iamListRoleNames(second.Roles)) + } + return nil + }) +} + +func IAMListRoles_success(s *S3Conf) error { + testName := "IAMListRoles_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + path := "/list-roles-" + genRandString(16) + "/" + roles := map[string]string{"list-roles-" + genRandString(16): path} + return withIAMListRoles(client, roles, func() error { + out, err := listIAMRoles(client, &iam.ListRolesInput{PathPrefix: &path}) + if err != nil { + return err + } + if err := checkIAMListRolesOutput(out); err != nil { + return err + } + return checkIAMListRoles(out.Roles, roles) + }) + }) +} + +func IAMListRoles_path_prefix(s *S3Conf) error { + testName := "IAMListRoles_path_prefix" + return iamActionHandler(s, testName, func(client *iam.Client) error { + basePath := "/list-roles-" + genRandString(16) + "/" + engineeringPath := basePath + "engineering/" + namePrefix := "list-roles-" + genRandString(8) + roles := map[string]string{ + namePrefix + "-root": basePath, + namePrefix + "-z": engineeringPath, + namePrefix + "-a": engineeringPath + "platform/", + namePrefix + "-ops": basePath + "operations/", + } + expected := map[string]string{ + namePrefix + "-a": engineeringPath + "platform/", + namePrefix + "-z": engineeringPath, + } + return withIAMListRoles(client, roles, func() error { + input := &iam.ListRolesInput{PathPrefix: &engineeringPath} + first, err := listIAMRoles(client, input) + if err != nil { + return err + } + second, err := listIAMRoles(client, input) + if err != nil { + return err + } + if err := checkIAMListRolesOutput(first); err != nil { + return err + } + if err := checkIAMListRoles(first.Roles, expected); err != nil { + return err + } + if !reflect.DeepEqual(iamListRoleNames(first.Roles), iamListRoleNames(second.Roles)) { + return fmt.Errorf("expected consistent results, instead got %v and %v", iamListRoleNames(first.Roles), iamListRoleNames(second.Roles)) + } + return nil + }) + }) +} + +func IAMListRoles_pagination(s *S3Conf) error { + testName := "IAMListRoles_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + path := "/list-roles-" + genRandString(16) + "/" + roles := make(map[string]string, 5) + for range 5 { + roles["list-roles-"+genRandString(16)] = path + } + return withIAMListRoles(client, roles, func() error { + input := iam.ListRolesInput{PathPrefix: &path, MaxItems: aws.Int32(2)} + firstPages, err := collectIAMListRolePages(client, input) + if err != nil { + return err + } + secondPages, err := collectIAMListRolePages(client, input) + if err != nil { + return err + } + if err := checkIAMListRolePages(firstPages, []int{2, 2, 1}, roles); err != nil { + return err + } + if !reflect.DeepEqual(iamListRolePageValues(firstPages), iamListRolePageValues(secondPages)) { + return fmt.Errorf("expected consistent pagination results") + } + return nil + }) + }) +} + +func IAMListRoles_path_prefix_pagination(s *S3Conf) error { + testName := "IAMListRoles_path_prefix_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + basePath := "/list-roles-" + genRandString(16) + "/" + matchingPath := basePath + "engineering/" + namePrefix := "list-roles-" + genRandString(8) + roles := map[string]string{ + namePrefix + "-outside": basePath, + namePrefix + "-e": matchingPath, + namePrefix + "-d": matchingPath, + namePrefix + "-c": matchingPath + "platform/", + namePrefix + "-b": matchingPath + "storage/", + namePrefix + "-a": matchingPath + "storage/archive/", + namePrefix + "-ops": basePath + "operations/", + } + expected := map[string]string{ + namePrefix + "-a": matchingPath + "storage/archive/", + namePrefix + "-b": matchingPath + "storage/", + namePrefix + "-c": matchingPath + "platform/", + namePrefix + "-d": matchingPath, + namePrefix + "-e": matchingPath, + } + return withIAMListRoles(client, roles, func() error { + input := iam.ListRolesInput{PathPrefix: &matchingPath, MaxItems: aws.Int32(2)} + firstPages, err := collectIAMListRolePages(client, input) + if err != nil { + return err + } + secondPages, err := collectIAMListRolePages(client, input) + if err != nil { + return err + } + if err := checkIAMListRolePages(firstPages, []int{2, 2, 1}, expected); err != nil { + return err + } + if !reflect.DeepEqual(iamListRolePageValues(firstPages), iamListRolePageValues(secondPages)) { + return fmt.Errorf("expected consistent filtered pagination results") + } + return nil + }) + }) +} + +func listIAMRoles(client *iam.Client, input *iam.ListRolesInput) (*iam.ListRolesOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListRoles(ctx, input) +} + +func withIAMListRoles(client *iam.Client, roles map[string]string, test func() error) (err error) { + created := make([]string, 0, len(roles)) + defer func() { + for _, name := range created { + if deleteErr := deleteIAMRole(client, name); deleteErr != nil { + err = errors.Join(err, fmt.Errorf("delete IAM role %q: %w", name, deleteErr)) + } + } + }() + + for name, path := range roles { + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &name, + Path: &path, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + created = append(created, name) + } + return test() +} + +func collectIAMListRolePages(client *iam.Client, input iam.ListRolesInput) ([]*iam.ListRolesOutput, error) { + var pages []*iam.ListRolesOutput + for { + out, err := listIAMRoles(client, &input) + if err != nil { + return nil, err + } + if err := checkIAMListRolesOutput(out); err != nil { + return nil, err + } + pages = append(pages, out) + if !out.IsTruncated { + return pages, nil + } + input.Marker = out.Marker + } +} + +func checkIAMListRolesOutput(out *iam.ListRolesOutput) error { + if out == nil { + return fmt.Errorf("expected ListRoles output") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListRoles response request id") + } + if out.IsTruncated != (out.Marker != nil && aws.ToString(out.Marker) != "") { + return fmt.Errorf("expected marker only when ListRoles output is truncated") + } + for _, role := range out.Roles { + if aws.ToString(role.Path) == "" || aws.ToString(role.RoleName) == "" || aws.ToString(role.RoleId) == "" || aws.ToString(role.Arn) == "" || role.CreateDate == nil || role.CreateDate.IsZero() { + return fmt.Errorf("expected all required fields for listed role, instead got %#v", role) + } + if !integrationIAMRoleIDPattern.MatchString(aws.ToString(role.RoleId)) { + return fmt.Errorf("expected AWS IAM role id, instead got %q", aws.ToString(role.RoleId)) + } + if role.RoleLastUsed != nil { + return fmt.Errorf("expected ListRoles RoleLastUsed to be nil (list/get asymmetry), instead got %#v", role.RoleLastUsed) + } + } + return nil +} + +func checkIAMListRoles(roles []iamtypes.Role, expected map[string]string) error { + if len(roles) != len(expected) { + return fmt.Errorf("expected %d roles, instead got %d: %v", len(expected), len(roles), iamListRoleNames(roles)) + } + names := iamListRoleNames(roles) + if !sort.StringsAreSorted(names) { + return fmt.Errorf("expected roles sorted by role name, instead got %v", names) + } + for _, role := range roles { + name := aws.ToString(role.RoleName) + path, ok := expected[name] + if !ok { + return fmt.Errorf("unexpected listed role %q", name) + } + if aws.ToString(role.Path) != path { + return fmt.Errorf("expected role %q path %q, instead got %q", name, path, aws.ToString(role.Path)) + } + if want := "arn:aws:iam::000000000000:role" + path + name; aws.ToString(role.Arn) != want { + return fmt.Errorf("expected role %q ARN %q, instead got %q", name, want, aws.ToString(role.Arn)) + } + } + return nil +} + +func checkIAMListRolePages(pages []*iam.ListRolesOutput, sizes []int, expected map[string]string) error { + if len(pages) != len(sizes) { + return fmt.Errorf("expected %d pages, instead got %d", len(sizes), len(pages)) + } + var roles []iamtypes.Role + for i, page := range pages { + if len(page.Roles) != sizes[i] { + return fmt.Errorf("expected page %d to contain %d roles, instead got %d", i+1, sizes[i], len(page.Roles)) + } + if page.IsTruncated != (i < len(pages)-1) { + return fmt.Errorf("unexpected IsTruncated value on page %d", i+1) + } + roles = append(roles, page.Roles...) + } + return checkIAMListRoles(roles, expected) +} + +func iamListRolePageValues(pages []*iam.ListRolesOutput) [][]string { + values := make([][]string, len(pages)) + for i, page := range pages { + values[i] = append([]string{fmt.Sprint(page.IsTruncated), aws.ToString(page.Marker)}, iamListRoleNames(page.Roles)...) + } + return values +} + +func iamListRoleNames(roles []iamtypes.Role) []string { + names := make([]string, len(roles)) + for i, role := range roles { + names[i] = aws.ToString(role.RoleName) + } + return names +} diff --git a/tests/integration/iam_update_assume_role_policy.go b/tests/integration/iam_update_assume_role_policy.go new file mode 100644 index 00000000..2935e0f5 --- /dev/null +++ b/tests/integration/iam_update_assume_role_policy.go @@ -0,0 +1,253 @@ +// 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" + "errors" + "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/policy" +) + +func IAMUpdateAssumeRolePolicy_missing_role_name(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_missing_role_name" + body := []byte(url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "Version": {"2010-05-08"}, + "PolicyDocument": {validTrustPolicyDocument}, + }.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 IAMUpdateAssumeRolePolicy_missing_policy_document(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_missing_policy_document" + body := []byte(url.Values{ + "Action": {"UpdateAssumeRolePolicy"}, + "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("policyDocument")) + }) +} + +func IAMUpdateAssumeRolePolicy_invalid_role_name(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_invalid_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: aws.String("invalid/role"), + PolicyDocument: aws.String(validTrustPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("roleName")) + }) +} + +func IAMUpdateAssumeRolePolicy_long_role_name(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_long_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: aws.String(strings.Repeat("a", 129)), + PolicyDocument: aws.String(validTrustPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 128)) + }) +} + +func IAMUpdateAssumeRolePolicy_non_existing_role(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + const roleName = "asdfadsf" + _, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: aws.String(roleName), + PolicyDocument: aws.String(validTrustPolicyDocument), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMUpdateAssumeRolePolicy_non_ascii_policy_document(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_non_ascii_policy_document" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: aws.String("asdfadsf"), + PolicyDocument: aws.String("emoji\U0001F600test"), + }) + return checkIAMApiErr(err, iamerr.InvalidCharset("policyDocument")) + }) +} + +func IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_trust_policy_size_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", 2000) + `","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}` + _, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: &roleName, + PolicyDocument: aws.String(oversized), + }) + return checkIAMApiErr(err, iamerr.TrustPolicySizeLimitExceeded(policy.MaxTrustPolicyBytes)) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMUpdateAssumeRolePolicy_success(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := newIAMRoleName() + created, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }) + if err != nil { + return err + } + + checkErr := func() error { + const updatedDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}` + out, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: &roleName, + PolicyDocument: aws.String(updatedDocument), + }) + if err != nil { + return err + } + if out == nil { + return fmt.Errorf("expected UpdateAssumeRolePolicy output") + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected UpdateAssumeRolePolicy response request id") + } + + got, err := getIAMRole(client, roleName) + if err != nil { + return err + } + if got == nil || got.Role == nil || created == nil || created.Role == nil { + return fmt.Errorf("expected created and updated roles") + } + gotDocument, err := url.QueryUnescape(aws.ToString(got.Role.AssumeRolePolicyDocument)) + if err != nil { + return fmt.Errorf("failed to url-decode assume role policy document %q: %w", aws.ToString(got.Role.AssumeRolePolicyDocument), err) + } + if gotDocument != updatedDocument { + return fmt.Errorf("expected updated assume role policy document %q, instead got %q", updatedDocument, gotDocument) + } + if aws.ToString(got.Role.RoleId) != aws.ToString(created.Role.RoleId) { + return fmt.Errorf("expected UpdateAssumeRolePolicy to preserve role id, want %q, instead got %q", aws.ToString(created.Role.RoleId), aws.ToString(got.Role.RoleId)) + } + if got.Role.CreateDate == nil || created.Role.CreateDate == nil || !got.Role.CreateDate.Equal(*created.Role.CreateDate) { + return fmt.Errorf("expected UpdateAssumeRolePolicy to preserve role create date") + } + return nil + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func updateIAMAssumeRolePolicy(client *iam.Client, input *iam.UpdateAssumeRolePolicyInput) (*iam.UpdateAssumeRolePolicyOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.UpdateAssumeRolePolicy(ctx, input) +} + +func IAMUpdateAssumeRolePolicy_trust_policy_document_grammar(s *S3Conf) error { + testName := "IAMUpdateAssumeRolePolicy_trust_policy_document_grammar" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, tt := range trustPolicyGrammarCases { + if err := checkUpdateAssumeRolePolicyTrustPolicyCase(client, tt.doc, tt.wantErr); err != nil { + return fmt.Errorf("%s: %w", tt.name, err) + } + } + return nil + }) +} + +// checkUpdateAssumeRolePolicyTrustPolicyCase verifies doc is accepted/rejected +// as expected when used to update an existing role's trust policy. +func checkUpdateAssumeRolePolicyTrustPolicyCase(client *iam.Client, doc string, wantErr iamerr.APIError) (err error) { + roleName := newIAMRoleName() + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return fmt.Errorf("create base role: %w", err) + } + defer func() { + if deleteErr := deleteIAMRole(client, roleName); deleteErr != nil { + err = errors.Join(err, fmt.Errorf("cleanup: %w", deleteErr)) + } + }() + + _, updateErr := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ + RoleName: &roleName, + PolicyDocument: aws.String(doc), + }) + if wantErr == nil { + if updateErr != nil { + return fmt.Errorf("UpdateAssumeRolePolicy: %w", updateErr) + } + return nil + } + return checkIAMApiErr(updateErr, wantErr) +} diff --git a/tests/integration/utils.go b/tests/integration/utils.go index 2a2e4856..48e5f63d 100644 --- a/tests/integration/utils.go +++ b/tests/integration/utils.go @@ -934,6 +934,60 @@ func checkIAMApiErr(err error, expected iamerr.APIError) error { return nil } +type trustPolicyGrammarCase struct { + name string + doc string + wantErr iamerr.APIError // nil means the document must be accepted +} + +// trustPolicyGrammarCases covers the role trust-policy grammar +var trustPolicyGrammarCases = []trustPolicyGrammarCase{ + {"valid AWS principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid without version", `{"Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid Service principal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid multiple principal type keys together", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*","Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid Federated non-cognito provider (looks suspicious, is valid)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"bogus.example.com"},"Action":"sts:AssumeRole"}]}`, nil}, + {"valid non-AssumeRole sts action", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:TagSession"}]}`, nil}, + {"valid NotAction with sts prefix", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"NotAction":"sts:AssumeRole"}]}`, nil}, + {"valid action array all sts prefixed", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":["sts:AssumeRole","sts:TagSession"]}]}`, nil}, + {"valid multiple unique sids", `{"Version":"2012-10-17","Statement":[{"Sid":"A","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Sid":"B","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, nil}, + {"cognito federated with condition", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"cognito-identity.amazonaws.com:aud":"us-east-1:abc"}}}]}`, nil}, + {"unrelated condition block ignored (looks suspicious, is valid)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"aws:SourceAccount":"123456789012"}}}]}`, nil}, + + {"invalid json syntax", `{invalid json`, iamerr.MalformedPolicyDocument("This policy contains invalid Json")}, + {"invalid version", `{"Version":"2020-01-01","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("The policy must contain a valid version string")}, + {"empty statement array", `{"Version":"2012-10-17","Statement":[]}`, iamerr.MalformedPolicyDocument("Could not parse the policy: Statement is empty!")}, + {"missing statement", `{"Version":"2012-10-17"}`, iamerr.MalformedPolicyDocument("Could not parse the policy: Statement is empty!")}, + + {"invalid effect value", `{"Version":"2012-10-17","Statement":[{"Effect":"Maybe","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Invalid effect: Maybe")}, + {"missing effect field", `{"Version":"2012-10-17","Statement":[{"Principal":{"Service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Missing required field Effect")}, + + {"missing principal (opposite of an identity policy, which forbids it)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Missing required field Principal")}, + {"empty principal object", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Missing required field Principal cannot be empty!")}, + {"principal as bare string", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Principal must be a JSON object.")}, + {"principal as array", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":["a"],"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Syntax error in policy.")}, + {"principal has invalid key", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"CanonicalUser":"abc"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "CanonicalUser"`)}, + {"principal key wrong case (looks like it should work, key match is case-sensitive)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"service":"s3.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "service"`)}, + {"principal has unrecognized service", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"invalid.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "SERVICE":"invalid.amazonaws.com"`)}, + {"principal has ec2 service (valid on real AWS, unsupported by this gateway)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument(`Invalid principal in policy: "SERVICE":"ec2.amazonaws.com"`)}, + + {"allow with notprincipal", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("Allow with NotPrincipal is not allowed.")}, + {"deny with notprincipal", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","NotPrincipal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy must not contain NotPrincipal field.")}, + + {"missing action and notaction", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"}}]}`, iamerr.MalformedPolicyDocument("Missing required field Action")}, + {"bare wildcard action rejected (legal in an identity policy, not here)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"*"}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.")}, + {"non-sts vendor action rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"s3:GetObject"}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.")}, + {"non-sts notaction rejected even on deny", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"AWS":"*"},"NotAction":"s3:GetObject"}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.")}, + {"one non-sts action in an otherwise-valid array rejected", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":["sts:AssumeRole","s3:GetObject"]}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.")}, + + {"resource forbidden", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Resource":"*"}]}`, iamerr.MalformedPolicyDocument("Has prohibited field Resource")}, + {"notresource forbidden", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","NotResource":"*"}]}`, iamerr.MalformedPolicyDocument("AssumeRole policy must not contain resources.")}, + + {"duplicate sid across statements", `{"Version":"2012-10-17","Statement":[{"Sid":"Dup","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Sid":"Dup","Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("The Statement Ids in the policy are not unique")}, + + {"cognito federated without condition (looks valid, Cognito needs a Condition)", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"cognito-identity.amazonaws.com"},"Action":"sts:AssumeRole"}]}`, iamerr.MalformedPolicyDocument("A condition block must be present for the Cognito provider")}, +} + func putObjects(client *s3.Client, objs []string, bucket string) ([]types.Object, error) { var contents []types.Object var size int64