Merge pull request #2336 from versity/sis/standalone-iam-chuid-ownership

fix: make --chuid/--chgid usable with the standalone IAM service
This commit is contained in:
Ben McClelland
2026-08-31 18:42:22 -07:00
committed by GitHub
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)