mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-22 14:04:28 +02: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>
692 lines
25 KiB
Go
692 lines
25 KiB
Go
package iamapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"regexp"
|
|
"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/gorilla/mux"
|
|
"github.com/jinzhu/copier"
|
|
"github.com/seaweedfs/seaweedfs/weed/credential"
|
|
"github.com/seaweedfs/seaweedfs/weed/credential/memory"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
|
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
var GetS3ApiConfiguration func(s3cfg *iam_pb.S3ApiConfiguration) (err error)
|
|
var PutS3ApiConfiguration func(s3cfg *iam_pb.S3ApiConfiguration) (err error)
|
|
var GetPolicies func(policies *Policies) (err error)
|
|
var PutPolicies func(policies *Policies) (err error)
|
|
|
|
var s3config = iam_pb.S3ApiConfiguration{}
|
|
var policiesFile = Policies{Policies: make(map[string]policy_engine.PolicyDocument)}
|
|
var ias = IamApiServer{s3ApiConfig: iamS3ApiConfigureMock{}}
|
|
|
|
type iamS3ApiConfigureMock struct{}
|
|
|
|
func (iam iamS3ApiConfigureMock) GetS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) (err error) {
|
|
_ = copier.Copy(&s3cfg.Identities, &s3config.Identities)
|
|
return nil
|
|
}
|
|
|
|
func (iam iamS3ApiConfigureMock) PutS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) (err error) {
|
|
_ = copier.Copy(&s3config.Identities, &s3cfg.Identities)
|
|
return nil
|
|
}
|
|
|
|
func (iam iamS3ApiConfigureMock) GetPolicies(policies *Policies) (err error) {
|
|
_ = copier.Copy(&policies, &policiesFile)
|
|
return nil
|
|
}
|
|
|
|
func (iam iamS3ApiConfigureMock) PutPolicies(policies *Policies) (err error) {
|
|
_ = copier.Copy(&policiesFile, &policies)
|
|
return nil
|
|
}
|
|
|
|
func TestCreateUser(t *testing.T) {
|
|
userName := aws.String("Test")
|
|
params := &iam.CreateUserInput{UserName: userName}
|
|
req, _ := iam.New(session.New()).CreateUserRequest(params)
|
|
_ = req.Build()
|
|
out := CreateUserResponse{}
|
|
response, err := executeRequest(req.HTTPRequest, out)
|
|
assert.Equal(t, nil, err)
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
//assert.Equal(t, out.XMLName, "lol")
|
|
}
|
|
|
|
func TestListUsers(t *testing.T) {
|
|
params := &iam.ListUsersInput{}
|
|
req, _ := iam.New(session.New()).ListUsersRequest(params)
|
|
_ = req.Build()
|
|
out := ListUsersResponse{}
|
|
response, err := executeRequest(req.HTTPRequest, out)
|
|
assert.Equal(t, nil, err)
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
}
|
|
|
|
func TestListUsersRequestIdMatchesResponseHeader(t *testing.T) {
|
|
params := &iam.ListUsersInput{}
|
|
req, _ := iam.New(session.New()).ListUsersRequest(params)
|
|
_ = req.Build()
|
|
|
|
out := ListUsersResponse{}
|
|
response, err := executeRequest(req.HTTPRequest, out)
|
|
assert.Equal(t, nil, err)
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
|
|
headerRequestID := response.Header().Get(request_id.AmzRequestIDHeader)
|
|
assert.NotEmpty(t, headerRequestID)
|
|
assert.Equal(t, headerRequestID, extractRequestID(response))
|
|
}
|
|
|
|
func TestListAccessKeys(t *testing.T) {
|
|
svc := iam.New(session.New())
|
|
params := &iam.ListAccessKeysInput{}
|
|
req, _ := svc.ListAccessKeysRequest(params)
|
|
_ = req.Build()
|
|
out := ListAccessKeysResponse{}
|
|
response, err := executeRequest(req.HTTPRequest, out)
|
|
assert.Equal(t, nil, err)
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
}
|
|
|
|
func TestUpdateAccessKey(t *testing.T) {
|
|
svc := iam.New(session.New())
|
|
|
|
createReq, _ := svc.CreateAccessKeyRequest(&iam.CreateAccessKeyInput{UserName: aws.String("Test")})
|
|
_ = createReq.Build()
|
|
createOut := CreateAccessKeyResponse{}
|
|
response, err := executeRequest(createReq.HTTPRequest, createOut)
|
|
assert.Equal(t, nil, err)
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
|
|
var createResp CreateAccessKeyResponse
|
|
err = xml.Unmarshal(response.Body.Bytes(), &createResp)
|
|
assert.Equal(t, nil, err)
|
|
accessKeyId := createResp.CreateAccessKeyResult.AccessKey.AccessKeyId
|
|
if accessKeyId == nil {
|
|
t.Fatalf("expected access key id to be set")
|
|
}
|
|
|
|
updateReq, _ := svc.UpdateAccessKeyRequest(&iam.UpdateAccessKeyInput{
|
|
UserName: aws.String("Test"),
|
|
AccessKeyId: accessKeyId,
|
|
Status: aws.String("Inactive"),
|
|
})
|
|
_ = updateReq.Build()
|
|
updateOut := UpdateAccessKeyResponse{}
|
|
response, err = executeRequest(updateReq.HTTPRequest, updateOut)
|
|
assert.Equal(t, nil, err)
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
|
|
listReq, _ := svc.ListAccessKeysRequest(&iam.ListAccessKeysInput{UserName: aws.String("Test")})
|
|
_ = listReq.Build()
|
|
listOut := ListAccessKeysResponse{}
|
|
response, err = executeRequest(listReq.HTTPRequest, listOut)
|
|
assert.Equal(t, nil, err)
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
|
|
var listResp ListAccessKeysResponse
|
|
err = xml.Unmarshal(response.Body.Bytes(), &listResp)
|
|
assert.Equal(t, nil, err)
|
|
found := false
|
|
for _, key := range listResp.ListAccessKeysResult.AccessKeyMetadata {
|
|
if key.AccessKeyId != nil && *key.AccessKeyId == *accessKeyId {
|
|
found = true
|
|
if assert.NotNil(t, key.Status) {
|
|
assert.Equal(t, "Inactive", *key.Status)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found)
|
|
}
|
|
|
|
func TestGetUser(t *testing.T) {
|
|
userName := aws.String("Test")
|
|
params := &iam.GetUserInput{UserName: userName}
|
|
req, _ := iam.New(session.New()).GetUserRequest(params)
|
|
_ = req.Build()
|
|
out := GetUserResponse{}
|
|
response, err := executeRequest(req.HTTPRequest, out)
|
|
assert.Equal(t, nil, err)
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
}
|
|
|
|
// Todo flat statement
|
|
func TestCreatePolicy(t *testing.T) {
|
|
params := &iam.CreatePolicyInput{
|
|
PolicyName: aws.String("S3-read-only-example-bucket"),
|
|
PolicyDocument: aws.String(`
|
|
{
|
|
"Version": "2012-10-17",
|
|
"Statement": [
|
|
{
|
|
"Effect": "Allow",
|
|
"Action": [
|
|
"s3:Get*",
|
|
"s3:List*"
|
|
],
|
|
"Resource": [
|
|
"arn:aws:s3:::EXAMPLE-BUCKET",
|
|
"arn:aws:s3:::EXAMPLE-BUCKET/*"
|
|
]
|
|
}
|
|
]
|
|
}`),
|
|
}
|
|
req, _ := iam.New(session.New()).CreatePolicyRequest(params)
|
|
_ = req.Build()
|
|
out := CreatePolicyResponse{}
|
|
response, err := executeRequest(req.HTTPRequest, out)
|
|
assert.Equal(t, nil, err)
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
}
|
|
|
|
// isolatedIamConfig is a self-contained IamS3ApiConfig backing whose state lives
|
|
// on the instance (not the package globals that executeRequest shares), so
|
|
// policy-version tests stay order-independent under -shuffle.
|
|
type isolatedIamConfig struct {
|
|
identities []*iam_pb.Identity
|
|
groups []*iam_pb.Group
|
|
policies Policies
|
|
}
|
|
|
|
func newIsolatedIamServer() *IamApiServer {
|
|
return &IamApiServer{s3ApiConfig: &isolatedIamConfig{
|
|
policies: Policies{Policies: make(map[string]policy_engine.PolicyDocument)},
|
|
}}
|
|
}
|
|
|
|
func (m *isolatedIamConfig) GetS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) error {
|
|
_ = copier.Copy(&s3cfg.Identities, &m.identities)
|
|
_ = copier.Copy(&s3cfg.Groups, &m.groups)
|
|
return nil
|
|
}
|
|
|
|
func (m *isolatedIamConfig) PutS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) error {
|
|
_ = copier.Copy(&m.identities, &s3cfg.Identities)
|
|
_ = copier.Copy(&m.groups, &s3cfg.Groups)
|
|
return nil
|
|
}
|
|
|
|
func (m *isolatedIamConfig) GetPolicies(policies *Policies) error {
|
|
_ = copier.Copy(&policies, &m.policies)
|
|
return nil
|
|
}
|
|
|
|
func (m *isolatedIamConfig) PutPolicies(policies *Policies) error {
|
|
_ = copier.Copy(&m.policies, &policies)
|
|
return nil
|
|
}
|
|
|
|
// TestCreatePolicyVersion reproduces issue #9785: the AWS Terraform provider
|
|
// updates a managed policy in place via CreatePolicyVersion, which previously
|
|
// returned 501 NotImplemented. The whole read/update surface Terraform relies on
|
|
// is exercised here: GetPolicy (DefaultVersionId), CreatePolicyVersion,
|
|
// GetPolicyVersion and ListPolicyVersions.
|
|
func TestCreatePolicyVersion(t *testing.T) {
|
|
srv := newIsolatedIamServer()
|
|
svc := iam.New(session.New())
|
|
policyName := "tf-managed-policy"
|
|
policyArn := aws.String("arn:aws:iam:::policy/" + policyName)
|
|
|
|
// Create the managed policy.
|
|
createReq, _ := svc.CreatePolicyRequest(&iam.CreatePolicyInput{
|
|
PolicyName: aws.String(policyName),
|
|
PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:Get*","s3:List*"],"Resource":["arn:aws:s3:::EXAMPLE-BUCKET"]}]}`),
|
|
})
|
|
_ = createReq.Build()
|
|
resp, err := executeRequestWith(srv, createReq.HTTPRequest, nil)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
// Update it in place (what Terraform does). This used to return 501.
|
|
cpvReq, _ := svc.CreatePolicyVersionRequest(&iam.CreatePolicyVersionInput{
|
|
PolicyArn: policyArn,
|
|
PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:Get*"],"Resource":["arn:aws:s3:::EXAMPLE-BUCKET"]}]}`),
|
|
SetAsDefault: aws.Bool(true),
|
|
})
|
|
_ = cpvReq.Build()
|
|
resp, err = executeRequestWith(srv, cpvReq.HTTPRequest, nil)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
var cpvResp CreatePolicyVersionResponse
|
|
require.NoError(t, xml.Unmarshal(resp.Body.Bytes(), &cpvResp))
|
|
require.NotNil(t, cpvResp.CreatePolicyVersionResult.PolicyVersion.VersionId)
|
|
assert.Equal(t, "v1", *cpvResp.CreatePolicyVersionResult.PolicyVersion.VersionId)
|
|
require.NotNil(t, cpvResp.CreatePolicyVersionResult.PolicyVersion.IsDefaultVersion)
|
|
assert.True(t, *cpvResp.CreatePolicyVersionResult.PolicyVersion.IsDefaultVersion)
|
|
|
|
// GetPolicy must advertise a default version so Terraform's read can chain
|
|
// into GetPolicyVersion.
|
|
gpReq, _ := svc.GetPolicyRequest(&iam.GetPolicyInput{PolicyArn: policyArn})
|
|
_ = gpReq.Build()
|
|
resp, err = executeRequestWith(srv, gpReq.HTTPRequest, nil)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
var gpResp GetPolicyResponse
|
|
require.NoError(t, xml.Unmarshal(resp.Body.Bytes(), &gpResp))
|
|
require.NotNil(t, gpResp.GetPolicyResult.Policy.DefaultVersionId)
|
|
assert.Equal(t, "v1", *gpResp.GetPolicyResult.Policy.DefaultVersionId)
|
|
|
|
// GetPolicyVersion must return the updated document (Get* only, List* gone).
|
|
gpvReq, _ := svc.GetPolicyVersionRequest(&iam.GetPolicyVersionInput{PolicyArn: policyArn, VersionId: aws.String("v1")})
|
|
_ = gpvReq.Build()
|
|
resp, err = executeRequestWith(srv, gpvReq.HTTPRequest, nil)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
var gpvResp GetPolicyVersionResponse
|
|
require.NoError(t, xml.Unmarshal(resp.Body.Bytes(), &gpvResp))
|
|
require.NotNil(t, gpvResp.GetPolicyVersionResult.PolicyVersion.Document)
|
|
assert.Contains(t, *gpvResp.GetPolicyVersionResult.PolicyVersion.Document, "s3:Get*")
|
|
assert.NotContains(t, *gpvResp.GetPolicyVersionResult.PolicyVersion.Document, "s3:List*")
|
|
|
|
// ListPolicyVersions must report exactly the single default version.
|
|
lpvReq, _ := svc.ListPolicyVersionsRequest(&iam.ListPolicyVersionsInput{PolicyArn: policyArn})
|
|
_ = lpvReq.Build()
|
|
resp, err = executeRequestWith(srv, lpvReq.HTTPRequest, nil)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
var lpvResp ListPolicyVersionsResponse
|
|
require.NoError(t, xml.Unmarshal(resp.Body.Bytes(), &lpvResp))
|
|
require.Len(t, lpvResp.ListPolicyVersionsResult.Versions, 1)
|
|
assert.Equal(t, "v1", *lpvResp.ListPolicyVersionsResult.Versions[0].VersionId)
|
|
}
|
|
|
|
// TestCreatePolicyVersionMissingPolicy verifies a NoSuchEntity (404) when the
|
|
// target policy does not exist, matching AWS.
|
|
func TestCreatePolicyVersionMissingPolicy(t *testing.T) {
|
|
srv := newIsolatedIamServer()
|
|
req, _ := iam.New(session.New()).CreatePolicyVersionRequest(&iam.CreatePolicyVersionInput{
|
|
PolicyArn: aws.String("arn:aws:iam:::policy/does-not-exist"),
|
|
PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:Get*"],"Resource":["arn:aws:s3:::EXAMPLE-BUCKET"]}]}`),
|
|
SetAsDefault: aws.Bool(true),
|
|
})
|
|
_ = req.Build()
|
|
resp, err := executeRequestWith(srv, req.HTTPRequest, nil)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusNotFound, resp.Code)
|
|
code, _ := extractErrorCodeAndMessage(resp)
|
|
assert.Equal(t, "NoSuchEntity", code)
|
|
}
|
|
|
|
// TestCreatePolicyVersionRequiresSetAsDefault verifies that, given the
|
|
// single-version model, a request that does not set SetAsDefault=true is
|
|
// rejected rather than silently overwriting the active document.
|
|
func TestCreatePolicyVersionRequiresSetAsDefault(t *testing.T) {
|
|
srv := newIsolatedIamServer()
|
|
svc := iam.New(session.New())
|
|
policyArn := aws.String("arn:aws:iam:::policy/tf-managed-policy")
|
|
|
|
createReq, _ := svc.CreatePolicyRequest(&iam.CreatePolicyInput{
|
|
PolicyName: aws.String("tf-managed-policy"),
|
|
PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:Get*"],"Resource":["arn:aws:s3:::EXAMPLE-BUCKET"]}]}`),
|
|
})
|
|
_ = createReq.Build()
|
|
resp, err := executeRequestWith(srv, createReq.HTTPRequest, nil)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
// SetAsDefault omitted (defaults to false) must be rejected.
|
|
cpvReq, _ := svc.CreatePolicyVersionRequest(&iam.CreatePolicyVersionInput{
|
|
PolicyArn: policyArn,
|
|
PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:Put*"],"Resource":["arn:aws:s3:::EXAMPLE-BUCKET"]}]}`),
|
|
})
|
|
_ = cpvReq.Build()
|
|
resp, err = executeRequestWith(srv, cpvReq.HTTPRequest, nil)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusBadRequest, resp.Code)
|
|
code, _ := extractErrorCodeAndMessage(resp)
|
|
assert.Equal(t, "InvalidInput", code)
|
|
|
|
// The live document must be untouched (still Get*, not Put*).
|
|
gpvReq, _ := svc.GetPolicyVersionRequest(&iam.GetPolicyVersionInput{PolicyArn: policyArn, VersionId: aws.String("v1")})
|
|
_ = gpvReq.Build()
|
|
resp, err = executeRequestWith(srv, gpvReq.HTTPRequest, nil)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
var gpvResp GetPolicyVersionResponse
|
|
require.NoError(t, xml.Unmarshal(resp.Body.Bytes(), &gpvResp))
|
|
require.NotNil(t, gpvResp.GetPolicyVersionResult.PolicyVersion.Document)
|
|
assert.Contains(t, *gpvResp.GetPolicyVersionResult.PolicyVersion.Document, "s3:Get*")
|
|
assert.NotContains(t, *gpvResp.GetPolicyVersionResult.PolicyVersion.Document, "s3:Put*")
|
|
}
|
|
|
|
func TestPutUserPolicy(t *testing.T) {
|
|
userName := aws.String("Test")
|
|
params := &iam.PutUserPolicyInput{
|
|
UserName: userName,
|
|
PolicyName: aws.String("S3-read-only-example-bucket"),
|
|
PolicyDocument: aws.String(
|
|
`{
|
|
"Version": "2012-10-17",
|
|
"Statement": [
|
|
{
|
|
"Effect": "Allow",
|
|
"Action": [
|
|
"s3:Get*",
|
|
"s3:List*"
|
|
],
|
|
"Resource": [
|
|
"arn:aws:s3:::EXAMPLE-BUCKET",
|
|
"arn:aws:s3:::EXAMPLE-BUCKET/*"
|
|
]
|
|
}
|
|
]
|
|
}`),
|
|
}
|
|
req, _ := iam.New(session.New()).PutUserPolicyRequest(params)
|
|
_ = req.Build()
|
|
out := PutUserPolicyResponse{}
|
|
response, err := executeRequest(req.HTTPRequest, out)
|
|
assert.Equal(t, nil, err)
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
}
|
|
|
|
func TestPutUserPolicyError(t *testing.T) {
|
|
userName := aws.String("InvalidUser")
|
|
params := &iam.PutUserPolicyInput{
|
|
UserName: userName,
|
|
PolicyName: aws.String("S3-read-only-example-bucket"),
|
|
PolicyDocument: aws.String(
|
|
`{
|
|
"Version": "2012-10-17",
|
|
"Statement": [
|
|
{
|
|
"Effect": "Allow",
|
|
"Action": [
|
|
"s3:Get*",
|
|
"s3:List*"
|
|
],
|
|
"Resource": [
|
|
"arn:aws:s3:::EXAMPLE-BUCKET",
|
|
"arn:aws:s3:::EXAMPLE-BUCKET/*"
|
|
]
|
|
}
|
|
]
|
|
}`),
|
|
}
|
|
req, _ := iam.New(session.New()).PutUserPolicyRequest(params)
|
|
_ = req.Build()
|
|
response, err := executeRequest(req.HTTPRequest, nil)
|
|
assert.Equal(t, nil, err)
|
|
assert.Equal(t, http.StatusNotFound, response.Code)
|
|
|
|
expectedMessage := "the user with name InvalidUser cannot be found"
|
|
expectedCode := "NoSuchEntity"
|
|
|
|
code, message := extractErrorCodeAndMessage(response)
|
|
|
|
assert.Equal(t, expectedMessage, message)
|
|
assert.Equal(t, expectedCode, code)
|
|
assert.Contains(t, response.Body.String(), "<RequestId>")
|
|
assert.NotContains(t, response.Body.String(), "<ResponseMetadata>")
|
|
assert.Equal(t, response.Header().Get(request_id.AmzRequestIDHeader), extractRequestID(response))
|
|
}
|
|
|
|
func extractErrorCodeAndMessage(response *httptest.ResponseRecorder) (string, string) {
|
|
pattern := `<Error><Code>(.*)</Code><Message>(.*)</Message><Type>(.*)</Type></Error>`
|
|
re := regexp.MustCompile(pattern)
|
|
|
|
code := re.FindStringSubmatch(response.Body.String())[1]
|
|
message := re.FindStringSubmatch(response.Body.String())[2]
|
|
return code, message
|
|
}
|
|
|
|
func extractRequestID(response *httptest.ResponseRecorder) string {
|
|
re := regexp.MustCompile(`<RequestId>([^<]+)</RequestId>`)
|
|
matches := re.FindStringSubmatch(response.Body.String())
|
|
if len(matches) < 2 {
|
|
return ""
|
|
}
|
|
return matches[1]
|
|
}
|
|
|
|
func TestGetUserPolicy(t *testing.T) {
|
|
userName := aws.String("Test")
|
|
params := &iam.GetUserPolicyInput{UserName: userName, PolicyName: aws.String("S3-read-only-example-bucket")}
|
|
req, _ := iam.New(session.New()).GetUserPolicyRequest(params)
|
|
_ = req.Build()
|
|
out := GetUserPolicyResponse{}
|
|
response, err := executeRequest(req.HTTPRequest, out)
|
|
assert.Equal(t, nil, err)
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
}
|
|
|
|
func TestUpdateUser(t *testing.T) {
|
|
userName := aws.String("Test")
|
|
newUserName := aws.String("Test-New")
|
|
params := &iam.UpdateUserInput{NewUserName: newUserName, UserName: userName}
|
|
req, _ := iam.New(session.New()).UpdateUserRequest(params)
|
|
_ = req.Build()
|
|
out := UpdateUserResponse{}
|
|
response, err := executeRequest(req.HTTPRequest, out)
|
|
assert.Equal(t, nil, err)
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
}
|
|
|
|
func TestDeleteUser(t *testing.T) {
|
|
userName := aws.String("Test-New")
|
|
params := &iam.DeleteUserInput{UserName: userName}
|
|
req, _ := iam.New(session.New()).DeleteUserRequest(params)
|
|
_ = req.Build()
|
|
out := DeleteUserResponse{}
|
|
response, err := executeRequest(req.HTTPRequest, out)
|
|
assert.Equal(t, nil, err)
|
|
assert.Equal(t, http.StatusConflict, response.Code)
|
|
|
|
listReq, _ := iam.New(session.New()).ListAccessKeysRequest(&iam.ListAccessKeysInput{UserName: userName})
|
|
_ = listReq.Build()
|
|
listOut := ListAccessKeysResponse{}
|
|
listResp, err := executeRequest(listReq.HTTPRequest, listOut)
|
|
assert.Equal(t, nil, err)
|
|
var listResult ListAccessKeysResponse
|
|
assert.Equal(t, nil, xml.Unmarshal(listResp.Body.Bytes(), &listResult))
|
|
for _, key := range listResult.ListAccessKeysResult.AccessKeyMetadata {
|
|
delKeyReq, _ := iam.New(session.New()).DeleteAccessKeyRequest(&iam.DeleteAccessKeyInput{
|
|
UserName: userName,
|
|
AccessKeyId: key.AccessKeyId,
|
|
})
|
|
_ = delKeyReq.Build()
|
|
_, err := executeRequest(delKeyReq.HTTPRequest, out)
|
|
assert.Equal(t, nil, err)
|
|
}
|
|
|
|
req, _ = iam.New(session.New()).DeleteUserRequest(params)
|
|
_ = req.Build()
|
|
response, err = executeRequest(req.HTTPRequest, out)
|
|
assert.Equal(t, nil, err)
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
}
|
|
|
|
func executeRequest(req *http.Request, v interface{}) (*httptest.ResponseRecorder, error) {
|
|
rr := httptest.NewRecorder()
|
|
apiRouter := mux.NewRouter().SkipClean(true)
|
|
apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(ias.DoActions)
|
|
apiRouter.ServeHTTP(rr, req)
|
|
return rr, xml.Unmarshal(rr.Body.Bytes(), &v)
|
|
}
|
|
|
|
func TestHandleImplicitUsername(t *testing.T) {
|
|
// Create a mock IamApiServer with credential store
|
|
// The handleImplicitUsername function now looks up the username from the
|
|
// credential store based on AccessKeyId, not from the region field in the auth header.
|
|
// Note: Using obviously fake access keys to avoid CI secret scanner false positives
|
|
|
|
// Create IAM directly as struct literal (same pattern as other tests)
|
|
iam := &s3api.IdentityAccessManagement{}
|
|
|
|
// Load test credentials - map access key to identity name
|
|
testConfig := &iam_pb.S3ApiConfiguration{
|
|
Identities: []*iam_pb.Identity{
|
|
{
|
|
Name: "testuser1",
|
|
Credentials: []*iam_pb.Credential{
|
|
{AccessKey: "AKIATESTFAKEKEY000001", SecretKey: "testsecretfake"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(t, testConfig))
|
|
if err != nil {
|
|
t.Fatalf("Failed to load test config: %v", err)
|
|
}
|
|
|
|
iama := &IamApiServer{
|
|
iam: iam,
|
|
}
|
|
|
|
var tests = []struct {
|
|
r *http.Request
|
|
values url.Values
|
|
userName string
|
|
}{
|
|
// No authorization header - should not set username
|
|
{&http.Request{}, url.Values{}, ""},
|
|
// Valid auth header with known access key - should look up and find "testuser1"
|
|
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, "testuser1"},
|
|
// Malformed auth header (no Credential=) - should not set username
|
|
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 =AKIATESTFAKEKEY000001/20220420/test1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
|
|
// Unknown access key - should not set username
|
|
{&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTUNKNOWN000000/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
|
|
}
|
|
|
|
for i, test := range tests {
|
|
iama.handleImplicitUsername(test.r, test.values)
|
|
if un := test.values.Get("UserName"); un != test.userName {
|
|
t.Errorf("No.%d: Got: %v, Expected: %v", i, un, test.userName)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustMarshalJSON(t *testing.T, v interface{}) []byte {
|
|
t.Helper()
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal JSON: %v", err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
// iamApiServerWithCredentialManager builds an IamApiServer backed by a real
|
|
// in-memory credential store. Used for tests that exercise the targeted
|
|
// credentialManager paths (e.g. CreateUser) instead of the legacy mock.
|
|
func iamApiServerWithCredentialManager() (*IamApiServer, *credential.CredentialManager, *countingConfigSaver) {
|
|
store := &memory.MemoryStore{}
|
|
store.Initialize(nil, "")
|
|
cm := &credential.CredentialManager{Store: store}
|
|
|
|
counter := &countingConfigSaver{cm: cm}
|
|
iamInstance := &s3api.IdentityAccessManagement{}
|
|
// Wire up the credential manager so GetCredentialManager() returns it.
|
|
iamInstance.SetCredentialManagerForTest(cm)
|
|
|
|
srv := &IamApiServer{
|
|
s3ApiConfig: counter,
|
|
iam: iamInstance,
|
|
}
|
|
return srv, cm, counter
|
|
}
|
|
|
|
// countingConfigSaver wraps the credential manager and counts full-config saves.
|
|
type countingConfigSaver struct {
|
|
cm *credential.CredentialManager
|
|
putCalled int
|
|
policies Policies
|
|
}
|
|
|
|
func (c *countingConfigSaver) GetS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) error {
|
|
config, err := c.cm.LoadConfiguration(context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s3cfg.Identities = config.Identities
|
|
s3cfg.Policies = config.Policies
|
|
s3cfg.Groups = config.Groups
|
|
s3cfg.ServiceAccounts = config.ServiceAccounts
|
|
return nil
|
|
}
|
|
|
|
func (c *countingConfigSaver) PutS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) error {
|
|
c.putCalled++
|
|
return c.cm.SaveConfiguration(context.Background(), s3cfg)
|
|
}
|
|
|
|
func (c *countingConfigSaver) GetPolicies(policies *Policies) error {
|
|
*policies = c.policies
|
|
if c.policies.Policies == nil && c.policies.InlinePolicies == nil {
|
|
return filer_pb.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *countingConfigSaver) PutPolicies(policies *Policies) error {
|
|
c.policies = *policies
|
|
return nil
|
|
}
|
|
|
|
func executeRequestWith(srv *IamApiServer, req *http.Request, v interface{}) (*httptest.ResponseRecorder, error) {
|
|
rr := httptest.NewRecorder()
|
|
apiRouter := mux.NewRouter().SkipClean(true)
|
|
apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(srv.DoActions)
|
|
apiRouter.ServeHTTP(rr, req)
|
|
return rr, xml.Unmarshal(rr.Body.Bytes(), &v)
|
|
}
|
|
|
|
// TestCreateUserDoesNotSaveAllUsers is a regression test for the bug where
|
|
// creating a new user triggered a full SaveConfiguration over all existing
|
|
// identities (causing N file writes + reload cycles in the filer_etc store).
|
|
// The fix uses credentialManager.CreateUser for a targeted single-file write.
|
|
func TestCreateUserDoesNotSaveAllUsers(t *testing.T) {
|
|
srv, cm, counter := iamApiServerWithCredentialManager()
|
|
ctx := context.Background()
|
|
|
|
// Pre-populate three existing users.
|
|
for _, name := range []string{"existing-1", "existing-2", "existing-3"} {
|
|
require.NoError(t, cm.CreateUser(ctx, &iam_pb.Identity{Name: name}))
|
|
}
|
|
|
|
// Create a new user via the HTTP API.
|
|
params := &iam.CreateUserInput{UserName: aws.String("new-user")}
|
|
req, _ := iam.New(session.New()).CreateUserRequest(params)
|
|
_ = req.Build()
|
|
out := CreateUserResponse{}
|
|
resp, err := executeRequestWith(srv, req.HTTPRequest, &out)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
// The new user must appear in the store.
|
|
newUser, userErr := cm.GetUser(ctx, "new-user")
|
|
require.NoError(t, userErr)
|
|
require.Equal(t, "new-user", newUser.Name)
|
|
|
|
// Critical: full SaveConfiguration (PutS3ApiConfiguration) must NOT have
|
|
// been called. Before the fix it was called once per CreateUser, rewriting
|
|
// every existing user file and triggering a cascade of reload events.
|
|
assert.Equal(t, 0, counter.putCalled,
|
|
"CreateUser must not trigger a full PutS3ApiConfiguration over all users")
|
|
|
|
// All pre-existing users must still be intact.
|
|
for _, name := range []string{"existing-1", "existing-2", "existing-3"} {
|
|
u, err := cm.GetUser(ctx, name)
|
|
require.NoError(t, err, "pre-existing user %s should still exist", name)
|
|
assert.Equal(t, name, u.Name)
|
|
}
|
|
}
|