mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-30 03:35:37 +00:00
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:
@@ -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"}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user