Browse Source

s3: fix ListBuckets not showing buckets created by authenticated users (#7648)

* s3: fix ListBuckets not showing buckets created by authenticated users

Fixes #7647

## Problem
Users with proper Admin permissions could create buckets but couldn't
list them. The issue occurred because ListBucketsHandler was not wrapped
with the Auth middleware, so the authenticated identity was never set in
the request context.

## Root Cause
- PutBucketHandler uses iam.Auth() middleware which sets identity in context
- ListBucketsHandler did NOT use iam.Auth() middleware
- Without the middleware, GetIdentityNameFromContext() returned empty string
- Bucket ownership checks failed because no identity was present

## Changes
1. Wrap ListBucketsHandler with iam.Auth() middleware (s3api_server.go)
2. Update ListBucketsHandler to get identity from context (s3api_bucket_handlers.go)
3. Add lookupByIdentityName() helper method (auth_credentials.go)
4. Add comprehensive test TestListBucketsIssue7647 (s3api_bucket_handlers_test.go)

## Testing
- All existing tests pass (1348 tests in s3api package)
- New test TestListBucketsIssue7647 validates the fix
- Verified admin users can see their created buckets
- Verified admin users can see all buckets
- Verified backward compatibility maintained

* s3: fix ListBuckets for JWT/Keycloak authentication

The previous fix broke JWT/Keycloak authentication because JWT identities
are created on-the-fly and not stored in the iam.identities list.
The lookupByIdentityName() would return nil for JWT users.

Solution: Store the full Identity object in the request context, not just
the name. This allows ListBucketsHandler to retrieve the complete identity
for all authentication types (SigV2, SigV4, JWT, Anonymous).

Changes:
- Add SetIdentityInContext/GetIdentityFromContext in s3_constants/header.go
- Update Auth middleware to store full identity in context
- Update ListBucketsHandler to retrieve identity from context first,
  with fallback to lookup for backward compatibility

* s3: optimize lookupByIdentityName to O(1) using map

Address code review feedback: Use a map for O(1) lookups instead of
O(N) linear scan through identities list.

Changes:
- Add nameToIdentity map to IdentityAccessManagement struct
- Populate map in loadS3ApiConfiguration (consistent with accessKeyIdent pattern)
- Update lookupByIdentityName to use map lookup instead of loop

This improves performance when many identities are configured and
aligns with the existing pattern used for accessKeyIdent lookups.

* s3: address code review feedback on nameToIdentity and logging

Address two code review points:

1. Wire nameToIdentity into env-var fallback path
   - The AWS env-var fallback in NewIdentityAccessManagementWithStore now
     populates nameToIdentity map along with accessKeyIdent
   - Keeps all identity lookup maps in sync
   - Avoids potential issues if handlers rely on lookupByIdentityName

2. Improve access key lookup logging
   - Reduce log verbosity: V(1) -> V(2) for failed lookups
   - Truncate access keys in logs (show first 4 chars + ***)
   - Include key length for debugging
   - Prevents credential exposure in production logs
   - Reduces log noise from misconfigured clients

* fmt

* s3: refactor truncation logic and improve error handling

Address additional code review feedback:

1. DRY principle: Extract key truncation logic into local function
   - Define truncate() helper at function start
   - Reuse throughout lookupByAccessKey
   - Eliminates code duplication

2. Enhanced security: Mask very short access keys
   - Keys <= 4 chars now show as '***' instead of full key
   - Prevents any credential exposure even for short keys
   - Consistent masking across all log statements

3. Improved robustness: Add warning log for type assertion failure
   - Log unexpected type when identity context object is wrong type
   - Helps debug potential middleware or context issues
   - Better production diagnostics

4. Documentation: Add comment about future optimization opportunity
   - Note potential for lightweight identity view in context
   - Suggests credential-free view for better data minimization
   - Documents design decision for future maintainers
pull/7652/head
Chris Lu 9 hours ago
committed by GitHub
parent
commit
f5c0bcafa3
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 38
      weed/s3api/auth_credentials.go
  2. 18
      weed/s3api/s3_constants/header.go
  3. 31
      weed/s3api/s3api_bucket_handlers.go
  4. 113
      weed/s3api/s3api_bucket_handlers_test.go
  5. 2
      weed/s3api/s3api_server.go

38
weed/s3api/auth_credentials.go

@ -40,6 +40,7 @@ type IdentityAccessManagement struct {
identities []*Identity identities []*Identity
accessKeyIdent map[string]*Identity accessKeyIdent map[string]*Identity
nameToIdentity map[string]*Identity // O(1) lookup by identity name
accounts map[string]*Account accounts map[string]*Account
emailAccount map[string]*Account emailAccount map[string]*Account
hashes map[string]*sync.Pool hashes map[string]*sync.Pool
@ -219,6 +220,7 @@ func NewIdentityAccessManagementWithStore(option *S3ApiServerOption, explicitSto
if len(iam.identities) == 0 { if len(iam.identities) == 0 {
iam.identities = []*Identity{envIdentity} iam.identities = []*Identity{envIdentity}
iam.accessKeyIdent = map[string]*Identity{accessKeyId: envIdentity} iam.accessKeyIdent = map[string]*Identity{accessKeyId: envIdentity}
iam.nameToIdentity = map[string]*Identity{envIdentity.Name: envIdentity}
iam.isAuthEnabled = true iam.isAuthEnabled = true
} }
iam.m.Unlock() iam.m.Unlock()
@ -270,6 +272,7 @@ func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3Api
var identities []*Identity var identities []*Identity
var identityAnonymous *Identity var identityAnonymous *Identity
accessKeyIdent := make(map[string]*Identity) accessKeyIdent := make(map[string]*Identity)
nameToIdentity := make(map[string]*Identity)
accounts := make(map[string]*Account) accounts := make(map[string]*Account)
emailAccount := make(map[string]*Account) emailAccount := make(map[string]*Account)
foundAccountAdmin := false foundAccountAdmin := false
@ -348,6 +351,7 @@ func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3Api
accessKeyIdent[cred.AccessKey] = t accessKeyIdent[cred.AccessKey] = t
} }
identities = append(identities, t) identities = append(identities, t)
nameToIdentity[t.Name] = t
} }
iam.m.Lock() iam.m.Lock()
@ -357,6 +361,7 @@ func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3Api
iam.accounts = accounts iam.accounts = accounts
iam.emailAccount = emailAccount iam.emailAccount = emailAccount
iam.accessKeyIdent = accessKeyIdent iam.accessKeyIdent = accessKeyIdent
iam.nameToIdentity = nameToIdentity
if !iam.isAuthEnabled { // one-directional, no toggling if !iam.isAuthEnabled { // one-directional, no toggling
iam.isAuthEnabled = len(identities) > 0 iam.isAuthEnabled = len(identities) > 0
} }
@ -384,25 +389,38 @@ func (iam *IdentityAccessManagement) lookupByAccessKey(accessKey string) (identi
iam.m.RLock() iam.m.RLock()
defer iam.m.RUnlock() defer iam.m.RUnlock()
glog.V(3).Infof("Looking up access key: %s (total keys registered: %d)", accessKey, len(iam.accessKeyIdent))
// Helper function to truncate access key for logging to avoid credential exposure
truncate := func(key string) string {
const mask = "***"
if len(key) > 4 {
return key[:4] + mask
}
// For very short keys, never log the full key
return mask
}
truncatedKey := truncate(accessKey)
glog.V(3).Infof("Looking up access key: %s (len=%d, total keys registered: %d)",
truncatedKey, len(accessKey), len(iam.accessKeyIdent))
if ident, ok := iam.accessKeyIdent[accessKey]; ok { if ident, ok := iam.accessKeyIdent[accessKey]; ok {
for _, credential := range ident.Credentials { for _, credential := range ident.Credentials {
if credential.AccessKey == accessKey { if credential.AccessKey == accessKey {
glog.V(2).Infof("Found access key %s for identity %s", accessKey, ident.Name)
glog.V(2).Infof("Found access key %s for identity %s", truncatedKey, ident.Name)
return ident, credential, true return ident, credential, true
} }
} }
} }
glog.V(1).Infof("Could not find access key %s. Available keys: %d, Auth enabled: %v",
accessKey, len(iam.accessKeyIdent), iam.isAuthEnabled)
glog.V(2).Infof("Could not find access key %s (len=%d). Available keys: %d, Auth enabled: %v",
truncatedKey, len(accessKey), len(iam.accessKeyIdent), iam.isAuthEnabled)
// Log all registered access keys at higher verbosity for debugging // Log all registered access keys at higher verbosity for debugging
if glog.V(3) { if glog.V(3) {
glog.V(3).Infof("Registered access keys:") glog.V(3).Infof("Registered access keys:")
for key := range iam.accessKeyIdent { for key := range iam.accessKeyIdent {
glog.V(3).Infof(" - %s", key)
glog.V(3).Infof(" - %s (len=%d)", truncate(key), len(key))
} }
} }
@ -418,6 +436,13 @@ func (iam *IdentityAccessManagement) lookupAnonymous() (identity *Identity, foun
return nil, false return nil, false
} }
func (iam *IdentityAccessManagement) lookupByIdentityName(name string) *Identity {
iam.m.RLock()
defer iam.m.RUnlock()
return iam.nameToIdentity[name]
}
// generatePrincipalArn generates an ARN for a user identity // generatePrincipalArn generates an ARN for a user identity
func generatePrincipalArn(identityName string) string { func generatePrincipalArn(identityName string) string {
// Handle special cases // Handle special cases
@ -463,6 +488,9 @@ func (iam *IdentityAccessManagement) Auth(f http.HandlerFunc, action Action) htt
// Store the authenticated identity in request context (secure, cannot be spoofed) // Store the authenticated identity in request context (secure, cannot be spoofed)
if identity != nil && identity.Name != "" { if identity != nil && identity.Name != "" {
ctx := s3_constants.SetIdentityNameInContext(r.Context(), identity.Name) ctx := s3_constants.SetIdentityNameInContext(r.Context(), identity.Name)
// Also store the full identity object for handlers that need it (e.g., ListBuckets)
// This is especially important for JWT users whose identity is not in the identities list
ctx = s3_constants.SetIdentityInContext(ctx, identity)
r = r.WithContext(ctx) r = r.WithContext(ctx)
} }
f(w, r) f(w, r)

18
weed/s3api/s3_constants/header.go

@ -178,7 +178,8 @@ func IsSeaweedFSInternalHeader(headerKey string) bool {
type contextKey string type contextKey string
const ( const (
contextKeyIdentityName contextKey = "s3-identity-name"
contextKeyIdentityName contextKey = "s3-identity-name"
contextKeyIdentityObject contextKey = "s3-identity-object"
) )
// SetIdentityNameInContext stores the authenticated identity name in the request context // SetIdentityNameInContext stores the authenticated identity name in the request context
@ -199,3 +200,18 @@ func GetIdentityNameFromContext(r *http.Request) string {
} }
return "" return ""
} }
// SetIdentityInContext stores the full authenticated identity object in the request context
// This is used to pass the full identity (including for JWT users) to handlers
func SetIdentityInContext(ctx context.Context, identity interface{}) context.Context {
if identity != nil {
return context.WithValue(ctx, contextKeyIdentityObject, identity)
}
return ctx
}
// GetIdentityFromContext retrieves the full identity object from the request context
// Returns nil if no identity is set (unauthenticated request)
func GetIdentityFromContext(r *http.Request) interface{} {
return r.Context().Value(contextKeyIdentityObject)
}

