fix: make --chuid/--chgid usable with the standalone IAM service

With the posix backend running --chuid/--chgid against the standalone IAM service, CreateBucket failed for every bucket name and left a half-created directory behind. Bucket ownership is fixed to the gateway's root account there, and that account was constructed from the root credentials alone, so its UserID/GroupID stayed at zero and the gateway tried to chown each new bucket to uid/gid 0 - something a process that is not root can never do. Root-account object writes failed the same way, because the identity the S3 request path uses for root also comes from the root credentials and never from the IAM backend. On top of that, the failed chown returned before the acl xattr was written, so the leftover directory made every later request for that name fail with "get bucket acl: no such key" until it was removed by hand.

The standalone IAM client now reports the root account with UserID, GroupID and ProjectID taken from --iam-standalone-default-uid, -gid and -project-id, returning a copy so the stored root account keeps the credentials it is compared against. ResolveDerivedKey copies that same identity onto root when the IAM backend fixes bucket ownership to the root access key, which keeps root's own writes consistent with the buckets root owns. CreateBucket now removes the bucket directory, its sidecar attributes and its versioning directory on any failure after the mkdir, so a failed create leaves nothing behind and the name stays retryable. A chown EPERM reports the target uid/gid, the flags that asked for it and the process euid/egid instead of a bare "operation not permitted", and the posix backend warns at startup when chuid/chgid are set on an unprivileged gateway.

