mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-22 14:32:35 +00:00
* s3api: persist single-identity IAM changes through the credential manager CreateAccessKey, DeleteAccessKey, UpdateAccessKey, PutUserPolicy, DeleteUserPolicy, SetUserStatus, UpdateUser and DeleteUser used to load the whole configuration, change one identity and then SaveConfiguration, rewriting and re-propagating every identity. They now write only the identity (and, for rename/delete, the groups and service accounts that referenced it) through the credential manager, which propagates that identity alone. Static identities and skipPersist keep the previous full-save/no-write contract. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * iamapi: persist single-identity IAM changes through the credential manager Same targeted-write change as the embedded IAM API for the standalone weed iam dispatcher: UpdateUser, DeleteUser, CreateAccessKey, DeleteAccessKey, UpdateAccessKey, PutUserPolicy and DeleteUserPolicy write one identity through the credential manager instead of saving the whole configuration. CreateAccessKey keeps the full save when the handler created a user the store does not know. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * credential/memory: re-key identity on UpdateUser rename UpdateUser stored the identity under the old username key even when identity.Name changed. Re-key it the way filer_etc does (delete old, save new) so the targeted rename path sees the moved record. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test: cover targeted single-identity IAM writes Recording-store tests assert each rerouted action uses the single-identity credential store methods and never SaveConfiguration, in both the embedded S3 IAM dispatcher and the standalone iamapi dispatcher, plus the skipPersist and static-identity contracts and a memory-store rename case. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * credential: keep not-found and rename-collision semantics in stores IamGrpcStore.DeleteAccessKey translates gRPC NotFound to ErrAccessKeyNotFound so a repeat delete stays the no-op the IAM handlers advertise. MemoryStore.UpdateUser rejects a rename onto an existing user instead of overwriting it, matching RenameUser. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
303 lines
11 KiB
Go
303 lines
11 KiB
Go
package iamapi
|
|
|
|
// Targeted-write regression tests: single-identity IAM actions must write only
|
|
// the identity they changed through the credential manager, not every identity
|
|
// via PutS3ApiConfiguration/SaveConfiguration.
|
|
// See https://github.com/seaweedfs/seaweedfs/issues/11865.
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/aws/session"
|
|
"github.com/aws/aws-sdk-go/service/iam"
|
|
"github.com/seaweedfs/seaweedfs/weed/credential"
|
|
"github.com/seaweedfs/seaweedfs/weed/credential/memory"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// targetedRecordingStore wraps a memory store and counts the write methods
|
|
// the dispatcher used, so a test can assert the single-identity path was
|
|
// taken. It embeds the concrete store (rather than the CredentialStore
|
|
// interface) so optional interfaces like InlinePolicyStore stay visible
|
|
// through the wrapper.
|
|
type targetedRecordingStore struct {
|
|
*memory.MemoryStore
|
|
saves int
|
|
createAccessKeys int
|
|
deleteAccessKeys int
|
|
updateUsers int
|
|
deleteUsers int
|
|
updateGroups int
|
|
}
|
|
|
|
func (r *targetedRecordingStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
|
|
r.saves++
|
|
return r.MemoryStore.SaveConfiguration(ctx, config)
|
|
}
|
|
|
|
func (r *targetedRecordingStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
|
|
r.createAccessKeys++
|
|
return r.MemoryStore.CreateAccessKey(ctx, username, cred)
|
|
}
|
|
|
|
func (r *targetedRecordingStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
|
|
r.deleteAccessKeys++
|
|
return r.MemoryStore.DeleteAccessKey(ctx, username, accessKey)
|
|
}
|
|
|
|
func (r *targetedRecordingStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
|
|
r.updateUsers++
|
|
return r.MemoryStore.UpdateUser(ctx, username, identity)
|
|
}
|
|
|
|
func (r *targetedRecordingStore) DeleteUser(ctx context.Context, username string) error {
|
|
r.deleteUsers++
|
|
return r.MemoryStore.DeleteUser(ctx, username)
|
|
}
|
|
|
|
func (r *targetedRecordingStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
|
|
r.updateGroups++
|
|
return r.MemoryStore.UpdateGroup(ctx, group)
|
|
}
|
|
|
|
func targetedIamApiServer() (*IamApiServer, *credential.CredentialManager, *countingConfigSaver, *targetedRecordingStore) {
|
|
srv, cm, counter := iamApiServerWithCredentialManager()
|
|
rec := &targetedRecordingStore{MemoryStore: cm.Store.(*memory.MemoryStore)}
|
|
cm.Store = rec
|
|
return srv, cm, counter, rec
|
|
}
|
|
|
|
const targetedTestPolicy = `{
|
|
"Version": "2012-10-17",
|
|
"Statement": [{"Effect": "Allow", "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::bench/*"]}]
|
|
}`
|
|
|
|
func TestIamApiCreateAccessKeyTargeted(t *testing.T) {
|
|
srv, cm, counter, rec := targetedIamApiServer()
|
|
ctx := context.Background()
|
|
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "alice"}))
|
|
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "bob"}))
|
|
|
|
req, _ := iam.New(session.New()).CreateAccessKeyRequest(&iam.CreateAccessKeyInput{UserName: aws.String("alice")})
|
|
_ = req.Build()
|
|
out := CreateAccessKeyResponse{}
|
|
resp, err := executeRequestWith(srv, req.HTTPRequest, &out)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
assert.Equal(t, 0, counter.putCalled, "CreateAccessKey must not PutS3ApiConfiguration")
|
|
assert.Equal(t, 0, rec.saves)
|
|
assert.Equal(t, 1, rec.createAccessKeys)
|
|
|
|
ident, err := cm.GetUser(ctx, "alice")
|
|
require.NoError(t, err)
|
|
require.Len(t, ident.Credentials, 1)
|
|
assert.Equal(t, *out.CreateAccessKeyResult.AccessKey.AccessKeyId, ident.Credentials[0].AccessKey)
|
|
}
|
|
|
|
// CreateAccessKey for a user the store does not know still needs the full save:
|
|
// the handler creates the identity in the config, so there is no existing
|
|
// record to write a key into.
|
|
func TestIamApiCreateAccessKeyUnknownUserFallsBack(t *testing.T) {
|
|
srv, cm, counter, _ := targetedIamApiServer()
|
|
ctx := context.Background()
|
|
|
|
req, _ := iam.New(session.New()).CreateAccessKeyRequest(&iam.CreateAccessKeyInput{UserName: aws.String("ghost")})
|
|
_ = req.Build()
|
|
out := CreateAccessKeyResponse{}
|
|
resp, err := executeRequestWith(srv, req.HTTPRequest, &out)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
assert.Equal(t, 1, counter.putCalled)
|
|
ident, err := cm.GetUser(ctx, "ghost")
|
|
require.NoError(t, err)
|
|
assert.Len(t, ident.Credentials, 1)
|
|
}
|
|
|
|
func TestIamApiDeleteAccessKeyTargeted(t *testing.T) {
|
|
srv, cm, counter, rec := targetedIamApiServer()
|
|
ctx := context.Background()
|
|
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{
|
|
Name: "alice",
|
|
Credentials: []*iam_pb.Credential{{AccessKey: "AKIATESTFAKEKEY00001", SecretKey: "testsecretfake"}},
|
|
}))
|
|
|
|
req, _ := iam.New(session.New()).DeleteAccessKeyRequest(&iam.DeleteAccessKeyInput{
|
|
UserName: aws.String("alice"),
|
|
AccessKeyId: aws.String("AKIATESTFAKEKEY00001"),
|
|
})
|
|
_ = req.Build()
|
|
out := DeleteAccessKeyResponse{}
|
|
resp, err := executeRequestWith(srv, req.HTTPRequest, &out)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
assert.Equal(t, 0, counter.putCalled, "DeleteAccessKey must not PutS3ApiConfiguration")
|
|
assert.Equal(t, 1, rec.deleteAccessKeys)
|
|
|
|
ident, err := cm.GetUser(ctx, "alice")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, ident.Credentials)
|
|
|
|
// Deleting a key that does not exist still answers 200 and stays targeted.
|
|
req, _ = iam.New(session.New()).DeleteAccessKeyRequest(&iam.DeleteAccessKeyInput{
|
|
UserName: aws.String("alice"),
|
|
AccessKeyId: aws.String("AKIATESTFAKEKEY99999"),
|
|
})
|
|
_ = req.Build()
|
|
resp, err = executeRequestWith(srv, req.HTTPRequest, &out)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
assert.Equal(t, 0, counter.putCalled)
|
|
}
|
|
|
|
func TestIamApiUpdateAccessKeyTargeted(t *testing.T) {
|
|
srv, cm, counter, rec := targetedIamApiServer()
|
|
ctx := context.Background()
|
|
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{
|
|
Name: "alice",
|
|
Credentials: []*iam_pb.Credential{{AccessKey: "AKIATESTFAKEKEY00001", SecretKey: "testsecretfake", Status: "Active"}},
|
|
}))
|
|
|
|
req, _ := iam.New(session.New()).UpdateAccessKeyRequest(&iam.UpdateAccessKeyInput{
|
|
UserName: aws.String("alice"),
|
|
AccessKeyId: aws.String("AKIATESTFAKEKEY00001"),
|
|
Status: aws.String(iam.StatusTypeInactive),
|
|
})
|
|
_ = req.Build()
|
|
out := UpdateAccessKeyResponse{}
|
|
resp, err := executeRequestWith(srv, req.HTTPRequest, &out)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
assert.Equal(t, 0, counter.putCalled, "UpdateAccessKey must not PutS3ApiConfiguration")
|
|
assert.Equal(t, 1, rec.updateUsers)
|
|
|
|
ident, err := cm.GetUser(ctx, "alice")
|
|
require.NoError(t, err)
|
|
require.Len(t, ident.Credentials, 1)
|
|
assert.Equal(t, iam.StatusTypeInactive, ident.Credentials[0].Status)
|
|
}
|
|
|
|
func TestIamApiPutUserPolicyTargeted(t *testing.T) {
|
|
srv, cm, counter, rec := targetedIamApiServer()
|
|
ctx := context.Background()
|
|
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "alice"}))
|
|
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "bob"}))
|
|
|
|
req, _ := iam.New(session.New()).PutUserPolicyRequest(&iam.PutUserPolicyInput{
|
|
UserName: aws.String("alice"),
|
|
PolicyName: aws.String("read-bench"),
|
|
PolicyDocument: aws.String(targetedTestPolicy),
|
|
})
|
|
_ = req.Build()
|
|
out := PutUserPolicyResponse{}
|
|
resp, err := executeRequestWith(srv, req.HTTPRequest, &out)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
assert.Equal(t, 0, counter.putCalled, "PutUserPolicy must not PutS3ApiConfiguration")
|
|
assert.Equal(t, 0, rec.saves)
|
|
assert.Equal(t, 1, rec.updateUsers)
|
|
|
|
ident, err := cm.GetUser(ctx, "alice")
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, ident.Actions, "recomputed Actions must be persisted for the one identity")
|
|
}
|
|
|
|
func TestIamApiDeleteUserPolicyTargeted(t *testing.T) {
|
|
srv, cm, counter, rec := targetedIamApiServer()
|
|
ctx := context.Background()
|
|
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "alice"}))
|
|
|
|
putReq, _ := iam.New(session.New()).PutUserPolicyRequest(&iam.PutUserPolicyInput{
|
|
UserName: aws.String("alice"),
|
|
PolicyName: aws.String("read-bench"),
|
|
PolicyDocument: aws.String(targetedTestPolicy),
|
|
})
|
|
_ = putReq.Build()
|
|
putOut := PutUserPolicyResponse{}
|
|
resp, err := executeRequestWith(srv, putReq.HTTPRequest, &putOut)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
delReq, _ := iam.New(session.New()).DeleteUserPolicyRequest(&iam.DeleteUserPolicyInput{
|
|
UserName: aws.String("alice"),
|
|
PolicyName: aws.String("read-bench"),
|
|
})
|
|
_ = delReq.Build()
|
|
delOut := DeleteUserPolicyResponse{}
|
|
resp, err = executeRequestWith(srv, delReq.HTTPRequest, &delOut)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
assert.Equal(t, 0, counter.putCalled, "DeleteUserPolicy must not PutS3ApiConfiguration")
|
|
assert.Equal(t, 2, rec.updateUsers)
|
|
|
|
policies := Policies{}
|
|
require.NoError(t, srv.s3ApiConfig.GetPolicies(&policies))
|
|
assert.Empty(t, policies.InlinePolicies["alice"])
|
|
}
|
|
|
|
func TestIamApiUpdateUserRenameTargeted(t *testing.T) {
|
|
srv, cm, counter, _ := targetedIamApiServer()
|
|
ctx := context.Background()
|
|
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "alice"}))
|
|
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "bob"}))
|
|
require.NoError(t, cm.CreateGroup(ctx, &iam_pb.Group{Name: "devs", Members: []string{"alice", "bob"}}))
|
|
|
|
req, _ := iam.New(session.New()).UpdateUserRequest(&iam.UpdateUserInput{
|
|
UserName: aws.String("alice"),
|
|
NewUserName: aws.String("alice-renamed"),
|
|
})
|
|
_ = req.Build()
|
|
out := UpdateUserResponse{}
|
|
resp, err := executeRequestWith(srv, req.HTTPRequest, &out)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
assert.Equal(t, 0, counter.putCalled, "UpdateUser rename must not PutS3ApiConfiguration")
|
|
|
|
_, err = cm.GetUser(ctx, "alice")
|
|
require.ErrorIs(t, err, credential.ErrUserNotFound)
|
|
_, err = cm.GetUser(ctx, "alice-renamed")
|
|
require.NoError(t, err)
|
|
|
|
g, err := cm.GetGroup(ctx, "devs")
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{"alice-renamed", "bob"}, g.Members)
|
|
}
|
|
|
|
func TestIamApiDeleteUserTargeted(t *testing.T) {
|
|
srv, cm, counter, rec := targetedIamApiServer()
|
|
ctx := context.Background()
|
|
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "alice"}))
|
|
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: "bob"}))
|
|
require.NoError(t, cm.CreateGroup(ctx, &iam_pb.Group{Name: "devs", Members: []string{"alice", "bob"}}))
|
|
|
|
req, _ := iam.New(session.New()).DeleteUserRequest(&iam.DeleteUserInput{UserName: aws.String("alice")})
|
|
_ = req.Build()
|
|
out := DeleteUserResponse{}
|
|
resp, err := executeRequestWith(srv, req.HTTPRequest, &out)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
assert.Equal(t, 0, counter.putCalled, "DeleteUser must not PutS3ApiConfiguration")
|
|
assert.Equal(t, 1, rec.deleteUsers)
|
|
assert.Equal(t, 1, rec.updateGroups)
|
|
|
|
_, err = cm.GetUser(ctx, "alice")
|
|
require.ErrorIs(t, err, credential.ErrUserNotFound)
|
|
g, err := cm.GetGroup(ctx, "devs")
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{"bob"}, g.Members)
|
|
_, err = cm.GetUser(ctx, "bob")
|
|
require.NoError(t, err, "other identities must be untouched")
|
|
}
|