mirror of
https://github.com/versity/versitygw.git
synced 2026-09-23 00:14:15 +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
|
||||
}
|
||||
Reference in New Issue
Block a user