The built-in IAM backends do not fix bucket ownership, so root and every other account reach the storage backend exactly as before.
This commit is contained in:
niksis02
2026-08-31 19:37:58 +04:00
parent 83b5e7041c
commit 87796306e2
7 changed files with 206 additions and 20 deletions
+24
View File
@@ -34,3 +34,27 @@ func ResolveFixedBucketOwner(iam IAMService) (Account, bool) {
return fbo.BucketOwner(), true
}
// rootIdentity returns the account a storage backend should see for a request
// signed with the gateway's root credentials. The S3 request path knows root
// only by its access key and secret, so root would otherwise reach the
// backend with the zero uid/gid — which the posix backend's --chuid/--chgid
// then tries to chown to, an operation an unprivileged gateway can never
// perform.
//
// An IAM backend that fixes bucket ownership to root also defines the POSIX
// identity root owns those buckets with, so take it from there: root's own
// object writes then land with the same ownership as the buckets root owns.
// Backends that do not fix ownership resolve a real per-account uid/gid for
// every other account and keep root exactly as it was.
func rootIdentity(iam IAMService, root Account) Account {
owner, fixed := ResolveFixedBucketOwner(iam)
if !fixed || owner.Access != root.Access {
return root
}
root.UserID = owner.UserID
root.GroupID = owner.GroupID
root.ProjectID = owner.ProjectID
return root
}
+20 -6
View File
@@ -87,8 +87,11 @@ type IAMServiceStandaloneConfig struct {
ClientCert string
ClientCertKey string
ServerCA string
// DefaultUserID/GroupID/ProjectID are assigned to every resolved
// (non-root) account. The standalone IAM service's user model
// DefaultUserID/GroupID/ProjectID are assigned to every account this
// client resolves, the locally-held root account included: bucket
// ownership is fixed to root here, so root must carry the same POSIX
// identity as everyone else or a backend chowning to it would target
// uid/gid 0. The standalone IAM service's user model
// (iamapi/types.User, mirroring real AWS IAM) has no POSIX uid/gid/
// project-id concept, so there is no per-user value to fetch instead —
// every standalone-backed account shares one POSIX identity for
@@ -412,7 +415,7 @@ func (s *IAMServiceStandalone) DeriveSigningKey(access, sessionToken, date, regi
if sessionToken != "" {
return nil, Account{}, ErrInvalidSessionToken
}
return sigv4auth.DeriveKey(s.rootAcc.Secret, date, region, service), s.rootAcc, nil
return sigv4auth.DeriveKey(s.rootAcc.Secret, date, region, service), s.rootAccount(), nil
}
var resp private.DeriveSigningKeyResponse
@@ -541,7 +544,7 @@ func decisionFromWireValue(v string) policyDecision {
// one per key.
func (s *IAMServiceStandalone) GetUserAccount(access string) (Account, error) {
if access == s.rootAcc.Access {
return s.rootAcc, nil
return s.rootAccount(), nil
}
accounts, err := s.resolveAccountDetails([]string{access})
@@ -581,7 +584,7 @@ func (s *IAMServiceStandalone) resolveAccountDetails(accesses []string) ([]resol
remoteIdx := make([]int, 0, len(accesses))
for i, access := range accesses {
if access == s.rootAcc.Access {
out[i] = resolvedAccount{Found: true, Account: s.rootAcc}
out[i] = resolvedAccount{Found: true, Account: s.rootAccount()}
continue
}
remote = append(remote, access)
@@ -641,7 +644,18 @@ func (s *IAMServiceStandalone) ResolveAccounts(accessKeyIDs []string) ([]string,
// BucketOwner implements FixedBucketOwner: every bucket is owned by the
// gateway's root account, the only account this process knows locally.
func (s *IAMServiceStandalone) BucketOwner() Account {
return s.rootAcc
return s.rootAccount()
}
// rootAccount returns the root account as an identity: a copy of the locally
// held root credentials carrying the same POSIX identity every other
// standalone-backed account gets.
func (s *IAMServiceStandalone) rootAccount() Account {
acc := s.rootAcc
acc.UserID = s.cfg.DefaultUserID
acc.GroupID = s.cfg.DefaultGroupID
acc.ProjectID = s.cfg.DefaultProjectID
return acc
}
// CreateAccount is not supported
+70
View File
@@ -619,3 +619,73 @@ func serveFakePrivate(t *testing.T, protocol string, status int, body string) st
return sockPath
}
// TestIAMServiceStandaloneRootCarriesPosixIdentity covers the identity a
// storage backend chowns to. Bucket ownership is fixed to root here, so a
// root account left at uid/gid 0 makes the posix backend's --chuid/--chgid
// target root for every bucket and for root's own object writes — which an
// unprivileged gateway can never do.
func TestIAMServiceStandaloneRootCarriesPosixIdentity(t *testing.T) {
_, sock := standaloneTestServer(t)
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin}
client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{
Endpoint: sock,
DefaultUserID: 1001,
DefaultGroupID: 1002,
DefaultProjectID: 1003,
})
if err != nil {
t.Fatalf("NewIAMServiceStandalone: %v", err)
}
defer client.Shutdown()
checkIDs := func(what string, acc Account) {
t.Helper()
if acc.UserID != 1001 || acc.GroupID != 1002 || acc.ProjectID != 1003 {
t.Errorf("%s posix ids = %v/%v/%v, want 1001/1002/1003",
what, acc.UserID, acc.GroupID, acc.ProjectID)
}
}
owner, fixed := ResolveFixedBucketOwner(client)
if !fixed {
t.Fatal("ResolveFixedBucketOwner: standalone client must fix bucket ownership")
}
if owner.Access != standaloneTestRootAccess {
t.Errorf("bucket owner = %q, want the root account %q", owner.Access, standaloneTestRootAccess)
}
checkIDs("BucketOwner()", owner)
// The same identity must come back wherever root is resolved, so that a
// bucket root owns and an object root writes get the same ownership.
acc, err := client.GetUserAccount(standaloneTestRootAccess)
if err != nil {
t.Fatalf("GetUserAccount(root): %v", err)
}
checkIDs("GetUserAccount(root)", acc)
yyyymmdd := time.Now().UTC().Format(sigv4auth.YYYYMMDD)
_, acc, err = client.DeriveSigningKey(standaloneTestRootAccess, "", yyyymmdd, "us-east-1", "s3")
if err != nil {
t.Fatalf("DeriveSigningKey(root): %v", err)
}
checkIDs("DeriveSigningKey(root)", acc)
missing, err := client.ResolveAccounts([]string{standaloneTestRootAccess})
if err != nil {
t.Fatalf("ResolveAccounts(root): %v", err)
}
if len(missing) != 0 {
t.Errorf("ResolveAccounts(root) = %v, want the root account to resolve", missing)
}
// The stored root account is compared against by credential, and must
// keep the credentials it was constructed with.
if client.rootAcc != rootAcc {
t.Errorf("stored root account was mutated: %+v, want %+v", client.rootAcc, rootAcc)
}
if acc.Secret != standaloneTestRootSecret || acc.Role != RoleAdmin {
t.Errorf("root identity lost its credentials or role: %+v", acc)
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ func ResolveDerivedKey(iam IAMService, root Account, access, sessionToken, date,
if sessionToken != "" {
return nil, Account{}, ErrInvalidSessionToken
}
return sigv4auth.DeriveKey(root.Secret, date, region, service), root, nil
return sigv4auth.DeriveKey(root.Secret, date, region, service), rootIdentity(iam, root), nil
}
if skp, ok := iam.(SigningKeyProvider); ok {
return skp.DeriveSigningKey(access, sessionToken, date, region, service)