feat: add IAM role inline policy CRUD

Add support for the `PutRolePolicy`, `GetRolePolicy`, `DeleteRolePolicy`, and `ListRolePolicies` actions in the IAM-compatible gateway service, extending role management with the same inline-policy lifecycle already available for IAM users. `PutRolePolicy` validates the policy name and document, parses the document for AWS-compatible syntax and semantic errors (missing actions/resources, malformed ARNs, disallowed principals, duplicate statement IDs, and so on), and rejects documents once the role's aggregate inline-policy size would exceed `MaxInlinePolicyBytesPerRole` (10240 bytes, distinct from the 2048-byte quota enforced for users). Putting a policy under an existing name overwrites its document in place. `GetRolePolicy` and `DeleteRolePolicy` look up or remove a named inline policy from a role, returning a `NoSuchEntity` error when the role or the policy is not found. `ListRolePolicies` returns a role's inline policy names in sorted order with marker-based pagination.

These actions are implemented for both the internal file-backed store and the Vault-backed store, wired into the IAM API router, and given their own XML response types under `iamapi/types`. A new `NoSuchEntityRolePolicy` error was added to `iamapi/iamerr` to mirror the existing user-policy error.
This commit is contained in:
niksis02
2026-08-25 01:03:22 +04:00
parent cbcc656f53
commit c9ce6ab37c
15 changed files with 2098 additions and 0 deletions
+130
View File
@@ -718,3 +718,133 @@ func (c IAMApiController) UpdateAssumeRolePolicy(ctx fiber.Ctx) (*Response, erro
return &Response{Data: &types.UpdateAssumeRolePolicyResponse{}}, nil
}
func (c IAMApiController) PutRolePolicy(ctx fiber.Ctx) (*Response, error) {
policyDocument, ok := iamutil.RequestParam(ctx, "PolicyDocument")
if !ok {
debuglogger.Logf("missing required PutRolePolicy parameter: PolicyDocument")
return nil, iamerr.MissingValue("policyDocument")
}
if err := policy.Validate("policyDocument", policyDocument); err != nil {
return nil, err
}
policyName, ok := iamutil.RequestParam(ctx, "PolicyName")
if !ok {
debuglogger.Logf("missing required PutRolePolicy parameter: PolicyName")
return nil, iamerr.MissingValue("policyName")
}
if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil {
return nil, err
}
roleName, err := iamutil.GetRoleName(ctx, "PutRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName"))
if err != nil {
return nil, err
}
// Confirm the role exists before inspecting policy document content
if _, err := c.store.GetRole(ctx.Context(), roleName); err != nil {
debuglogger.Logf("failed to get IAM role %q for PutRolePolicy: %v", roleName, err)
return nil, err
}
if err := policy.Parse(policyDocument); err != nil {
return nil, err
}
if err := c.store.PutRolePolicy(ctx.Context(), storage.PutRolePolicyInput{
RoleName: roleName,
PolicyName: policyName,
PolicyDocument: policyDocument,
}); err != nil {
debuglogger.Logf("failed to put IAM role policy %q for role %q: %v", policyName, roleName, err)
return nil, err
}
return &Response{Data: &types.PutRolePolicyResponse{}}, nil
}
func (c IAMApiController) GetRolePolicy(ctx fiber.Ctx) (*Response, error) {
policyName, ok := iamutil.RequestParam(ctx, "PolicyName")
if !ok {
debuglogger.Logf("missing required GetRolePolicy parameter: PolicyName")
return nil, iamerr.MissingValue("policyName")
}
if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil {
return nil, err
}
roleName, err := iamutil.GetRoleName(ctx, "GetRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName"))
if err != nil {
return nil, err
}
entry, err := c.store.GetRolePolicy(ctx.Context(), roleName, policyName)
if err != nil {
debuglogger.Logf("failed to get IAM role policy %q for role %q: %v", policyName, roleName, err)
return nil, err
}
return &Response{Data: &types.GetRolePolicyResponse{
Result: types.GetRolePolicyResult{
RoleName: roleName,
PolicyName: entry.PolicyName,
PolicyDocument: iamutil.EncodePolicyDocument(entry.PolicyDocument),
},
}}, nil
}
func (c IAMApiController) DeleteRolePolicy(ctx fiber.Ctx) (*Response, error) {
policyName, ok := iamutil.RequestParam(ctx, "PolicyName")
if !ok {
debuglogger.Logf("missing required DeleteRolePolicy parameter: PolicyName")
return nil, iamerr.MissingValue("policyName")
}
if err := iamutil.ValidateName("policyName", policyName, iamutil.MaxUserLookupLen); err != nil {
return nil, err
}
roleName, err := iamutil.GetRoleName(ctx, "DeleteRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName"))
if err != nil {
return nil, err
}
if err := c.store.DeleteRolePolicy(ctx.Context(), roleName, policyName); err != nil {
debuglogger.Logf("failed to delete IAM role policy %q for role %q: %v", policyName, roleName, err)
return nil, err
}
return &Response{Data: &types.DeleteRolePolicyResponse{}}, nil
}
func (c IAMApiController) ListRolePolicies(ctx fiber.Ctx) (*Response, error) {
roleName, err := iamutil.GetRoleName(ctx, "ListRolePolicies", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName"))
if err != nil {
return nil, err
}
maxItems, err := iamutil.ParseMaxItems(ctx, "ListRolePolicies")
if err != nil {
return nil, err
}
marker, _ := iamutil.RequestParam(ctx, "Marker")
out, err := c.store.ListRolePolicies(ctx.Context(), storage.ListRolePoliciesInput{
RoleName: roleName,
Marker: marker,
MaxItems: maxItems,
})
if err != nil {
debuglogger.Logf("failed to list IAM role policies for role %q: %v", roleName, err)
return nil, err
}
return &Response{Data: &types.ListRolePoliciesResponse{
Result: types.ListRolePoliciesResult{
PolicyNames: types.PolicyNameList{Members: out.PolicyNames},
IsTruncated: out.IsTruncated,
Marker: out.Marker,
},
}}, nil
}
+370
View File
@@ -1301,6 +1301,376 @@ func TestIAMApiControllerDeleteAndUpdateAssumeRolePolicyErrors(t *testing.T) {
}
}
func TestIAMApiControllerRolePolicyLifecycle(t *testing.T) {
server := newIAMControllerTestServer(t)
createRole := doIAMAction(t, server, url.Values{
"Action": {"CreateRole"},
"RoleName": {"my-role"},
"AssumeRolePolicyDocument": {validTrustPolicy},
})
if createRole.StatusCode != http.StatusOK {
t.Fatalf("CreateRole status = %d, body=%s", createRole.StatusCode, readBody(t, createRole))
}
policyDoc := `{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}]}`
put := doIAMAction(t, server, url.Values{
"Action": {"PutRolePolicy"},
"RoleName": {"my-role"},
"PolicyName": {"ReadOnly"},
"PolicyDocument": {policyDoc},
})
if put.StatusCode != http.StatusOK {
t.Fatalf("PutRolePolicy status = %d, body=%s", put.StatusCode, readBody(t, put))
}
var putOut iamtypes.PutRolePolicyResponse
unmarshalXML(t, readBody(t, put), &putOut)
if putOut.XMLName.Space != "https://iam.amazonaws.com/doc/2010-05-08/" || putOut.XMLName.Local != "PutRolePolicyResponse" {
t.Fatalf("PutRolePolicy XMLName = %#v", putOut.XMLName)
}
if putOut.ResponseMetadata.RequestID == "" {
t.Fatal("PutRolePolicy missing RequestId")
}
get := doIAMAction(t, server, url.Values{
"Action": {"GetRolePolicy"},
"RoleName": {"my-role"},
"PolicyName": {"ReadOnly"},
})
if get.StatusCode != http.StatusOK {
t.Fatalf("GetRolePolicy status = %d, body=%s", get.StatusCode, readBody(t, get))
}
var getOut iamtypes.GetRolePolicyResponse
unmarshalXML(t, readBody(t, get), &getOut)
if getOut.Result.RoleName != "my-role" || getOut.Result.PolicyName != "ReadOnly" {
t.Fatalf("GetRolePolicy result = %#v", getOut.Result)
}
if !strings.Contains(getOut.Result.PolicyDocument, "%20") {
t.Fatalf("GetRolePolicy PolicyDocument = %q, want RFC 3986 percent-encoding (%%20 for space)", getOut.Result.PolicyDocument)
}
decoded, err := url.QueryUnescape(getOut.Result.PolicyDocument)
if err != nil {
t.Fatalf("QueryUnescape: %v", err)
}
if decoded != policyDoc {
t.Fatalf("GetRolePolicy PolicyDocument = %q, want verbatim %q", decoded, policyDoc)
}
list := doIAMAction(t, server, url.Values{
"Action": {"ListRolePolicies"},
"RoleName": {"my-role"},
})
if list.StatusCode != http.StatusOK {
t.Fatalf("ListRolePolicies status = %d, body=%s", list.StatusCode, readBody(t, list))
}
var listOut iamtypes.ListRolePoliciesResponse
unmarshalXML(t, readBody(t, list), &listOut)
if len(listOut.Result.PolicyNames.Members) != 1 || listOut.Result.PolicyNames.Members[0] != "ReadOnly" {
t.Fatalf("ListRolePolicies = %#v, want [ReadOnly]", listOut.Result.PolicyNames.Members)
}
if listOut.Result.IsTruncated {
t.Fatal("ListRolePolicies IsTruncated = true, want false")
}
// Re-Put-ing the same PolicyName replaces it rather than erroring or
// stacking toward the aggregate size quota.
overwritePut := doIAMAction(t, server, url.Values{
"Action": {"PutRolePolicy"},
"RoleName": {"my-role"},
"PolicyName": {"ReadOnly"},
"PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`},
})
if overwritePut.StatusCode != http.StatusOK {
t.Fatalf("overwrite PutRolePolicy status = %d, body=%s", overwritePut.StatusCode, readBody(t, overwritePut))
}
overwriteGet := doIAMAction(t, server, url.Values{
"Action": {"GetRolePolicy"},
"RoleName": {"my-role"},
"PolicyName": {"ReadOnly"},
})
var overwriteOut iamtypes.GetRolePolicyResponse
unmarshalXML(t, readBody(t, overwriteGet), &overwriteOut)
overwriteDecoded, err := url.QueryUnescape(overwriteOut.Result.PolicyDocument)
if err != nil {
t.Fatalf("QueryUnescape: %v", err)
}
if !strings.Contains(overwriteDecoded, "Deny") {
t.Fatalf("GetRolePolicy after overwrite = %q, want the Deny statement", overwriteDecoded)
}
del := doIAMAction(t, server, url.Values{
"Action": {"DeleteRolePolicy"},
"RoleName": {"my-role"},
"PolicyName": {"ReadOnly"},
})
if del.StatusCode != http.StatusOK {
t.Fatalf("DeleteRolePolicy status = %d, body=%s", del.StatusCode, readBody(t, del))
}
var delOut iamtypes.DeleteRolePolicyResponse
unmarshalXML(t, readBody(t, del), &delOut)
if delOut.XMLName.Local != "DeleteRolePolicyResponse" || delOut.ResponseMetadata.RequestID == "" {
t.Fatalf("DeleteRolePolicy output = %#v", delOut)
}
missing := doIAMAction(t, server, url.Values{
"Action": {"GetRolePolicy"},
"RoleName": {"my-role"},
"PolicyName": {"ReadOnly"},
})
requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", "The role policy with name ReadOnly cannot be found.")
// A second delete of the same (now-gone) policy is a hard error, not an
// idempotent success.
doubleDelete := doIAMAction(t, server, url.Values{
"Action": {"DeleteRolePolicy"},
"RoleName": {"my-role"},
"PolicyName": {"ReadOnly"},
})
requireIAMError(t, doubleDelete, http.StatusNotFound, "Sender", "NoSuchEntity", "The role policy with name ReadOnly cannot be found.")
}
func TestIAMApiControllerRolePolicyValidationErrors(t *testing.T) {
validDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`
tests := []struct {
name string
setupRole bool
params url.Values
status int
code string
message string
}{
{
name: "put missing policy document",
setupRole: true,
params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must not be null",
},
{
name: "put missing policy name",
setupRole: true,
params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyDocument": {validDoc}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'policyName' failed to satisfy constraint: Member must not be null",
},
{
name: "put missing role name",
params: url.Values{"Action": {"PutRolePolicy"}, "PolicyName": {"P"}, "PolicyDocument": {validDoc}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'roleName' failed to satisfy constraint: Member must not be null",
},
{
name: "put invalid policy name characters",
setupRole: true,
params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"bad/name"}, "PolicyDocument": {validDoc}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "The specified value for policyName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-",
},
{
name: "put long policy name",
setupRole: true,
params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {strings.Repeat("p", 129)}, "PolicyDocument": {validDoc}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'policyName' failed to satisfy constraint: Member must have length less than or equal to 128",
},
{
name: "put non-ascii policy document",
setupRole: true,
params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}, "PolicyDocument": {"emoji\U0001F600test"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "The specified value for policyDocument is invalid. It must contain only printable ASCII characters.",
},
{
name: "put role does not exist",
params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"nonexistent"}, "PolicyName": {"P"}, "PolicyDocument": {validDoc}},
status: http.StatusNotFound,
code: "NoSuchEntity",
message: "The role with name nonexistent cannot be found.",
},
{
name: "put nonexistent role wins over malformed document",
params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"nonexistent"}, "PolicyName": {"P"}, "PolicyDocument": {"{not valid json"}},
status: http.StatusNotFound,
code: "NoSuchEntity",
message: "The role with name nonexistent cannot be found.",
},
{
name: "put malformed policy document",
setupRole: true,
params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}, "PolicyDocument": {"{not valid json"}},
status: http.StatusBadRequest,
code: "MalformedPolicyDocument",
message: "Syntax errors in policy.",
},
{
name: "put policy document with principal",
setupRole: true,
params: url.Values{"Action": {"PutRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}, "PolicyDocument": {
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"*"}]}`,
}},
status: http.StatusBadRequest,
code: "MalformedPolicyDocument",
message: "Policy document should not specify a principal.",
},
{
name: "get role does not exist",
params: url.Values{"Action": {"GetRolePolicy"}, "RoleName": {"nonexistent"}, "PolicyName": {"P"}},
status: http.StatusNotFound,
code: "NoSuchEntity",
message: "The role with name nonexistent cannot be found.",
},
{
name: "get policy does not exist",
setupRole: true,
params: url.Values{"Action": {"GetRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"NoSuchPolicy"}},
status: http.StatusNotFound,
code: "NoSuchEntity",
message: "The role policy with name NoSuchPolicy cannot be found.",
},
{
name: "delete role does not exist",
params: url.Values{"Action": {"DeleteRolePolicy"}, "RoleName": {"nonexistent"}, "PolicyName": {"P"}},
status: http.StatusNotFound,
code: "NoSuchEntity",
message: "The role with name nonexistent cannot be found.",
},
{
name: "delete policy does not exist",
setupRole: true,
params: url.Values{"Action": {"DeleteRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"NoSuchPolicy"}},
status: http.StatusNotFound,
code: "NoSuchEntity",
message: "The role policy with name NoSuchPolicy cannot be found.",
},
{
name: "list role does not exist",
params: url.Values{"Action": {"ListRolePolicies"}, "RoleName": {"nonexistent"}},
status: http.StatusNotFound,
code: "NoSuchEntity",
message: "The role with name nonexistent cannot be found.",
},
{
name: "list max items too large",
setupRole: true,
params: url.Values{"Action": {"ListRolePolicies"}, "RoleName": {"my-role"}, "MaxItems": {"1001"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value '1001' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := newIAMControllerTestServer(t)
if tt.setupRole {
resp := doIAMAction(t, server, url.Values{
"Action": {"CreateRole"},
"RoleName": {"my-role"},
"AssumeRolePolicyDocument": {validTrustPolicy},
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("CreateRole status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
}
resp := doIAMAction(t, server, tt.params)
requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message)
})
}
}
func TestIAMApiControllerDeleteRolePolicyConflict(t *testing.T) {
server := newIAMControllerTestServer(t)
create := doIAMAction(t, server, url.Values{
"Action": {"CreateRole"},
"RoleName": {"my-role"},
"AssumeRolePolicyDocument": {validTrustPolicy},
})
if create.StatusCode != http.StatusOK {
t.Fatalf("CreateRole status = %d, body=%s", create.StatusCode, readBody(t, create))
}
put := doIAMAction(t, server, url.Values{
"Action": {"PutRolePolicy"},
"RoleName": {"my-role"},
"PolicyName": {"P"},
"PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`},
})
if put.StatusCode != http.StatusOK {
t.Fatalf("PutRolePolicy status = %d, body=%s", put.StatusCode, readBody(t, put))
}
deleteRole := doIAMAction(t, server, url.Values{"Action": {"DeleteRole"}, "RoleName": {"my-role"}})
requireIAMError(t, deleteRole, http.StatusConflict, "Sender", "DeleteConflict", "Cannot delete entity, must delete policies first.")
delPolicy := doIAMAction(t, server, url.Values{"Action": {"DeleteRolePolicy"}, "RoleName": {"my-role"}, "PolicyName": {"P"}})
if delPolicy.StatusCode != http.StatusOK {
t.Fatalf("DeleteRolePolicy status = %d, body=%s", delPolicy.StatusCode, readBody(t, delPolicy))
}
deleteRoleAfter := doIAMAction(t, server, url.Values{"Action": {"DeleteRole"}, "RoleName": {"my-role"}})
if deleteRoleAfter.StatusCode != http.StatusOK {
t.Fatalf("DeleteRole status = %d, body=%s", deleteRoleAfter.StatusCode, readBody(t, deleteRoleAfter))
}
}
func TestIAMApiControllerPutRolePolicyOversizedDocument(t *testing.T) {
// A >131072 byte PolicyDocument does not fit in a GET query string
// against this test server's header/URL read-buffer limit, matching
// real IAM's own guidance to use POST rather than GET for large
// policy documents - so this one case is exercised over POST directly
// rather than through the doIAMAction GET helper used elsewhere.
server := newIAMControllerTestServer(t)
create := doIAMAction(t, server, url.Values{
"Action": {"CreateRole"},
"RoleName": {"my-role"},
"AssumeRolePolicyDocument": {validTrustPolicy},
})
if create.StatusCode != http.StatusOK {
t.Fatalf("CreateRole status = %d, body=%s", create.StatusCode, readBody(t, create))
}
resp := doIAMActionPost(t, server, url.Values{
"Action": {"PutRolePolicy"},
"RoleName": {"my-role"},
"PolicyName": {"P"},
"PolicyDocument": {strings.Repeat("x", 131073)},
})
requireIAMError(t, resp, http.StatusBadRequest, "Sender", "ValidationError",
"1 validation error detected: Value at 'policyDocument' failed to satisfy constraint: Member must have length less than or equal to 131072")
}
func TestIAMApiControllerPutRolePolicyExceedsQuota(t *testing.T) {
// The role's aggregate inline-policy quota (10240 bytes) is well over
// this test server's GET header/URL read-buffer limit, so this case
// is exercised over POST, same as TestIAMApiControllerPutRolePolicyOversizedDocument.
server := newIAMControllerTestServer(t)
create := doIAMAction(t, server, url.Values{
"Action": {"CreateRole"},
"RoleName": {"my-role"},
"AssumeRolePolicyDocument": {validTrustPolicy},
})
if create.StatusCode != http.StatusOK {
t.Fatalf("CreateRole status = %d, body=%s", create.StatusCode, readBody(t, create))
}
oversizedDoc := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 10300) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`
resp := doIAMActionPost(t, server, url.Values{
"Action": {"PutRolePolicy"},
"RoleName": {"my-role"},
"PolicyName": {"P"},
"PolicyDocument": {oversizedDoc},
})
requireIAMError(t, resp, http.StatusConflict, "Sender", "LimitExceeded", "Maximum policy size of 10240 bytes exceeded for role my-role")
}
func newIAMControllerTestServer(t *testing.T) *IAMApiServer {
t.Helper()
+4
View File
@@ -453,6 +453,10 @@ func NoSuchEntityUserPolicy(userName, policyName string) Error {
return newSenderError("NoSuchEntity", fmt.Sprintf("The user policy with name %s cannot be found.", policyName), http.StatusNotFound)
}
func NoSuchEntityRolePolicy(roleName, policyName string) Error {
return newSenderError("NoSuchEntity", fmt.Sprintf("The role policy with name %s cannot be found.", policyName), http.StatusNotFound)
}
func InlinePolicyQuotaExceeded(entityKind, entityName string, maxBytes int) Error {
return newSenderError("LimitExceeded", fmt.Sprintf("Maximum policy size of %d bytes exceeded for %s %s", maxBytes, entityKind, entityName), http.StatusConflict)
}
+5
View File
@@ -68,6 +68,11 @@ func (r *IAMApiRouter) Init() {
"ListRoles": ctrl.ListRoles,
"DeleteRole": ctrl.DeleteRole,
"UpdateAssumeRolePolicy": ctrl.UpdateAssumeRolePolicy,
// Role Inline Policy CRUD
"PutRolePolicy": ctrl.PutRolePolicy,
"GetRolePolicy": ctrl.GetRolePolicy,
"DeleteRolePolicy": ctrl.DeleteRolePolicy,
"ListRolePolicies": ctrl.ListRolePolicies,
}
actionRoute := ProcessHandlers(r.routeAction, iammiddleware.VerifyIAMAuth(r.rootCreds))
+153
View File
@@ -816,6 +816,159 @@ func (s *InternalStore) UpdateAssumeRolePolicy(_ context.Context, input UpdateAs
return cloneRole(updated), nil
}
func (s *InternalStore) PutRolePolicy(_ context.Context, input PutRolePolicyInput) error {
s.Lock()
defer s.Unlock()
err := s.engine.StoreIAM(func(data []byte) ([]byte, error) {
conf, err := s.engine.ParseIAM(data)
if err != nil {
return nil, err
}
canonical, role, ok := lookupRole(conf, input.RoleName)
if !ok {
return nil, iamerr.NoSuchEntityRole(input.RoleName)
}
now := time.Now().UTC().Truncate(time.Second)
newTotal := len(input.PolicyDocument)
replaceAt := -1
for i, p := range role.Policies.Inline {
if p.PolicyName == input.PolicyName {
replaceAt = i
continue
}
newTotal += len(p.PolicyDocument)
}
if newTotal > MaxInlinePolicyBytesPerRole {
return nil, iamerr.InlinePolicyQuotaExceeded("role", input.RoleName, MaxInlinePolicyBytesPerRole)
}
if replaceAt >= 0 {
role.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument
role.Policies.Inline[replaceAt].UpdateDate = now
} else {
role.Policies.Inline = append(role.Policies.Inline, types.PolicyEntry{
PolicyName: input.PolicyName,
PolicyDocument: input.PolicyDocument,
CreateDate: now,
UpdateDate: now,
})
}
conf.Roles[canonical] = role
return json.Marshal(conf)
})
return unwrapAPIError(err)
}
func (s *InternalStore) GetRolePolicy(_ context.Context, roleName, policyName string) (*types.PolicyEntry, error) {
s.RLock()
defer s.RUnlock()
conf, err := s.engine.GetIAM()
if err != nil {
return nil, err
}
_, role, ok := lookupRole(conf, roleName)
if !ok {
return nil, iamerr.NoSuchEntityRole(roleName)
}
for _, p := range role.Policies.Inline {
if p.PolicyName == policyName {
cloned := p
return &cloned, nil
}
}
return nil, iamerr.NoSuchEntityRolePolicy(roleName, policyName)
}
func (s *InternalStore) DeleteRolePolicy(_ context.Context, roleName, policyName string) error {
s.Lock()
defer s.Unlock()
err := s.engine.StoreIAM(func(data []byte) ([]byte, error) {
conf, err := s.engine.ParseIAM(data)
if err != nil {
return nil, err
}
canonical, role, ok := lookupRole(conf, roleName)
if !ok {
return nil, iamerr.NoSuchEntityRole(roleName)
}
idx := -1
for i, p := range role.Policies.Inline {
if p.PolicyName == policyName {
idx = i
break
}
}
if idx == -1 {
return nil, iamerr.NoSuchEntityRolePolicy(roleName, policyName)
}
role.Policies.Inline = slices.Delete(role.Policies.Inline, idx, idx+1)
conf.Roles[canonical] = role
return json.Marshal(conf)
})
return unwrapAPIError(err)
}
func (s *InternalStore) ListRolePolicies(_ context.Context, input ListRolePoliciesInput) (*ListRolePoliciesOutput, error) {
s.RLock()
defer s.RUnlock()
conf, err := s.engine.GetIAM()
if err != nil {
return nil, err
}
_, role, ok := lookupRole(conf, input.RoleName)
if !ok {
return nil, iamerr.NoSuchEntityRole(input.RoleName)
}
names := make([]string, 0, len(role.Policies.Inline))
for _, p := range role.Policies.Inline {
names = append(names, p.PolicyName)
}
sort.Strings(names)
start := 0
if input.Marker != "" {
start = len(names)
for i, name := range names {
if name == input.Marker {
start = i + 1
break
}
}
}
names = names[start:]
limit := len(names)
if input.MaxItems > 0 && int(input.MaxItems) < limit {
limit = int(input.MaxItems)
}
out := &ListRolePoliciesOutput{
PolicyNames: make([]string, limit),
}
copy(out.PolicyNames, names[:limit])
if limit < len(names) {
out.IsTruncated = true
out.Marker = out.PolicyNames[limit-1]
}
return out, nil
}
func cloneUser(user types.User) *types.User {
cloned := user
cloned.Tags = slices.Clone(user.Tags)
+27
View File
@@ -33,6 +33,10 @@ const MaxAccessKeysPerUser = 2
// all of a single IAM user's inline policy documents combined
const MaxInlinePolicyBytesPerUser = 2048
// MaxInlinePolicyBytesPerRole is the maximum aggregate size, in bytes, of
// all of a single IAM role's inline policy documents combined
const MaxInlinePolicyBytesPerRole = 10240
var (
ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists")
ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists")
@@ -126,6 +130,24 @@ type UpdateAssumeRolePolicyInput struct {
PolicyDocument string
}
type PutRolePolicyInput struct {
RoleName string
PolicyName string
PolicyDocument string
}
type ListRolePoliciesInput struct {
RoleName string
Marker string
MaxItems int32
}
type ListRolePoliciesOutput struct {
PolicyNames []string
IsTruncated bool
Marker string
}
// Storer is the IAM API storage backend contract.
type Storer interface {
CreateUser(ctx context.Context, user types.User) (*types.User, error)
@@ -150,6 +172,11 @@ type Storer interface {
ListRoles(ctx context.Context, input ListRolesInput) (*ListRolesOutput, error)
DeleteRole(ctx context.Context, roleName string) error
UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error)
PutRolePolicy(ctx context.Context, input PutRolePolicyInput) error
GetRolePolicy(ctx context.Context, roleName, policyName string) (*types.PolicyEntry, error)
DeleteRolePolicy(ctx context.Context, roleName, policyName string) error
ListRolePolicies(ctx context.Context, input ListRolePoliciesInput) (*ListRolePoliciesOutput, error)
}
func unwrapAPIError(err error) error {
+127
View File
@@ -384,3 +384,130 @@ func TestInternalStoreRoleCRUDAndPagination(t *testing.T) {
t.Fatalf("DeleteRole missing err = %v, want NoSuchEntity", err)
}
}
func TestInternalStoreRolePolicyCRUD(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
store, err := NewInternal(dir)
if err != nil {
t.Fatalf("NewInternal: %v", err)
}
if _, err := store.CreateRole(ctx, types.Role{
RoleName: "alice-role",
RoleID: "AROA22222222222222222",
AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`,
}); err != nil {
t.Fatalf("CreateRole: %v", err)
}
if err := store.PutRolePolicy(ctx, PutRolePolicyInput{
RoleName: "ALICE-ROLE",
PolicyName: "ReadOnly",
PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`,
}); err != nil {
t.Fatalf("PutRolePolicy: %v", err)
}
if err := store.PutRolePolicy(ctx, PutRolePolicyInput{RoleName: "missing-role", PolicyName: "P", PolicyDocument: "{}"}); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) {
t.Fatalf("PutRolePolicy missing role err = %v, want NoSuchEntity", err)
}
entry, err := store.GetRolePolicy(ctx, "alice-role", "ReadOnly")
if err != nil {
t.Fatalf("GetRolePolicy: %v", err)
}
if entry.PolicyDocument != `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` {
t.Fatalf("GetRolePolicy document = %q", entry.PolicyDocument)
}
if entry.CreateDate.IsZero() || entry.UpdateDate.IsZero() {
t.Fatalf("GetRolePolicy CreateDate/UpdateDate zero: %#v", entry)
}
if _, err := store.GetRolePolicy(ctx, "alice-role", "NoSuchPolicy"); !errors.Is(err, iamerr.NoSuchEntityRolePolicy("alice-role", "NoSuchPolicy")) {
t.Fatalf("GetRolePolicy missing policy err = %v, want NoSuchEntity", err)
}
if _, err := store.GetRolePolicy(ctx, "missing-role", "P"); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) {
t.Fatalf("GetRolePolicy missing role err = %v, want NoSuchEntity", err)
}
// Overwriting an existing PolicyName replaces its document rather than
// stacking toward the aggregate size quota.
if err := store.PutRolePolicy(ctx, PutRolePolicyInput{
RoleName: "alice-role",
PolicyName: "ReadOnly",
PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`,
}); err != nil {
t.Fatalf("overwrite PutRolePolicy: %v", err)
}
overwritten, err := store.GetRolePolicy(ctx, "alice-role", "ReadOnly")
if err != nil {
t.Fatalf("GetRolePolicy after overwrite: %v", err)
}
if !strings.Contains(overwritten.PolicyDocument, "Deny") {
t.Fatalf("GetRolePolicy after overwrite = %q, want the Deny statement", overwritten.PolicyDocument)
}
// Aggregate inline policy size for a role is capped at
// MaxInlinePolicyBytesPerRole (10240), distinct from and larger than
// the 2048 byte cap for users.
oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 10300) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`
if err := store.PutRolePolicy(ctx, PutRolePolicyInput{RoleName: "alice-role", PolicyName: "TooBig", PolicyDocument: oversized}); !errors.Is(err, iamerr.InlinePolicyQuotaExceeded("role", "alice-role", MaxInlinePolicyBytesPerRole)) {
t.Fatalf("PutRolePolicy oversized err = %v, want LimitExceeded", err)
}
if err := store.PutRolePolicy(ctx, PutRolePolicyInput{
RoleName: "alice-role",
PolicyName: "SecondPolicy",
PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`,
}); err != nil {
t.Fatalf("PutRolePolicy second policy: %v", err)
}
list, err := store.ListRolePolicies(ctx, ListRolePoliciesInput{RoleName: "ALICE-ROLE", MaxItems: 1})
if err != nil {
t.Fatalf("ListRolePolicies page1: %v", err)
}
if len(list.PolicyNames) != 1 || list.PolicyNames[0] != "ReadOnly" || !list.IsTruncated || list.Marker != "ReadOnly" {
t.Fatalf("ListRolePolicies page1 = %#v, want truncated ReadOnly page", list)
}
page2, err := store.ListRolePolicies(ctx, ListRolePoliciesInput{RoleName: "alice-role", Marker: list.Marker, MaxItems: 10})
if err != nil {
t.Fatalf("ListRolePolicies page2: %v", err)
}
if len(page2.PolicyNames) != 1 || page2.PolicyNames[0] != "SecondPolicy" || page2.IsTruncated {
t.Fatalf("ListRolePolicies page2 = %#v, want final SecondPolicy page", page2)
}
if _, err := store.ListRolePolicies(ctx, ListRolePoliciesInput{RoleName: "missing-role"}); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) {
t.Fatalf("ListRolePolicies missing role err = %v, want NoSuchEntity", err)
}
// A role with attached inline policies cannot be deleted until they are
// all removed first.
if err := store.DeleteRole(ctx, "alice-role"); !errors.Is(err, iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)) {
t.Fatalf("DeleteRole with policies err = %v, want DeleteConflict", err)
}
if err := store.DeleteRolePolicy(ctx, "alice-role", "SecondPolicy"); err != nil {
t.Fatalf("DeleteRolePolicy: %v", err)
}
if err := store.DeleteRolePolicy(ctx, "alice-role", "NoSuchPolicy"); !errors.Is(err, iamerr.NoSuchEntityRolePolicy("alice-role", "NoSuchPolicy")) {
t.Fatalf("DeleteRolePolicy missing policy err = %v, want NoSuchEntity", err)
}
if err := store.DeleteRolePolicy(ctx, "missing-role", "P"); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) {
t.Fatalf("DeleteRolePolicy missing role err = %v, want NoSuchEntity", err)
}
reopened, err := NewInternal(dir)
if err != nil {
t.Fatalf("reopen NewInternal: %v", err)
}
if _, err := reopened.GetRolePolicy(ctx, "alice-role", "ReadOnly"); err != nil {
t.Fatalf("GetRolePolicy after reopen: %v", err)
}
if err := reopened.DeleteRolePolicy(ctx, "alice-role", "ReadOnly"); err != nil {
t.Fatalf("DeleteRolePolicy: %v", err)
}
if err := reopened.DeleteRole(ctx, "alice-role"); err != nil {
t.Fatalf("DeleteRole after removing all policies: %v", err)
}
}
+116
View File
@@ -940,6 +940,122 @@ func (s *VaultStore) UpdateAssumeRolePolicy(ctx context.Context, input UpdateAss
return s.replaceRole(ctx, *role)
}
func (s *VaultStore) PutRolePolicy(ctx context.Context, input PutRolePolicyInput) error {
role, err := s.GetRole(ctx, input.RoleName)
if err != nil {
return err
}
newTotal := len(input.PolicyDocument)
replaceAt := -1
for i, p := range role.Policies.Inline {
if p.PolicyName == input.PolicyName {
replaceAt = i
continue
}
newTotal += len(p.PolicyDocument)
}
if newTotal > MaxInlinePolicyBytesPerRole {
return iamerr.InlinePolicyQuotaExceeded("role", input.RoleName, MaxInlinePolicyBytesPerRole)
}
now := time.Now().UTC().Truncate(time.Second)
if replaceAt >= 0 {
role.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument
role.Policies.Inline[replaceAt].UpdateDate = now
} else {
role.Policies.Inline = append(role.Policies.Inline, types.PolicyEntry{
PolicyName: input.PolicyName,
PolicyDocument: input.PolicyDocument,
CreateDate: now,
UpdateDate: now,
})
}
_, err = s.replaceRole(ctx, *role)
return err
}
func (s *VaultStore) GetRolePolicy(ctx context.Context, roleName, policyName string) (*types.PolicyEntry, error) {
role, err := s.GetRole(ctx, roleName)
if err != nil {
return nil, err
}
for _, p := range role.Policies.Inline {
if p.PolicyName == policyName {
cloned := p
return &cloned, nil
}
}
return nil, iamerr.NoSuchEntityRolePolicy(roleName, policyName)
}
func (s *VaultStore) DeleteRolePolicy(ctx context.Context, roleName, policyName string) error {
role, err := s.GetRole(ctx, roleName)
if err != nil {
return err
}
idx := -1
for i, p := range role.Policies.Inline {
if p.PolicyName == policyName {
idx = i
break
}
}
if idx == -1 {
return iamerr.NoSuchEntityRolePolicy(roleName, policyName)
}
role.Policies.Inline = slices.Delete(role.Policies.Inline, idx, idx+1)
_, err = s.replaceRole(ctx, *role)
return err
}
func (s *VaultStore) ListRolePolicies(ctx context.Context, input ListRolePoliciesInput) (*ListRolePoliciesOutput, error) {
role, err := s.GetRole(ctx, input.RoleName)
if err != nil {
return nil, err
}
names := make([]string, 0, len(role.Policies.Inline))
for _, p := range role.Policies.Inline {
names = append(names, p.PolicyName)
}
sort.Strings(names)
start := 0
if input.Marker != "" {
start = len(names)
for i, name := range names {
if name == input.Marker {
start = i + 1
break
}
}
}
names = names[start:]
limit := len(names)
if input.MaxItems > 0 && int(input.MaxItems) < limit {
limit = int(input.MaxItems)
}
out := &ListRolePoliciesOutput{
PolicyNames: make([]string, limit),
}
copy(out.PolicyNames, names[:limit])
if limit < len(names) {
out.IsTruncated = true
out.Marker = out.PolicyNames[limit-1]
}
return out, nil
}
// replaceRole overwrites the stored document for role.RoleName by deleting
// all existing versions and recreating with CAS=0.
func (s *VaultStore) replaceRole(ctx context.Context, role types.Role) (*types.Role, error) {
+50
View File
@@ -94,3 +94,53 @@ type ListUserPoliciesResult struct {
type PolicyNameList struct {
Members []string `xml:"member"`
}
type PutRolePolicyResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutRolePolicyResponse"`
ResponseMetadata ResponseMetadata
}
func (r *PutRolePolicyResponse) SetRequestID(requestID string) {
r.ResponseMetadata.RequestID = requestID
}
type DeleteRolePolicyResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteRolePolicyResponse"`
ResponseMetadata ResponseMetadata
}
func (r *DeleteRolePolicyResponse) SetRequestID(requestID string) {
r.ResponseMetadata.RequestID = requestID
}
type GetRolePolicyResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetRolePolicyResponse"`
Result GetRolePolicyResult `xml:"GetRolePolicyResult"`
ResponseMetadata ResponseMetadata
}
func (r *GetRolePolicyResponse) SetRequestID(requestID string) {
r.ResponseMetadata.RequestID = requestID
}
type GetRolePolicyResult struct {
RoleName string
PolicyName string
PolicyDocument string
}
type ListRolePoliciesResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListRolePoliciesResponse"`
Result ListRolePoliciesResult `xml:"ListRolePoliciesResult"`
ResponseMetadata ResponseMetadata
}
func (r *ListRolePoliciesResponse) SetRequestID(requestID string) {
r.ResponseMetadata.RequestID = requestID
}
type ListRolePoliciesResult struct {
PolicyNames PolicyNameList
IsTruncated bool
Marker string `xml:",omitempty"`
}