feat(iam): add SetUserStatus and UpdateAccessKey actions (#7750)
feat(iam): add SetUserStatus and UpdateAccessKey actions (#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
This commit is contained in:
@@ -29,3 +29,9 @@ const (
|
||||
AccessKeyIdLength = 21
|
||||
SecretAccessKeyLength = 42
|
||||
)
|
||||
|
||||
// Access key status values (AWS IAM compatible)
|
||||
const (
|
||||
AccessKeyStatusActive = "Active"
|
||||
AccessKeyStatusInactive = "Inactive"
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -81,12 +83,26 @@ func iamMapToIdentitiesAction(action string) string {
|
||||
return iamlib.MapToIdentitiesAction(action)
|
||||
}
|
||||
|
||||
// iamValidateStatus validates that status is either Active or Inactive.
|
||||
func iamValidateStatus(status string) error {
|
||||
switch status {
|
||||
case iamAccessKeyStatusActive, iamAccessKeyStatusInactive:
|
||||
return nil
|
||||
case "":
|
||||
return fmt.Errorf("Status parameter is required")
|
||||
default:
|
||||
return fmt.Errorf("Status must be '%s' or '%s'", iamAccessKeyStatusActive, iamAccessKeyStatusInactive)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +167,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 +208,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 +277,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 +501,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 err := iamValidateStatus(status); err != nil {
|
||||
return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err}
|
||||
}
|
||||
|
||||
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 err := iamValidateStatus(status); err != nil {
|
||||
return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err}
|
||||
}
|
||||
|
||||
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 for user %s cannot be found", accessKeyId, userName)}
|
||||
}
|
||||
|
||||
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 +795,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{}
|
||||
|
||||
@@ -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,466 @@ func TestEmbeddedIamGetActionsFromPolicy(t *testing.T) {
|
||||
assert.Contains(t, actions, "Write:mybucket")
|
||||
}
|
||||
|
||||
// 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: "AKIATEST12345", SecretKey: "secret", Status: "Active"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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) {
|
||||
// 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: "AKIATEST12345", SecretKey: "secret", Status: "Inactive"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
t.Run("MissingStatus", func(t *testing.T) {
|
||||
form := url.Values{}
|
||||
form.Set("Action", "UpdateAccessKey")
|
||||
form.Set("UserName", "TestUser")
|
||||
form.Set("AccessKeyId", "AKIATEST12345")
|
||||
|
||||
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: "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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user