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:
niksis02
2026-08-25 01:03:22 +04:00
parent 3328501fea
commit cbcc656f53
22 changed files with 3434 additions and 33 deletions
+246 -25
View File
@@ -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
}
+24
View File
@@ -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 {
+163
View File
@@ -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
View File
@@ -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