diff options
Diffstat (limited to 'weed/s3api')
| -rw-r--r-- | weed/s3api/auth_credentials.go | 17 | ||||
| -rw-r--r-- | weed/s3api/auto_signature_v4_test.go | 4 | ||||
| -rw-r--r-- | weed/s3api/s3api_bucket_handlers_test.go | 6 | ||||
| -rw-r--r-- | weed/s3api/s3api_embedded_iam.go | 105 | ||||
| -rw-r--r-- | weed/s3api/s3api_embedded_iam_test.go | 416 |
5 files changed, 534 insertions, 14 deletions
diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index c81fb3a88..5acd711cd 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -66,6 +66,7 @@ type Identity struct { Credentials []*Credential Actions []Action PrincipalArn string // ARN for IAM authorization (e.g., "arn:aws:iam::account-id:user/username") + Disabled bool // User status: false = enabled (default), true = disabled } // Account represents a system user, a system user can @@ -101,6 +102,7 @@ var ( type Credential struct { AccessKey string SecretKey string + Status string // Access key status: "Active" or "Inactive" (empty treated as "Active") } // "Permission": "FULL_CONTROL"|"WRITE"|"WRITE_ACP"|"READ"|"READ_ACP" @@ -318,12 +320,13 @@ func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3Api emailAccount[AccountAnonymous.EmailAddress] = &AccountAnonymous } for _, ident := range config.Identities { - glog.V(3).Infof("loading identity %s", ident.Name) + glog.V(3).Infof("loading identity %s (disabled=%v)", ident.Name, ident.Disabled) t := &Identity{ Name: ident.Name, Credentials: nil, Actions: nil, PrincipalArn: generatePrincipalArn(ident.Name), + Disabled: ident.Disabled, // false (default) = enabled, true = disabled } switch { case ident.Name == AccountAnonymous.Id: @@ -347,6 +350,7 @@ func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3Api t.Credentials = append(t.Credentials, &Credential{ AccessKey: cred.AccessKey, SecretKey: cred.SecretKey, + Status: cred.Status, // Load access key status }) accessKeyIdent[cred.AccessKey] = t } @@ -405,8 +409,19 @@ func (iam *IdentityAccessManagement) lookupByAccessKey(accessKey string) (identi truncatedKey, len(accessKey), len(iam.accessKeyIdent)) if ident, ok := iam.accessKeyIdent[accessKey]; ok { + // Check if user is disabled + if ident.Disabled { + glog.V(2).Infof("User %s is disabled, rejecting access key %s", ident.Name, truncatedKey) + return nil, nil, false + } + for _, credential := range ident.Credentials { if credential.AccessKey == accessKey { + // Check if access key is inactive (empty Status treated as Active for backward compatibility) + if credential.Status == iamAccessKeyStatusInactive { + glog.V(2).Infof("Access key %s for identity %s is inactive", truncatedKey, ident.Name) + return nil, nil, false + } glog.V(2).Infof("Found access key %s for identity %s", truncatedKey, ident.Name) return ident, credential, true } diff --git a/weed/s3api/auto_signature_v4_test.go b/weed/s3api/auto_signature_v4_test.go index b23756f33..7079273ee 100644 --- a/weed/s3api/auto_signature_v4_test.go +++ b/weed/s3api/auto_signature_v4_test.go @@ -190,7 +190,7 @@ func mustNewRequest(method string, urlStr string, contentLength int64, body io.R // is signed with AWS Signature V4, fails if not able to do so. func mustNewSignedRequest(method string, urlStr string, contentLength int64, body io.ReadSeeker, t *testing.T) *http.Request { req := mustNewRequest(method, urlStr, contentLength, body, t) - cred := &Credential{"access_key_1", "secret_key_1"} + cred := &Credential{AccessKey: "access_key_1", SecretKey: "secret_key_1"} if err := signRequestV4(req, cred.AccessKey, cred.SecretKey); err != nil { t.Fatalf("Unable to initialized new signed http request %s", err) } @@ -201,7 +201,7 @@ func mustNewSignedRequest(method string, urlStr string, contentLength int64, bod // is presigned with AWS Signature V4, fails if not able to do so. func mustNewPresignedRequest(iam *IdentityAccessManagement, method string, urlStr string, contentLength int64, body io.ReadSeeker, t *testing.T) *http.Request { req := mustNewRequest(method, urlStr, contentLength, body, t) - cred := &Credential{"access_key_1", "secret_key_1"} + cred := &Credential{AccessKey: "access_key_1", SecretKey: "secret_key_1"} if err := preSignV4(iam, req, cred.AccessKey, cred.SecretKey, int64(10*time.Minute.Seconds())); err != nil { t.Fatalf("Unable to initialized new signed http request %s", err) } diff --git a/weed/s3api/s3api_bucket_handlers_test.go b/weed/s3api/s3api_bucket_handlers_test.go index 40357a2b7..c2870b15e 100644 --- a/weed/s3api/s3api_bucket_handlers_test.go +++ b/weed/s3api/s3api_bucket_handlers_test.go @@ -670,7 +670,7 @@ func TestListBucketsIssue7647(t *testing.T) { t.Run("admin user can see their created buckets", func(t *testing.T) { // Simulate the exact scenario from issue #7647: // User "root" with ["Admin", "Read", "Write", "Tagging", "List"] permissions - + // Create identity for root user with Admin action rootIdentity := &Identity{ Name: "root", @@ -730,7 +730,7 @@ func TestListBucketsIssue7647(t *testing.T) { t.Run("admin user sees buckets without owner metadata", func(t *testing.T) { // Admin users should see buckets even if they don't have owner metadata // (this can happen with legacy buckets or manual creation) - + rootIdentity := &Identity{ Name: "root", Actions: []Action{ @@ -754,7 +754,7 @@ func TestListBucketsIssue7647(t *testing.T) { t.Run("non-admin user cannot see buckets without owner", func(t *testing.T) { // Non-admin users should not see buckets without owner metadata - + regularUser := &Identity{ Name: "user1", Actions: []Action{ diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index 5dce8a3e0..11c03386f 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -56,6 +56,8 @@ type ( iamPutUserPolicyResponse = iamlib.PutUserPolicyResponse iamDeleteUserPolicyResponse = iamlib.DeleteUserPolicyResponse iamGetUserPolicyResponse = iamlib.GetUserPolicyResponse + iamSetUserStatusResponse = iamlib.SetUserStatusResponse + iamUpdateAccessKeyResponse = iamlib.UpdateAccessKeyResponse iamErrorResponse = iamlib.ErrorResponse iamError = iamlib.Error ) @@ -83,10 +85,12 @@ func iamMapToIdentitiesAction(action string) string { // Constants from shared package const ( - iamCharsetUpper = iamlib.CharsetUpper - iamCharset = iamlib.Charset - iamPolicyDocumentVersion = iamlib.PolicyDocumentVersion - iamUserDoesNotExist = iamlib.UserDoesNotExist + iamCharsetUpper = iamlib.CharsetUpper + iamCharset = iamlib.Charset + iamPolicyDocumentVersion = iamlib.PolicyDocumentVersion + iamUserDoesNotExist = iamlib.UserDoesNotExist + iamAccessKeyStatusActive = iamlib.AccessKeyStatusActive + iamAccessKeyStatusInactive = iamlib.AccessKeyStatusInactive ) func newIamErrorResponse(errCode string, errMsg string) iamErrorResponse { @@ -151,15 +155,23 @@ func (e *EmbeddedIamApi) ListUsers(s3cfg *iam_pb.S3ApiConfiguration, values url. // ListAccessKeys lists access keys for a user. func (e *EmbeddedIamApi) ListAccessKeys(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) iamListAccessKeysResponse { var resp iamListAccessKeysResponse - status := iam.StatusTypeActive userName := values.Get("UserName") for _, ident := range s3cfg.Identities { if userName != "" && userName != ident.Name { continue } for _, cred := range ident.Credentials { + // Return actual status from credential, default to Active if not set + status := cred.Status + if status == "" { + status = iamAccessKeyStatusActive + } + // Capture copies to avoid loop variable pointer aliasing + identName := ident.Name + accessKey := cred.AccessKey + statusCopy := status resp.ListAccessKeysResult.AccessKeyMetadata = append(resp.ListAccessKeysResult.AccessKeyMetadata, - &iam.AccessKeyMetadata{UserName: &ident.Name, AccessKeyId: &cred.AccessKey, Status: &status}, + &iam.AccessKeyMetadata{UserName: &identName, AccessKeyId: &accessKey, Status: &statusCopy}, ) } } @@ -184,7 +196,7 @@ func (e *EmbeddedIamApi) CreateUser(s3cfg *iam_pb.S3ApiConfiguration, values url } resp.CreateUserResult.User.UserName = &userName - s3cfg.Identities = append(s3cfg.Identities, &iam_pb.Identity{Name: userName}) + s3cfg.Identities = append(s3cfg.Identities, &iam_pb.Identity{Name: userName}) // Disabled defaults to false (enabled) return resp, nil } @@ -253,7 +265,7 @@ func (e *EmbeddedIamApi) CreateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, value for _, ident := range s3cfg.Identities { if userName == ident.Name { ident.Credentials = append(ident.Credentials, - &iam_pb.Credential{AccessKey: accessKeyId, SecretKey: secretAccessKey}) + &iam_pb.Credential{AccessKey: accessKeyId, SecretKey: secretAccessKey, Status: iamAccessKeyStatusActive}) return resp, nil } } @@ -477,6 +489,70 @@ func (e *EmbeddedIamApi) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, valu return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)} } +// SetUserStatus enables or disables a user without deleting them. +// This is a SeaweedFS extension for temporary user suspension, offboarding, etc. +// When a user is disabled, all API requests using their credentials will return AccessDenied. +func (e *EmbeddedIamApi) SetUserStatus(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (iamSetUserStatusResponse, *iamError) { + var resp iamSetUserStatusResponse + userName := values.Get("UserName") + status := values.Get("Status") + + // Validate UserName + if userName == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("UserName is required")} + } + + // Validate Status - must be "Active" or "Inactive" + if status != iamAccessKeyStatusActive && status != iamAccessKeyStatusInactive { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("Status must be '%s' or '%s'", iamAccessKeyStatusActive, iamAccessKeyStatusInactive)} + } + + for _, ident := range s3cfg.Identities { + if ident.Name == userName { + // Set disabled based on status: Active = not disabled, Inactive = disabled + ident.Disabled = (status == iamAccessKeyStatusInactive) + return resp, nil + } + } + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)} +} + +// UpdateAccessKey updates the status of an access key (Active or Inactive). +// This allows key rotation workflows where old keys are deactivated before deletion. +func (e *EmbeddedIamApi) UpdateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (iamUpdateAccessKeyResponse, *iamError) { + var resp iamUpdateAccessKeyResponse + userName := values.Get("UserName") + accessKeyId := values.Get("AccessKeyId") + status := values.Get("Status") + + // Validate required parameters + if userName == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("UserName is required")} + } + if accessKeyId == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("AccessKeyId is required")} + } + if status != iamAccessKeyStatusActive && status != iamAccessKeyStatusInactive { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("Status must be '%s' or '%s'", iamAccessKeyStatusActive, iamAccessKeyStatusInactive)} + } + + for _, ident := range s3cfg.Identities { + if ident.Name != userName { + continue + } + for _, cred := range ident.Credentials { + if cred.AccessKey == accessKeyId { + cred.Status = status + return resp, nil + } + } + // User found but access key not found + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("the access key with id %s cannot be found", accessKeyId)} + } + + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)} +} + // handleImplicitUsername adds username who signs the request to values if 'username' is not specified. // According to AWS documentation: "If you do not specify a user name, IAM determines the user name // implicitly based on the Amazon Web Services access key ID signing the request." @@ -707,6 +783,19 @@ func (e *EmbeddedIamApi) DoActions(w http.ResponseWriter, r *http.Request) { e.writeIamErrorResponse(w, r, iamErr) return } + case "SetUserStatus": + response, iamErr = e.SetUserStatus(s3cfg, values) + if iamErr != nil { + e.writeIamErrorResponse(w, r, iamErr) + return + } + case "UpdateAccessKey": + e.handleImplicitUsername(r, values) + response, iamErr = e.UpdateAccessKey(s3cfg, values) + if iamErr != nil { + e.writeIamErrorResponse(w, r, iamErr) + return + } default: errNotImplemented := s3err.GetAPIError(s3err.ErrNotImplemented) errorResponse := iamErrorResponse{} diff --git a/weed/s3api/s3api_embedded_iam_test.go b/weed/s3api/s3api_embedded_iam_test.go index 81839084b..1b44df64c 100644 --- a/weed/s3api/s3api_embedded_iam_test.go +++ b/weed/s3api/s3api_embedded_iam_test.go @@ -137,6 +137,19 @@ func (e *EmbeddedIamApiForTest) DoActions(w http.ResponseWriter, r *http.Request e.writeIamErrorResponse(w, r, iamErr) return } + case "SetUserStatus": + response, iamErr = e.SetUserStatus(s3cfg, values) + if iamErr != nil { + e.writeIamErrorResponse(w, r, iamErr) + return + } + case "UpdateAccessKey": + e.handleImplicitUsername(r, values) + response, iamErr = e.UpdateAccessKey(s3cfg, values) + if iamErr != nil { + e.writeIamErrorResponse(w, r, iamErr) + return + } default: http.Error(w, "Not implemented", http.StatusNotImplemented) return @@ -1026,3 +1039,406 @@ func TestEmbeddedIamGetActionsFromPolicy(t *testing.T) { assert.Contains(t, actions, "Write:mybucket") } +// TestEmbeddedIamSetUserStatus tests enabling/disabling a user +func TestEmbeddedIamSetUserStatus(t *testing.T) { + api := NewEmbeddedIamApiForTest() + api.mockConfig = &iam_pb.S3ApiConfiguration{ + Identities: []*iam_pb.Identity{ + {Name: "TestUser", Disabled: false}, + }, + } + + t.Run("DisableUser", func(t *testing.T) { + 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) { + 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) + }) +} + +// TestEmbeddedIamUpdateAccessKey tests updating access key status +func TestEmbeddedIamUpdateAccessKey(t *testing.T) { + api := NewEmbeddedIamApiForTest() + api.mockConfig = &iam_pb.S3ApiConfiguration{ + Identities: []*iam_pb.Identity{ + { + Name: "TestUser", + Credentials: []*iam_pb.Credential{ + {AccessKey: "AKIATEST12345", SecretKey: "secret", Status: "Active"}, + }, + }, + }, + } + + t.Run("DeactivateAccessKey", func(t *testing.T) { + form := url.Values{} + form.Set("Action", "UpdateAccessKey") + form.Set("UserName", "TestUser") + form.Set("AccessKeyId", "AKIATEST12345") + 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) { + form := url.Values{} + form.Set("Action", "UpdateAccessKey") + form.Set("UserName", "TestUser") + form.Set("AccessKeyId", "AKIATEST12345") + 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: "AKIATEST12345", 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", "AKIATEST12345") + 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", "AKIATEST12345") + 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", "AKIATEST12345") + 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) + }) +} + +// 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: "AKIAACTIVE123", SecretKey: "secret1", Status: "Active"}, + {AccessKey: "AKIAINACTIVE1", SecretKey: "secret2", Status: "Inactive"}, + {AccessKey: "AKIADEFAULT12", 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["AKIAACTIVE123"]) + assert.Equal(t, "Inactive", statusMap["AKIAINACTIVE1"]) + assert.Equal(t, "Active", statusMap["AKIADEFAULT12"]) // 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: "AKIAENABLED123", SecretKey: "secret1"}, + }, + }, + { + Name: "disabledUser", + Disabled: true, + Credentials: []*iam_pb.Credential{ + {AccessKey: "AKIADISABLED12", SecretKey: "secret2"}, + }, + }, + }, + } + err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(testConfig)) + assert.NoError(t, err) + + // Enabled user should be found + identity, cred, found := iam.LookupByAccessKey("AKIAENABLED123") + 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("AKIADISABLED12") + 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: "AKIAACTIVE123", SecretKey: "secret1", Status: "Active"}, + {AccessKey: "AKIAINACTIVE1", SecretKey: "secret2", Status: "Inactive"}, + {AccessKey: "AKIADEFAULT12", SecretKey: "secret3"}, // No status = Active + }, + }, + }, + } + err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(testConfig)) + assert.NoError(t, err) + + // Active key should be found + identity, cred, found := iam.LookupByAccessKey("AKIAACTIVE123") + assert.True(t, found) + assert.NotNil(t, identity) + assert.NotNil(t, cred) + + // Inactive key should NOT be found + identity, cred, found = iam.LookupByAccessKey("AKIAINACTIVE1") + 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("AKIADEFAULT12") + assert.True(t, found) + assert.NotNil(t, identity) + assert.NotNil(t, cred) +} + |
