mirror of
https://github.com/versity/versitygw.git
synced 2026-08-17 12:46:23 +00:00
feat: add IAM Role CRUD
Adds `CreateRole`, `GetRole`, `ListRoles`, `DeleteRole`, and `UpdateAssumeRolePolicy` to the standalone IAM service, following the same controller/storage patterns established for users. Both the internal filesystem/S3-backed store and the Vault-backed store implement the new `Storer` methods, with role-specific indexing and lookup helpers mirroring the existing user ones. Role creation requires a trust policy, passed as `AssumeRolePolicyDocument`. A trust policy is a distinct kind of IAM policy document that governs who (or what) is allowed to assume a role, rather than what actions the role itself is permitted to perform. Its grammar is effectively the inverse of an identity policy: `Principal` is required, `Action`/`NotAction` values must carry the `sts:` prefix, and `Resource`/`NotResource` are forbidden. This is implemented in `iamapi/policy/trust.go` as a new validation path alongside the existing identity-policy validation, and is reused by `UpdateAssumeRolePolicy` when replacing a role's trust policy. Also fixes user name uniqueness enforcement to be case-insensitive, matching AWS IAM behavior, and applies the same case-insensitive handling to role names. The internal store now maintains lowercase name indexes for both users and roles, and the Vault store resolves the canonical stored key via a case-insensitive list-and-compare fallback since Vault's KV paths are case-sensitive.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+29
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
+246
-25
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+320
-6
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user