package s3api import ( "context" "encoding/json" "encoding/xml" "fmt" "net/http" "net/http/httptest" "net/url" "regexp" "strings" "sync" "testing" "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/arn" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/iam" "github.com/gorilla/mux" "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/policy_engine" . "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" "github.com/seaweedfs/seaweedfs/weed/util/request_id" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" ) // EmbeddedIamApiForTest is a testable version of EmbeddedIamApi type EmbeddedIamApiForTest struct { *EmbeddedIamApi mockConfig *iam_pb.S3ApiConfiguration } func NewEmbeddedIamApiForTest() *EmbeddedIamApiForTest { store := &memory.MemoryStore{} store.Initialize(nil, "") cm := &credential.CredentialManager{Store: store} e := &EmbeddedIamApiForTest{ EmbeddedIamApi: &EmbeddedIamApi{ iam: &IdentityAccessManagement{credentialManager: cm}, credentialManager: cm, }, mockConfig: &iam_pb.S3ApiConfiguration{}, } var syncOnce sync.Once e.getS3ApiConfigurationFunc = func(s3cfg *iam_pb.S3ApiConfiguration) error { // If mockConfig was set directly in test, sync it to store first (only once) var syncErr error syncOnce.Do(func() { if e.mockConfig != nil { syncErr = cm.SaveConfiguration(context.Background(), e.mockConfig) } }) if syncErr != nil { return syncErr } config, err := cm.LoadConfiguration(context.Background()) if err == nil { e.mockConfig = config proto.Reset(s3cfg) // Manually copy identities and other fields to avoid Merge issues with slices s3cfg.Identities = make([]*iam_pb.Identity, len(config.Identities)) for i, ident := range config.Identities { s3cfg.Identities[i] = proto.Clone(ident).(*iam_pb.Identity) } s3cfg.Policies = make([]*iam_pb.Policy, len(config.Policies)) for i, p := range config.Policies { s3cfg.Policies[i] = proto.Clone(p).(*iam_pb.Policy) } s3cfg.Groups = make([]*iam_pb.Group, len(config.Groups)) for i, g := range config.Groups { s3cfg.Groups[i] = proto.Clone(g).(*iam_pb.Group) } } return err } e.putS3ApiConfigurationFunc = func(s3cfg *iam_pb.S3ApiConfiguration) error { e.mockConfig = proto.Clone(s3cfg).(*iam_pb.S3ApiConfiguration) return cm.SaveConfiguration(context.Background(), s3cfg) } e.reloadConfigurationFunc = func() error { config, err := cm.LoadConfiguration(context.Background()) if err != nil { return err } e.mockConfig = config // Also refresh the IAM state so lookup functions see the updated configuration if err := e.iam.LoadS3ApiConfigurationFromCredentialManager(); err != nil { return err } return nil } return e } // DoActions handles IAM API actions for testing func (e *EmbeddedIamApiForTest) DoActions(w http.ResponseWriter, r *http.Request) { // Call the real DoActions e.EmbeddedIamApi.DoActions(w, r) } // executeEmbeddedIamRequest executes an IAM request against the given API instance. // If v is non-nil, the response body is unmarshalled into it. func executeEmbeddedIamRequest(api *EmbeddedIamApiForTest, req *http.Request, v interface{}) (*httptest.ResponseRecorder, error) { rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) if v != nil { if err := xml.Unmarshal(rr.Body.Bytes(), v); err != nil { return rr, err } } return rr, nil } // embeddedIamErrorResponseForTest is used for parsing IAM error responses in tests type embeddedIamErrorResponseForTest struct { Error struct { Code string `xml:"Code"` Message string `xml:"Message"` } `xml:"Error"` } func extractEmbeddedIamErrorCodeAndMessage(response *httptest.ResponseRecorder) (string, string) { body := response.Body.Bytes() // Try parsing with ErrorResponse root type localError struct { Code string `xml:"Code"` Message string `xml:"Message"` } type localResponse struct { XMLName xml.Name `xml:"ErrorResponse"` Error localError `xml:"Error"` } var lr localResponse if err := xml.Unmarshal(body, &lr); err == nil && lr.Error.Code != "" { return lr.Error.Code, lr.Error.Message } // Try parsing with Error root type simpleError struct { XMLName xml.Name `xml:"Error"` Code string `xml:"Code"` Message string `xml:"Message"` } var se simpleError if err := xml.Unmarshal(body, &se); err == nil && se.Code != "" { return se.Code, se.Message } var er embeddedIamErrorResponseForTest if err := xml.Unmarshal(body, &er); err == nil { return er.Error.Code, er.Error.Message } return "", "" } func extractEmbeddedIamRequestID(response *httptest.ResponseRecorder) string { re := regexp.MustCompile(`([^<]+)`) matches := re.FindStringSubmatch(response.Body.String()) if len(matches) < 2 { return "" } return matches[1] } // TestEmbeddedIamCreateUser tests creating a user via the embedded IAM API func TestEmbeddedIamCreateUser(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{} userName := aws.String("TestUser") params := &iam.CreateUserInput{UserName: userName} req, _ := iam.New(session.New()).CreateUserRequest(params) _ = req.Build() out := iamCreateUserResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) // Verify response contains correct username assert.NotNil(t, out.CreateUserResult.User.UserName) assert.Equal(t, "TestUser", *out.CreateUserResult.User.UserName) // Issue #9786: terraform aws provider >= 6.41 blocks on a valid ARN. assert.True(t, arn.IsARN(aws.StringValue(out.CreateUserResult.User.Arn)), "CreateUser must return a valid ARN, got %q", aws.StringValue(out.CreateUserResult.User.Arn)) // Verify user was persisted in config assert.Len(t, api.mockConfig.Identities, 1) assert.Equal(t, "TestUser", api.mockConfig.Identities[0].Name) } // TestEmbeddedIamCreateUserDoesNotSaveAllUsers 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 TestEmbeddedIamCreateUserDoesNotSaveAllUsers(t *testing.T) { api := NewEmbeddedIamApiForTest() ctx := context.Background() // Seed both mockConfig and the credential store directly so the test does // not rely on the getS3ApiConfigurationFunc fixture's syncOnce side effect // to populate the store. Pre-call assertions below fail loudly if seeding // breaks for any reason. existing := []string{"existing-user-1", "existing-user-2", "existing-user-3"} api.mockConfig = &iam_pb.S3ApiConfiguration{} for _, name := range existing { api.mockConfig.Identities = append(api.mockConfig.Identities, &iam_pb.Identity{Name: name}) } require.NoError(t, api.credentialManager.SaveConfiguration(ctx, api.mockConfig)) for _, name := range existing { u, err := api.credentialManager.GetUser(ctx, name) require.NoError(t, err, "seed precondition: %s must exist", name) require.Equal(t, name, u.Name) } // Track calls to PutS3ApiConfiguration (the full-config write path). putConfigCalls := 0 api.putS3ApiConfigurationFunc = func(s3cfg *iam_pb.S3ApiConfiguration) error { putConfigCalls++ api.mockConfig = proto.Clone(s3cfg).(*iam_pb.S3ApiConfiguration) return api.credentialManager.SaveConfiguration(ctx, s3cfg) } params := &iam.CreateUserInput{UserName: aws.String("new-user")} req, _ := iam.New(session.New()).CreateUserRequest(params) _ = req.Build() out := iamCreateUserResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) require.NoError(t, err) require.Equal(t, http.StatusOK, response.Code) // The new user must be visible. newUser, userErr := api.credentialManager.GetUser(ctx, "new-user") require.NoError(t, userErr) require.Equal(t, "new-user", newUser.Name) // The critical assertion: full SaveConfiguration must NOT have been called. // Previously this was called once for every CreateUser, re-writing all N user // files and triggering N reload loops. assert.Equal(t, 0, putConfigCalls, "CreateUser must not call PutS3ApiConfiguration (full-config save)") // Pre-existing users must be untouched. for _, name := range existing { u, err := api.credentialManager.GetUser(ctx, name) require.NoError(t, err, "pre-existing user %s should still exist", name) assert.Equal(t, name, u.Name) } } // TestEmbeddedIamCreateUserSkipPersist asserts that ExecuteAction("CreateUser", // skipPersist=true) performs no persistent write to the credential store. The // targeted-create optimization must respect the skipPersist contract documented // on ExecuteAction; otherwise no-persist callers would silently leak writes. func TestEmbeddedIamCreateUserSkipPersist(t *testing.T) { api := NewEmbeddedIamApiForTest() ctx := context.Background() api.putS3ApiConfigurationFunc = func(s3cfg *iam_pb.S3ApiConfiguration) error { t.Fatalf("PutS3ApiConfiguration must not be called when skipPersist=true") return nil } vals := url.Values{} vals.Set("Action", "CreateUser") vals.Set("UserName", "no-persist-user") resp, iamErr := api.ExecuteAction(ctx, vals, true /*skipPersist*/, "") require.Nil(t, iamErr) require.NotNil(t, resp) // The credential store must not have the user — skipPersist contract. _, err := api.credentialManager.GetUser(ctx, "no-persist-user") require.ErrorIs(t, err, credential.ErrUserNotFound, "skipPersist=true must not write the new user to the credential store") } // TestEmbeddedIamListUsers tests listing users via the embedded IAM API func TestEmbeddedIamListUsers(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "User1"}, {Name: "User2"}, }, } params := &iam.ListUsersInput{} req, _ := iam.New(session.New()).ListUsersRequest(params) _ = req.Build() out := iamListUsersResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) // Verify response contains the users assert.Len(t, out.ListUsersResult.Users, 2) assert.NotEmpty(t, response.Header().Get(request_id.AmzRequestIDHeader)) assert.Equal(t, response.Header().Get(request_id.AmzRequestIDHeader), out.ResponseMetadata.RequestId) } // TestEmbeddedIamListAccessKeys tests listing access keys via the embedded IAM API func TestEmbeddedIamListAccessKeys(t *testing.T) { api := NewEmbeddedIamApiForTest() svc := iam.New(session.New()) params := &iam.ListAccessKeysInput{} req, _ := svc.ListAccessKeysRequest(params) _ = req.Build() out := iamListAccessKeysResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) } // TestEmbeddedIamGetUser tests getting a user via the embedded IAM API func TestEmbeddedIamGetUser(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser"}, }, } userName := aws.String("TestUser") params := &iam.GetUserInput{UserName: userName} req, _ := iam.New(session.New()).GetUserRequest(params) _ = req.Build() out := iamGetUserResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) // Verify response contains correct username assert.NotNil(t, out.GetUserResult.User.UserName) assert.Equal(t, "TestUser", *out.GetUserResult.User.UserName) // Issue #9786: the terraform aws provider reads the user back after creating // it and waits until GetUser returns a value that passes arn.IsARN. assert.True(t, arn.IsARN(aws.StringValue(out.GetUserResult.User.Arn)), "GetUser must return a valid ARN, got %q", aws.StringValue(out.GetUserResult.User.Arn)) } // TestEmbeddedIamGetUserImplicitUsername verifies GetUser without a UserName defaults // to the user that signed the request rather than returning NoSuchEntity for an empty // username, matching documented AWS IAM behavior. func TestEmbeddedIamGetUserImplicitUsername(t *testing.T) { const accessKey = UserAccessKeyPrefix + "TESTFAKEKEY000001" api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: "TestUser", Credentials: []*iam_pb.Credential{ {AccessKey: accessKey, SecretKey: "testsecretfake"}, }, }, }, } // handleImplicitUsername resolves the username via iam.LookupByAccessKey, // so the iam state must know about the credential. if err := api.iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(api.mockConfig)); err != nil { t.Fatalf("failed to load iam config: %v", err) } // GetUser request with NO UserName, but signed by accessKey. form := url.Values{} form.Set("Action", "GetUser") req, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+accessKey+ "/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesig") out := iamGetUserResponse{} rr, err := executeEmbeddedIamRequest(api, req, &out) require.NoError(t, err) require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) require.NotNil(t, out.GetUserResult.User.UserName) assert.Equal(t, "TestUser", *out.GetUserResult.User.UserName) } // TestEmbeddedIamCreatePolicy tests creating a policy via the embedded IAM API func TestEmbeddedIamCreatePolicy(t *testing.T) { api := NewEmbeddedIamApiForTest() 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 := iamCreatePolicyResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) // Verify response contains policy metadata assert.NotNil(t, out.CreatePolicyResult.Policy.PolicyName) assert.Equal(t, "S3-read-only-example-bucket", *out.CreatePolicyResult.Policy.PolicyName) assert.NotNil(t, out.CreatePolicyResult.Policy.Arn) assert.NotNil(t, out.CreatePolicyResult.Policy.PolicyId) } // TestEmbeddedIamCreatePolicyVersion covers the embedded IAM path for issue // #9785: updating a managed policy in place via CreatePolicyVersion (used by the // AWS Terraform provider) instead of returning 501 NotImplemented. func TestEmbeddedIamCreatePolicyVersion(t *testing.T) { api := NewEmbeddedIamApiForTest() svc := iam.New(session.New()) policyArn := aws.String("arn:aws:iam:::policy/tf-managed") // Create the managed policy. createReq, _ := svc.CreatePolicyRequest(&iam.CreatePolicyInput{ PolicyName: aws.String("tf-managed"), 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 := executeEmbeddedIamRequest(api, createReq.HTTPRequest, nil) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.Code) // Update it in place. This used to return 501 NotImplemented. 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() out := iamCreatePolicyVersionResponse{} resp, err = executeEmbeddedIamRequest(api, cpvReq.HTTPRequest, &out) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.Code) require.NotNil(t, out.CreatePolicyVersionResult.PolicyVersion.VersionId) assert.Equal(t, "v1", *out.CreatePolicyVersionResult.PolicyVersion.VersionId) require.NotNil(t, out.CreatePolicyVersionResult.PolicyVersion.IsDefaultVersion) assert.True(t, *out.CreatePolicyVersionResult.PolicyVersion.IsDefaultVersion) // The stored document must reflect the update. gpvReq, _ := svc.GetPolicyVersionRequest(&iam.GetPolicyVersionInput{PolicyArn: policyArn, VersionId: aws.String("v1")}) _ = gpvReq.Build() gpvOut := iamGetPolicyVersionResponse{} resp, err = executeEmbeddedIamRequest(api, gpvReq.HTTPRequest, &gpvOut) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.Code) require.NotNil(t, gpvOut.GetPolicyVersionResult.PolicyVersion.Document) assert.Contains(t, *gpvOut.GetPolicyVersionResult.PolicyVersion.Document, "s3:Get*") assert.NotContains(t, *gpvOut.GetPolicyVersionResult.PolicyVersion.Document, "s3:List*") } // TestEmbeddedIamCreatePolicyVersionMissingPolicy verifies NoSuchEntity when the // target policy does not exist. func TestEmbeddedIamCreatePolicyVersionMissingPolicy(t *testing.T) { api := NewEmbeddedIamApiForTest() 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 := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) require.NoError(t, err) assert.Equal(t, http.StatusNotFound, resp.Code) code, _ := extractEmbeddedIamErrorCodeAndMessage(resp) assert.Equal(t, "NoSuchEntity", code) } // TestEmbeddedIamCreatePolicyVersionRequiresSetAsDefault verifies the embedded // path also rejects a non-default version request given the single-version model. func TestEmbeddedIamCreatePolicyVersionRequiresSetAsDefault(t *testing.T) { api := NewEmbeddedIamApiForTest() svc := iam.New(session.New()) policyArn := aws.String("arn:aws:iam:::policy/tf-managed") createReq, _ := svc.CreatePolicyRequest(&iam.CreatePolicyInput{ PolicyName: aws.String("tf-managed"), PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:Get*"],"Resource":["arn:aws:s3:::EXAMPLE-BUCKET"]}]}`), }) _ = createReq.Build() resp, err := executeEmbeddedIamRequest(api, createReq.HTTPRequest, nil) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.Code) // SetAsDefault omitted 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 = executeEmbeddedIamRequest(api, cpvReq.HTTPRequest, nil) require.NoError(t, err) assert.Equal(t, http.StatusBadRequest, resp.Code) code, _ := extractEmbeddedIamErrorCodeAndMessage(resp) assert.Equal(t, "InvalidInput", code) } // TestEmbeddedIamPutUserPolicy tests attaching a policy to a user func TestEmbeddedIamPutUserPolicy(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser"}, }, } userName := aws.String("TestUser") 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 := iamPutUserPolicyResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) // Verify policy was attached to the user (actions should be set) assert.Len(t, api.mockConfig.Identities, 1) assert.NotEmpty(t, api.mockConfig.Identities[0].Actions) } // TestEmbeddedIamPutUserPolicyError tests error handling when user doesn't exist func TestEmbeddedIamPutUserPolicyError(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{} 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 := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) assert.NoError(t, err) assert.Equal(t, http.StatusNotFound, response.Code) expectedCode := "NoSuchEntity" code, _ := extractEmbeddedIamErrorCodeAndMessage(response) assert.Equal(t, expectedCode, code) } // TestEmbeddedIamGetUserPolicy tests getting a user's policy func TestEmbeddedIamGetUserPolicy(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: "TestUser", Actions: []string{"Read", "List"}, }, }, } userName := aws.String("TestUser") params := &iam.GetUserPolicyInput{ UserName: userName, PolicyName: aws.String("S3-read-only-example-bucket"), } req, _ := iam.New(session.New()).GetUserPolicyRequest(params) _ = req.Build() out := iamGetUserPolicyResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) } // TestEmbeddedIamDeleteUserPolicy tests deleting a user's policy (clears actions) func TestEmbeddedIamDeleteUserPolicy(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: "TestUser", Actions: []string{"Read", "Write", "List"}, Credentials: []*iam_pb.Credential{ {AccessKey: UserAccessKeyPrefix + "TEST12345", SecretKey: "secret"}, }, }, }, } // Use direct form post for DeleteUserPolicy form := url.Values{} form.Set("Action", "DeleteUserPolicy") form.Set("UserName", "TestUser") form.Set("PolicyName", "TestPolicy") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) // CRITICAL: Verify user still exists (was NOT deleted) assert.Len(t, api.mockConfig.Identities, 1, "User should NOT be deleted") assert.Equal(t, "TestUser", api.mockConfig.Identities[0].Name) // Verify credentials are still intact assert.Len(t, api.mockConfig.Identities[0].Credentials, 1, "Credentials should NOT be deleted") assert.Equal(t, UserAccessKeyPrefix+"TEST12345", api.mockConfig.Identities[0].Credentials[0].AccessKey) // Verify actions/policy was cleared assert.Nil(t, api.mockConfig.Identities[0].Actions, "Actions should be cleared") } // TestEmbeddedIamDeleteUserPolicyUserNotFound tests error when user doesn't exist func TestEmbeddedIamDeleteUserPolicyUserNotFound(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{} form := url.Values{} form.Set("Action", "DeleteUserPolicy") form.Set("UserName", "NonExistentUser") form.Set("PolicyName", "TestPolicy") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusNotFound, rr.Code) } // TestEmbeddedIamListUserPolicies tests listing inline policies for a user. func TestEmbeddedIamListUserPolicies(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: "UserWithPolicy", Actions: []string{"Read", "Write"}, Credentials: []*iam_pb.Credential{ {AccessKey: UserAccessKeyPrefix + "TEST12345", SecretKey: "secret"}, }, }, { Name: "UserWithoutPolicy", Credentials: []*iam_pb.Credential{ {AccessKey: UserAccessKeyPrefix + "TEST67890", SecretKey: "secret"}, }, }, }, } // List policies for user with actions form := url.Values{} form.Set("Action", "ListUserPolicies") form.Set("UserName", "UserWithPolicy") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) assert.Contains(t, rr.Body.String(), "ListUserPoliciesResponse") assert.Contains(t, rr.Body.String(), "PolicyNames") assert.Contains(t, rr.Body.String(), "UserWithPolicy_policy") // List policies for user without actions form2 := url.Values{} form2.Set("Action", "ListUserPolicies") form2.Set("UserName", "UserWithoutPolicy") req2, _ := http.NewRequest("POST", "/", nil) req2.PostForm = form2 req2.Form = form2 req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr2 := httptest.NewRecorder() apiRouter.ServeHTTP(rr2, req2) assert.Equal(t, http.StatusOK, rr2.Code) assert.Contains(t, rr2.Body.String(), "ListUserPoliciesResponse") // List policies for nonexistent user form3 := url.Values{} form3.Set("Action", "ListUserPolicies") form3.Set("UserName", "NonExistentUser") req3, _ := http.NewRequest("POST", "/", nil) req3.PostForm = form3 req3.Form = form3 req3.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr3 := httptest.NewRecorder() apiRouter.ServeHTTP(rr3, req3) assert.Equal(t, http.StatusNotFound, rr3.Code) } // TestEmbeddedIamGroupInlinePolicies_CRUD exercises the Put/Get/List/Delete // round-trip for group inline policies and verifies that an unknown policy // surfaces as NoSuchEntity (not the legacy NotImplemented). func TestEmbeddedIamGroupInlinePolicies_CRUD(t *testing.T) { api := NewEmbeddedIamApiForTest() s3cfg := &iam_pb.S3ApiConfiguration{ Groups: []*iam_pb.Group{ {Name: "developers", Members: []string{"alice"}}, }, } api.mockConfig = s3cfg policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::*"}]}` _, iamErr := api.PutGroupPolicy(s3cfg, url.Values{ "GroupName": {"developers"}, "PolicyName": {"DevPolicy"}, "PolicyDocument": {policyDoc}, }) require.Nil(t, iamErr, "PutGroupPolicy must succeed; got: %+v", iamErr) listResp, iamErr := api.ListGroupPolicies(s3cfg, url.Values{"GroupName": {"developers"}}) require.Nil(t, iamErr) assert.Equal(t, []string{"DevPolicy"}, listResp.ListGroupPoliciesResult.PolicyNames) getResp, iamErr := api.GetGroupPolicy(s3cfg, url.Values{"GroupName": {"developers"}, "PolicyName": {"DevPolicy"}}) require.Nil(t, iamErr) assert.Equal(t, "developers", getResp.GetGroupPolicyResult.GroupName) assert.Equal(t, "DevPolicy", getResp.GetGroupPolicyResult.PolicyName) assert.Contains(t, getResp.GetGroupPolicyResult.PolicyDocument, "s3:GetObject") _, iamErr = api.DeleteGroupPolicy(s3cfg, url.Values{"GroupName": {"developers"}, "PolicyName": {"DevPolicy"}}) require.Nil(t, iamErr) _, iamErr = api.GetGroupPolicy(s3cfg, url.Values{"GroupName": {"developers"}, "PolicyName": {"DevPolicy"}}) require.NotNil(t, iamErr) assert.Equal(t, iam.ErrCodeNoSuchEntityException, iamErr.Code) _, iamErr = api.GetGroupPolicy(s3cfg, url.Values{"GroupName": {"ghosts"}, "PolicyName": {"DevPolicy"}}) require.NotNil(t, iamErr, "GetGroupPolicy on a missing group must error") assert.Equal(t, iam.ErrCodeNoSuchEntityException, iamErr.Code) } // TestEmbeddedIamAttachUserPolicy tests attaching a managed policy to a user. func TestEmbeddedIamAttachUserPolicy(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser"}, }, Policies: []*iam_pb.Policy{ {Name: "TestManagedPolicy", Content: `{"Version":"2012-10-17","Statement":[]}`}, }, } params := &iam.AttachUserPolicyInput{ UserName: aws.String("TestUser"), PolicyArn: aws.String("arn:aws:iam:::policy/TestManagedPolicy"), } req, _ := iam.New(session.New()).AttachUserPolicyRequest(params) _ = req.Build() out := iamAttachUserPolicyResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) assert.Equal(t, []string{"TestManagedPolicy"}, api.mockConfig.Identities[0].PolicyNames) } func TestEmbeddedIamAttachUserPolicyRefreshesIAM(t *testing.T) { api := NewEmbeddedIamApiForTest() ctx := context.Background() cm := api.credentialManager user := &iam_pb.Identity{ Name: "policyRefreshUser", Credentials: []*iam_pb.Credential{ {AccessKey: "REFRESHACCESS", SecretKey: "REFRESHSECRET"}, }, } require.NoError(t, cm.CreateUser(ctx, user)) policy := policy_engine.PolicyDocument{ Version: policy_engine.PolicyVersion2012_10_17, Statement: []policy_engine.PolicyStatement{ { Effect: policy_engine.PolicyEffectAllow, Action: policy_engine.NewStringOrStringSlice("s3:GetObject"), Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::bucket/*"), }, }, } require.NoError(t, cm.PutPolicy(ctx, "RefreshPolicy", policy)) require.NoError(t, api.iam.LoadS3ApiConfigurationFromCredentialManager()) identity := api.iam.lookupByIdentityName("policyRefreshUser") require.NotNil(t, identity) assert.Empty(t, identity.PolicyNames) values := url.Values{} values.Set("UserName", "policyRefreshUser") values.Set("PolicyArn", "arn:aws:iam:::policy/RefreshPolicy") _, iamErr := api.AttachUserPolicy(ctx, values) require.Nil(t, iamErr) identity = api.iam.lookupByIdentityName("policyRefreshUser") require.NotNil(t, identity) assert.Equal(t, []string{"RefreshPolicy"}, identity.PolicyNames) } // TestEmbeddedIamAttachUserPolicyNoSuchPolicy tests attach failure when managed policy does not exist. func TestEmbeddedIamAttachUserPolicyNoSuchPolicy(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser"}, }, } params := &iam.AttachUserPolicyInput{ UserName: aws.String("TestUser"), PolicyArn: aws.String("arn:aws:iam:::policy/DoesNotExist"), } req, _ := iam.New(session.New()).AttachUserPolicyRequest(params) _ = req.Build() response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) assert.NoError(t, err) assert.Equal(t, http.StatusNotFound, response.Code) code, _ := extractEmbeddedIamErrorCodeAndMessage(response) assert.Equal(t, "NoSuchEntity", code) } // TestEmbeddedIamDetachUserPolicy tests detaching a managed policy from a user. func TestEmbeddedIamDetachUserPolicy(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser", PolicyNames: []string{"TestManagedPolicy", "KeepPolicy"}}, }, Policies: []*iam_pb.Policy{ {Name: "TestManagedPolicy", Content: `{"Version":"2012-10-17","Statement":[]}`}, {Name: "KeepPolicy", Content: `{"Version":"2012-10-17","Statement":[]}`}, }, } params := &iam.DetachUserPolicyInput{ UserName: aws.String("TestUser"), PolicyArn: aws.String("arn:aws:iam:::policy/TestManagedPolicy"), } req, _ := iam.New(session.New()).DetachUserPolicyRequest(params) _ = req.Build() out := iamDetachUserPolicyResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) assert.Equal(t, []string{"KeepPolicy"}, api.mockConfig.Identities[0].PolicyNames) } // TestEmbeddedIamDeletePolicyInUse ensures deleting a policy that is still attached returns conflict. func TestEmbeddedIamDeletePolicyInUse(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser", PolicyNames: []string{"TestPolicy"}}, }, Policies: []*iam_pb.Policy{ {Name: "TestPolicy", Content: `{"Version":"2012-10-17","Statement":[]}`}, }, } params := &iam.DeletePolicyInput{ PolicyArn: aws.String("arn:aws:iam:::policy/TestPolicy"), } req, _ := iam.New(session.New()).DeletePolicyRequest(params) _ = req.Build() response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) assert.NoError(t, err) assert.Equal(t, http.StatusConflict, response.Code) code, _ := extractEmbeddedIamErrorCodeAndMessage(response) assert.Equal(t, iam.ErrCodeDeleteConflictException, code) assert.Len(t, api.mockConfig.Policies, 1) assert.Equal(t, "TestPolicy", api.mockConfig.Policies[0].Name) assert.Len(t, api.mockConfig.Identities, 1) assert.Equal(t, "TestUser", api.mockConfig.Identities[0].Name) assert.Contains(t, api.mockConfig.Identities[0].PolicyNames, "TestPolicy") } // TestEmbeddedIamAttachAlreadyAttachedPolicy ensures attaching a policy already // present on the user is idempotent. func TestEmbeddedIamAttachAlreadyAttachedPolicy(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser", PolicyNames: []string{"TestManagedPolicy"}}, }, Policies: []*iam_pb.Policy{ {Name: "TestManagedPolicy", Content: `{"Version":"2012-10-17","Statement":[]}`}, }, } params := &iam.AttachUserPolicyInput{ UserName: aws.String("TestUser"), PolicyArn: aws.String("arn:aws:iam:::policy/TestManagedPolicy"), } req, _ := iam.New(session.New()).AttachUserPolicyRequest(params) _ = req.Build() out := iamAttachUserPolicyResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) assert.Equal(t, []string{"TestManagedPolicy"}, api.mockConfig.Identities[0].PolicyNames) } // TestEmbeddedIamDetachNotAttachedPolicy verifies detaching a policy that's not // attached returns NoSuchEntity. func TestEmbeddedIamDetachNotAttachedPolicy(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser"}, }, Policies: []*iam_pb.Policy{ {Name: "MissingPolicy", Content: `{"Version":"2012-10-17","Statement":[]}`}, }, } params := &iam.DetachUserPolicyInput{ UserName: aws.String("TestUser"), PolicyArn: aws.String("arn:aws:iam:::policy/MissingPolicy"), } req, _ := iam.New(session.New()).DetachUserPolicyRequest(params) _ = req.Build() response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) assert.NoError(t, err) assert.Equal(t, http.StatusNotFound, response.Code) code, _ := extractEmbeddedIamErrorCodeAndMessage(response) assert.Equal(t, "NoSuchEntity", code) } // TestEmbeddedIamAttachPolicyLimitExceeded ensures we honor the managed policy limit. func TestEmbeddedIamAttachPolicyLimitExceeded(t *testing.T) { api := NewEmbeddedIamApiForTest() existingPolicies := make([]string, 0, MaxManagedPoliciesPerUser) configPolicies := make([]*iam_pb.Policy, 0, MaxManagedPoliciesPerUser+1) for i := 0; i < MaxManagedPoliciesPerUser; i++ { name := fmt.Sprintf("ManagedPolicy%d", i) existingPolicies = append(existingPolicies, name) configPolicies = append(configPolicies, &iam_pb.Policy{ Name: name, Content: `{"Version":"2012-10-17","Statement":[]}`, }) } configPolicies = append(configPolicies, &iam_pb.Policy{ Name: "NewPolicy", Content: `{"Version":"2012-10-17","Statement":[]}`, }) api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser", PolicyNames: existingPolicies}, }, Policies: configPolicies, } params := &iam.AttachUserPolicyInput{ UserName: aws.String("TestUser"), PolicyArn: aws.String("arn:aws:iam:::policy/NewPolicy"), } req, _ := iam.New(session.New()).AttachUserPolicyRequest(params) _ = req.Build() response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) assert.NoError(t, err) assert.Equal(t, http.StatusForbidden, response.Code) code, _ := extractEmbeddedIamErrorCodeAndMessage(response) assert.Equal(t, iam.ErrCodeLimitExceededException, code) assert.Len(t, api.mockConfig.Identities[0].PolicyNames, MaxManagedPoliciesPerUser) } // TestEmbeddedIamListAttachedUserPolicies tests listing managed policies attached to a user. func TestEmbeddedIamListAttachedUserPolicies(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser", PolicyNames: []string{"PolicyA", "PolicyB"}}, }, Policies: []*iam_pb.Policy{ {Name: "PolicyA", Content: `{"Version":"2012-10-17","Statement":[]}`}, {Name: "PolicyB", Content: `{"Version":"2012-10-17","Statement":[]}`}, }, } params := &iam.ListAttachedUserPoliciesInput{ UserName: aws.String("TestUser"), } req, _ := iam.New(session.New()).ListAttachedUserPoliciesRequest(params) _ = req.Build() out := iamListAttachedUserPoliciesResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) assert.False(t, out.ListAttachedUserPoliciesResult.IsTruncated) assert.Len(t, out.ListAttachedUserPoliciesResult.AttachedPolicies, 2) got := map[string]string{} for _, attached := range out.ListAttachedUserPoliciesResult.AttachedPolicies { got[aws.StringValue(attached.PolicyName)] = aws.StringValue(attached.PolicyArn) } assert.Equal(t, "arn:aws:iam:::policy/PolicyA", got["PolicyA"]) assert.Equal(t, "arn:aws:iam:::policy/PolicyB", got["PolicyB"]) } // TestEmbeddedIamUpdateUser tests updating a user func TestEmbeddedIamUpdateUser(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser"}, }, } userName := aws.String("TestUser") newUserName := aws.String("TestUser-New") params := &iam.UpdateUserInput{NewUserName: newUserName, UserName: userName} req, _ := iam.New(session.New()).UpdateUserRequest(params) _ = req.Build() out := iamUpdateUserResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) } // TestEmbeddedIamUpdateUserPreservesInlinePolicies makes sure renaming a // user keeps that user's stored inline policies under the new name. // Without the explicit policy migration in UpdateUser, SaveConfiguration // prunes the old name and CASCADE drops the policies. func TestEmbeddedIamUpdateUserPreservesInlinePolicies(t *testing.T) { api := NewEmbeddedIamApiForTest() const ( oldName = "RenameMe" newName = "Renamed" policyName = "ReadBucket" ) api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{{Name: oldName}}, } doc := policy_engine.PolicyDocument{ Version: "2012-10-17", Statement: []policy_engine.PolicyStatement{{ Effect: policy_engine.PolicyEffectAllow, Action: policy_engine.NewStringOrStringSlice("s3:GetObject"), Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::bucket/*"), }}, } require.NoError(t, api.credentialManager.PutUserInlinePolicy(context.Background(), oldName, policyName, doc)) params := &iam.UpdateUserInput{NewUserName: aws.String(newName), UserName: aws.String(oldName)} req, _ := iam.New(session.New()).UpdateUserRequest(params) _ = req.Build() out := iamUpdateUserResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) got, err := api.credentialManager.GetUserInlinePolicy(context.Background(), newName, policyName) require.NoError(t, err) require.NotNil(t, got, "inline policy should have been migrated to the new username") assert.Equal(t, doc.Version, got.Version) require.Len(t, got.Statement, 1) assert.Equal(t, policy_engine.PolicyEffectAllow, got.Statement[0].Effect) } // TestEmbeddedIamDeleteUser tests deleting a user func TestEmbeddedIamDeleteUser(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser-New"}, }, } userName := aws.String("TestUser-New") params := &iam.DeleteUserInput{UserName: userName} req, _ := iam.New(session.New()).DeleteUserRequest(params) _ = req.Build() out := iamDeleteUserResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) } // TestEmbeddedIamCreateAccessKey tests creating an access key func TestEmbeddedIamCreateAccessKey(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser"}, }, } userName := aws.String("TestUser") params := &iam.CreateAccessKeyInput{UserName: userName} req, _ := iam.New(session.New()).CreateAccessKeyRequest(params) _ = req.Build() out := iamCreateAccessKeyResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) // Verify response contains access key credentials assert.NotNil(t, out.CreateAccessKeyResult.AccessKey.AccessKeyId) assert.NotEmpty(t, *out.CreateAccessKeyResult.AccessKey.AccessKeyId) assert.NotNil(t, out.CreateAccessKeyResult.AccessKey.SecretAccessKey) assert.NotEmpty(t, *out.CreateAccessKeyResult.AccessKey.SecretAccessKey) assert.NotNil(t, out.CreateAccessKeyResult.AccessKey.UserName) assert.Equal(t, "TestUser", *out.CreateAccessKeyResult.AccessKey.UserName) // Verify credentials were persisted assert.Len(t, api.mockConfig.Identities[0].Credentials, 1) } // TestEmbeddedIamCreateAccessKeyRejectsMissingUser verifies CreateAccessKey // returns NoSuchEntity for an unknown user without mutating the config. func TestEmbeddedIamCreateAccessKeyRejectsMissingUser(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{{Name: "ExistingUser"}}, } form := url.Values{} form.Set("Action", "CreateAccessKey") form.Set("UserName", "GhostUser") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.NotEqual(t, http.StatusOK, rr.Code) // No new identity and no credential appended to the existing one. assert.Len(t, api.mockConfig.Identities, 1) assert.Len(t, api.mockConfig.Identities[0].Credentials, 0) } // TestEmbeddedIamCreateAccessKeyWithCallerSuppliedKeys tests creating an access key with caller-supplied credentials func TestEmbeddedIamCreateAccessKeyWithCallerSuppliedKeys(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser"}, }, } form := url.Values{} form.Set("Action", "CreateAccessKey") form.Set("UserName", "TestUser") form.Set("AccessKeyId", "myapp") form.Set("SecretAccessKey", "mysecret123") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) // Verify caller-supplied keys were used, not random ones var out iamCreateAccessKeyResponse err := xml.Unmarshal(rr.Body.Bytes(), &out) require.NoError(t, err, "failed to unmarshal CreateAccessKey response") require.NotNil(t, out.CreateAccessKeyResult.AccessKey.AccessKeyId) require.NotNil(t, out.CreateAccessKeyResult.AccessKey.SecretAccessKey) require.NotNil(t, out.CreateAccessKeyResult.AccessKey.UserName) assert.Equal(t, "myapp", *out.CreateAccessKeyResult.AccessKey.AccessKeyId) assert.Equal(t, "mysecret123", *out.CreateAccessKeyResult.AccessKey.SecretAccessKey) assert.Equal(t, "TestUser", *out.CreateAccessKeyResult.AccessKey.UserName) // Verify credentials were persisted with caller-supplied keys require.Len(t, api.mockConfig.Identities, 1) require.Len(t, api.mockConfig.Identities[0].Credentials, 1) assert.Equal(t, "myapp", api.mockConfig.Identities[0].Credentials[0].AccessKey) assert.Equal(t, "mysecret123", api.mockConfig.Identities[0].Credentials[0].SecretKey) } // TestEmbeddedIamCreateAccessKeyRejectsWeakKeys tests that weak caller-supplied keys are rejected func TestEmbeddedIamCreateAccessKeyRejectsWeakKeys(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser"}, }, } // AccessKeyId too short form := url.Values{} form.Set("Action", "CreateAccessKey") form.Set("UserName", "TestUser") form.Set("AccessKeyId", "ab") form.Set("SecretAccessKey", "validsecret123") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.NotEqual(t, http.StatusOK, rr.Code) assert.Contains(t, rr.Body.String(), "AccessKeyId must be 4 to 128 alphanumeric characters") // SecretAccessKey too short form.Set("AccessKeyId", "validkey") form.Set("SecretAccessKey", "short") req, _ = http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = httptest.NewRecorder() apiRouter.ServeHTTP(rr, req) assert.NotEqual(t, http.StatusOK, rr.Code) assert.Contains(t, rr.Body.String(), "SecretAccessKey must be between 8 and 128 characters") // AccessKeyId with SigV4 delimiters form.Set("AccessKeyId", "foo/bar=baz") form.Set("SecretAccessKey", "validsecret123") req, _ = http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = httptest.NewRecorder() apiRouter.ServeHTTP(rr, req) assert.NotEqual(t, http.StatusOK, rr.Code) assert.Contains(t, rr.Body.String(), "AccessKeyId must be 4 to 128 alphanumeric characters") } // TestEmbeddedIamCreateAccessKeyRejectsCollision tests that duplicate access keys are rejected func TestEmbeddedIamCreateAccessKeyRejectsCollision(t *testing.T) { api := NewEmbeddedIamApiForTest() // Use a distinctive owner name ("ownerAlpha") so the leak assertion // cannot accidentally match a word embedded in the error body. const ownerName = "ownerAlpha" api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: ownerName, Credentials: []*iam_pb.Credential{ {AccessKey: "takenkey", SecretKey: "existingsecret"}, }, }, {Name: "NewUser"}, }, } form := url.Values{} form.Set("Action", "CreateAccessKey") form.Set("UserName", "NewUser") form.Set("AccessKeyId", "takenkey") form.Set("SecretAccessKey", "newsecret123") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.NotEqual(t, http.StatusOK, rr.Code) assert.Contains(t, rr.Body.String(), "already in use") assert.NotContains(t, rr.Body.String(), ownerName, "should not leak owner name") // Verify no credentials were added to NewUser. Look up by name because the // memory store backs LoadConfiguration with a map, so Identities order is // not stable across a save/load round trip. var newUser *iam_pb.Identity for _, ident := range api.mockConfig.Identities { if ident.Name == "NewUser" { newUser = ident break } } require.NotNil(t, newUser, "NewUser identity should still exist") assert.Len(t, newUser.Credentials, 0) } // TestEmbeddedIamCreateAccessKeyRejectsPartialSupply tests that supplying only // one of AccessKeyId / SecretAccessKey is rejected. func TestEmbeddedIamCreateAccessKeyRejectsPartialSupply(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser"}, }, } apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) // AccessKeyId supplied, SecretAccessKey omitted form := url.Values{} form.Set("Action", "CreateAccessKey") form.Set("UserName", "TestUser") form.Set("AccessKeyId", "myappkey") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter.ServeHTTP(rr, req) assert.NotEqual(t, http.StatusOK, rr.Code) assert.Contains(t, rr.Body.String(), "AccessKeyId and SecretAccessKey must be supplied together") assert.Len(t, api.mockConfig.Identities[0].Credentials, 0) // SecretAccessKey supplied, AccessKeyId omitted form = url.Values{} form.Set("Action", "CreateAccessKey") form.Set("UserName", "TestUser") form.Set("SecretAccessKey", "validsecret1") req, _ = http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = httptest.NewRecorder() apiRouter.ServeHTTP(rr, req) assert.NotEqual(t, http.StatusOK, rr.Code) assert.Contains(t, rr.Body.String(), "AccessKeyId and SecretAccessKey must be supplied together") assert.Len(t, api.mockConfig.Identities[0].Credentials, 0) } // TestEmbeddedIamCreateAccessKeyBoundary tests key length boundaries func TestEmbeddedIamCreateAccessKeyBoundary(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser"}, }, } apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) // Exactly 4 chars — should pass form := url.Values{} form.Set("Action", "CreateAccessKey") form.Set("UserName", "TestUser") form.Set("AccessKeyId", "abcd") form.Set("SecretAccessKey", "validsecret1") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) // Exactly 3 chars — should fail api.mockConfig.Identities[0].Credentials = nil form.Set("AccessKeyId", "abc") req, _ = http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = httptest.NewRecorder() apiRouter.ServeHTTP(rr, req) assert.NotEqual(t, http.StatusOK, rr.Code) assert.Contains(t, rr.Body.String(), "alphanumeric") // Exactly 128 chars — should pass api.mockConfig.Identities[0].Credentials = nil ak128 := strings.Repeat("a", 128) sk128 := strings.Repeat("s", 128) form.Set("AccessKeyId", ak128) form.Set("SecretAccessKey", sk128) req, _ = http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = httptest.NewRecorder() apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) // 129 chars AccessKeyId — should fail api.mockConfig.Identities[0].Credentials = nil form.Set("AccessKeyId", strings.Repeat("a", 129)) form.Set("SecretAccessKey", sk128) req, _ = http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = httptest.NewRecorder() apiRouter.ServeHTTP(rr, req) assert.NotEqual(t, http.StatusOK, rr.Code) assert.Contains(t, rr.Body.String(), "alphanumeric") // 7-char SecretAccessKey — should fail api.mockConfig.Identities[0].Credentials = nil form.Set("AccessKeyId", "validkey") form.Set("SecretAccessKey", "1234567") req, _ = http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = httptest.NewRecorder() apiRouter.ServeHTTP(rr, req) assert.NotEqual(t, http.StatusOK, rr.Code) assert.Contains(t, rr.Body.String(), "SecretAccessKey must be between 8 and 128 characters") // Exactly 8-char SecretAccessKey — should pass (lower boundary) api.mockConfig.Identities[0].Credentials = nil form.Set("AccessKeyId", "validkey") form.Set("SecretAccessKey", "12345678") req, _ = http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = httptest.NewRecorder() apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) // 129-char SecretAccessKey — should fail api.mockConfig.Identities[0].Credentials = nil form.Set("AccessKeyId", "validkey") form.Set("SecretAccessKey", strings.Repeat("s", 129)) req, _ = http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = httptest.NewRecorder() apiRouter.ServeHTTP(rr, req) assert.NotEqual(t, http.StatusOK, rr.Code) assert.Contains(t, rr.Body.String(), "SecretAccessKey must be between 8 and 128 characters") } // TestEmbeddedIamDeleteAccessKey tests deleting an access key via direct form post func TestEmbeddedIamDeleteAccessKey(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: "TestUser", Credentials: []*iam_pb.Credential{ {AccessKey: UserAccessKeyPrefix + "TEST12345", SecretKey: "secret"}, }, }, }, } // Use direct form post since AWS SDK may format differently form := url.Values{} form.Set("Action", "DeleteAccessKey") form.Set("UserName", "TestUser") form.Set("AccessKeyId", UserAccessKeyPrefix+"TEST12345") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) // Verify the access key was deleted assert.Len(t, api.mockConfig.Identities[0].Credentials, 0) } // TestEmbeddedIamHandleImplicitUsername tests implicit username extraction from authorization header func TestEmbeddedIamHandleImplicitUsername(t *testing.T) { // Create IAM with test credentials - the handleImplicitUsername function now looks // up the username from the credential store based on AccessKeyId // Note: Using obviously fake access keys to avoid secret scanner false positives iam := &IdentityAccessManagement{} testConfig := &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: "testuser1", Credentials: []*iam_pb.Credential{ {AccessKey: UserAccessKeyPrefix + "TESTFAKEKEY000001", SecretKey: "testsecretfake"}, }, }, }, } err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(testConfig)) if err != nil { t.Fatalf("Failed to load test config: %v", err) } embeddedApi := &EmbeddedIamApi{ 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=" + UserAccessKeyPrefix + "TESTFAKEKEY000001/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 =" + UserAccessKeyPrefix + "TESTFAKEKEY000001/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=" + UserAccessKeyPrefix + "TESTUNKNOWN000000/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""}, } for i, test := range tests { embeddedApi.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(v interface{}) []byte { data, err := json.Marshal(v) if err != nil { panic(err) } return data } // TestEmbeddedIamFullWorkflow tests a complete user lifecycle func TestEmbeddedIamFullWorkflow(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{} // 1. Create user t.Run("CreateUser", func(t *testing.T) { userName := aws.String("WorkflowUser") params := &iam.CreateUserInput{UserName: userName} req, _ := iam.New(session.New()).CreateUserRequest(params) _ = req.Build() response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) }) // 2. Create access key for user t.Run("CreateAccessKey", func(t *testing.T) { userName := aws.String("WorkflowUser") params := &iam.CreateAccessKeyInput{UserName: userName} req, _ := iam.New(session.New()).CreateAccessKeyRequest(params) _ = req.Build() response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) }) // 3. Attach policy to user t.Run("PutUserPolicy", func(t *testing.T) { params := &iam.PutUserPolicyInput{ UserName: aws.String("WorkflowUser"), PolicyName: aws.String("ReadWritePolicy"), PolicyDocument: aws.String(`{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": ["s3:Get*", "s3:Put*"], "Resource": ["arn:aws:s3:::*"] }] }`), } req, _ := iam.New(session.New()).PutUserPolicyRequest(params) _ = req.Build() response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) }) // 4. List users to verify t.Run("ListUsers", func(t *testing.T) { params := &iam.ListUsersInput{} req, _ := iam.New(session.New()).ListUsersRequest(params) _ = req.Build() response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) }) // 5. Delete user t.Run("DeleteUser", func(t *testing.T) { params := &iam.DeleteUserInput{UserName: aws.String("WorkflowUser")} req, _ := iam.New(session.New()).DeleteUserRequest(params) _ = req.Build() response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) }) } // TestIamStringSlicesEqual tests the iamStringSlicesEqual helper function func TestIamStringSlicesEqual(t *testing.T) { tests := []struct { name string a []string b []string expected bool }{ {"both empty", []string{}, []string{}, true}, {"both nil", nil, nil, true}, {"same elements same order", []string{"a", "b", "c"}, []string{"a", "b", "c"}, true}, {"same elements different order", []string{"c", "a", "b"}, []string{"a", "b", "c"}, true}, {"different lengths", []string{"a", "b"}, []string{"a", "b", "c"}, false}, {"different elements", []string{"a", "b", "c"}, []string{"a", "b", "d"}, false}, {"one empty one not", []string{}, []string{"a"}, false}, {"duplicates same", []string{"a", "a", "b"}, []string{"a", "b", "a"}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := iamStringSlicesEqual(tt.a, tt.b) assert.Equal(t, tt.expected, result) }) } } // TestIamHash tests the iamHash function func TestIamHash(t *testing.T) { input := "test-policy-document" hash := iamHash(&input) // Hash should be non-empty assert.NotEmpty(t, hash) // Same input should produce same hash hash2 := iamHash(&input) assert.Equal(t, hash, hash2) // Different input should produce different hash input2 := "different-policy" hash3 := iamHash(&input2) assert.NotEqual(t, hash, hash3) } // TestIamStringWithCharset tests the cryptographically secure random string generator func TestIamStringWithCharset(t *testing.T) { charset := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" length := 20 str, err := iamStringWithCharset(length, charset) assert.NoError(t, err) assert.Len(t, str, length) // All characters should be from the charset for _, c := range str { assert.Contains(t, charset, string(c)) } // Two calls should produce different strings (with very high probability) str2, err := iamStringWithCharset(length, charset) assert.NoError(t, err) assert.NotEqual(t, str, str2) } // TestIamMapToStatementAction tests action mapping func TestIamMapToStatementAction(t *testing.T) { // iamMapToStatementAction maps IAM statement action patterns to internal action names tests := []struct { input string expected string }{ {"*", "Admin"}, {"Get*", "Read"}, {"Put*", "Write"}, {"List*", "List"}, {"Tagging*", "Tagging"}, {"DeleteBucket*", "DeleteBucket"}, {"PutBucketAcl", "WriteAcp"}, {"GetBucketAcl", "ReadAcp"}, {"InvalidAction", ""}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { result := iamMapToStatementAction(tt.input) assert.Equal(t, tt.expected, result) }) } } // TestIamMapToIdentitiesAction tests reverse action mapping func TestIamMapToIdentitiesAction(t *testing.T) { // iamMapToIdentitiesAction maps internal action names to IAM statement action patterns tests := []struct { input string expected string }{ {"Admin", "*"}, {"Read", "Get*"}, {"Write", "Put*"}, {"List", "List*"}, {"Tagging", "Tagging*"}, {"Unknown", ""}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { result := iamMapToIdentitiesAction(tt.input) assert.Equal(t, tt.expected, result) }) } } // TestEmbeddedIamGetUserNotFound tests GetUser with non-existent user func TestEmbeddedIamGetUserNotFound(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "ExistingUser"}, }, } userName := aws.String("NonExistentUser") params := &iam.GetUserInput{UserName: userName} req, _ := iam.New(session.New()).GetUserRequest(params) _ = req.Build() response, _ := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) assert.Equal(t, http.StatusNotFound, response.Code) } // TestEmbeddedIamDeleteUserNotFound tests DeleteUser with non-existent user func TestEmbeddedIamDeleteUserNotFound(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{} userName := aws.String("NonExistentUser") params := &iam.DeleteUserInput{UserName: userName} req, _ := iam.New(session.New()).DeleteUserRequest(params) _ = req.Build() response, _ := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) assert.Equal(t, http.StatusNotFound, response.Code) } // TestEmbeddedIamUpdateUserNotFound tests UpdateUser with non-existent user func TestEmbeddedIamUpdateUserNotFound(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{} params := &iam.UpdateUserInput{ UserName: aws.String("NonExistentUser"), NewUserName: aws.String("NewName"), } req, _ := iam.New(session.New()).UpdateUserRequest(params) _ = req.Build() response, _ := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) assert.Equal(t, http.StatusNotFound, response.Code) } // TestEmbeddedIamCreateAccessKeyForExistingUser tests CreateAccessKey creates credentials for existing user func TestEmbeddedIamCreateAccessKeyForExistingUser(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "ExistingUser"}, }, } // Use direct form post form := url.Values{} form.Set("Action", "CreateAccessKey") form.Set("UserName", "ExistingUser") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) // Verify credentials were created assert.Len(t, api.mockConfig.Identities[0].Credentials, 1) } // TestEmbeddedIamPutGetUserPolicyRoundTrip is a regression test for // https://github.com/seaweedfs/seaweedfs/issues/9008: put-user-policy followed // by get-user-policy must return the same policy document, with Action and Resource // lists intact (no wildcard expansion, no duplication, no collapsing). func TestEmbeddedIamPutGetUserPolicyRoundTrip(t *testing.T) { api := NewEmbeddedIamApiForTest() s3cfg := &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{{Name: "steward"}}, } api.mockConfig = s3cfg policyJSON := `{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket"], "Resource": ["arn:aws:s3:::b-le*", "arn:aws:s3:::b-le*/*"] }] }` // PutUserPolicy _, iamErr := api.PutUserPolicy(s3cfg, url.Values{ "UserName": []string{"steward"}, "PolicyName": []string{"steward_policy"}, "PolicyDocument": []string{policyJSON}, }) assert.Nil(t, iamErr) // GetUserPolicy should return the exact document resp, iamErr := api.GetUserPolicy(s3cfg, url.Values{ "UserName": []string{"steward"}, "PolicyName": []string{"steward_policy"}, }) assert.Nil(t, iamErr) var got policy_engine.PolicyDocument assert.NoError(t, json.Unmarshal([]byte(resp.GetUserPolicyResult.PolicyDocument), &got)) assert.Equal(t, "2012-10-17", got.Version) require.Equal(t, 1, len(got.Statement)) stmt := got.Statement[0] assert.Equal(t, policy_engine.PolicyEffectAllow, stmt.Effect) assert.ElementsMatch(t, []string{"s3:GetObject", "s3:PutObject", "s3:ListBucket"}, stmt.Action.Strings()) assert.ElementsMatch(t, []string{"arn:aws:s3:::b-le*", "arn:aws:s3:::b-le*/*"}, stmt.Resource.Strings()) } // TestEmbeddedIamGetUserPolicyFallback tests the lossy fallback reconstruction // when no stored inline policy is available (pre-existing ident.Actions only). func TestEmbeddedIamGetUserPolicyFallback(t *testing.T) { api := NewEmbeddedIamApiForTest() s3cfg := &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{{ Name: "steward", Actions: []string{"Read:b-le*", "Write:b-le*", "List:b-le*", "Read:b-le*/*", "Write:b-le*/*", "List:b-le*/*"}, }}, } api.mockConfig = s3cfg // No stored inline policy — GetUserPolicy must reconstruct from ident.Actions resp, iamErr := api.GetUserPolicy(s3cfg, url.Values{ "UserName": []string{"steward"}, "PolicyName": []string{"steward_policy"}, }) assert.Nil(t, iamErr) var got policy_engine.PolicyDocument assert.NoError(t, json.Unmarshal([]byte(resp.GetUserPolicyResult.PolicyDocument), &got)) // Fallback is lossy but must not duplicate actions require.Equal(t, 1, len(got.Statement), "fallback should merge equal-action statements") stmt := got.Statement[0] assert.ElementsMatch(t, []string{"s3:Get*", "s3:Put*", "s3:List*"}, stmt.Action.Strings()) // Bucket-level and object-level resources stay distinct assert.ElementsMatch(t, []string{"arn:aws:s3:::b-le*", "arn:aws:s3:::b-le*/*"}, stmt.Resource.Strings()) } // TestEmbeddedIamGetUserPolicyUserNotFound tests GetUserPolicy with non-existent user func TestEmbeddedIamGetUserPolicyUserNotFound(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{} params := &iam.GetUserPolicyInput{ UserName: aws.String("NonExistentUser"), PolicyName: aws.String("TestPolicy"), } req, _ := iam.New(session.New()).GetUserPolicyRequest(params) _ = req.Build() response, _ := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) assert.Equal(t, http.StatusNotFound, response.Code) } // TestEmbeddedIamCreatePolicyMalformed tests CreatePolicy with invalid policy document func TestEmbeddedIamCreatePolicyMalformed(t *testing.T) { api := NewEmbeddedIamApiForTest() params := &iam.CreatePolicyInput{ PolicyName: aws.String("TestPolicy"), PolicyDocument: aws.String("invalid json"), } req, _ := iam.New(session.New()).CreatePolicyRequest(params) _ = req.Build() response, _ := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) assert.Equal(t, http.StatusBadRequest, response.Code) } // TestEmbeddedIamListAccessKeysForUser tests listing access keys for a specific user func TestEmbeddedIamListAccessKeysForUser(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: "TestUser", Credentials: []*iam_pb.Credential{ {AccessKey: UserAccessKeyPrefix + "TEST1", SecretKey: "secret1"}, {AccessKey: UserAccessKeyPrefix + "TEST2", SecretKey: "secret2"}, }, }, }, } params := &iam.ListAccessKeysInput{UserName: aws.String("TestUser")} req, _ := iam.New(session.New()).ListAccessKeysRequest(params) _ = req.Build() out := iamListAccessKeysResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) // Verify both access keys are listed assert.Len(t, out.ListAccessKeysResult.AccessKeyMetadata, 2) } // TestEmbeddedIamNotImplementedAction tests handling of unimplemented actions func TestEmbeddedIamNotImplementedAction(t *testing.T) { api := NewEmbeddedIamApiForTest() form := url.Values{} form.Set("Action", "SomeUnknownAction") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusNotImplemented, rr.Code) assert.Contains(t, rr.Body.String(), "") assert.NotContains(t, rr.Body.String(), "") assert.Equal(t, rr.Header().Get(request_id.AmzRequestIDHeader), extractEmbeddedIamRequestID(rr)) } // TestGetPolicyDocument tests parsing of policy documents func TestGetPolicyDocument(t *testing.T) { api := NewEmbeddedIamApiForTest() validPolicy := `{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::bucket/*"] }] }` doc, err := api.GetPolicyDocument(&validPolicy) assert.NoError(t, err) assert.Equal(t, "2012-10-17", doc.Version) assert.Len(t, doc.Statement, 1) // Test invalid JSON invalidPolicy := "not valid json" _, err = api.GetPolicyDocument(&invalidPolicy) assert.Error(t, err) } // TestEmbeddedIamGetActionsFromPolicy tests action extraction from policy documents func TestEmbeddedIamGetActionsFromPolicy(t *testing.T) { api := NewEmbeddedIamApiForTest() // Actions must use wildcards (Get*, Put*, List*, etc.) as expected by the mapper policyDoc := `{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": ["s3:Get*", "s3:Put*"], "Resource": ["arn:aws:s3:::mybucket/*"] }] }` policy, err := api.GetPolicyDocument(&policyDoc) assert.NoError(t, err) actions, err := api.getActions(&policy) assert.NoError(t, err) assert.NotEmpty(t, actions) // Should have Read and Write actions for the bucket // arn:aws:s3:::mybucket/* means all objects in mybucket, represented as "Action:mybucket" assert.Contains(t, actions, "Read:mybucket") assert.Contains(t, actions, "Write:mybucket") } // TestEmbeddedIamGetActionsPrefixScopedResource verifies that a prefix-scoped // resource keeps its trailing wildcard so CanDo authorizes objects under the // prefix. Stripping the wildcard produced a non-wildcard action that only // matched at bucket level, denying every PutObject under the prefix. func TestEmbeddedIamGetActionsPrefixScopedResource(t *testing.T) { api := NewEmbeddedIamApiForTest() policyDoc := `{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": ["s3:Get*", "s3:Put*", "s3:DeleteObject"], "Resource": ["arn:aws:s3:::mcp-upload/mcp/TMS/api_keys/example/*"] }] }` policy, err := api.GetPolicyDocument(&policyDoc) require.NoError(t, err) actions, err := api.getActions(&policy) require.NoError(t, err) assert.Contains(t, actions, "Read:mcp-upload/mcp/TMS/api_keys/example/*") assert.Contains(t, actions, "Write:mcp-upload/mcp/TMS/api_keys/example/*") identity := &Identity{Name: "prefix-user"} for _, a := range actions { identity.Actions = append(identity.Actions, Action(a)) } assert.True(t, identity.CanDo(ACTION_WRITE, "mcp-upload", "mcp/TMS/api_keys/example/logo.png"), "PutObject under the allowed prefix should be authorized") assert.True(t, identity.CanDo(ACTION_READ, "mcp-upload", "mcp/TMS/api_keys/example/logo.png"), "GetObject under the allowed prefix should be authorized") assert.False(t, identity.CanDo(ACTION_WRITE, "mcp-upload", "other/logo.png"), "PutObject outside the allowed prefix should be denied") } // TestEmbeddedIamPutUserPolicyAllResourceWildcard reproduces issue #9209: // an AWS-style policy using "Action":"s3:*" with a bare "Resource":"*" is // valid AWS IAM syntax (meaning "any resource") but was rejected with // "no valid actions found in policy document" because the resource parser // required a full ARN. func TestEmbeddedIamPutUserPolicyAllResourceWildcard(t *testing.T) { api := NewEmbeddedIamApiForTest() // From issue #9209: bare "*" resource and bare "s3:*" action. policyDoc := `{ "Version": "2012-10-17", "Statement": [{ "Sid": "AllowS3Admin", "Effect": "Allow", "Action": "s3:*", "Resource": "*" }] }` policy, err := api.GetPolicyDocument(&policyDoc) require.NoError(t, err) actions, err := api.getActions(&policy) require.NoError(t, err, "getActions must accept bare \"*\" resource") assert.Contains(t, actions, ACTION_ADMIN, "s3:* should map to admin action") } // TestEmbeddedIamSetUserStatus tests enabling/disabling a user func TestEmbeddedIamSetUserStatus(t *testing.T) { api := NewEmbeddedIamApiForTest() t.Run("DisableUser", func(t *testing.T) { // Reset state for test isolation api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser", Disabled: false}, }, } form := url.Values{} form.Set("Action", "SetUserStatus") form.Set("UserName", "TestUser") form.Set("Status", "Inactive") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) // Verify user is now disabled assert.True(t, api.mockConfig.Identities[0].Disabled) }) t.Run("EnableUser", func(t *testing.T) { // Reset state for test isolation - start with disabled user api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser", Disabled: true}, }, } form := url.Values{} form.Set("Action", "SetUserStatus") form.Set("UserName", "TestUser") form.Set("Status", "Active") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) // Verify user is now enabled assert.False(t, api.mockConfig.Identities[0].Disabled) }) } // TestEmbeddedIamSetUserStatusErrors tests error handling for SetUserStatus func TestEmbeddedIamSetUserStatusErrors(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ {Name: "TestUser"}, }, } t.Run("UserNotFound", func(t *testing.T) { form := url.Values{} form.Set("Action", "SetUserStatus") form.Set("UserName", "NonExistentUser") form.Set("Status", "Inactive") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusNotFound, rr.Code) }) t.Run("InvalidStatus", func(t *testing.T) { form := url.Values{} form.Set("Action", "SetUserStatus") form.Set("UserName", "TestUser") form.Set("Status", "InvalidStatus") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusBadRequest, rr.Code) }) t.Run("MissingUserName", func(t *testing.T) { form := url.Values{} form.Set("Action", "SetUserStatus") form.Set("Status", "Inactive") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusBadRequest, rr.Code) }) t.Run("MissingStatus", func(t *testing.T) { form := url.Values{} form.Set("Action", "SetUserStatus") form.Set("UserName", "TestUser") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusBadRequest, rr.Code) }) } // TestEmbeddedIamUpdateAccessKey tests updating access key status func TestEmbeddedIamUpdateAccessKey(t *testing.T) { api := NewEmbeddedIamApiForTest() t.Run("DeactivateAccessKey", func(t *testing.T) { // Reset state for test isolation api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: "TestUser", Credentials: []*iam_pb.Credential{ {AccessKey: UserAccessKeyPrefix + "TEST12345", SecretKey: "secret", Status: "Active"}, }, }, }, } form := url.Values{} form.Set("Action", "UpdateAccessKey") form.Set("UserName", "TestUser") form.Set("AccessKeyId", UserAccessKeyPrefix+"TEST12345") form.Set("Status", "Inactive") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) // Verify access key is now inactive assert.Equal(t, "Inactive", api.mockConfig.Identities[0].Credentials[0].Status) }) t.Run("ActivateAccessKey", func(t *testing.T) { // Reset state for test isolation - start with inactive key api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: "TestUser", Credentials: []*iam_pb.Credential{ {AccessKey: UserAccessKeyPrefix + "TEST12345", SecretKey: "secret", Status: "Inactive"}, }, }, }, } form := url.Values{} form.Set("Action", "UpdateAccessKey") form.Set("UserName", "TestUser") form.Set("AccessKeyId", UserAccessKeyPrefix+"TEST12345") form.Set("Status", "Active") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) // Verify access key is now active assert.Equal(t, "Active", api.mockConfig.Identities[0].Credentials[0].Status) }) } // TestEmbeddedIamUpdateAccessKeyErrors tests error handling for UpdateAccessKey func TestEmbeddedIamUpdateAccessKeyErrors(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: "TestUser", Credentials: []*iam_pb.Credential{ {AccessKey: UserAccessKeyPrefix + "TEST12345", SecretKey: "secret"}, }, }, }, } t.Run("AccessKeyNotFound", func(t *testing.T) { form := url.Values{} form.Set("Action", "UpdateAccessKey") form.Set("UserName", "TestUser") form.Set("AccessKeyId", "NONEXISTENT123") form.Set("Status", "Inactive") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusNotFound, rr.Code) }) t.Run("InvalidStatus", func(t *testing.T) { form := url.Values{} form.Set("Action", "UpdateAccessKey") form.Set("UserName", "TestUser") form.Set("AccessKeyId", UserAccessKeyPrefix+"TEST12345") form.Set("Status", "InvalidStatus") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusBadRequest, rr.Code) }) t.Run("MissingUserName", func(t *testing.T) { form := url.Values{} form.Set("Action", "UpdateAccessKey") form.Set("AccessKeyId", UserAccessKeyPrefix+"TEST12345") form.Set("Status", "Inactive") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusBadRequest, rr.Code) }) t.Run("MissingAccessKeyId", func(t *testing.T) { form := url.Values{} form.Set("Action", "UpdateAccessKey") form.Set("UserName", "TestUser") form.Set("Status", "Inactive") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusBadRequest, rr.Code) }) t.Run("UserNotFound", func(t *testing.T) { form := url.Values{} form.Set("Action", "UpdateAccessKey") form.Set("UserName", "NonExistentUser") form.Set("AccessKeyId", UserAccessKeyPrefix+"TEST12345") form.Set("Status", "Inactive") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusNotFound, rr.Code) }) t.Run("MissingStatus", func(t *testing.T) { form := url.Values{} form.Set("Action", "UpdateAccessKey") form.Set("UserName", "TestUser") form.Set("AccessKeyId", UserAccessKeyPrefix+"TEST12345") req, _ := http.NewRequest("POST", "/", nil) req.PostForm = form req.Form = form req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() apiRouter := mux.NewRouter().SkipClean(true) apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) apiRouter.ServeHTTP(rr, req) assert.Equal(t, http.StatusBadRequest, rr.Code) }) } // TestEmbeddedIamListAccessKeysShowsStatus tests that ListAccessKeys returns the access key status func TestEmbeddedIamListAccessKeysShowsStatus(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: "TestUser", Credentials: []*iam_pb.Credential{ {AccessKey: UserAccessKeyPrefix + "ACTIVE123", SecretKey: "secret1", Status: "Active"}, {AccessKey: UserAccessKeyPrefix + "INACTIVE1", SecretKey: "secret2", Status: "Inactive"}, {AccessKey: UserAccessKeyPrefix + "DEFAULT12", SecretKey: "secret3"}, // No status set, should default to Active }, }, }, } params := &iam.ListAccessKeysInput{UserName: aws.String("TestUser")} req, _ := iam.New(session.New()).ListAccessKeysRequest(params) _ = req.Build() out := iamListAccessKeysResponse{} response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out) assert.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code) // Verify all three access keys are listed with correct status assert.Len(t, out.ListAccessKeysResult.AccessKeyMetadata, 3) // Find each key and verify status statusMap := make(map[string]string) for _, meta := range out.ListAccessKeysResult.AccessKeyMetadata { statusMap[*meta.AccessKeyId] = *meta.Status } assert.Equal(t, "Active", statusMap[UserAccessKeyPrefix+"ACTIVE123"]) assert.Equal(t, "Inactive", statusMap[UserAccessKeyPrefix+"INACTIVE1"]) assert.Equal(t, "Active", statusMap[UserAccessKeyPrefix+"DEFAULT12"]) // Default to Active } // TestDisabledUserLookupFails tests that disabled users cannot authenticate func TestDisabledUserLookupFails(t *testing.T) { iam := &IdentityAccessManagement{} testConfig := &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: "enabledUser", Disabled: false, Credentials: []*iam_pb.Credential{ {AccessKey: UserAccessKeyPrefix + "ENABLED123", SecretKey: "secret1"}, }, }, { Name: "disabledUser", Disabled: true, Credentials: []*iam_pb.Credential{ {AccessKey: UserAccessKeyPrefix + "DISABLED12", SecretKey: "secret2"}, }, }, }, } err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(testConfig)) assert.NoError(t, err) // Enabled user should be found identity, cred, found := iam.LookupByAccessKey(UserAccessKeyPrefix + "ENABLED123") assert.True(t, found) assert.NotNil(t, identity) assert.NotNil(t, cred) assert.Equal(t, "enabledUser", identity.Name) // Disabled user should NOT be found identity, cred, found = iam.LookupByAccessKey(UserAccessKeyPrefix + "DISABLED12") assert.False(t, found) assert.Nil(t, identity) assert.Nil(t, cred) } // TestInactiveAccessKeyLookupFails tests that inactive access keys cannot authenticate func TestInactiveAccessKeyLookupFails(t *testing.T) { iam := &IdentityAccessManagement{} testConfig := &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: "testUser", Credentials: []*iam_pb.Credential{ {AccessKey: UserAccessKeyPrefix + "ACTIVE123", SecretKey: "secret1", Status: "Active"}, {AccessKey: UserAccessKeyPrefix + "INACTIVE1", SecretKey: "secret2", Status: "Inactive"}, {AccessKey: UserAccessKeyPrefix + "DEFAULT12", SecretKey: "secret3"}, // No status = Active }, }, }, } err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(testConfig)) assert.NoError(t, err) // Active key should be found identity, cred, found := iam.LookupByAccessKey(UserAccessKeyPrefix + "ACTIVE123") assert.True(t, found) assert.NotNil(t, identity) assert.NotNil(t, cred) // Inactive key should NOT be found identity, cred, found = iam.LookupByAccessKey(UserAccessKeyPrefix + "INACTIVE1") assert.False(t, found) assert.Nil(t, identity) assert.Nil(t, cred) // Key with no status (default Active) should be found identity, cred, found = iam.LookupByAccessKey(UserAccessKeyPrefix + "DEFAULT12") assert.True(t, found) assert.NotNil(t, identity) assert.NotNil(t, cred) } // TestAuthIamAuthenticatesBeforeParseForm verifies that AuthIam authenticates the request // BEFORE parsing the form. This is critical because ParseForm() consumes the request body, // but IAM signature verification needs to hash the body. // This test reproduces the bug described in GitHub issue #7802. func TestAuthIamAuthenticatesBeforeParseForm(t *testing.T) { // Create IAM with test credentials iam := &IdentityAccessManagement{ hashes: make(map[string]*sync.Pool), hashCounters: make(map[string]*int32), } testConfig := &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: "admin", Credentials: []*iam_pb.Credential{ { AccessKey: "admin_access_key", SecretKey: "admin_secret_key", Status: "Active", }, }, Actions: []string{"Admin"}, }, }, } err := iam.loadS3ApiConfiguration(testConfig) assert.NoError(t, err) embeddedApi := &EmbeddedIamApi{ iam: iam, } // Create a properly signed IAM request payload := "Action=CreateUser&Version=2010-05-08&UserName=bob" // Use current time to avoid clock skew now := time.Now().UTC() amzDate := now.Format(iso8601Format) dateStamp := now.Format(yyyymmdd) credentialScope := dateStamp + "/us-east-1/iam/aws4_request" req, err := http.NewRequest("POST", "http://localhost:8333/", strings.NewReader(payload)) assert.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") req.Header.Set("Host", "localhost:8333") req.Header.Set("X-Amz-Date", amzDate) // Calculate the correct signature using IAM service payloadHash := getSHA256Hash([]byte(payload)) canonicalRequest := fmt.Sprintf("POST\n/\n\ncontent-type:application/x-www-form-urlencoded; charset=utf-8\nhost:localhost:8333\nx-amz-date:%s\n\ncontent-type;host;x-amz-date\n%s", amzDate, payloadHash) canonicalRequestHash := getSHA256Hash([]byte(canonicalRequest)) stringToSign := fmt.Sprintf("AWS4-HMAC-SHA256\n%s\n%s\n%s", amzDate, credentialScope, canonicalRequestHash) signingKey := getSigningKey("admin_secret_key", dateStamp, "us-east-1", "iam") signature := getSignature(signingKey, stringToSign) authHeader := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=admin_access_key/%s, SignedHeaders=content-type;host;x-amz-date, Signature=%s", credentialScope, signature) req.Header.Set("Authorization", authHeader) // Create a test handler that just returns OK handlerCalled := false testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handlerCalled = true w.WriteHeader(http.StatusOK) }) // Wrap with AuthIam authHandler := embeddedApi.AuthIam(testHandler, ACTION_WRITE) // Execute the request rr := httptest.NewRecorder() authHandler.ServeHTTP(rr, req) // The handler should be called (authentication succeeded) // Before the fix, this would fail with SignatureDoesNotMatch because // ParseForm was called before authentication, consuming the body assert.True(t, handlerCalled, "Handler was not called - authentication failed") assert.Equal(t, http.StatusOK, rr.Code, "Expected OK status, got %d", rr.Code) } // TestOldCodeOrderWouldFail demonstrates why the old code order was broken. // This test shows that ParseForm() before signature verification causes auth failure. func TestOldCodeOrderWouldFail(t *testing.T) { // Create IAM with test credentials iam := &IdentityAccessManagement{ hashes: make(map[string]*sync.Pool), hashCounters: make(map[string]*int32), } testConfig := &iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: "admin", Credentials: []*iam_pb.Credential{ { AccessKey: "admin_access_key", SecretKey: "admin_secret_key", Status: "Active", }, }, Actions: []string{"Admin"}, }, }, } err := iam.loadS3ApiConfiguration(testConfig) assert.NoError(t, err) // Create a properly signed IAM request payload := "Action=CreateUser&Version=2010-05-08&UserName=bob" now := time.Now().UTC() amzDate := now.Format(iso8601Format) dateStamp := now.Format(yyyymmdd) credentialScope := dateStamp + "/us-east-1/iam/aws4_request" req, err := http.NewRequest("POST", "http://localhost:8333/", strings.NewReader(payload)) assert.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") req.Header.Set("Host", "localhost:8333") req.Header.Set("X-Amz-Date", amzDate) // Calculate the correct signature using IAM service payloadHash := getSHA256Hash([]byte(payload)) canonicalRequest := fmt.Sprintf("POST\n/\n\ncontent-type:application/x-www-form-urlencoded; charset=utf-8\nhost:localhost:8333\nx-amz-date:%s\n\ncontent-type;host;x-amz-date\n%s", amzDate, payloadHash) canonicalRequestHash := getSHA256Hash([]byte(canonicalRequest)) stringToSign := fmt.Sprintf("AWS4-HMAC-SHA256\n%s\n%s\n%s", amzDate, credentialScope, canonicalRequestHash) signingKey := getSigningKey("admin_secret_key", dateStamp, "us-east-1", "iam") signature := getSignature(signingKey, stringToSign) authHeader := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=admin_access_key/%s, SignedHeaders=content-type;host;x-amz-date, Signature=%s", credentialScope, signature) req.Header.Set("Authorization", authHeader) // Simulate OLD buggy code: ParseForm BEFORE authentication // This consumes the request body! err = req.ParseForm() assert.NoError(t, err) assert.Equal(t, "CreateUser", req.Form.Get("Action")) // Form parsing works // Now try to authenticate - this should FAIL because body is consumed identity, errCode := iam.AuthSignatureOnly(req) // With old code order, this would fail with SignatureDoesNotMatch // because the body is empty when signature verification tries to hash it assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode, "Expected SignatureDoesNotMatch when ParseForm is called before auth") assert.Nil(t, identity) t.Log("This demonstrates the bug: ParseForm before auth causes SignatureDoesNotMatch") } // TestEmbeddedIamExecuteAction tests calling ExecuteAction directly func TestEmbeddedIamExecuteAction(t *testing.T) { api := NewEmbeddedIamApiForTest() api.mockConfig = &iam_pb.S3ApiConfiguration{} // Test case: CreateUser via ExecuteAction vals := url.Values{} vals.Set("Action", "CreateUser") vals.Set("UserName", "ExecuteActionUser") ctx := context.Background() resp, iamErr := api.ExecuteAction(ctx, vals, false, "") assert.Nil(t, iamErr) // Verify response type createResp, ok := resp.(*iamCreateUserResponse) assert.True(t, ok) assert.Equal(t, "ExecuteActionUser", *createResp.CreateUserResult.User.UserName) // CreateUser now uses a targeted write (credentialManager.CreateUser) rather than // SaveConfiguration, so persistence is checked via the credential manager directly. user, err := api.credentialManager.GetUser(ctx, "ExecuteActionUser") assert.NoError(t, err) assert.Equal(t, "ExecuteActionUser", user.Name) } // TestEmbeddedIamReadOnly tests that write operations are blocked when readOnly is true func TestEmbeddedIamReadOnly(t *testing.T) { api := NewEmbeddedIamApiForTest() api.readOnly = true // Try CreateUser (Write) userName := aws.String("ReadOnlyUser") params := &iam.CreateUserInput{UserName: userName} req, _ := iam.New(session.New()).CreateUserRequest(params) _ = req.Build() response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) assert.NoError(t, err) assert.Equal(t, http.StatusForbidden, response.Code) code, msg := extractEmbeddedIamErrorCodeAndMessage(response) assert.Equal(t, "AccessDenied", code) assert.Contains(t, msg, "IAM write operations are disabled") // Try ListUsers (Read) - Should succeed paramsList := &iam.ListUsersInput{} reqList, _ := iam.New(session.New()).ListUsersRequest(paramsList) _ = reqList.Build() outList := iamListUsersResponse{} responseList, err := executeEmbeddedIamRequest(api, reqList.HTTPRequest, &outList) assert.NoError(t, err) assert.Equal(t, http.StatusOK, responseList.Code) }