feat: IAM user inline policies (#462)

This commit is contained in:
Julian Sommer
2026-07-01 15:53:19 +10:00
parent 88f043027c
commit 8641629313
16 changed files with 889 additions and 14 deletions
+4 -4
View File
@@ -475,10 +475,10 @@ All IAM operations are account-scoped. Root user (account `000000000000`) bypass
| `list-users` | `--path-prefix` | `--max-items`, `--marker` | **DONE** |
| `delete-user` | `--user-name` | — | **DONE** |
| `update-user` | — | `--user-name`, `--new-path`, `--new-user-name` | **NOT STARTED** |
| `put-user-policy` | — | `--user-name`, `--policy-name`, `--policy-document` | **NOT STARTED** |
| `get-user-policy` | — | `--user-name`, `--policy-name` | **NOT STARTED** |
| `delete-user-policy` | — | `--user-name`, `--policy-name` | **NOT STARTED** |
| `list-user-policies` | — | `--user-name` | **NOT STARTED** |
| `put-user-policy` | `--user-name`, `--policy-name`, `--policy-document` | — | **DONE** |
| `get-user-policy` | `--user-name`, `--policy-name` | — | **DONE** |
| `delete-user-policy` | `--user-name`, `--policy-name` | — | **DONE** |
| `list-user-policies` | `--user-name` | `--max-items`, `--marker` | **DONE** |
| `tag-user` | — | `--user-name`, `--tags` | **NOT STARTED** |
| `untag-user` | — | `--user-name`, `--tag-keys` | **NOT STARTED** |
| `list-user-tags` | — | `--user-name` | **NOT STARTED** |
+12
View File
@@ -222,6 +222,18 @@ func (m *mockIAMService) DeleteGroupPolicy(_ string, _ *iam.DeleteGroupPolicyInp
func (m *mockIAMService) ListGroupPolicies(_ string, _ *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) {
return &iam.ListGroupPoliciesOutput{}, nil
}
func (m *mockIAMService) PutUserPolicy(_ string, _ *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) {
return &iam.PutUserPolicyOutput{}, nil
}
func (m *mockIAMService) GetUserPolicy(_ string, _ *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) {
return &iam.GetUserPolicyOutput{}, nil
}
func (m *mockIAMService) DeleteUserPolicy(_ string, _ *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) {
return &iam.DeleteUserPolicyOutput{}, nil
}
func (m *mockIAMService) ListUserPolicies(_ string, _ *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) {
return &iam.ListUserPoliciesOutput{}, nil
}
// testMasterKey is a fixed 32-byte key for deterministic tests.
var testMasterKey []byte
@@ -212,6 +212,18 @@ func (f *fakeIAMService) DeleteGroupPolicy(string, *iam.DeleteGroupPolicyInput)
func (f *fakeIAMService) ListGroupPolicies(string, *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) {
return &iam.ListGroupPoliciesOutput{}, nil
}
func (f *fakeIAMService) PutUserPolicy(string, *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) {
return &iam.PutUserPolicyOutput{}, nil
}
func (f *fakeIAMService) GetUserPolicy(string, *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) {
return &iam.GetUserPolicyOutput{}, nil
}
func (f *fakeIAMService) DeleteUserPolicy(string, *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) {
return &iam.DeleteUserPolicyOutput{}, nil
}
func (f *fakeIAMService) ListUserPolicies(string, *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) {
return &iam.ListUserPoliciesOutput{}, nil
}
var _ handlers_iam.IAMService = (*fakeIAMService)(nil)
+14
View File
@@ -97,6 +97,20 @@ var iamActions = map[string]IAMHandler{
return gateway_iam.ListAttachedUserPolicies(accountID, input, gw.IAMService)
}),
// User inline policies
"PutUserPolicy": iamHandler(func(accountID string, input *iam.PutUserPolicyInput, gw *GatewayConfig) (any, error) {
return gateway_iam.PutUserPolicy(accountID, input, gw.IAMService)
}),
"GetUserPolicy": iamHandler(func(accountID string, input *iam.GetUserPolicyInput, gw *GatewayConfig) (any, error) {
return gateway_iam.GetUserPolicy(accountID, input, gw.IAMService)
}),
"DeleteUserPolicy": iamHandler(func(accountID string, input *iam.DeleteUserPolicyInput, gw *GatewayConfig) (any, error) {
return gateway_iam.DeleteUserPolicy(accountID, input, gw.IAMService)
}),
"ListUserPolicies": iamHandler(func(accountID string, input *iam.ListUserPoliciesInput, gw *GatewayConfig) (any, error) {
return gateway_iam.ListUserPolicies(accountID, input, gw.IAMService)
}),
// Role CRUD
"CreateRole": iamHandler(func(accountID string, input *iam.CreateRoleInput, gw *GatewayConfig) (any, error) {
return gateway_iam.CreateRole(accountID, input, gw.IAMService)
+12
View File
@@ -217,6 +217,18 @@ func (s *stubIAMService) DeleteGroupPolicy(_ string, _ *iam.DeleteGroupPolicyInp
func (s *stubIAMService) ListGroupPolicies(_ string, _ *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) {
return &iam.ListGroupPoliciesOutput{}, nil
}
func (s *stubIAMService) PutUserPolicy(_ string, _ *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) {
return &iam.PutUserPolicyOutput{}, nil
}
func (s *stubIAMService) GetUserPolicy(_ string, _ *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) {
return &iam.GetUserPolicyOutput{}, nil
}
func (s *stubIAMService) DeleteUserPolicy(_ string, _ *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) {
return &iam.DeleteUserPolicyOutput{}, nil
}
func (s *stubIAMService) ListUserPolicies(_ string, _ *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) {
return &iam.ListUserPoliciesOutput{}, nil
}
func TestCreateUser(t *testing.T) {
svc := &stubIAMService{}
+40
View File
@@ -32,3 +32,43 @@ func DeleteUser(accountID string, input *iam.DeleteUserInput, svc handlers_iam.I
}
return svc.DeleteUser(accountID, input)
}
func PutUserPolicy(accountID string, input *iam.PutUserPolicyInput, svc handlers_iam.IAMService) (*iam.PutUserPolicyOutput, error) {
if input.UserName == nil || *input.UserName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
if input.PolicyName == nil || *input.PolicyName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
if input.PolicyDocument == nil || *input.PolicyDocument == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
return svc.PutUserPolicy(accountID, input)
}
func GetUserPolicy(accountID string, input *iam.GetUserPolicyInput, svc handlers_iam.IAMService) (*iam.GetUserPolicyOutput, error) {
if input.UserName == nil || *input.UserName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
if input.PolicyName == nil || *input.PolicyName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
return svc.GetUserPolicy(accountID, input)
}
func DeleteUserPolicy(accountID string, input *iam.DeleteUserPolicyInput, svc handlers_iam.IAMService) (*iam.DeleteUserPolicyOutput, error) {
if input.UserName == nil || *input.UserName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
if input.PolicyName == nil || *input.PolicyName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
return svc.DeleteUserPolicy(accountID, input)
}
func ListUserPolicies(accountID string, input *iam.ListUserPoliciesInput, svc handlers_iam.IAMService) (*iam.ListUserPoliciesOutput, error) {
if input.UserName == nil || *input.UserName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
return svc.ListUserPolicies(accountID, input)
}
+117
View File
@@ -0,0 +1,117 @@
package gateway_iam
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const validUserInlineDoc = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"ec2:DescribeInstances","Resource":"*"}]}`
func TestPutUserPolicy(t *testing.T) {
svc := &stubIAMService{}
tests := []struct {
name string
input *iam.PutUserPolicyInput
wantErr string
}{
{"nil UserName", &iam.PutUserPolicyInput{PolicyName: aws.String("p"), PolicyDocument: aws.String(validUserInlineDoc)}, awserrors.ErrorMissingParameter},
{"empty UserName", &iam.PutUserPolicyInput{UserName: aws.String(""), PolicyName: aws.String("p"), PolicyDocument: aws.String(validUserInlineDoc)}, awserrors.ErrorMissingParameter},
{"nil PolicyName", &iam.PutUserPolicyInput{UserName: aws.String("u"), PolicyDocument: aws.String(validUserInlineDoc)}, awserrors.ErrorMissingParameter},
{"empty PolicyName", &iam.PutUserPolicyInput{UserName: aws.String("u"), PolicyName: aws.String(""), PolicyDocument: aws.String(validUserInlineDoc)}, awserrors.ErrorMissingParameter},
{"nil PolicyDocument", &iam.PutUserPolicyInput{UserName: aws.String("u"), PolicyName: aws.String("p")}, awserrors.ErrorMissingParameter},
{"empty PolicyDocument", &iam.PutUserPolicyInput{UserName: aws.String("u"), PolicyName: aws.String("p"), PolicyDocument: aws.String("")}, awserrors.ErrorMissingParameter},
{"valid", &iam.PutUserPolicyInput{UserName: aws.String("u"), PolicyName: aws.String("p"), PolicyDocument: aws.String(validUserInlineDoc)}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := PutUserPolicy(testAccountID, tc.input, svc)
if tc.wantErr != "" {
require.Error(t, err)
assert.Equal(t, tc.wantErr, err.Error())
} else {
require.NoError(t, err)
}
})
}
}
func TestGetUserPolicy(t *testing.T) {
svc := &stubIAMService{}
tests := []struct {
name string
input *iam.GetUserPolicyInput
wantErr string
}{
{"nil UserName", &iam.GetUserPolicyInput{PolicyName: aws.String("p")}, awserrors.ErrorMissingParameter},
{"empty UserName", &iam.GetUserPolicyInput{UserName: aws.String(""), PolicyName: aws.String("p")}, awserrors.ErrorMissingParameter},
{"nil PolicyName", &iam.GetUserPolicyInput{UserName: aws.String("u")}, awserrors.ErrorMissingParameter},
{"empty PolicyName", &iam.GetUserPolicyInput{UserName: aws.String("u"), PolicyName: aws.String("")}, awserrors.ErrorMissingParameter},
{"valid", &iam.GetUserPolicyInput{UserName: aws.String("u"), PolicyName: aws.String("p")}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := GetUserPolicy(testAccountID, tc.input, svc)
if tc.wantErr != "" {
require.Error(t, err)
assert.Equal(t, tc.wantErr, err.Error())
} else {
require.NoError(t, err)
}
})
}
}
func TestDeleteUserPolicy(t *testing.T) {
svc := &stubIAMService{}
tests := []struct {
name string
input *iam.DeleteUserPolicyInput
wantErr string
}{
{"nil UserName", &iam.DeleteUserPolicyInput{PolicyName: aws.String("p")}, awserrors.ErrorMissingParameter},
{"empty UserName", &iam.DeleteUserPolicyInput{UserName: aws.String(""), PolicyName: aws.String("p")}, awserrors.ErrorMissingParameter},
{"nil PolicyName", &iam.DeleteUserPolicyInput{UserName: aws.String("u")}, awserrors.ErrorMissingParameter},
{"empty PolicyName", &iam.DeleteUserPolicyInput{UserName: aws.String("u"), PolicyName: aws.String("")}, awserrors.ErrorMissingParameter},
{"valid", &iam.DeleteUserPolicyInput{UserName: aws.String("u"), PolicyName: aws.String("p")}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := DeleteUserPolicy(testAccountID, tc.input, svc)
if tc.wantErr != "" {
require.Error(t, err)
assert.Equal(t, tc.wantErr, err.Error())
} else {
require.NoError(t, err)
}
})
}
}
func TestListUserPolicies(t *testing.T) {
svc := &stubIAMService{}
tests := []struct {
name string
input *iam.ListUserPoliciesInput
wantErr string
}{
{"nil UserName", &iam.ListUserPoliciesInput{}, awserrors.ErrorMissingParameter},
{"empty UserName", &iam.ListUserPoliciesInput{UserName: aws.String("")}, awserrors.ErrorMissingParameter},
{"valid", &iam.ListUserPoliciesInput{UserName: aws.String("u")}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := ListUserPolicies(testAccountID, tc.input, svc)
if tc.wantErr != "" {
require.Error(t, err)
assert.Equal(t, tc.wantErr, err.Error())
} else {
require.NoError(t, err)
}
})
}
}
+12
View File
@@ -247,6 +247,18 @@ func (m *flexMockIAMService) DeleteGroupPolicy(_ string, _ *iam.DeleteGroupPolic
func (m *flexMockIAMService) ListGroupPolicies(_ string, _ *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) {
return &iam.ListGroupPoliciesOutput{}, nil
}
func (m *flexMockIAMService) PutUserPolicy(_ string, _ *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) {
return &iam.PutUserPolicyOutput{}, nil
}
func (m *flexMockIAMService) GetUserPolicy(_ string, _ *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) {
return &iam.GetUserPolicyOutput{}, nil
}
func (m *flexMockIAMService) DeleteUserPolicy(_ string, _ *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) {
return &iam.DeleteUserPolicyOutput{}, nil
}
func (m *flexMockIAMService) ListUserPolicies(_ string, _ *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) {
return &iam.ListUserPoliciesOutput{}, nil
}
// setupIAMRequestHandler wires an http.Handler for IAM_Request with injected SigV4 context values.
func setupIAMRequestHandler(svc handlers_iam.IAMService) http.Handler {
+12
View File
@@ -272,6 +272,18 @@ func (s *stubIAMService) DeleteGroupPolicy(string, *iam.DeleteGroupPolicyInput)
func (s *stubIAMService) ListGroupPolicies(string, *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) {
panic("unexpected ListGroupPolicies call")
}
func (s *stubIAMService) PutUserPolicy(string, *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) {
panic("unexpected PutUserPolicy call")
}
func (s *stubIAMService) GetUserPolicy(string, *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) {
panic("unexpected GetUserPolicy call")
}
func (s *stubIAMService) DeleteUserPolicy(string, *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) {
panic("unexpected DeleteUserPolicy call")
}
func (s *stubIAMService) ListUserPolicies(string, *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) {
panic("unexpected ListUserPolicies call")
}
func TestAssumeRole_HappyPath(t *testing.T) {
var calledWith struct {
+6
View File
@@ -31,6 +31,12 @@ type IAMService interface {
DetachUserPolicy(accountID string, input *iam.DetachUserPolicyInput) (*iam.DetachUserPolicyOutput, error)
ListAttachedUserPolicies(accountID string, input *iam.ListAttachedUserPoliciesInput) (*iam.ListAttachedUserPoliciesOutput, error)
// User inline policies — account-scoped
PutUserPolicy(accountID string, input *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error)
GetUserPolicy(accountID string, input *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error)
DeleteUserPolicy(accountID string, input *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error)
ListUserPolicies(accountID string, input *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error)
// Role CRUD — account-scoped
CreateRole(accountID string, input *iam.CreateRoleInput) (*iam.CreateRoleOutput, error)
GetRole(accountID string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error)
+132
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"log/slog"
"maps"
"slices"
"strconv"
"strings"
@@ -392,6 +393,9 @@ func (s *IAMServiceImpl) DeleteUser(accountID string, input *iam.DeleteUserInput
if len(user.Groups) > 0 {
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
}
if len(user.InlinePolicies) > 0 {
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
}
if err := s.usersBucket.Delete(kvKey); err != nil {
return nil, fmt.Errorf("delete user: %w", err)
@@ -1274,6 +1278,123 @@ func (s *IAMServiceImpl) ListAttachedUserPolicies(accountID string, input *iam.L
}, nil
}
// ---------------------------------------------------------------------------
// User inline policies
// ---------------------------------------------------------------------------
// PutUserPolicy embeds an inline policy document in a user, keyed by PolicyName.
// Idempotent upsert: a same-name policy is overwritten, mirroring AWS. Uses a
// blind read-modify-write Put like the other user writers (no CAS).
func (s *IAMServiceImpl) PutUserPolicy(accountID string, input *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) {
userName := *input.UserName
policyName := *input.PolicyName
policyDoc := *input.PolicyDocument
userKVKey := accountID + "." + userName
if err := validatePolicyName(policyName); err != nil {
return nil, errors.New(awserrors.ErrorIAMInvalidInput)
}
if _, err := ValidatePolicyDocument(policyDoc); err != nil {
return nil, errors.New(awserrors.ErrorIAMMalformedPolicyDocument)
}
user, err := s.getUser(accountID, userName)
if err != nil {
return nil, err
}
if user.InlinePolicies == nil {
user.InlinePolicies = map[string]string{}
}
user.InlinePolicies[policyName] = policyDoc
userData, err := json.Marshal(user)
if err != nil {
return nil, fmt.Errorf("marshal user: %w", err)
}
if _, err := s.usersBucket.Put(userKVKey, userData); err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
slog.Info("IAM inline policy put on user", "accountID", accountID, "userName", userName, "policyName", policyName)
return &iam.PutUserPolicyOutput{}, nil
}
// GetUserPolicy returns a user's inline policy document by name as a raw JSON
// string, matching the in-repo convention used by GetGroupPolicy.
func (s *IAMServiceImpl) GetUserPolicy(accountID string, input *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) {
userName := *input.UserName
policyName := *input.PolicyName
user, err := s.getUser(accountID, userName)
if err != nil {
return nil, err
}
doc, ok := user.InlinePolicies[policyName]
if !ok {
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
}
return &iam.GetUserPolicyOutput{
UserName: aws.String(userName),
PolicyName: aws.String(policyName),
PolicyDocument: aws.String(doc),
}, nil
}
// DeleteUserPolicy removes a user's inline policy by name. A missing name
// yields NoSuchEntity, matching AWS. Blind Put like the other user writers.
func (s *IAMServiceImpl) DeleteUserPolicy(accountID string, input *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) {
userName := *input.UserName
policyName := *input.PolicyName
userKVKey := accountID + "." + userName
user, err := s.getUser(accountID, userName)
if err != nil {
return nil, err
}
if _, ok := user.InlinePolicies[policyName]; !ok {
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
}
delete(user.InlinePolicies, policyName)
userData, err := json.Marshal(user)
if err != nil {
return nil, fmt.Errorf("marshal user: %w", err)
}
if _, err := s.usersBucket.Put(userKVKey, userData); err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
slog.Info("IAM inline policy deleted from user", "accountID", accountID, "userName", userName, "policyName", policyName)
return &iam.DeleteUserPolicyOutput{}, nil
}
// ListUserPolicies returns the names of a user's inline policies, sorted for
// deterministic output. Pagination is not implemented: IsTruncated is always false.
func (s *IAMServiceImpl) ListUserPolicies(accountID string, input *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) {
user, err := s.getUser(accountID, *input.UserName)
if err != nil {
return nil, err
}
rawNames := slices.Sorted(maps.Keys(user.InlinePolicies))
names := make([]*string, 0, len(rawNames))
for _, name := range rawNames {
names = append(names, aws.String(name))
}
return &iam.ListUserPoliciesOutput{
PolicyNames: names,
IsTruncated: aws.Bool(false),
}, nil
}
// GetUserPolicies resolves all policy documents attached to a user.
// Used internally by the gateway for policy evaluation.
func (s *IAMServiceImpl) GetUserPolicies(accountID, userName string) ([]PolicyDocument, error) {
@@ -1295,6 +1416,17 @@ func (s *IAMServiceImpl) GetUserPolicies(accountID, userName string) ([]PolicyDo
}
}
// Direct user-inline policies. A malformed document fails closed so a broken
// inline grant denies rather than silently permitting, mirroring the
// group-inline handling below.
for name, raw := range user.InlinePolicies {
var doc PolicyDocument
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return nil, fmt.Errorf("parse user inline policy %s/%s: %w", userName, name, err)
}
docs = append(docs, doc)
}
// Group-inherited managed policies. A membership to a deleted group is inert
// (AWS makes that state impossible by refusing to delete a non-empty group),
// so a missing group is skipped with a warn and the user keeps their other
+11 -10
View File
@@ -28,16 +28,17 @@ type (
// User represents an IAM user stored in JetStream KV.
type User struct {
UserName string `json:"user_name"`
UserID string `json:"user_id"`
AccountID string `json:"account_id"`
ARN string `json:"arn"`
Path string `json:"path"`
CreatedAt string `json:"created_at"`
AccessKeys []string `json:"access_keys"`
Tags []Tag `json:"tags"`
AttachedPolicies []string `json:"attached_policies"` // policy ARNs
Groups []string `json:"groups"` // group NAMES the user belongs to (≤10)
UserName string `json:"user_name"`
UserID string `json:"user_id"`
AccountID string `json:"account_id"`
ARN string `json:"arn"`
Path string `json:"path"`
CreatedAt string `json:"created_at"`
AccessKeys []string `json:"access_keys"`
Tags []Tag `json:"tags"`
AttachedPolicies []string `json:"attached_policies"` // policy ARNs
Groups []string `json:"groups"` // group NAMES the user belongs to (≤10)
InlinePolicies map[string]string `json:"inline_policies,omitempty"` // policyName → document JSON
}
// Group is a permission-grouping IAM identity stored in JetStream KV.
+397
View File
@@ -0,0 +1,397 @@
package handlers_iam
import (
"encoding/json"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ============================================================================
// User Inline Policy Tests (Put / Get / Delete / List)
// ============================================================================
func TestPutUserPolicy_RoundTrip(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "inline-user")
_, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{
UserName: aws.String("inline-user"),
PolicyName: aws.String("AllowDescribe"),
PolicyDocument: aws.String(validPolicyDocument()),
})
require.NoError(t, err)
out, err := svc.GetUserPolicy(testAccountID, &iam.GetUserPolicyInput{
UserName: aws.String("inline-user"),
PolicyName: aws.String("AllowDescribe"),
})
require.NoError(t, err)
assert.Equal(t, "inline-user", *out.UserName)
assert.Equal(t, "AllowDescribe", *out.PolicyName)
assert.Equal(t, validPolicyDocument(), *out.PolicyDocument)
}
func TestPutUserPolicy_IdempotentOverwrite(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "overwrite-user")
_, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{
UserName: aws.String("overwrite-user"),
PolicyName: aws.String("Policy"),
PolicyDocument: aws.String(validPolicyDocument()),
})
require.NoError(t, err)
_, err = svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{
UserName: aws.String("overwrite-user"),
PolicyName: aws.String("Policy"),
PolicyDocument: aws.String(inlineDenyDocument()),
})
require.NoError(t, err)
out, err := svc.GetUserPolicy(testAccountID, &iam.GetUserPolicyInput{
UserName: aws.String("overwrite-user"),
PolicyName: aws.String("Policy"),
})
require.NoError(t, err)
assert.Equal(t, inlineDenyDocument(), *out.PolicyDocument)
list, err := svc.ListUserPolicies(testAccountID, &iam.ListUserPoliciesInput{
UserName: aws.String("overwrite-user"),
})
require.NoError(t, err)
assert.Len(t, list.PolicyNames, 1, "overwrite must not duplicate the name")
}
func TestPutUserPolicy_InvalidName(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "badname-user")
_, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{
UserName: aws.String("badname-user"),
PolicyName: aws.String("bad name"),
PolicyDocument: aws.String(validPolicyDocument()),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput)
}
func TestPutUserPolicy_MalformedDocument(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "malformed-user")
_, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{
UserName: aws.String("malformed-user"),
PolicyName: aws.String("Bad"),
PolicyDocument: aws.String(`{not valid json`),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMMalformedPolicyDocument)
}
func TestPutUserPolicy_OversizedDocument(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "oversized-user")
huge := strings.Repeat("a", maxPolicyDocumentSize+1)
_, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{
UserName: aws.String("oversized-user"),
PolicyName: aws.String("Huge"),
PolicyDocument: aws.String(huge),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMMalformedPolicyDocument)
}
func TestPutUserPolicy_UserNotFound(t *testing.T) {
svc := setupTestIAMService(t)
_, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{
UserName: aws.String("ghost"),
PolicyName: aws.String("Policy"),
PolicyDocument: aws.String(validPolicyDocument()),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
func TestGetUserPolicy_UnknownName(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "get-unknown")
_, err := svc.GetUserPolicy(testAccountID, &iam.GetUserPolicyInput{
UserName: aws.String("get-unknown"),
PolicyName: aws.String("missing"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
func TestGetUserPolicy_UserNotFound(t *testing.T) {
svc := setupTestIAMService(t)
_, err := svc.GetUserPolicy(testAccountID, &iam.GetUserPolicyInput{
UserName: aws.String("ghost"),
PolicyName: aws.String("Policy"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
func TestListUserPolicies_Sorted(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "list-user")
for _, name := range []string{"Charlie", "Alpha", "Bravo"} {
_, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{
UserName: aws.String("list-user"),
PolicyName: aws.String(name),
PolicyDocument: aws.String(validPolicyDocument()),
})
require.NoError(t, err)
}
out, err := svc.ListUserPolicies(testAccountID, &iam.ListUserPoliciesInput{
UserName: aws.String("list-user"),
})
require.NoError(t, err)
require.False(t, *out.IsTruncated)
got := make([]string, 0, len(out.PolicyNames))
for _, n := range out.PolicyNames {
got = append(got, *n)
}
assert.Equal(t, []string{"Alpha", "Bravo", "Charlie"}, got)
}
func TestListUserPolicies_Empty(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "empty-inline-user")
out, err := svc.ListUserPolicies(testAccountID, &iam.ListUserPoliciesInput{
UserName: aws.String("empty-inline-user"),
})
require.NoError(t, err)
assert.NotNil(t, out.PolicyNames)
assert.Len(t, out.PolicyNames, 0)
assert.False(t, *out.IsTruncated)
}
func TestListUserPolicies_UserNotFound(t *testing.T) {
svc := setupTestIAMService(t)
_, err := svc.ListUserPolicies(testAccountID, &iam.ListUserPoliciesInput{
UserName: aws.String("ghost"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
func TestDeleteUserPolicy(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "del-inline-user")
_, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{
UserName: aws.String("del-inline-user"),
PolicyName: aws.String("Doomed"),
PolicyDocument: aws.String(validPolicyDocument()),
})
require.NoError(t, err)
_, err = svc.DeleteUserPolicy(testAccountID, &iam.DeleteUserPolicyInput{
UserName: aws.String("del-inline-user"),
PolicyName: aws.String("Doomed"),
})
require.NoError(t, err)
_, err = svc.GetUserPolicy(testAccountID, &iam.GetUserPolicyInput{
UserName: aws.String("del-inline-user"),
PolicyName: aws.String("Doomed"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
list, err := svc.ListUserPolicies(testAccountID, &iam.ListUserPoliciesInput{
UserName: aws.String("del-inline-user"),
})
require.NoError(t, err)
assert.Len(t, list.PolicyNames, 0)
}
func TestDeleteUserPolicy_DoubleDelete(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "double-del-user")
_, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{
UserName: aws.String("double-del-user"),
PolicyName: aws.String("Once"),
PolicyDocument: aws.String(validPolicyDocument()),
})
require.NoError(t, err)
_, err = svc.DeleteUserPolicy(testAccountID, &iam.DeleteUserPolicyInput{
UserName: aws.String("double-del-user"),
PolicyName: aws.String("Once"),
})
require.NoError(t, err)
_, err = svc.DeleteUserPolicy(testAccountID, &iam.DeleteUserPolicyInput{
UserName: aws.String("double-del-user"),
PolicyName: aws.String("Once"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
func TestDeleteUserPolicy_UserNotFound(t *testing.T) {
svc := setupTestIAMService(t)
_, err := svc.DeleteUserPolicy(testAccountID, &iam.DeleteUserPolicyInput{
UserName: aws.String("ghost"),
PolicyName: aws.String("Policy"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
// ============================================================================
// Authorization Integration — the linchpin (GetUserPolicies resolves user inline)
// ============================================================================
// putRawUserInlinePolicy writes an inline policy document into a user record
// directly via the bucket, bypassing PutUserPolicy validation. Used to plant a
// corrupt stored document that the write path would otherwise reject.
func putRawUserInlinePolicy(t *testing.T, svc *IAMServiceImpl, userName, policyName, raw string) {
t.Helper()
user, err := svc.getUser(testAccountID, userName)
require.NoError(t, err)
if user.InlinePolicies == nil {
user.InlinePolicies = map[string]string{}
}
user.InlinePolicies[policyName] = raw
data, err := json.Marshal(user)
require.NoError(t, err)
_, err = svc.usersBucket.Put(testAccountID+"."+userName, data)
require.NoError(t, err)
}
// TestGetUserPolicies_UserInlineEnforced is the linchpin: a user's own inline
// policy must contribute its grant to the user's effective permission set, and
// stop contributing once the inline policy is removed. Without this, put-user-policy
// appears to succeed while granting nothing — a silent no-op.
func TestGetUserPolicies_UserInlineEnforced(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "quinn")
// Before the inline policy exists: the grant is absent.
docs, err := svc.GetUserPolicies(testAccountID, "quinn")
require.NoError(t, err)
assert.False(t, policiesGrant(docs, "ec2:DescribeInstances"), "grant must be absent before the inline policy")
_, err = svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{
UserName: aws.String("quinn"),
PolicyName: aws.String("InlineGrant"),
PolicyDocument: aws.String(validPolicyDocument()),
})
require.NoError(t, err)
// After the inline policy is added: the grant flows through.
docs, err = svc.GetUserPolicies(testAccountID, "quinn")
require.NoError(t, err)
require.Len(t, docs, 1)
assert.True(t, policiesGrant(docs, "ec2:DescribeInstances"), "user inline grant must be enforced")
// After removing the inline policy: the grant disappears again.
_, err = svc.DeleteUserPolicy(testAccountID, &iam.DeleteUserPolicyInput{
UserName: aws.String("quinn"),
PolicyName: aws.String("InlineGrant"),
})
require.NoError(t, err)
docs, err = svc.GetUserPolicies(testAccountID, "quinn")
require.NoError(t, err)
assert.False(t, policiesGrant(docs, "ec2:DescribeInstances"), "grant must vanish after the inline policy is deleted")
}
// TestGetUserPolicies_UserManagedAndInline proves a user's direct managed
// attachment and its own inline document both surface in one combined effective
// set — inline grants supplement managed grants rather than replacing them, so a
// deny-wins evaluator can honour an inline Deny alongside a managed Allow.
func TestGetUserPolicies_UserManagedAndInline(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "rita")
managed := createTestPolicy(t, svc, "ManagedGrant")
_, err := svc.AttachUserPolicy(testAccountID, &iam.AttachUserPolicyInput{
UserName: aws.String("rita"),
PolicyArn: managed.Arn,
})
require.NoError(t, err)
_, err = svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{
UserName: aws.String("rita"),
PolicyName: aws.String("InlineDeny"),
PolicyDocument: aws.String(inlineDenyDocument()),
})
require.NoError(t, err)
docs, err := svc.GetUserPolicies(testAccountID, "rita")
require.NoError(t, err)
require.Len(t, docs, 2, "both the managed attachment and the inline document must resolve")
assert.True(t, policiesGrant(docs, "ec2:DescribeInstances"), "managed Allow surfaced")
assert.True(t, policiesGrant(docs, "s3:DeleteObject"), "inline doc surfaced")
}
// TestGetUserPolicies_UserInlineMalformedFailsClosed proves a corrupt inline
// document on a user fails the whole resolution closed rather than silently
// dropping the grant source, mirroring the group-inline handling.
func TestGetUserPolicies_UserInlineMalformedFailsClosed(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "sam")
putRawUserInlinePolicy(t, svc, "sam", "Bad", `{not valid json`)
_, err := svc.GetUserPolicies(testAccountID, "sam")
assert.Error(t, err, "a malformed user inline doc must fail closed")
}
// ============================================================================
// DeleteUser inline-policy guard
// ============================================================================
// TestDeleteUser_WithInlinePolicy proves a user carrying an inline policy refuses
// deletion until the inline policy is removed, mirroring DeleteGroup.
func TestDeleteUser_WithInlinePolicy(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "inline-conflict-user")
_, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{
UserName: aws.String("inline-conflict-user"),
PolicyName: aws.String("Blocker"),
PolicyDocument: aws.String(validPolicyDocument()),
})
require.NoError(t, err)
_, err = svc.DeleteUser(testAccountID, &iam.DeleteUserInput{
UserName: aws.String("inline-conflict-user"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMDeleteConflict)
// Succeeds once the inline policy is removed.
_, err = svc.DeleteUserPolicy(testAccountID, &iam.DeleteUserPolicyInput{
UserName: aws.String("inline-conflict-user"),
PolicyName: aws.String("Blocker"),
})
require.NoError(t, err)
_, err = svc.DeleteUser(testAccountID, &iam.DeleteUserInput{
UserName: aws.String("inline-conflict-user"),
})
require.NoError(t, err)
}
+12
View File
@@ -776,6 +776,18 @@ func iamDeleteUserBestEffort(fix *Fixture, user string) {
})
}
}
// Delete any inline policies so the inline-policy guard can't block the final
// DeleteUser after a partial run.
if inline, err := fix.AWS.IAM.ListUserPolicies(&iam.ListUserPoliciesInput{
UserName: aws.String(user),
}); err == nil {
for _, name := range inline.PolicyNames {
_, _ = fix.AWS.IAM.DeleteUserPolicy(&iam.DeleteUserPolicyInput{
UserName: aws.String(user),
PolicyName: name,
})
}
}
// Delete every access key for this user.
keys, err := fix.AWS.IAM.ListAccessKeys(&iam.ListAccessKeysInput{
UserName: aws.String(user),
+88
View File
@@ -0,0 +1,88 @@
//go:build e2e
package iam
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/mulgadc/spinifex/tests/e2e/harness"
"github.com/stretchr/testify/require"
)
const (
usrInlineEnforceUser = "usr-e2e-inl-enf-user"
usrInlineEnforceName = "usr-e2e-inl-enf-describe-regions"
// A single-action grant so the positive case proves the user's own inline
// document was resolved and evaluated — not a blanket allow.
usrInlinePolicyDoc = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"ec2:DescribeRegions","Resource":"*"}]}`
)
// runIAMUserInlineEnforcement proves the user-inline linchpin: a policy embedded
// directly in a user record grants that user its permission with no group or
// managed attachment involved. A user with its own access key is denied a guarded
// action; once an inline policy granting that action is put on the user, the same
// live credentials are allowed (policies resolved per request); after the inline
// policy is deleted the action is denied again, isolating the user's own inline
// document as the sole grant source. Mirrors runIAMGroupInlineEnforcement without
// the group indirection.
func runIAMUserInlineEnforcement(t *testing.T, fix *Fixture) {
harness.Phase(t, "Single — IAM User inline-policy authorization enforcement")
// No group or managed policy — the inline doc lives on the user record, so the
// best-effort sweep clears it via ListUserPolicies/DeleteUserPolicy.
sweep := func() {
iamDeleteUserBestEffort(fix, usrInlineEnforceUser)
}
sweep()
fix.Harness.RegisterCleanup(sweep)
// User with its own static credentials.
harness.Step(t, "create-user %q + access key", usrInlineEnforceUser)
_, err := fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String(usrInlineEnforceUser)})
require.NoError(t, err, "create-user")
key, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{UserName: aws.String(usrInlineEnforceUser)})
require.NoError(t, err, "create-access-key")
userCli := harness.NewAWSClientWithCreds(t, fix.Env,
aws.StringValue(key.AccessKey.AccessKeyId),
aws.StringValue(key.AccessKey.SecretAccessKey))
// No grant anywhere yet: the active key authenticates, then default-denies.
harness.Step(t, "describe-regions with no grant (expect AccessDenied)")
harness.ExpectError(t, "AccessDenied", func() error {
_, e := userCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{})
return e
})
// Put an inline policy granting ec2:DescribeRegions directly on the user.
harness.Step(t, "put-user-policy %q (grants ec2:DescribeRegions)", usrInlineEnforceName)
_, err = fix.AWS.IAM.PutUserPolicy(&iam.PutUserPolicyInput{
UserName: aws.String(usrInlineEnforceUser),
PolicyName: aws.String(usrInlineEnforceName),
PolicyDocument: aws.String(usrInlinePolicyDoc),
})
require.NoError(t, err, "put-user-policy")
// Same live credentials are now allowed — the user's own inline grant applies.
harness.Step(t, "describe-regions after inline user grant (expect success)")
_, err = userCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{})
require.NoError(t, err, "describe-regions must be allowed once the user inline policy grants it")
// Delete the inline policy — the grant must evaporate, isolating the inline
// document as the sole grant source.
harness.Step(t, "delete-user-policy %s", usrInlineEnforceName)
_, err = fix.AWS.IAM.DeleteUserPolicy(&iam.DeleteUserPolicyInput{
UserName: aws.String(usrInlineEnforceUser),
PolicyName: aws.String(usrInlineEnforceName),
})
require.NoError(t, err, "delete-user-policy")
harness.Step(t, "describe-regions after inline policy removed (expect AccessDenied)")
harness.ExpectError(t, "AccessDenied", func() error {
_, e := userCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{})
return e
})
}
+8
View File
@@ -86,6 +86,14 @@ func TestIAMGroupInlineEnforcement(t *testing.T) {
runIAMGroupInlineEnforcement(t, requireIAMFixture(t))
}
// TestIAMUserInlineEnforcement proves a user's own inline policy grants its
// permission with no group or managed attachment: denied with no grant, allowed
// once the inline policy is put, denied again after it is deleted. Sequential to
// avoid racing the mid-test grant.
func TestIAMUserInlineEnforcement(t *testing.T) {
runIAMUserInlineEnforcement(t, requireIAMFixture(t))
}
// TestIAMInstanceProfileAssociation exercises the EC2 IAM instance-profile
// association lifecycle (associate/replace/disassociate, RunInstances
// --iam-instance-profile, auto-disassociate on terminate). It boots its own