s3api: fix static IAM policy enforcement after reload (#8532)

* s3api: honor attached IAM policies over legacy actions

* s3api: hydrate IAM policy docs during config reload

* s3api: use policy-aware auth when listing buckets

* credential: propagate context through filer_etc policy reads

* credential: make legacy policy deletes durable

* s3api: exercise managed policy runtime loader

* s3api: allow static IAM users without session tokens

* iam: deny unmatched attached policies under default allow

* iam: load embedded policy files from filer store

* s3api: require session tokens for IAM presigning

* s3api: sync runtime policies into zero-config IAM

* credential: respect context in policy file loads

* credential: serialize legacy policy deletes

* iam: align filer policy store naming

* s3api: use authenticated principals for presigning

* iam: deep copy policy conditions

* s3api: require request creation in policy tests

* filer: keep ReadInsideFiler as the context-aware API

* iam: harden filer policy store writes

* credential: strengthen legacy policy serialization test

* credential: forward runtime policy loaders through wrapper

* s3api: harden runtime policy merging

* iam: require typed already-exists errors
This commit is contained in:
Chris Lu
2026-03-06 12:35:08 -08:00
committed by GitHub
parent 338be16254
commit f9311a3422
30 changed files with 1903 additions and 168 deletions

View File

@@ -1553,6 +1553,165 @@ func (iam *IdentityAccessManagement) GetCredentialManager() *credential.Credenti
return iam.credentialManager
}
type managedPolicyLoader interface {
LoadManagedPolicies(ctx context.Context) ([]*iam_pb.Policy, error)
}
type inlinePolicyLoader interface {
LoadInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error)
}
func inlinePolicyRuntimeName(userName, policyName string) string {
return "__inline_policy__/" + userName + "/" + policyName
}
func mergePoliciesIntoConfiguration(config *iam_pb.S3ApiConfiguration, policies []*iam_pb.Policy) {
if len(policies) == 0 {
return
}
existingPolicies := make(map[string]int, len(config.Policies))
for idx, policy := range config.Policies {
if policy == nil || policy.Name == "" {
continue
}
existingPolicies[policy.Name] = idx
}
for _, policy := range policies {
if policy == nil || policy.Name == "" {
continue
}
policyCopy := &iam_pb.Policy{Name: policy.Name, Content: policy.Content}
if existingIdx, found := existingPolicies[policy.Name]; found {
config.Policies[existingIdx] = policyCopy
continue
}
config.Policies = append(config.Policies, policyCopy)
existingPolicies[policy.Name] = len(config.Policies) - 1
}
}
func appendUniquePolicyName(policyNames []string, policyName string) []string {
for _, existingPolicyName := range policyNames {
if existingPolicyName == policyName {
return policyNames
}
}
return append(policyNames, policyName)
}
func (iam *IdentityAccessManagement) loadManagedPoliciesForRuntime(ctx context.Context) ([]*iam_pb.Policy, error) {
store := iam.credentialManager.GetStore()
if store == nil {
return nil, nil
}
if loader, ok := store.(managedPolicyLoader); ok {
return loader.LoadManagedPolicies(ctx)
}
policies, err := iam.credentialManager.GetPolicies(ctx)
if err != nil {
return nil, err
}
managedPolicies := make([]*iam_pb.Policy, 0, len(policies))
for name, policyDocument := range policies {
content, err := json.Marshal(policyDocument)
if err != nil {
return nil, fmt.Errorf("failed to marshal policy %q: %w", name, err)
}
managedPolicies = append(managedPolicies, &iam_pb.Policy{
Name: name,
Content: string(content),
})
}
return managedPolicies, nil
}
func (iam *IdentityAccessManagement) hydrateRuntimePolicies(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
if iam.credentialManager == nil || config == nil {
return nil
}
managedPolicies, err := iam.loadManagedPoliciesForRuntime(ctx)
if err != nil {
return fmt.Errorf("failed to load managed policies for runtime: %w", err)
}
mergePoliciesIntoConfiguration(config, managedPolicies)
store := iam.credentialManager.GetStore()
if store == nil {
return nil
}
inlineLoader, ok := store.(inlinePolicyLoader)
if !ok {
return nil
}
inlinePoliciesByUser, err := inlineLoader.LoadInlinePolicies(ctx)
if err != nil {
return fmt.Errorf("failed to load inline policies for runtime: %w", err)
}
if len(inlinePoliciesByUser) == 0 {
return nil
}
identityByName := make(map[string]*iam_pb.Identity, len(config.Identities))
for _, identity := range config.Identities {
identityByName[identity.Name] = identity
}
inlinePolicies := make([]*iam_pb.Policy, 0)
for userName, userPolicies := range inlinePoliciesByUser {
identity, found := identityByName[userName]
if !found {
continue
}
for policyName, policyDocument := range userPolicies {
content, err := json.Marshal(policyDocument)
if err != nil {
return fmt.Errorf("failed to marshal inline policy %q for user %q: %w", policyName, userName, err)
}
runtimePolicyName := inlinePolicyRuntimeName(userName, policyName)
inlinePolicies = append(inlinePolicies, &iam_pb.Policy{
Name: runtimePolicyName,
Content: string(content),
})
identity.PolicyNames = appendUniquePolicyName(identity.PolicyNames, runtimePolicyName)
}
}
mergePoliciesIntoConfiguration(config, inlinePolicies)
return nil
}
func (iam *IdentityAccessManagement) syncRuntimePoliciesToIAMManager(ctx context.Context, policies []*iam_pb.Policy) error {
if iam == nil || iam.iamIntegration == nil {
return nil
}
provider, ok := iam.iamIntegration.(IAMManagerProvider)
if !ok {
return nil
}
manager := provider.GetIAMManager()
if manager == nil {
return nil
}
return manager.SyncRuntimePolicies(ctx, policies)
}
// LoadS3ApiConfigurationFromCredentialManager loads configuration using the credential manager
func (iam *IdentityAccessManagement) LoadS3ApiConfigurationFromCredentialManager() error {
glog.V(1).Infof("Loading S3 API configuration from credential manager")
@@ -1566,6 +1725,15 @@ func (iam *IdentityAccessManagement) LoadS3ApiConfigurationFromCredentialManager
glog.V(2).Infof("Credential manager returned %d identities and %d accounts",
len(s3ApiConfiguration.Identities), len(s3ApiConfiguration.Accounts))
if err := iam.hydrateRuntimePolicies(context.Background(), s3ApiConfiguration); err != nil {
glog.Errorf("Failed to hydrate runtime IAM policies: %v", err)
return err
}
if err := iam.syncRuntimePoliciesToIAMManager(context.Background(), s3ApiConfiguration.Policies); err != nil {
glog.Errorf("Failed to sync runtime IAM policies to advanced IAM manager: %v", err)
return err
}
if err := iam.loadS3ApiConfiguration(s3ApiConfiguration); err != nil {
glog.Errorf("Failed to load S3 API configuration: %v", err)
return err
@@ -1726,11 +1894,23 @@ func (iam *IdentityAccessManagement) VerifyActionPermission(r *http.Request, ide
hasSessionToken := r.Header.Get("X-SeaweedFS-Session-Token") != "" ||
r.Header.Get("X-Amz-Security-Token") != "" ||
r.URL.Query().Get("X-Amz-Security-Token") != ""
hasAttachedPolicies := len(identity.PolicyNames) > 0
if (len(identity.Actions) == 0 || hasSessionToken) && iam.iamIntegration != nil {
if (len(identity.Actions) == 0 || hasSessionToken || hasAttachedPolicies) && iam.iamIntegration != nil {
return iam.authorizeWithIAM(r, identity, action, bucket, object)
}
// Attached IAM policies are authoritative for IAM users. The legacy Actions
// field is a lossy projection that cannot represent deny statements,
// conditions, or fine-grained action differences such as PutObject vs
// DeleteObject.
if hasAttachedPolicies {
if iam.evaluateIAMPolicies(r, identity, action, bucket, object) {
return s3err.ErrNone
}
return s3err.ErrAccessDenied
}
// Traditional actions-based authorization from static S3 config.
if len(identity.Actions) > 0 {
if !identity.CanDo(action, bucket, object) {
@@ -1739,14 +1919,6 @@ func (iam *IdentityAccessManagement) VerifyActionPermission(r *http.Request, ide
return s3err.ErrNone
}
// IAM policy fallback for identities with attached policies but without IAM integration.
if len(identity.PolicyNames) > 0 {
if iam.evaluateIAMPolicies(r, identity, action, bucket, object) {
return s3err.ErrNone
}
return s3err.ErrAccessDenied
}
return s3err.ErrAccessDenied
}

View File

@@ -1,6 +1,7 @@
package s3api
import (
"context"
"crypto/tls"
"fmt"
"net/http"
@@ -10,18 +11,71 @@ import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/credential/memory"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
. "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
jsonpb "google.golang.org/protobuf/encoding/protojson"
_ "github.com/seaweedfs/seaweedfs/weed/credential/filer_etc"
_ "github.com/seaweedfs/seaweedfs/weed/credential/memory"
)
type loadConfigurationDropsPoliciesStore struct {
*memory.MemoryStore
loadManagedPoliciesCalled bool
}
func (store *loadConfigurationDropsPoliciesStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
config, err := store.MemoryStore.LoadConfiguration(ctx)
if err != nil {
return nil, err
}
stripped := *config
stripped.Policies = nil
return &stripped, nil
}
func (store *loadConfigurationDropsPoliciesStore) LoadManagedPolicies(ctx context.Context) ([]*iam_pb.Policy, error) {
store.loadManagedPoliciesCalled = true
config, err := store.MemoryStore.LoadConfiguration(ctx)
if err != nil {
return nil, err
}
policies := make([]*iam_pb.Policy, 0, len(config.Policies))
for _, policy := range config.Policies {
policies = append(policies, &iam_pb.Policy{
Name: policy.Name,
Content: policy.Content,
})
}
return policies, nil
}
type inlinePolicyRuntimeStore struct {
*memory.MemoryStore
inlinePolicies map[string]map[string]policy_engine.PolicyDocument
}
func (store *inlinePolicyRuntimeStore) LoadInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error) {
_ = ctx
return store.inlinePolicies, nil
}
func newPolicyAuthRequest(t *testing.T, method string) *http.Request {
t.Helper()
req, err := http.NewRequest(method, "http://s3.amazonaws.com/test-bucket/test-object", nil)
require.NoError(t, err)
return req
}
func TestIdentityListFileFormat(t *testing.T) {
s3ApiConfiguration := &iam_pb.S3ApiConfiguration{}
@@ -374,6 +428,25 @@ func TestVerifyActionPermissionPolicyFallback(t *testing.T) {
assert.Equal(t, s3err.ErrNone, errCode)
})
t.Run("attached policies override coarse legacy actions", func(t *testing.T) {
iam := &IdentityAccessManagement{}
err := iam.PutPolicy("putOnly", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::test-bucket/*"}]}`)
assert.NoError(t, err)
identity := &Identity{
Name: "policy-user",
Account: &AccountAdmin,
Actions: []Action{"Write:test-bucket"},
PolicyNames: []string{"putOnly"},
}
putErrCode := iam.VerifyActionPermission(buildRequest(t, http.MethodPut), identity, Action(ACTION_WRITE), "test-bucket", "test-object")
assert.Equal(t, s3err.ErrNone, putErrCode)
deleteErrCode := iam.VerifyActionPermission(buildRequest(t, http.MethodDelete), identity, Action(ACTION_WRITE), "test-bucket", "test-object")
assert.Equal(t, s3err.ErrAccessDenied, deleteErrCode)
})
t.Run("valid policy updated to invalid denies access", func(t *testing.T) {
iam := &IdentityAccessManagement{}
err := iam.PutPolicy("myPolicy", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*"}]}`)
@@ -409,6 +482,288 @@ func TestVerifyActionPermissionPolicyFallback(t *testing.T) {
})
}
func TestLoadS3ApiConfigurationFromCredentialManagerHydratesManagedPolicies(t *testing.T) {
baseStore := &memory.MemoryStore{}
assert.NoError(t, baseStore.Initialize(nil, ""))
store := &loadConfigurationDropsPoliciesStore{MemoryStore: baseStore}
cm := &credential.CredentialManager{Store: store}
config := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: "managed-user",
PolicyNames: []string{"managedGet"},
Credentials: []*iam_pb.Credential{
{AccessKey: "AKIAMANAGED000001", SecretKey: "managed-secret"},
},
},
},
Policies: []*iam_pb.Policy{
{
Name: "managedGet",
Content: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*"}]}`,
},
},
}
assert.NoError(t, cm.SaveConfiguration(context.Background(), config))
iam := &IdentityAccessManagement{credentialManager: cm}
assert.NoError(t, iam.LoadS3ApiConfigurationFromCredentialManager())
assert.True(t, store.loadManagedPoliciesCalled)
identity := iam.lookupByIdentityName("managed-user")
if !assert.NotNil(t, identity) {
return
}
errCode := iam.VerifyActionPermission(newPolicyAuthRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "test-object")
assert.Equal(t, s3err.ErrNone, errCode)
}
func TestLoadS3ApiConfigurationFromCredentialManagerHydratesManagedPoliciesThroughPropagatingStore(t *testing.T) {
baseStore := &memory.MemoryStore{}
assert.NoError(t, baseStore.Initialize(nil, ""))
upstream := &loadConfigurationDropsPoliciesStore{MemoryStore: baseStore}
wrappedStore := credential.NewPropagatingCredentialStore(upstream, nil, nil)
cm := &credential.CredentialManager{Store: wrappedStore}
config := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: "managed-user",
PolicyNames: []string{"managedGet"},
Credentials: []*iam_pb.Credential{
{AccessKey: "AKIAMANAGED000010", SecretKey: "managed-secret"},
},
},
},
Policies: []*iam_pb.Policy{
{
Name: "managedGet",
Content: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*"}]}`,
},
},
}
assert.NoError(t, cm.SaveConfiguration(context.Background(), config))
iam := &IdentityAccessManagement{credentialManager: cm}
assert.NoError(t, iam.LoadS3ApiConfigurationFromCredentialManager())
assert.True(t, upstream.loadManagedPoliciesCalled)
identity := iam.lookupByIdentityName("managed-user")
if !assert.NotNil(t, identity) {
return
}
errCode := iam.VerifyActionPermission(newPolicyAuthRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "test-object")
assert.Equal(t, s3err.ErrNone, errCode)
}
func TestLoadS3ApiConfigurationFromCredentialManagerSyncsPoliciesToIAMManager(t *testing.T) {
ctx := context.Background()
baseStore := &memory.MemoryStore{}
assert.NoError(t, baseStore.Initialize(nil, ""))
cm := &credential.CredentialManager{Store: baseStore}
config := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: "managed-user",
PolicyNames: []string{"managedPut"},
Credentials: []*iam_pb.Credential{
{AccessKey: "AKIAMANAGED000002", SecretKey: "managed-secret"},
},
},
},
Policies: []*iam_pb.Policy{
{
Name: "managedPut",
Content: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:PutObject","s3:ListBucket"],"Resource":["arn:aws:s3:::cli-allowed-bucket","arn:aws:s3:::cli-allowed-bucket/*"]}]}`,
},
},
}
assert.NoError(t, cm.SaveConfiguration(ctx, config))
iamManager, err := loadIAMManagerFromConfig("", func() string { return "localhost:8888" }, func() string {
return "fallback-key-for-zero-config"
})
assert.NoError(t, err)
iamManager.SetUserStore(cm)
iam := &IdentityAccessManagement{credentialManager: cm}
iam.SetIAMIntegration(NewS3IAMIntegration(iamManager, ""))
assert.NoError(t, iam.LoadS3ApiConfigurationFromCredentialManager())
identity := iam.lookupByIdentityName("managed-user")
if !assert.NotNil(t, identity) {
return
}
allowedErrCode := iam.VerifyActionPermission(newPolicyAuthRequest(t, http.MethodPut), identity, Action(ACTION_WRITE), "cli-allowed-bucket", "test-object")
assert.Equal(t, s3err.ErrNone, allowedErrCode)
forbiddenErrCode := iam.VerifyActionPermission(newPolicyAuthRequest(t, http.MethodPut), identity, Action(ACTION_WRITE), "cli-forbidden-bucket", "test-object")
assert.Equal(t, s3err.ErrAccessDenied, forbiddenErrCode)
}
func TestLoadS3ApiConfigurationFromCredentialManagerHydratesInlinePolicies(t *testing.T) {
baseStore := &memory.MemoryStore{}
assert.NoError(t, baseStore.Initialize(nil, ""))
inlinePolicy := policy_engine.PolicyDocument{
Version: policy_engine.PolicyVersion2012_10_17,
Statement: []policy_engine.PolicyStatement{
{
Effect: policy_engine.PolicyEffectAllow,
Action: policy_engine.NewStringOrStringSlice("s3:PutObject"),
Resource: policy_engine.NewStringOrStringSlice("arn:aws:s3:::test-bucket/*"),
},
},
}
store := &inlinePolicyRuntimeStore{
MemoryStore: baseStore,
inlinePolicies: map[string]map[string]policy_engine.PolicyDocument{
"inline-user": {
"PutOnly": inlinePolicy,
},
},
}
cm := &credential.CredentialManager{Store: store}
config := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: "inline-user",
Actions: []string{"Write:test-bucket"},
Credentials: []*iam_pb.Credential{
{AccessKey: "AKIAINLINE0000001", SecretKey: "inline-secret"},
},
},
},
}
assert.NoError(t, cm.SaveConfiguration(context.Background(), config))
iam := &IdentityAccessManagement{credentialManager: cm}
assert.NoError(t, iam.LoadS3ApiConfigurationFromCredentialManager())
identity := iam.lookupByIdentityName("inline-user")
if !assert.NotNil(t, identity) {
return
}
assert.Contains(t, identity.PolicyNames, inlinePolicyRuntimeName("inline-user", "PutOnly"))
putErrCode := iam.VerifyActionPermission(newPolicyAuthRequest(t, http.MethodPut), identity, Action(ACTION_WRITE), "test-bucket", "test-object")
assert.Equal(t, s3err.ErrNone, putErrCode)
deleteErrCode := iam.VerifyActionPermission(newPolicyAuthRequest(t, http.MethodDelete), identity, Action(ACTION_WRITE), "test-bucket", "test-object")
assert.Equal(t, s3err.ErrAccessDenied, deleteErrCode)
}
func TestLoadS3ApiConfigurationFromCredentialManagerHydratesInlinePoliciesThroughPropagatingStore(t *testing.T) {
baseStore := &memory.MemoryStore{}
assert.NoError(t, baseStore.Initialize(nil, ""))
inlinePolicy := policy_engine.PolicyDocument{
Version: policy_engine.PolicyVersion2012_10_17,
Statement: []policy_engine.PolicyStatement{
{
Effect: policy_engine.PolicyEffectAllow,
Action: policy_engine.NewStringOrStringSlice("s3:PutObject"),
Resource: policy_engine.NewStringOrStringSlice("arn:aws:s3:::test-bucket/*"),
},
},
}
upstream := &inlinePolicyRuntimeStore{
MemoryStore: baseStore,
inlinePolicies: map[string]map[string]policy_engine.PolicyDocument{
"inline-user": {
"PutOnly": inlinePolicy,
},
},
}
wrappedStore := credential.NewPropagatingCredentialStore(upstream, nil, nil)
cm := &credential.CredentialManager{Store: wrappedStore}
config := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: "inline-user",
Actions: []string{"Write:test-bucket"},
Credentials: []*iam_pb.Credential{
{AccessKey: "AKIAINLINE0000010", SecretKey: "inline-secret"},
},
},
},
}
assert.NoError(t, cm.SaveConfiguration(context.Background(), config))
iam := &IdentityAccessManagement{credentialManager: cm}
assert.NoError(t, iam.LoadS3ApiConfigurationFromCredentialManager())
identity := iam.lookupByIdentityName("inline-user")
if !assert.NotNil(t, identity) {
return
}
assert.Contains(t, identity.PolicyNames, inlinePolicyRuntimeName("inline-user", "PutOnly"))
putErrCode := iam.VerifyActionPermission(newPolicyAuthRequest(t, http.MethodPut), identity, Action(ACTION_WRITE), "test-bucket", "test-object")
assert.Equal(t, s3err.ErrNone, putErrCode)
deleteErrCode := iam.VerifyActionPermission(newPolicyAuthRequest(t, http.MethodDelete), identity, Action(ACTION_WRITE), "test-bucket", "test-object")
assert.Equal(t, s3err.ErrAccessDenied, deleteErrCode)
}
func TestLoadConfigurationDropsPoliciesStoreDoesNotMutateSourceConfig(t *testing.T) {
baseStore := &memory.MemoryStore{}
require.NoError(t, baseStore.Initialize(nil, ""))
config := &iam_pb.S3ApiConfiguration{
Policies: []*iam_pb.Policy{
{Name: "managedGet", Content: `{"Version":"2012-10-17","Statement":[]}`},
},
}
require.NoError(t, baseStore.SaveConfiguration(context.Background(), config))
store := &loadConfigurationDropsPoliciesStore{MemoryStore: baseStore}
stripped, err := store.LoadConfiguration(context.Background())
require.NoError(t, err)
assert.Nil(t, stripped.Policies)
source, err := baseStore.LoadConfiguration(context.Background())
require.NoError(t, err)
require.Len(t, source.Policies, 1)
assert.Equal(t, "managedGet", source.Policies[0].Name)
}
func TestMergePoliciesIntoConfigurationSkipsNilPolicies(t *testing.T) {
config := &iam_pb.S3ApiConfiguration{
Policies: []*iam_pb.Policy{
nil,
{Name: "existing", Content: "old"},
},
}
mergePoliciesIntoConfiguration(config, []*iam_pb.Policy{
nil,
{Name: "", Content: "ignored"},
{Name: "existing", Content: "updated"},
{Name: "new", Content: "created"},
})
require.Len(t, config.Policies, 3)
assert.Nil(t, config.Policies[0])
assert.Equal(t, "existing", config.Policies[1].Name)
assert.Equal(t, "updated", config.Policies[1].Content)
assert.Equal(t, "new", config.Policies[2].Name)
assert.Equal(t, "created", config.Policies[2].Content)
}
type LoadS3ApiConfigurationTestCase struct {
pbAccount *iam_pb.Account
pbIdent *iam_pb.Identity

View File

@@ -248,7 +248,7 @@ func (s3iam *S3IAMIntegration) AuthorizeAction(ctx context.Context, identity *IA
return s3err.ErrNone // Fallback to existing authorization
}
if identity.SessionToken == "" {
if identity == nil || identity.Principal == "" {
return s3err.ErrAccessDenied
}
@@ -292,9 +292,12 @@ func (s3iam *S3IAMIntegration) AuthorizeAction(ctx context.Context, identity *IA
// Create action request
actionRequest := &integration.ActionRequest{
Principal: identity.Principal,
Action: specificAction,
Resource: resourceArn,
Principal: identity.Principal,
Action: specificAction,
Resource: resourceArn,
// Static SigV4 IAM users do not carry a session token. IAMManager
// evaluates their attached policies directly and only validates STS/OIDC
// session state when a token is actually present.
SessionToken: identity.SessionToken,
RequestContext: requestContext,
PolicyNames: identity.PolicyNames,

View File

@@ -13,16 +13,15 @@ import (
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestS3IAMMiddleware tests the basic S3 IAM middleware functionality
func TestS3IAMMiddleware(t *testing.T) {
// Create IAM manager
iamManager := integration.NewIAMManager()
func newTestS3IAMManagerWithDefaultEffect(t *testing.T, defaultEffect string) *integration.IAMManager {
t.Helper()
// Initialize with test configuration
iamManager := integration.NewIAMManager()
config := &integration.IAMConfig{
STS: &sts.STSConfig{
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
@@ -31,7 +30,7 @@ func TestS3IAMMiddleware(t *testing.T) {
SigningKey: []byte("test-signing-key-32-characters-long"),
},
Policy: &policy.PolicyEngineConfig{
DefaultEffect: "Deny",
DefaultEffect: defaultEffect,
StoreType: "memory",
},
Roles: &integration.RoleStoreConfig{
@@ -40,10 +39,22 @@ func TestS3IAMMiddleware(t *testing.T) {
}
err := iamManager.Initialize(config, func() string {
return "localhost:8888" // Mock filer address for testing
return "localhost:8888"
})
require.NoError(t, err)
return iamManager
}
func newTestS3IAMManager(t *testing.T) *integration.IAMManager {
t.Helper()
return newTestS3IAMManagerWithDefaultEffect(t, "Deny")
}
// TestS3IAMMiddleware tests the basic S3 IAM middleware functionality
func TestS3IAMMiddleware(t *testing.T) {
iamManager := newTestS3IAMManager(t)
// Create S3 IAM integration
s3IAMIntegration := NewS3IAMIntegration(iamManager, "localhost:8888")
@@ -52,6 +63,74 @@ func TestS3IAMMiddleware(t *testing.T) {
assert.True(t, s3IAMIntegration.enabled)
}
func TestS3IAMMiddlewareStaticV4ManagedPolicies(t *testing.T) {
ctx := context.Background()
iamManager := newTestS3IAMManager(t)
allowPolicy := &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{
{
Effect: "Allow",
Action: policy.StringList{"s3:PutObject", "s3:ListBucket"},
Resource: policy.StringList{"arn:aws:s3:::cli-allowed-bucket", "arn:aws:s3:::cli-allowed-bucket/*"},
},
},
}
require.NoError(t, iamManager.CreatePolicy(ctx, "localhost:8888", "cli-bucket-access-policy", allowPolicy))
s3IAMIntegration := NewS3IAMIntegration(iamManager, "localhost:8888")
identity := &IAMIdentity{
Name: "cli-test-user",
Principal: "arn:aws:iam::000000000000:user/cli-test-user",
PolicyNames: []string{"cli-bucket-access-policy"},
}
putReq := httptest.NewRequest(http.MethodPut, "http://example.com/cli-allowed-bucket/test-file.txt", http.NoBody)
putErrCode := s3IAMIntegration.AuthorizeAction(ctx, identity, s3_constants.ACTION_WRITE, "cli-allowed-bucket", "test-file.txt", putReq)
assert.Equal(t, s3err.ErrNone, putErrCode)
listReq := httptest.NewRequest(http.MethodGet, "http://example.com/cli-allowed-bucket/", http.NoBody)
listErrCode := s3IAMIntegration.AuthorizeAction(ctx, identity, s3_constants.ACTION_LIST, "cli-allowed-bucket", "", listReq)
assert.Equal(t, s3err.ErrNone, listErrCode)
}
func TestS3IAMMiddlewareAttachedPoliciesRestrictDefaultAllow(t *testing.T) {
ctx := context.Background()
iamManager := newTestS3IAMManagerWithDefaultEffect(t, "Allow")
allowPolicy := &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{
{
Effect: "Allow",
Action: policy.StringList{"s3:PutObject", "s3:ListBucket"},
Resource: policy.StringList{"arn:aws:s3:::cli-allowed-bucket", "arn:aws:s3:::cli-allowed-bucket/*"},
},
},
}
require.NoError(t, iamManager.CreatePolicy(ctx, "localhost:8888", "cli-bucket-access-policy", allowPolicy))
s3IAMIntegration := NewS3IAMIntegration(iamManager, "localhost:8888")
identity := &IAMIdentity{
Name: "cli-test-user",
Principal: "arn:aws:iam::000000000000:user/cli-test-user",
PolicyNames: []string{"cli-bucket-access-policy"},
}
allowedReq := httptest.NewRequest(http.MethodPut, "http://example.com/cli-allowed-bucket/test-file.txt", http.NoBody)
allowedErrCode := s3IAMIntegration.AuthorizeAction(ctx, identity, s3_constants.ACTION_WRITE, "cli-allowed-bucket", "test-file.txt", allowedReq)
assert.Equal(t, s3err.ErrNone, allowedErrCode)
forbiddenReq := httptest.NewRequest(http.MethodPut, "http://example.com/cli-forbidden-bucket/forbidden-file.txt", http.NoBody)
forbiddenErrCode := s3IAMIntegration.AuthorizeAction(ctx, identity, s3_constants.ACTION_WRITE, "cli-forbidden-bucket", "forbidden-file.txt", forbiddenReq)
assert.Equal(t, s3err.ErrAccessDenied, forbiddenErrCode)
forbiddenListReq := httptest.NewRequest(http.MethodGet, "http://example.com/cli-forbidden-bucket/", http.NoBody)
forbiddenListErrCode := s3IAMIntegration.AuthorizeAction(ctx, identity, s3_constants.ACTION_LIST, "cli-forbidden-bucket", "", forbiddenListReq)
assert.Equal(t, s3err.ErrAccessDenied, forbiddenListErrCode)
}
// TestS3IAMMiddlewareJWTAuth tests JWT authentication
func TestS3IAMMiddlewareJWTAuth(t *testing.T) {
// Skip for now since it requires full setup

View File

@@ -101,21 +101,10 @@ func (pm *S3PresignedURLManager) GeneratePresignedURLWithIAM(ctx context.Context
if pm.s3iam == nil || !pm.s3iam.enabled {
return nil, fmt.Errorf("IAM integration not enabled")
}
// Validate session token and get identity
// Use a proper ARN format for the principal
principalArn := fmt.Sprintf("arn:aws:sts::assumed-role/PresignedUser/presigned-session")
iamIdentity := &IAMIdentity{
SessionToken: req.SessionToken,
Principal: principalArn,
Name: "presigned-user",
Account: &AccountAdmin,
if req == nil || strings.TrimSpace(req.SessionToken) == "" {
return nil, fmt.Errorf("IAM authorization failed: session token is required")
}
// Determine S3 action from method
action := determineS3ActionFromMethodAndPath(req.Method, req.Bucket, req.ObjectKey)
// Check IAM permissions before generating URL
authRequest := &http.Request{
Method: req.Method,
URL: &url.URL{Path: "/" + req.Bucket + "/" + req.ObjectKey},
@@ -124,7 +113,16 @@ func (pm *S3PresignedURLManager) GeneratePresignedURLWithIAM(ctx context.Context
authRequest.Header.Set("Authorization", "Bearer "+req.SessionToken)
authRequest = authRequest.WithContext(ctx)
errCode := pm.s3iam.AuthorizeAction(ctx, iamIdentity, action, req.Bucket, req.ObjectKey, authRequest)
iamIdentity, errCode := pm.s3iam.AuthenticateJWT(ctx, authRequest)
if errCode != s3err.ErrNone {
return nil, fmt.Errorf("IAM authorization failed: invalid session token")
}
// Determine S3 action from method
action := determineS3ActionFromMethodAndPath(req.Method, req.Bucket, req.ObjectKey)
// Check IAM permissions before generating URL
errCode = pm.s3iam.AuthorizeAction(ctx, iamIdentity, action, req.Bucket, req.ObjectKey, authRequest)
if errCode != s3err.ErrNone {
return nil, fmt.Errorf("IAM authorization failed: user does not have permission for action %s on resource %s/%s", action, req.Bucket, req.ObjectKey)
}

View File

@@ -220,6 +220,35 @@ func TestPresignedURLGeneration(t *testing.T) {
}
}
func TestPresignedURLGenerationUsesAuthenticatedPrincipal(t *testing.T) {
iamManager := setupTestIAMManagerForPresigned(t)
s3iam := NewS3IAMIntegration(iamManager, "localhost:8888")
s3iam.enabled = true
presignedManager := NewS3PresignedURLManager(s3iam)
ctx := context.Background()
setupTestRolesForPresigned(ctx, iamManager)
validJWTToken := createTestJWTPresigned(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
WebIdentityToken: validJWTToken,
RoleSessionName: "presigned-read-only-session",
})
require.NoError(t, err)
_, err = presignedManager.GeneratePresignedURLWithIAM(ctx, &PresignedURLRequest{
Method: "PUT",
Bucket: "test-bucket",
ObjectKey: "new-file.txt",
Expiration: time.Hour,
SessionToken: response.Credentials.SessionToken,
}, "http://localhost:8333")
require.Error(t, err)
assert.Contains(t, err.Error(), "IAM authorization failed")
}
// TestPresignedURLExpiration tests URL expiration validation
func TestPresignedURLExpiration(t *testing.T) {
tests := []struct {

View File

@@ -88,20 +88,7 @@ func (s3a *S3ApiServer) ListBucketsHandler(w http.ResponseWriter, r *http.Reques
// Skip permission check if user is already the owner (optimization)
if !isOwner {
hasPermission := false
// Check permissions for each bucket
// For JWT-authenticated users, use IAM authorization
sessionToken := r.Header.Get("X-SeaweedFS-Session-Token")
if s3a.iam.iamIntegration != nil && sessionToken != "" {
// Use IAM authorization for JWT users
errCode := s3a.iam.authorizeWithIAM(r, identity, s3_constants.ACTION_LIST, entry.Name, "")
hasPermission = (errCode == s3err.ErrNone)
} else {
// Use legacy authorization for non-JWT users
hasPermission = identity.CanDo(s3_constants.ACTION_LIST, entry.Name, "")
}
if !hasPermission {
if errCode := s3a.iam.VerifyActionPermission(r, identity, s3_constants.ACTION_LIST, entry.Name, ""); errCode != s3err.ErrNone {
continue
}
}

View File

@@ -1043,3 +1043,43 @@ func TestListBucketsIssue7796(t *testing.T) {
"geoserver should NOT see buckets they neither own nor have permission for")
})
}
func TestListBucketsIssue8516PolicyBasedVisibility(t *testing.T) {
iam := &IdentityAccessManagement{}
require.NoError(t, iam.PutPolicy("listOnly", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::policy-bucket"}]}`))
identity := &Identity{
Name: "policy-user",
Account: &AccountAdmin,
PolicyNames: []string{"listOnly"},
}
req := httptest.NewRequest("GET", "http://s3.amazonaws.com/", nil)
buckets := []*filer_pb.Entry{
{
Name: "policy-bucket",
IsDirectory: true,
Extended: map[string][]byte{s3_constants.AmzIdentityId: []byte("admin")},
Attributes: &filer_pb.FuseAttributes{Crtime: time.Now().Unix()},
},
{
Name: "other-bucket",
IsDirectory: true,
Extended: map[string][]byte{s3_constants.AmzIdentityId: []byte("admin")},
Attributes: &filer_pb.FuseAttributes{Crtime: time.Now().Unix()},
},
}
var visibleBuckets []string
for _, entry := range buckets {
isOwner := isBucketOwnedByIdentity(entry, identity)
if !isOwner {
if errCode := iam.VerifyActionPermission(req, identity, s3_constants.ACTION_LIST, entry.Name, ""); errCode != s3err.ErrNone {
continue
}
}
visibleBuckets = append(visibleBuckets, entry.Name)
}
assert.Equal(t, []string{"policy-bucket"}, visibleBuckets)
}

View File

@@ -1,6 +1,7 @@
package s3api
import (
"context"
"errors"
"fmt"
"net/http"
@@ -34,7 +35,7 @@ func NewCircuitBreaker(option *S3ApiServerOption) *CircuitBreaker {
// Use WithOneOfGrpcFilerClients to support multiple filers with failover
err := pb.WithOneOfGrpcFilerClients(false, option.Filers, option.GrpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
content, err := filer.ReadInsideFiler(client, s3_constants.CircuitBreakerConfigDir, s3_constants.CircuitBreakerConfigFile)
content, err := filer.ReadInsideFiler(context.Background(), client, s3_constants.CircuitBreakerConfigDir, s3_constants.CircuitBreakerConfigFile)
if errors.Is(err, filer_pb.ErrNotFound) {
return nil
}