mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-15 16:28:30 +01:00
* credential: migrate the legacy IAM file without overwriting newer entries The migration wrote every identity, service account and group from the snapshot the load read, over whatever the per-entry files held by then, so a revoke or delete landing in between was undone for good. It now migrates only the legacy file's own entries, creates each per-entry file only when none exists, and waits for the next load when the legacy file changed after the snapshot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * credential: test changes landing between the IAM load and its migration Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * credential: keep a per-entry file deleted after the IAM load deleted An entry the load read from its own file is already migrated. When that file is removed before the migration runs, the legacy copy must not recreate it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * credential: test a deleted per-entry file the legacy file also holds Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
406 lines
13 KiB
Go
406 lines
13 KiB
Go
package filer_etc
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"sort"
|
|
"strconv"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
type policyTestFilerServer struct {
|
|
filer_pb.UnimplementedSeaweedFilerServer
|
|
mu sync.RWMutex
|
|
entries map[string]*filer_pb.Entry
|
|
contentlessListEntry map[string]struct{}
|
|
beforeLookup func(context.Context, string, string) error
|
|
afterListEntry func(string, string)
|
|
beforeDelete func(string, string) error
|
|
beforeUpdate func(string, string) error
|
|
}
|
|
|
|
func newPolicyTestFilerServer() *policyTestFilerServer {
|
|
return &policyTestFilerServer{
|
|
entries: make(map[string]*filer_pb.Entry),
|
|
contentlessListEntry: make(map[string]struct{}),
|
|
}
|
|
}
|
|
|
|
func (s *policyTestFilerServer) LookupDirectoryEntry(ctx context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
|
s.mu.RLock()
|
|
beforeLookup := s.beforeLookup
|
|
s.mu.RUnlock()
|
|
if beforeLookup != nil {
|
|
if err := beforeLookup(ctx, req.Directory, req.Name); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
entry, found := s.entries[filerEntryKey(req.Directory, req.Name)]
|
|
if !found {
|
|
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
|
}
|
|
|
|
return &filer_pb.LookupDirectoryEntryResponse{Entry: cloneEntry(entry)}, nil
|
|
}
|
|
|
|
func (s *policyTestFilerServer) ListEntries(req *filer_pb.ListEntriesRequest, stream grpc.ServerStreamingServer[filer_pb.ListEntriesResponse]) error {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
names := make([]string, 0)
|
|
for key := range s.entries {
|
|
dir, name := splitFilerEntryKey(key)
|
|
if dir != req.Directory {
|
|
continue
|
|
}
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
for _, name := range names {
|
|
entry := cloneEntry(s.entries[filerEntryKey(req.Directory, name)])
|
|
if _, found := s.contentlessListEntry[filerEntryKey(req.Directory, name)]; found {
|
|
entry.Content = nil
|
|
}
|
|
if err := stream.Send(&filer_pb.ListEntriesResponse{Entry: entry}); err != nil {
|
|
return err
|
|
}
|
|
if s.afterListEntry != nil {
|
|
s.afterListEntry(req.Directory, name)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *policyTestFilerServer) CreateEntry(_ context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
key := filerEntryKey(req.Directory, req.Entry.Name)
|
|
if _, found := s.entries[key]; found && req.OExcl {
|
|
return &filer_pb.CreateEntryResponse{Error: filer_pb.ErrEntryAlreadyExists.Error(), ErrorCode: filer_pb.FilerError_ENTRY_ALREADY_EXISTS}, nil
|
|
}
|
|
s.entries[key] = cloneEntry(req.Entry)
|
|
return &filer_pb.CreateEntryResponse{}, nil
|
|
}
|
|
|
|
func (s *policyTestFilerServer) AtomicRenameEntry(_ context.Context, req *filer_pb.AtomicRenameEntryRequest) (*filer_pb.AtomicRenameEntryResponse, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
oldKey := filerEntryKey(req.OldDirectory, req.OldName)
|
|
entry, found := s.entries[oldKey]
|
|
if !found {
|
|
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
|
}
|
|
delete(s.entries, oldKey)
|
|
entry.Name = req.NewName
|
|
s.entries[filerEntryKey(req.NewDirectory, req.NewName)] = entry
|
|
return &filer_pb.AtomicRenameEntryResponse{}, nil
|
|
}
|
|
|
|
func (s *policyTestFilerServer) UpdateEntry(_ context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
|
|
s.mu.RLock()
|
|
beforeUpdate := s.beforeUpdate
|
|
s.mu.RUnlock()
|
|
if beforeUpdate != nil {
|
|
if err := beforeUpdate(req.Directory, req.Entry.Name); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.entries[filerEntryKey(req.Directory, req.Entry.Name)] = cloneEntry(req.Entry)
|
|
return &filer_pb.UpdateEntryResponse{}, nil
|
|
}
|
|
|
|
func (s *policyTestFilerServer) DeleteEntry(_ context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) {
|
|
s.mu.RLock()
|
|
beforeDelete := s.beforeDelete
|
|
s.mu.RUnlock()
|
|
if beforeDelete != nil {
|
|
if err := beforeDelete(req.Directory, req.Name); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
key := filerEntryKey(req.Directory, req.Name)
|
|
if _, found := s.entries[key]; !found {
|
|
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
|
}
|
|
|
|
delete(s.entries, key)
|
|
return &filer_pb.DeleteEntryResponse{}, nil
|
|
}
|
|
|
|
func newPolicyTestStore(t *testing.T) *FilerEtcStore {
|
|
store, _ := newPolicyTestStoreWithServer(t)
|
|
return store
|
|
}
|
|
|
|
func newPolicyTestStoreWithServer(t *testing.T) (*FilerEtcStore, *policyTestFilerServer) {
|
|
t.Helper()
|
|
|
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
|
|
server := newPolicyTestFilerServer()
|
|
grpcServer := pb.NewGrpcServer()
|
|
filer_pb.RegisterSeaweedFilerServer(grpcServer, server)
|
|
go func() {
|
|
_ = grpcServer.Serve(lis)
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
grpcServer.Stop()
|
|
_ = lis.Close()
|
|
})
|
|
|
|
store := &FilerEtcStore{}
|
|
host, portString, err := net.SplitHostPort(lis.Addr().String())
|
|
require.NoError(t, err)
|
|
grpcPort, err := strconv.Atoi(portString)
|
|
require.NoError(t, err)
|
|
store.SetFilerAddressFunc(func() pb.ServerAddress {
|
|
return pb.NewServerAddress(host, 1, grpcPort)
|
|
}, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
|
|
return store, server
|
|
}
|
|
|
|
func TestFilerEtcStoreListPolicyNamesIncludesLegacyPolicies(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newPolicyTestStore(t)
|
|
|
|
legacyPolicies := newPoliciesCollection()
|
|
legacyPolicies.Policies["legacy-only"] = testPolicyDocument("s3:GetObject", "arn:aws:s3:::legacy-only/*")
|
|
legacyPolicies.Policies["shared"] = testPolicyDocument("s3:GetObject", "arn:aws:s3:::shared/*")
|
|
require.NoError(t, store.saveLegacyPoliciesCollection(ctx, legacyPolicies))
|
|
|
|
require.NoError(t, store.savePolicy(ctx, "multi-file-only", testPolicyDocument("s3:PutObject", "arn:aws:s3:::multi-file-only/*")))
|
|
require.NoError(t, store.savePolicy(ctx, "shared", testPolicyDocument("s3:DeleteObject", "arn:aws:s3:::shared/*")))
|
|
|
|
names, err := store.ListPolicyNames(ctx)
|
|
require.NoError(t, err)
|
|
|
|
assert.ElementsMatch(t, []string{"legacy-only", "multi-file-only", "shared"}, names)
|
|
}
|
|
|
|
// A non-JSON auxiliary file in the policies directory must not be listed as a
|
|
// policy name (it cannot be retrieved by GetPolicy either).
|
|
func TestFilerEtcStoreListPolicyNamesSkipsNonJsonAuxiliary(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, server := newPolicyTestStoreWithServer(t)
|
|
|
|
require.NoError(t, store.savePolicy(ctx, "real", testPolicyDocument("s3:GetObject", "arn:aws:s3:::real/*")))
|
|
|
|
polDir := filer.IamConfigDirectory + "/" + IamPoliciesDirectory
|
|
putEntry(t, server, polDir, "notes.md", []byte(`# not a policy`))
|
|
|
|
names, err := store.ListPolicyNames(ctx)
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{"real"}, names)
|
|
}
|
|
|
|
func TestFilerEtcStoreDeletePolicyRemovesLegacyManagedCopy(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newPolicyTestStore(t)
|
|
|
|
inlinePolicy := testPolicyDocument("s3:PutObject", "arn:aws:s3:::inline-user/*")
|
|
legacyPolicies := newPoliciesCollection()
|
|
legacyPolicies.Policies["legacy-only"] = testPolicyDocument("s3:GetObject", "arn:aws:s3:::legacy-only/*")
|
|
legacyPolicies.InlinePolicies["inline-user"] = map[string]policy_engine.PolicyDocument{
|
|
"PutOnly": inlinePolicy,
|
|
}
|
|
require.NoError(t, store.saveLegacyPoliciesCollection(ctx, legacyPolicies))
|
|
|
|
managedPolicies, err := store.LoadManagedPolicies(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"legacy-only"}, managedPolicyNames(managedPolicies))
|
|
|
|
require.NoError(t, store.DeletePolicy(ctx, "legacy-only"))
|
|
|
|
managedPolicies, err = store.LoadManagedPolicies(ctx)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, managedPolicies)
|
|
|
|
inlinePolicies, err := store.LoadInlinePolicies(ctx)
|
|
require.NoError(t, err)
|
|
assertInlinePolicyPreserved(t, inlinePolicies, "inline-user", "PutOnly")
|
|
|
|
loadedLegacyPolicies, foundLegacy, err := store.loadLegacyPoliciesCollection(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, foundLegacy)
|
|
assert.Empty(t, loadedLegacyPolicies.Policies)
|
|
assertInlinePolicyPreserved(t, loadedLegacyPolicies.InlinePolicies, "inline-user", "PutOnly")
|
|
}
|
|
|
|
func TestFilerEtcStoreDeletePolicySerializesLegacyUpdates(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, server := newPolicyTestStoreWithServer(t)
|
|
|
|
legacyPolicies := newPoliciesCollection()
|
|
legacyPolicies.Policies["first"] = testPolicyDocument("s3:GetObject", "arn:aws:s3:::first/*")
|
|
legacyPolicies.Policies["second"] = testPolicyDocument("s3:GetObject", "arn:aws:s3:::second/*")
|
|
require.NoError(t, store.saveLegacyPoliciesCollection(ctx, legacyPolicies))
|
|
require.NoError(t, store.savePolicy(ctx, "first", testPolicyDocument("s3:GetObject", "arn:aws:s3:::first/*")))
|
|
require.NoError(t, store.savePolicy(ctx, "second", testPolicyDocument("s3:GetObject", "arn:aws:s3:::second/*")))
|
|
|
|
firstSaveStarted := make(chan struct{})
|
|
releaseFirstSave := make(chan struct{})
|
|
secondReachedDelete := make(chan struct{}, 1)
|
|
var blockOnce sync.Once
|
|
|
|
server.mu.Lock()
|
|
server.beforeUpdate = func(dir string, name string) error {
|
|
if dir == filer.IamConfigDirectory && name == filer.IamPoliciesFile {
|
|
blockOnce.Do(func() {
|
|
close(firstSaveStarted)
|
|
<-releaseFirstSave
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
server.beforeDelete = func(dir string, name string) error {
|
|
if dir == filer.IamConfigDirectory+"/"+IamPoliciesDirectory && name == "second.json" {
|
|
select {
|
|
case secondReachedDelete <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
server.mu.Unlock()
|
|
|
|
firstDeleteErr := make(chan error, 1)
|
|
go func() {
|
|
firstDeleteErr <- store.DeletePolicy(ctx, "first")
|
|
}()
|
|
|
|
<-firstSaveStarted
|
|
|
|
secondDeleteErr := make(chan error, 1)
|
|
go func() {
|
|
secondDeleteErr <- store.DeletePolicy(ctx, "second")
|
|
}()
|
|
|
|
select {
|
|
case <-secondReachedDelete:
|
|
t.Fatal("second delete reached filer mutation while first delete was still blocked")
|
|
case <-time.After(300 * time.Millisecond):
|
|
}
|
|
|
|
close(releaseFirstSave)
|
|
|
|
require.NoError(t, <-firstDeleteErr)
|
|
require.NoError(t, <-secondDeleteErr)
|
|
|
|
loadedLegacyPolicies, foundLegacy, err := store.loadLegacyPoliciesCollection(ctx)
|
|
require.NoError(t, err)
|
|
require.True(t, foundLegacy)
|
|
assert.Empty(t, loadedLegacyPolicies.Policies)
|
|
}
|
|
|
|
func TestFilerEtcStoreLoadManagedPoliciesRespectsReadContext(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
store, server := newPolicyTestStoreWithServer(t)
|
|
|
|
require.NoError(t, store.savePolicy(context.Background(), "cancel-me", testPolicyDocument("s3:GetObject", "arn:aws:s3:::cancel-me/*")))
|
|
|
|
server.mu.Lock()
|
|
server.contentlessListEntry[filerEntryKey(filer.IamConfigDirectory+"/"+IamPoliciesDirectory, "cancel-me.json")] = struct{}{}
|
|
server.beforeLookup = func(ctx context.Context, dir string, name string) error {
|
|
if dir == filer.IamConfigDirectory+"/"+IamPoliciesDirectory && name == "cancel-me.json" {
|
|
cancel()
|
|
return status.Error(codes.Canceled, context.Canceled.Error())
|
|
}
|
|
return nil
|
|
}
|
|
server.mu.Unlock()
|
|
|
|
// the canceled read must fail the snapshot, not silently omit the policy
|
|
managedPolicies, err := store.LoadManagedPolicies(ctx)
|
|
require.ErrorContains(t, err, "cancel-me.json")
|
|
assert.Empty(t, managedPolicies)
|
|
}
|
|
|
|
func testPolicyDocument(action string, resource string) policy_engine.PolicyDocument {
|
|
return policy_engine.PolicyDocument{
|
|
Version: policy_engine.PolicyVersion2012_10_17,
|
|
Statement: []policy_engine.PolicyStatement{
|
|
{
|
|
Effect: policy_engine.PolicyEffectAllow,
|
|
Action: policy_engine.NewStringOrStringSlice(action),
|
|
Resource: policy_engine.NewStringOrStringSlicePtr(resource),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func managedPolicyNames(policies []*iam_pb.Policy) []string {
|
|
names := make([]string, 0, len(policies))
|
|
for _, policy := range policies {
|
|
names = append(names, policy.Name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func assertInlinePolicyPreserved(t *testing.T, inlinePolicies map[string]map[string]policy_engine.PolicyDocument, userName string, policyName string) {
|
|
t.Helper()
|
|
|
|
userPolicies, found := inlinePolicies[userName]
|
|
require.True(t, found)
|
|
|
|
policy, found := userPolicies[policyName]
|
|
require.True(t, found)
|
|
assert.Equal(t, policy_engine.PolicyVersion2012_10_17, policy.Version)
|
|
require.Len(t, policy.Statement, 1)
|
|
assert.Equal(t, policy_engine.PolicyEffectAllow, policy.Statement[0].Effect)
|
|
}
|
|
|
|
func cloneEntry(entry *filer_pb.Entry) *filer_pb.Entry {
|
|
if entry == nil {
|
|
return nil
|
|
}
|
|
return proto.Clone(entry).(*filer_pb.Entry)
|
|
}
|
|
|
|
func filerEntryKey(dir string, name string) string {
|
|
return dir + "\x00" + name
|
|
}
|
|
|
|
func splitFilerEntryKey(key string) (dir string, name string) {
|
|
for idx := 0; idx < len(key); idx++ {
|
|
if key[idx] == '\x00' {
|
|
return key[:idx], key[idx+1:]
|
|
}
|
|
}
|
|
return key, ""
|
|
}
|