mirror of
https://github.com/versity/versitygw.git
synced 2026-09-20 06:54:47 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user