Files
seaweedfs/weed/iamapi/iamapi_test.go
T
Chris LuandDevin 56fbc3deb5 s3api: align S3/IAM error responses with AWS (#11632)
* s3err: add InvalidArgument and AuthorizationHeaderMalformed codes

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: answer unrecognized bucket PUT sub-resources with 501

A PUT on a bucket carrying an unrecognized query (logging, metrics,
intelligent-tiering, ...) fell through to the bare CreateBucket route
and returned BucketAlreadyOwnedByYou or re-created the bucket. AWS
answers these with NotImplemented.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: reject malformed copy-source and multipart PUT parameters

A malformed X-Amz-Copy-Source or a non-numeric partNumber fell through
to the plain PutObject route and stored the body as a regular object.
Answer them with InvalidArgument-class errors instead of writing data.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: verify x-amz-content-sha256 against the streamed body

A PUT carrying a hex or base64 payload hash now streams through a
verifier that reports a mismatch once the stream is exhausted, instead
of storing an object that does not match its declared hash. The error
is deferred so intermediate reads that drop (n>0, err) results cannot
silently swallow it. Sentinel values (unsigned/streaming payloads)
remain exempt and malformed values fail fast.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: map truncated SigV4 headers to the error for the missing field

AWS answers an Authorization header missing Credential= with
InvalidArgument and one missing or malformed Signature= with
AuthorizationHeaderMalformed, instead of a generic MissingFields.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: answer IAM/STS failures in the query-protocol envelope

Embedded IAM and STS routes now report authentication, form-parse and
authorization failures with the IAM ErrorResponse body instead of the
S3 Error envelope, so IAM SDK clients can parse them. Requests signed
for s3 keep the S3 envelope, keyed off the credential scope.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: return 403 AccessDenied when the request has no Date

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: answer throttling rejections as SlowDown

ErrTooManyRequest and ErrRequestBytesExceed reported made-up codes;
AWS serves these throttling rejections as SlowDown.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: reject versionId requests on buckets that never had versioning

GET, HEAD and DELETE carrying a non-empty versionId on an unversioned
bucket now fail with InvalidArgument instead of being answered as a
plain object request.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: answer DeleteObjects over 1000 keys with MalformedXML

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: InvalidArgument for non-numeric or out-of-range part numbers

partNumber=abc, 0 and >10000 all resolve to InvalidArgument, matching
AWS, instead of InvalidPart.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* iam: correct LimitExceeded, InvalidAction and ServiceFailure mappings

LimitExceeded is a conflict (409), an unknown Action is InvalidAction
(404) rather than NotImplemented, and internal failures report the IAM
receiver fault type. Applies to both the embedded IAM endpoint and the
standalone iamapi server.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* iam: refuse DeleteUser while access keys remain

Deleting a user with live credentials orphaned its access keys; AWS
answers DeleteConflict until they are removed first.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test/s3: add S3/IAM error-response compatibility harness

* s3: return InvalidArgument for malformed x-amz-content-sha256

A header value that decodes to neither 32-byte hex nor base64 is a
malformed argument, not a hash mismatch.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: stop spawned mini when readiness times out

A slow-starting server otherwise survives the failure path and keeps
the S3 port occupied for the next run.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: register atexit cleanup before setup

A failed setup previously skipped cleanup, leaking the bucket and IAM
user on persistent servers.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: reject unrouted subresources on the DELETE bucket catch-all

PutBucketHandler gained the same guard when the route-level check moved
into the handlers; DeleteBucketHandler was missed, so an authorized
DELETE /bucket?logging could delete the bucket instead of answering
NotImplemented.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: tolerate unset fixture variables in cleanup and cover DELETE ?logging

Cleanup now runs its IAM/multipart steps only when setup reached them, so
an early setup failure still removes the bucket. Added a DELETE
bucket-subresource case asserting NotImplemented and bucket survival.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: fail a case when its side-effect check reports a regression

A non-empty check note now fails the case, so a deleted bucket or an
object created by a malformed request cannot slip through behind a
passing status check.

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>
2026-10-07 21:02:38 +08:00

683 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/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
}
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
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 {
return nil
}
func (c *countingConfigSaver) PutPolicies(policies *Policies) error {
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)
}
}