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:
@@ -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