diff options
| author | chrislu <chris.lu@gmail.com> | 2025-12-14 16:31:06 -0800 |
|---|---|---|
| committer | chrislu <chris.lu@gmail.com> | 2025-12-14 17:43:57 -0800 |
| commit | e03b2ee8bbeb6e23bf46bcc26cb72ada615a24c2 (patch) | |
| tree | 2688ad5cb600bbeef7f760184cfa5044711930a6 | |
| parent | 8bdc4390a04604af79f91c7dce94e3b2b58442f7 (diff) | |
| download | seaweedfs-origin/feature/iam-user-status-management-7745.tar.xz seaweedfs-origin/feature/iam-user-status-management-7745.zip | |
feat(iam): add SetUserStatus and UpdateAccessKey actions (#7745)origin/feature/iam-user-status-management-7745
Add ability to enable/disable users and access keys without deleting them.
## Changes
### Protocol Buffer Updates
- Add `disabled` field (bool) to Identity message for user status
- false (default) = enabled, true = disabled
- No backward compatibility hack needed since zero value is correct
- Add `status` field (string: Active/Inactive) to Credential message
### New IAM Actions
- SetUserStatus: Enable or disable a user (requires admin)
- UpdateAccessKey: Change access key status (self-service or admin)
### Behavior
- Disabled users: All API requests return AccessDenied
- Inactive access keys: Signature validation fails
- Status check happens early in auth flow for performance
- Backward compatible: existing configs default to enabled (disabled=false)
### Use Cases
1. Temporary suspension: Disable user access during investigation
2. Key rotation: Deactivate old key before deletion
3. Offboarding: Disable rather than delete for audit purposes
4. Emergency response: Quickly disable compromised credentials
Fixes #7745
| -rw-r--r-- | weed/iam/constants.go | 6 | ||||
| -rw-r--r-- | weed/iam/responses.go | 12 | ||||
| -rw-r--r-- | weed/pb/iam.proto | 4 | ||||
| -rw-r--r-- | weed/pb/iam_pb/iam.pb.go | 24 | ||||
| -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 |
9 files changed, 575 insertions, 19 deletions
diff --git a/weed/iam/constants.go b/weed/iam/constants.go index 206132201..7dea524e8 100644 --- a/weed/iam/constants.go +++ b/weed/iam/constants.go @@ -29,3 +29,9 @@ const ( AccessKeyIdLength = 21 SecretAccessKeyLength = 42 ) + +// Access key status values (AWS IAM compatible) +const ( + AccessKeyStatusActive = "Active" + AccessKeyStatusInactive = "Inactive" +) diff --git a/weed/iam/responses.go b/weed/iam/responses.go index a45c9fd16..47ec7b8c4 100644 --- a/weed/iam/responses.go +++ b/weed/iam/responses.go @@ -138,3 +138,15 @@ type Policies struct { Policies map[string]interface{} `json:"policies"` } +// SetUserStatusResponse is the response for SetUserStatus action. +// This is a SeaweedFS extension to enable/disable users without deleting them. +type SetUserStatusResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ SetUserStatusResponse"` +} + +// UpdateAccessKeyResponse is the response for UpdateAccessKey action. +type UpdateAccessKeyResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateAccessKeyResponse"` +} diff --git a/weed/pb/iam.proto b/weed/pb/iam.proto index 99bb65ef2..342063f8d 100644 --- a/weed/pb/iam.proto +++ b/weed/pb/iam.proto @@ -24,13 +24,13 @@ message Identity { repeated Credential credentials = 2; repeated string actions = 3; Account account = 4; + bool disabled = 5; // User status: false = enabled (default), true = disabled } message Credential { string access_key = 1; string secret_key = 2; - // uint64 expiration = 3; - // bool is_disabled = 4; + string status = 3; // Access key status: "Active" or "Inactive" } message Account { diff --git a/weed/pb/iam_pb/iam.pb.go b/weed/pb/iam_pb/iam.pb.go index 4eabf8dc3..8eeaf8488 100644 --- a/weed/pb/iam_pb/iam.pb.go +++ b/weed/pb/iam_pb/iam.pb.go @@ -79,6 +79,7 @@ type Identity struct { Credentials []*Credential `protobuf:"bytes,2,rep,name=credentials,proto3" json:"credentials,omitempty"` Actions []string `protobuf:"bytes,3,rep,name=actions,proto3" json:"actions,omitempty"` Account *Account `protobuf:"bytes,4,opt,name=account,proto3" json:"account,omitempty"` + Disabled bool `protobuf:"varint,5,opt,name=disabled,proto3" json:"disabled,omitempty"` // User status: false = enabled (default), true = disabled unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -141,10 +142,18 @@ func (x *Identity) GetAccount() *Account { return nil } +func (x *Identity) GetDisabled() bool { + if x != nil { + return x.Disabled + } + return false +} + type Credential struct { state protoimpl.MessageState `protogen:"open.v1"` AccessKey string `protobuf:"bytes,1,opt,name=access_key,json=accessKey,proto3" json:"access_key,omitempty"` SecretKey string `protobuf:"bytes,2,opt,name=secret_key,json=secretKey,proto3" json:"secret_key,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` // Access key status: "Active" or "Inactive" unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -193,6 +202,13 @@ func (x *Credential) GetSecretKey() string { return "" } +func (x *Credential) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + type Account struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` @@ -262,18 +278,20 @@ const file_iam_proto_rawDesc = "" + "\n" + "identities\x18\x01 \x03(\v2\x10.iam_pb.IdentityR\n" + "identities\x12+\n" + - "\baccounts\x18\x02 \x03(\v2\x0f.iam_pb.AccountR\baccounts\"\x99\x01\n" + + "\baccounts\x18\x02 \x03(\v2\x0f.iam_pb.AccountR\baccounts\"\xb5\x01\n" + "\bIdentity\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x124\n" + "\vcredentials\x18\x02 \x03(\v2\x12.iam_pb.CredentialR\vcredentials\x12\x18\n" + "\aactions\x18\x03 \x03(\tR\aactions\x12)\n" + - "\aaccount\x18\x04 \x01(\v2\x0f.iam_pb.AccountR\aaccount\"J\n" + + "\aaccount\x18\x04 \x01(\v2\x0f.iam_pb.AccountR\aaccount\x12\x1a\n" + + "\bdisabled\x18\x05 \x01(\bR\bdisabled\"b\n" + "\n" + "Credential\x12\x1d\n" + "\n" + "access_key\x18\x01 \x01(\tR\taccessKey\x12\x1d\n" + "\n" + - "secret_key\x18\x02 \x01(\tR\tsecretKey\"a\n" + + "secret_key\x18\x02 \x01(\tR\tsecretKey\x12\x16\n" + + "\x06status\x18\x03 \x01(\tR\x06status\"a\n" + "\aAccount\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12!\n" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12#\n" + 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) +} + |
