fix(s3): preserve exact policy document in embedded IAM put/get-user-policy (#9025)

* fix(s3): preserve exact policy document in embedded IAM PutUserPolicy/GetUserPolicy (#9008)

The embedded IAM implementation (used when IAM requests go through the
S3 gateway) discarded the original policy document on PutUserPolicy,
storing only the lossy ident.Actions representation. GetUserPolicy then
reconstructed the document from these coarse-grained actions, producing
wildcard-expanded actions (s3:GetObject → s3:Get*), duplicates, and
collapsed resources (array → single string).

PR #9009 fixed this in the standalone IAM server (weed/iamapi/) but the
embedded IAM (weed/s3api/) — which is the code path most users hit —
had the same bugs.

Changes:

- Add InlinePolicyStore optional interface to credential store, with
  implementations for FilerEtcStore (uses existing PoliciesCollection),
  MemoryStore, and PropagatingCredentialStore.

- Embedded IAM PutUserPolicy now persists the original policy document
  via CredentialManager.PutUserInlinePolicy for lossless round-trips.

- Embedded IAM GetUserPolicy first tries the stored inline policy; only
  falls back to lossy reconstruction from ident.Actions when no stored
  document exists (e.g. policies created before this fix).

- Fix the fallback reconstruction: add action deduplication and preserve
  resource paths verbatim (no more spurious /* appending).

- Update DeleteUserPolicy/ListUserPolicies to use stored inline policies.

* fix(s3): address PR review feedback for embedded IAM inline policies

- Validate PolicyName is non-empty in PutUserPolicy and DeleteUserPolicy
- Add recomputeActions() to aggregate ident.Actions from ALL stored
  inline policies on put/delete, fixing the issue where a second
  PutUserPolicy would overwrite the first policy's enforcement
- Log errors from GetUserInlinePolicy in the GetUserPolicy fallback
  instead of silently ignoring them
- Add initialization guards to MemoryStore GetUserInlinePolicy and
  ListUserInlinePolicies for consistency with other read methods

* fix(s3): make inline policy persistence fatal and propagate recompute errors

Address second round of review feedback:

- recomputeActions() now returns ([]string, error) so callers can
  distinguish store failures from "no stored policies" and abort the
  mutation on transient errors instead of silently falling back.

- PutUserInlinePolicy and DeleteUserInlinePolicy failures are now fatal:
  the API call returns ServiceFailure instead of logging and continuing,
  keeping ident.Actions and stored policy state in sync.

* chore: gofmt weed/s3api/iceberg/handlers_oauth.go

Pre-existing formatting issue from #9017; fixes S3 Tables Format Check CI.
This commit is contained in:
Chris Lu
2026-04-10 18:09:22 -07:00
committed by GitHub
parent e648c76bcf
commit c390448906
8 changed files with 429 additions and 24 deletions

View File

@@ -271,6 +271,40 @@ func (cm *CredentialManager) UpdatePolicy(ctx context.Context, name string, docu
return cm.Store.PutPolicy(ctx, name, document)
}
// PutUserInlinePolicy stores a per-user inline policy document.
// Returns nil without error if the underlying store does not support inline policies.
func (cm *CredentialManager) PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error {
if store, ok := cm.Store.(InlinePolicyStore); ok {
return store.PutUserInlinePolicy(ctx, userName, policyName, document)
}
return nil
}
// GetUserInlinePolicy retrieves a per-user inline policy document.
// Returns nil without error if the underlying store does not support inline policies.
func (cm *CredentialManager) GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error) {
if store, ok := cm.Store.(InlinePolicyStore); ok {
return store.GetUserInlinePolicy(ctx, userName, policyName)
}
return nil, nil
}
// DeleteUserInlinePolicy removes a per-user inline policy document.
func (cm *CredentialManager) DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error {
if store, ok := cm.Store.(InlinePolicyStore); ok {
return store.DeleteUserInlinePolicy(ctx, userName, policyName)
}
return nil
}
// ListUserInlinePolicies returns the names of all inline policies for a user.
func (cm *CredentialManager) ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error) {
if store, ok := cm.Store.(InlinePolicyStore); ok {
return store.ListUserInlinePolicies(ctx, userName)
}
return nil, nil
}
// LoadS3ConfigFile reads a static S3 identity config file and registers
// the identities so they appear in LoadConfiguration and listing results.
func (cm *CredentialManager) LoadS3ConfigFile(path string) error {

View File

@@ -137,5 +137,16 @@ type PolicyManager interface {
GetPolicy(ctx context.Context, name string) (*policy_engine.PolicyDocument, error)
}
// InlinePolicyStore is an optional interface for credential stores that support
// per-user inline policy storage. Stores that implement this interface preserve
// the exact policy document submitted via PutUserPolicy, enabling lossless
// round-trips through GetUserPolicy.
type InlinePolicyStore interface {
PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error
GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error)
DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error
ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error)
}
// Stores holds all available credential store implementations
var Stores []CredentialStore

View File

@@ -316,6 +316,69 @@ func (store *FilerEtcStore) GetPolicy(ctx context.Context, name string) (*policy
return nil, nil // Policy not found
}
// PutUserInlinePolicy stores a per-user inline policy document.
func (store *FilerEtcStore) PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error {
store.policyMu.Lock()
defer store.policyMu.Unlock()
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return err
}
if policiesCollection.InlinePolicies[userName] == nil {
policiesCollection.InlinePolicies[userName] = make(map[string]policy_engine.PolicyDocument)
}
policiesCollection.InlinePolicies[userName][policyName] = document
return store.saveLegacyPoliciesCollection(ctx, policiesCollection)
}
// GetUserInlinePolicy retrieves a per-user inline policy document.
func (store *FilerEtcStore) GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error) {
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return nil, err
}
if userPolicies := policiesCollection.InlinePolicies[userName]; userPolicies != nil {
if doc, exists := userPolicies[policyName]; exists {
return &doc, nil
}
}
return nil, nil
}
// DeleteUserInlinePolicy removes a per-user inline policy document.
func (store *FilerEtcStore) DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error {
store.policyMu.Lock()
defer store.policyMu.Unlock()
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return err
}
if userPolicies := policiesCollection.InlinePolicies[userName]; userPolicies != nil {
delete(userPolicies, policyName)
if len(userPolicies) == 0 {
delete(policiesCollection.InlinePolicies, userName)
}
}
return store.saveLegacyPoliciesCollection(ctx, policiesCollection)
}
// ListUserInlinePolicies returns the names of all inline policies for a user.
func (store *FilerEtcStore) ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error) {
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return nil, err
}
userPolicies := policiesCollection.InlinePolicies[userName]
names := make([]string, 0, len(userPolicies))
for name := range userPolicies {
names = append(names, name)
}
return names, nil
}
// ListPolicyNames returns all managed policy names stored in the filer.
func (store *FilerEtcStore) ListPolicyNames(ctx context.Context) ([]string, error) {
names := make([]string, 0)

View File

@@ -105,3 +105,71 @@ func (store *MemoryStore) DeletePolicy(ctx context.Context, name string) error {
delete(store.policies, name)
return nil
}
// PutUserInlinePolicy stores a per-user inline policy document.
func (store *MemoryStore) PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
if store.inlinePolicies[userName] == nil {
store.inlinePolicies[userName] = make(map[string]policy_engine.PolicyDocument)
}
store.inlinePolicies[userName][policyName] = document
return nil
}
// GetUserInlinePolicy retrieves a per-user inline policy document.
func (store *MemoryStore) GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
if userPolicies := store.inlinePolicies[userName]; userPolicies != nil {
if doc, exists := userPolicies[policyName]; exists {
return &doc, nil
}
}
return nil, nil
}
// DeleteUserInlinePolicy removes a per-user inline policy document.
func (store *MemoryStore) DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
if userPolicies := store.inlinePolicies[userName]; userPolicies != nil {
delete(userPolicies, policyName)
if len(userPolicies) == 0 {
delete(store.inlinePolicies, userName)
}
}
return nil
}
// ListUserInlinePolicies returns the names of all inline policies for a user.
func (store *MemoryStore) ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
userPolicies := store.inlinePolicies[userName]
names := make([]string, 0, len(userPolicies))
for name := range userPolicies {
names = append(names, name)
}
return names, nil
}

View File

@@ -18,12 +18,13 @@ func init() {
// This is primarily intended for testing purposes
type MemoryStore struct {
mu sync.RWMutex
users map[string]*iam_pb.Identity // username -> identity
accessKeys map[string]string // access_key -> username
serviceAccounts map[string]*iam_pb.ServiceAccount // id -> service_account
serviceAccountAccessKeys map[string]string // access_key -> id
policies map[string]policy_engine.PolicyDocument // policy_name -> policy_document
groups map[string]*iam_pb.Group // group_name -> group
users map[string]*iam_pb.Identity // username -> identity
accessKeys map[string]string // access_key -> username
serviceAccounts map[string]*iam_pb.ServiceAccount // id -> service_account
serviceAccountAccessKeys map[string]string // access_key -> id
policies map[string]policy_engine.PolicyDocument // policy_name -> policy_document
inlinePolicies map[string]map[string]policy_engine.PolicyDocument // username -> policy_name -> document
groups map[string]*iam_pb.Group // group_name -> group
initialized bool
}
@@ -44,6 +45,7 @@ func (store *MemoryStore) Initialize(configuration util.Configuration, prefix st
store.serviceAccounts = make(map[string]*iam_pb.ServiceAccount)
store.serviceAccountAccessKeys = make(map[string]string)
store.policies = make(map[string]policy_engine.PolicyDocument)
store.inlinePolicies = make(map[string]map[string]policy_engine.PolicyDocument)
store.groups = make(map[string]*iam_pb.Group)
store.initialized = true
@@ -59,6 +61,7 @@ func (store *MemoryStore) Shutdown() {
store.serviceAccounts = nil
store.serviceAccountAccessKeys = nil
store.policies = nil
store.inlinePolicies = nil
store.groups = nil
store.initialized = false
}
@@ -74,6 +77,7 @@ func (store *MemoryStore) Reset() {
store.serviceAccounts = make(map[string]*iam_pb.ServiceAccount)
store.serviceAccountAccessKeys = make(map[string]string)
store.policies = make(map[string]policy_engine.PolicyDocument)
store.inlinePolicies = make(map[string]map[string]policy_engine.PolicyDocument)
store.groups = make(map[string]*iam_pb.Group)
}
}

View File

@@ -280,6 +280,34 @@ func (s *PropagatingCredentialStore) LoadInlinePolicies(ctx context.Context) (ma
return nil, nil
}
func (s *PropagatingCredentialStore) PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error {
if store, ok := s.CredentialStore.(InlinePolicyStore); ok {
return store.PutUserInlinePolicy(ctx, userName, policyName, document)
}
return nil
}
func (s *PropagatingCredentialStore) GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error) {
if store, ok := s.CredentialStore.(InlinePolicyStore); ok {
return store.GetUserInlinePolicy(ctx, userName, policyName)
}
return nil, nil
}
func (s *PropagatingCredentialStore) DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error {
if store, ok := s.CredentialStore.(InlinePolicyStore); ok {
return store.DeleteUserInlinePolicy(ctx, userName, policyName)
}
return nil
}
func (s *PropagatingCredentialStore) ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error) {
if store, ok := s.CredentialStore.(InlinePolicyStore); ok {
return store.ListUserInlinePolicies(ctx, userName)
}
return nil, nil
}
func (s *PropagatingCredentialStore) CreatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
if pm, ok := s.CredentialStore.(PolicyManager); ok {
if err := pm.CreatePolicy(ctx, name, document); err != nil {

View File

@@ -840,10 +840,54 @@ func (e *EmbeddedIamApi) getActions(policy *policy_engine.PolicyDocument) ([]str
return actions, nil
}
// recomputeActions aggregates ident.Actions from all stored inline policies
// for a user. Returns (nil, nil) when the credential manager is unavailable
// (caller should keep existing actions). Returns a non-nil error on store
// failures so callers can abort the mutation.
func (e *EmbeddedIamApi) recomputeActions(ctx context.Context, userName string) ([]string, error) {
if e.credentialManager == nil {
return nil, nil
}
policyNames, err := e.credentialManager.ListUserInlinePolicies(ctx, userName)
if err != nil {
return nil, fmt.Errorf("list inline policies for user %s: %w", userName, err)
}
if len(policyNames) == 0 {
return nil, nil
}
actionSet := make(map[string]bool)
var aggregated []string
for _, name := range policyNames {
doc, err := e.credentialManager.GetUserInlinePolicy(ctx, userName, name)
if err != nil {
return nil, fmt.Errorf("read inline policy %q for user %s: %w", name, userName, err)
}
if doc == nil {
continue
}
actions, err := e.getActions(doc)
if err != nil {
glog.Warningf("recomputeActions: failed to parse inline policy %q for user %s: %v", name, userName, err)
continue
}
for _, a := range actions {
if !actionSet[a] {
actionSet[a] = true
aggregated = append(aggregated, a)
}
}
}
return aggregated, nil
}
// PutUserPolicy attaches a policy to a user.
func (e *EmbeddedIamApi) PutUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamPutUserPolicyResponse, *iamError) {
resp := &iamPutUserPolicyResponse{}
userName := values.Get("UserName")
policyName := values.Get("PolicyName")
if policyName == "" {
return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("PolicyName is required")}
}
policyDocumentString := values.Get("PolicyDocument")
policyDocument, err := e.GetPolicyDocument(&policyDocumentString)
if err != nil {
@@ -858,7 +902,27 @@ func (e *EmbeddedIamApi) PutUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values
if userName != ident.Name {
continue
}
ident.Actions = actions
// Persist the original policy document for lossless round-trip via GetUserPolicy.
// This must succeed before updating ident.Actions to keep both in sync.
ctx := context.Background()
if e.credentialManager != nil {
if storeErr := e.credentialManager.PutUserInlinePolicy(ctx, userName, policyName, policyDocument); storeErr != nil {
return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: storeErr}
}
}
// Recompute ident.Actions from ALL stored inline policies so that
// multiple policies are properly aggregated for enforcement.
aggregated, recomputeErr := e.recomputeActions(ctx, userName)
if recomputeErr != nil {
return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: recomputeErr}
}
if aggregated != nil {
ident.Actions = aggregated
} else {
ident.Actions = actions
}
return resp, nil
}
return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("the user with name %s cannot be found", userName)}
@@ -876,34 +940,58 @@ func (e *EmbeddedIamApi) GetUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values
resp.GetUserPolicyResult.UserName = userName
resp.GetUserPolicyResult.PolicyName = policyName
// Try to retrieve the stored inline policy document for a lossless round-trip
if e.credentialManager != nil {
ctx := context.Background()
storedDoc, storeErr := e.credentialManager.GetUserInlinePolicy(ctx, userName, policyName)
if storeErr != nil {
glog.Warningf("GetUserPolicy: failed to read stored inline policy %q for user %s: %v; falling back to reconstruction", policyName, userName, storeErr)
}
if storeErr == nil && storedDoc != nil {
policyDocumentJSON, err := json.Marshal(storedDoc)
if err != nil {
return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
resp.GetUserPolicyResult.PolicyDocument = string(policyDocumentJSON)
return resp, nil
}
}
// Fallback: reconstruct from ident.Actions (lossy - fine-grained actions
// collapse to wildcards like s3:Get*, s3:Put*, s3:List*)
if len(ident.Actions) == 0 {
return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: errors.New("no actions found")}
}
policyDocument := policy_engine.PolicyDocument{Version: iamPolicyDocumentVersion}
statements := make(map[string][]string)
seenAction := make(map[string]map[string]bool)
for _, action := range ident.Actions {
// Action format: "ActionType" (global) or "ActionType:bucket" or "ActionType:bucket/path"
actionType, bucketPath, hasPath := strings.Cut(action, ":")
var resource string
if !hasPath {
// Global action (no bucket specified)
resource = "*"
} else if strings.Contains(bucketPath, "/") {
// Path-specific: bucket/path -> arn:aws:s3:::bucket/path/*
resource = fmt.Sprintf("arn:aws:s3:::%s/*", bucketPath)
} else {
// Bucket-level: bucket -> arn:aws:s3:::bucket/*
resource = fmt.Sprintf("arn:aws:s3:::%s/*", bucketPath)
// Use SplitN so the path component (which may contain ':') is preserved intact.
act := strings.SplitN(action, ":", 2)
resource := "*"
if len(act) == 2 {
// Preserve the stored path verbatim so bucket-level and
// object-level resources remain distinguishable.
resource = fmt.Sprintf("arn:aws:s3:::%s", act[1])
}
statements[resource] = append(statements[resource],
fmt.Sprintf("s3:%s", iamMapToIdentitiesAction(actionType)),
)
s3Action := fmt.Sprintf("s3:%s", iamMapToIdentitiesAction(act[0]))
// Dedupe actions per resource
if seenAction[resource] == nil {
seenAction[resource] = make(map[string]bool)
}
if seenAction[resource][s3Action] {
continue
}
seenAction[resource][s3Action] = true
statements[resource] = append(statements[resource], s3Action)
}
for resource, actions := range statements {
isEqAction := false
for i, statement := range policyDocument.Statement {
// Use order-independent comparison to avoid duplicates from different action orderings
if iamStringSlicesEqual(statement.Action.Strings(), actions) {
policyDocument.Statement[i].Resource = policy_engine.NewStringOrStringSlicePtr(append(
policyDocument.Statement[i].Resource.Strings(), resource)...)
@@ -931,13 +1019,36 @@ func (e *EmbeddedIamApi) GetUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values
return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)}
}
// DeleteUserPolicy removes the inline policy from a user (clears their actions).
// DeleteUserPolicy removes the inline policy from a user.
func (e *EmbeddedIamApi) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamDeleteUserPolicyResponse, *iamError) {
resp := &iamDeleteUserPolicyResponse{}
userName := values.Get("UserName")
policyName := values.Get("PolicyName")
if policyName == "" {
return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("PolicyName is required")}
}
for _, ident := range s3cfg.Identities {
if ident.Name == userName {
ident.Actions = nil
ctx := context.Background()
// Remove the stored inline policy document.
// Must succeed before updating ident.Actions to keep both in sync.
if e.credentialManager != nil {
if err := e.credentialManager.DeleteUserInlinePolicy(ctx, userName, policyName); err != nil {
return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
}
// Recompute ident.Actions from remaining inline policies
aggregated, recomputeErr := e.recomputeActions(ctx, userName)
if recomputeErr != nil {
return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: recomputeErr}
}
if aggregated != nil {
ident.Actions = aggregated
} else {
ident.Actions = nil
}
return resp, nil
}
}
@@ -951,6 +1062,16 @@ func (e *EmbeddedIamApi) ListUserPolicies(s3cfg *iam_pb.S3ApiConfiguration, valu
userName := values.Get("UserName")
for _, ident := range s3cfg.Identities {
if ident.Name == userName {
// Try to list stored inline policy names
if e.credentialManager != nil {
ctx := context.Background()
if names, err := e.credentialManager.ListUserInlinePolicies(ctx, userName); err == nil && len(names) > 0 {
resp.ListUserPoliciesResult.PolicyNames = names
resp.ListUserPoliciesResult.IsTruncated = false
return resp, nil
}
}
// Fallback: infer a single policy name from actions
if len(ident.Actions) > 0 {
resp.ListUserPoliciesResult.PolicyNames = []string{userName + "_policy"}
}

View File

@@ -1259,6 +1259,82 @@ func TestEmbeddedIamCreateAccessKeyForExistingUser(t *testing.T) {
assert.Len(t, api.mockConfig.Identities[0].Credentials, 1)
}
// TestEmbeddedIamPutGetUserPolicyRoundTrip is a regression test for
// https://github.com/seaweedfs/seaweedfs/issues/9008: put-user-policy followed
// by get-user-policy must return the same policy document, with Action and Resource
// lists intact (no wildcard expansion, no duplication, no collapsing).
func TestEmbeddedIamPutGetUserPolicyRoundTrip(t *testing.T) {
api := NewEmbeddedIamApiForTest()
s3cfg := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "steward"}},
}
api.mockConfig = s3cfg
policyJSON := `{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::b-le*", "arn:aws:s3:::b-le*/*"]
}]
}`
// PutUserPolicy
_, iamErr := api.PutUserPolicy(s3cfg, url.Values{
"UserName": []string{"steward"},
"PolicyName": []string{"steward_policy"},
"PolicyDocument": []string{policyJSON},
})
assert.Nil(t, iamErr)
// GetUserPolicy should return the exact document
resp, iamErr := api.GetUserPolicy(s3cfg, url.Values{
"UserName": []string{"steward"},
"PolicyName": []string{"steward_policy"},
})
assert.Nil(t, iamErr)
var got policy_engine.PolicyDocument
assert.NoError(t, json.Unmarshal([]byte(resp.GetUserPolicyResult.PolicyDocument), &got))
assert.Equal(t, "2012-10-17", got.Version)
require.Equal(t, 1, len(got.Statement))
stmt := got.Statement[0]
assert.Equal(t, policy_engine.PolicyEffectAllow, stmt.Effect)
assert.ElementsMatch(t, []string{"s3:GetObject", "s3:PutObject", "s3:ListBucket"}, stmt.Action.Strings())
assert.ElementsMatch(t, []string{"arn:aws:s3:::b-le*", "arn:aws:s3:::b-le*/*"}, stmt.Resource.Strings())
}
// TestEmbeddedIamGetUserPolicyFallback tests the lossy fallback reconstruction
// when no stored inline policy is available (pre-existing ident.Actions only).
func TestEmbeddedIamGetUserPolicyFallback(t *testing.T) {
api := NewEmbeddedIamApiForTest()
s3cfg := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{
Name: "steward",
Actions: []string{"Read:b-le*", "Write:b-le*", "List:b-le*", "Read:b-le*/*", "Write:b-le*/*", "List:b-le*/*"},
}},
}
api.mockConfig = s3cfg
// No stored inline policy — GetUserPolicy must reconstruct from ident.Actions
resp, iamErr := api.GetUserPolicy(s3cfg, url.Values{
"UserName": []string{"steward"},
"PolicyName": []string{"steward_policy"},
})
assert.Nil(t, iamErr)
var got policy_engine.PolicyDocument
assert.NoError(t, json.Unmarshal([]byte(resp.GetUserPolicyResult.PolicyDocument), &got))
// Fallback is lossy but must not duplicate actions
require.Equal(t, 1, len(got.Statement), "fallback should merge equal-action statements")
stmt := got.Statement[0]
assert.ElementsMatch(t, []string{"s3:Get*", "s3:Put*", "s3:List*"}, stmt.Action.Strings())
// Bucket-level and object-level resources stay distinct
assert.ElementsMatch(t, []string{"arn:aws:s3:::b-le*", "arn:aws:s3:::b-le*/*"}, stmt.Resource.Strings())
}
// TestEmbeddedIamGetUserPolicyUserNotFound tests GetUserPolicy with non-existent user
func TestEmbeddedIamGetUserPolicyUserNotFound(t *testing.T) {
api := NewEmbeddedIamApiForTest()