Files
seaweedfs/weed/credential/filer_etc/filer_etc_group.go
T
Chris Lu 5ff49909a0 fix(s3api/iam): avoid transient AccessDenied from full reloads on single IAM file changes (#11271)
* fix(s3api/iam): fail config snapshot on empty or malformed IAM files

A full IAM reload reads every identity/policy/service-account/group file
from the filer. When an external secrets tool rewrites a file, a reload
that reads it mid-rewrite sees empty or partially-written content. The
identity, policy and service-account loaders silently skipped such files
(``continue``), so the snapshot was missing entries that still existed
on disk. The atomic swap then installed an incomplete identity set while
``isAuthEnabled`` stayed on, denying unrelated clients mid-reload
(#11259).

The group loader and the read-error paths already fail the snapshot in
this situation (a skipped entry reads as deleted). Apply the same
behavior to empty content and unmarshal failures across the identity,
policy, service-account and group loaders, so a transient mid-rewrite
fails the reload (preserving the last known-good state) instead of
silently dropping entries.

* fix(s3api/iam): coalesce burst IAM config reloads through the reload queue

onIamConfigChange did a full synchronous reload for every identity/policy
file change event. When several independently-refreshing credentials
rewrite their files within the same second, that produced a burst of
dozens of back-to-back full reloads, each reading the whole store and
widening the window where a mid-rewrite file is observed (#11259).

Route every IAM config change through the existing coalescing reload
queue (scheduleReload/reloadRetryLoop) instead. A burst of N events now
collapses into a single reload (plus one tail reload for events that
arrived while one was in flight). scheduleReload gains a reason argument
for the existing log line; the reloadRetryLoop already retries failed
reloads, so the per-event failure handoff is no longer needed.

Tests that asserted on the synchronous reload now wire up the queue
(centralized in newTestS3ApiServerWithMemoryIAM) and poll via
waitForIdentity/waitForIdentityGone. Adds TestOnIamConfigChangeCoalescesBurstReloads
showing 50 events coalesce into <=3 reloads.

* fix(s3api/iam): skip non-JSON auxiliary files before failing IAM snapshot

Per review: the multi-file loaders unmarshal every entry in an IAM
directory, so a non-JSON auxiliary file (README, .DS_Store, a migration
backup such as identity.json.old) would hit the new empty/malformed
errors and reject the whole snapshot, blocking all later IAM reloads.

Only *.json files are IAM objects (SeaweedFS writes identities,
policies, service accounts and groups as <name>.json, and other call
sites already gate on the .json suffix). Skip non-.json entries at the
top of each loader loop, before reading content, so auxiliary files are
ignored while empty/malformed .json files still fail the snapshot.

Adds TestLoadConfigurationIgnoresNonJsonAuxiliaryFiles.

* fix(s3api/iam): reject IAM files with empty identifiers and skip aux in listing

Per review:

- ListPolicyNames listed every regular entry in the policies directory as a
  policy name, including non-JSON auxiliary files, but GetPolicy cannot
  retrieve them. Apply the same .json suffix filter used by the loader so
  the list only exposes retrievable policies.

- json.Unmarshal accepts `{}` and unknown fields. The identity and group
  loaders merge by the decoded Name (not the file name), so a `{}` file
  could install an empty-key record and displace a real one; the
  service-account loader accepted an empty Id. Validate Identity.Name,
  Group.Name and ServiceAccount.Id (via validateServiceAccountId) after
  unmarshal and fail the snapshot on empty identifiers.

Adds TestFilerEtcStoreListPolicyNamesSkipsNonJsonAuxiliary and
empty-identifier regression tests for identity, group and service-account
files.
2026-09-11 10:42:19 -07:00

190 lines
5.2 KiB
Go

package filer_etc
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
const IamGroupsDirectory = "groups"
func (store *FilerEtcStore) loadGroupsFromMultiFile(ctx context.Context, s3cfg *iam_pb.S3ApiConfiguration) error {
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
dir := filer.IamConfigDirectory + "/" + IamGroupsDirectory
entries, err := listEntries(ctx, client, dir)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDirectory {
continue
}
if !strings.HasSuffix(entry.Name, ".json") {
continue
}
var content []byte
if len(entry.Content) > 0 {
content = entry.Content
} else {
c, err := filer.ReadInsideFiler(ctx, client, dir, entry.Name)
if err != nil {
return fmt.Errorf("failed to read group file %s: %w", entry.Name, err)
}
content = c
}
if len(content) == 0 {
return fmt.Errorf("group file %s is empty", entry.Name)
}
g := &iam_pb.Group{}
if err := json.Unmarshal(content, g); err != nil {
return fmt.Errorf("failed to unmarshal group %s: %w", entry.Name, err)
}
if g.Name == "" {
return fmt.Errorf("group file %s has empty name", entry.Name)
}
// Merge: overwrite existing group with same name or append
found := false
for i, existing := range s3cfg.Groups {
if existing.Name == g.Name {
s3cfg.Groups[i] = g
found = true
break
}
}
if !found {
s3cfg.Groups = append(s3cfg.Groups, g)
}
}
return nil
})
}
func (store *FilerEtcStore) saveGroup(ctx context.Context, group *iam_pb.Group) error {
if group == nil {
return fmt.Errorf("group is nil")
}
group.Name = strings.TrimSpace(group.Name)
if group.Name == "" {
return fmt.Errorf("group name is required")
}
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
data, err := json.MarshalIndent(group, "", " ")
if err != nil {
return err
}
return filer.SaveInsideFiler(context.Background(), client, filer.IamConfigDirectory+"/"+IamGroupsDirectory, group.Name+".json", data)
})
}
func (store *FilerEtcStore) deleteGroupFile(ctx context.Context, groupName string) error {
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
Directory: filer.IamConfigDirectory + "/" + IamGroupsDirectory,
Name: groupName + ".json",
})
if err != nil {
if strings.Contains(err.Error(), filer_pb.ErrNotFound.Error()) {
return credential.ErrGroupNotFound
}
return err
}
if resp != nil && resp.Error != "" {
if strings.Contains(resp.Error, filer_pb.ErrNotFound.Error()) {
return credential.ErrGroupNotFound
}
return fmt.Errorf("delete group %s: %s", groupName, resp.Error)
}
return nil
})
}
func (store *FilerEtcStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error {
if group != nil {
group.Name = strings.TrimSpace(group.Name)
}
if group == nil || group.Name == "" {
return fmt.Errorf("group name is required")
}
existing, err := store.GetGroup(ctx, group.Name)
if err != nil {
if !errors.Is(err, credential.ErrGroupNotFound) {
return err
}
} else if existing != nil {
return credential.ErrGroupAlreadyExists
}
return store.saveGroup(ctx, group)
}
func (store *FilerEtcStore) GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) {
var group *iam_pb.Group
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
data, err := filer.ReadInsideFiler(ctx, client, filer.IamConfigDirectory+"/"+IamGroupsDirectory, groupName+".json")
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
return credential.ErrGroupNotFound
}
return err
}
if len(data) == 0 {
return credential.ErrGroupNotFound
}
group = &iam_pb.Group{}
return json.Unmarshal(data, group)
})
return group, err
}
func (store *FilerEtcStore) DeleteGroup(ctx context.Context, groupName string) error {
if _, err := store.GetGroup(ctx, groupName); err != nil {
return err
}
return store.deleteGroupFile(ctx, groupName)
}
func (store *FilerEtcStore) ListGroups(ctx context.Context) ([]string, error) {
var names []string
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
entries, err := listEntries(ctx, client, filer.IamConfigDirectory+"/"+IamGroupsDirectory)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
return nil
}
return err
}
for _, entry := range entries {
if !entry.IsDirectory && strings.HasSuffix(entry.Name, ".json") {
names = append(names, strings.TrimSuffix(entry.Name, ".json"))
}
}
return nil
})
return names, err
}
func (store *FilerEtcStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
if group != nil {
group.Name = strings.TrimSpace(group.Name)
}
if group == nil || group.Name == "" {
return fmt.Errorf("group name is required")
}
if _, err := store.GetGroup(ctx, group.Name); err != nil {
return err
}
return store.saveGroup(ctx, group)
}