31
weed/s3api/s3api_bucket_handlers.go

@ -38,15 +38,28 @@ func (s3a *S3ApiServer) ListBucketsHandler(w http.ResponseWriter, r *http.Reques
glog.V(3).Infof("ListBucketsHandler") glog.V(3).Infof("ListBucketsHandler")
// Get authenticated identity from context (set by Auth middleware)
// For unauthenticated requests, this returns empty string
identityId := s3_constants.GetIdentityNameFromContext(r)
// Get the full identity object for permission and ownership checks
// This is especially important for JWT users whose identity is not in the identities list
// Note: We store the full Identity object in context for simplicity. Future optimization
// could use a lightweight, credential-free view (name, account, actions, principal ARN)
// for better data minimization.
var identity *Identity var identity *Identity
var s3Err s3err.ErrorCode
if s3a.iam.isEnabled() { if s3a.iam.isEnabled() {
// Use authRequest instead of authUser for consistency with other endpoints
// This ensures the same authentication flow and any fixes (like prefix handling) are applied
identity, s3Err = s3a.iam.authRequest(r, s3_constants.ACTION_LIST)
if s3Err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, s3Err)
return
// Try to get the full identity from context first (works for all auth types including JWT)
if identityObj := s3_constants.GetIdentityFromContext(r); identityObj != nil {
if id, ok := identityObj.(*Identity); ok {
identity = id
} else {
glog.Warningf("ListBucketsHandler: identity object in context has unexpected type: %T", identityObj)
}
}
// Fallback to looking up by name if not in context (backward compatibility)
if identity == nil && identityId != "" {
identity = s3a.iam.lookupByIdentityName(identityId)
} }
} }
@ -59,10 +72,6 @@ func (s3a *S3ApiServer) ListBucketsHandler(w http.ResponseWriter, r *http.Reques
return return
} }
// Get authenticated identity from context (secure, cannot be spoofed)
// For unauthenticated requests, this returns empty string
identityId := s3_constants.GetIdentityNameFromContext(r)
var listBuckets ListAllMyBucketsList var listBuckets ListAllMyBucketsList
for _, entry := range entries { for _, entry := range entries {
if entry.IsDirectory { if entry.IsDirectory {

113
weed/s3api/s3api_bucket_handlers_test.go

@ -663,3 +663,116 @@ func TestListBucketsOwnershipCaseSensitivity(t *testing.T) {
}) })
} }
} }
// TestListBucketsIssue7647 reproduces and verifies the fix for issue #7647
// where an admin user with proper permissions could create buckets but couldn't list them
func TestListBucketsIssue7647(t *testing.T) {
t.Run("admin user can see their created buckets", func(t *testing.T) {
// Simulate the exact scenario from issue #7647:
// User "root" with ["Admin", "Read", "Write", "Tagging", "List"] permissions
// Create identity for root user with Admin action
rootIdentity := &Identity{
Name: "root",
Credentials: []*Credential{
{
AccessKey: "ROOTID",
SecretKey: "ROOTSECRET",
},
},
Actions: []Action{
s3_constants.ACTION_ADMIN,
s3_constants.ACTION_READ,
s3_constants.ACTION_WRITE,
s3_constants.ACTION_TAGGING,
s3_constants.ACTION_LIST,
},
}
// Create a bucket entry as if it was created by the root user
bucketEntry := &filer_pb.Entry{
Name: "test",
IsDirectory: true,
Extended: map[string][]byte{
s3_constants.AmzIdentityId: []byte("root"),
},
Attributes: &filer_pb.FuseAttributes{
Crtime: time.Now().Unix(),
Mtime: time.Now().Unix(),
},
}
// Test bucket visibility - should be visible to root (owner)
isVisible := isBucketVisibleToIdentity(bucketEntry, rootIdentity)
assert.True(t, isVisible, "Root user should see their own bucket")
// Test that admin can also see buckets they don't own
otherUserBucket := &filer_pb.Entry{
Name: "other-bucket",
IsDirectory: true,
Extended: map[string][]byte{
s3_constants.AmzIdentityId: []byte("otheruser"),
},
Attributes: &filer_pb.FuseAttributes{
Crtime: time.Now().Unix(),
Mtime: time.Now().Unix(),
},
}
isVisible = isBucketVisibleToIdentity(otherUserBucket, rootIdentity)
assert.True(t, isVisible, "Admin user should see all buckets, even ones they don't own")
// Test permission check for List action
canList := rootIdentity.canDo(s3_constants.ACTION_LIST, "test", "")
assert.True(t, canList, "Root user with List action should be able to list buckets")
})
t.Run("admin user sees buckets without owner metadata", func(t *testing.T) {
// Admin users should see buckets even if they don't have owner metadata
// (this can happen with legacy buckets or manual creation)
rootIdentity := &Identity{
Name: "root",
Actions: []Action{
s3_constants.ACTION_ADMIN,
s3_constants.ACTION_LIST,
},
}
bucketWithoutOwner := &filer_pb.Entry{
Name: "legacy-bucket",
IsDirectory: true,
Extended: map[string][]byte{}, // No owner metadata
Attributes: &filer_pb.FuseAttributes{
Crtime: time.Now().Unix(),
},
}
isVisible := isBucketVisibleToIdentity(bucketWithoutOwner, rootIdentity)
assert.True(t, isVisible, "Admin should see buckets without owner metadata")
})
t.Run("non-admin user cannot see buckets without owner", func(t *testing.T) {
// Non-admin users should not see buckets without owner metadata
regularUser := &Identity{
Name: "user1",
Actions: []Action{
s3_constants.ACTION_READ,
s3_constants.ACTION_LIST,
},
}
bucketWithoutOwner := &filer_pb.Entry{
Name: "legacy-bucket",
IsDirectory: true,
Extended: map[string][]byte{}, // No owner metadata
Attributes: &filer_pb.FuseAttributes{
Crtime: time.Now().Unix(),
},
}
isVisible := isBucketVisibleToIdentity(bucketWithoutOwner, regularUser)
assert.False(t, isVisible, "Non-admin should not see buckets without owner metadata")
})
}

2
weed/s3api/s3api_server.go

@ -537,7 +537,7 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
}) })
// ListBuckets // ListBuckets
apiRouter.Methods(http.MethodGet).Path("/").HandlerFunc(track(s3a.ListBucketsHandler, "LIST"))
apiRouter.Methods(http.MethodGet).Path("/").HandlerFunc(track(s3a.iam.Auth(s3a.ListBucketsHandler, ACTION_LIST), "LIST"))
// NotFound // NotFound
apiRouter.NotFoundHandler = http.HandlerFunc(s3err.NotFoundHandler) apiRouter.NotFoundHandler = http.HandlerFunc(s3err.NotFoundHandler)

Loading…
Cancel
Save