Repository: deis/deis

Issue:
[controller] When deleting a user, its apps are deleted from the database but keep running
When deleting a user, all the apps it owns are deleted from the controller's database. However, the apps (fleet services and docker containers) keep running. Afterwards, when re-creating an app with the same name as one of the apps that was deleted, `deis logs` will show the logs of the deleted app of the same name.

To reproduce:
- deis register http://controller.com
- deis create someapp
- deis auth:cancel
- deis login (with another admin user)

Notice that `deis apps` doesn't list the app that was deleted (`someapp`) but `fleetctl list-units | grep someapp` show that the app is still running and `http://someapp.controller.com` is still up.

deis v1.12.2



Discussion:
https://github.com/deis/workflow/pull/391 fixes the same but in v2, takes the approach of denying the `auth:cancel` if the user has applications assigned.

It should be straightforward to backport

:+1: 


Resolving patch:
diff --git a/controller/api/views.py b/controller/api/views.py
--- a/controller/api/views.py
+++ b/controller/api/views.py
@@ -59,6 +59,11 @@ def destroy(self, request, **kwargs):
             else:
                 raise PermissionDenied()
 
+        # A user can not be removed without apps changing ownership first
+        if len(models.App.objects.filter(owner=target_obj)) > 0:
+            msg = '{} still has applications assigned. Delete or transfer ownership'.format(str(target_obj))  # noqa
+            return Response({'detail': msg}, status=status.HTTP_409_CONFLICT)
+
         target_obj.delete()
         return Response(status=status.HTTP_204_NO_CONTENT)
 


Test patch:
diff --git a/client/controller/models/auth/auth_test.go b/client/controller/models/auth/auth_test.go
--- a/client/controller/models/auth/auth_test.go
+++ b/client/controller/models/auth/auth_test.go
@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/url"
+	"reflect"
 	"testing"
 
 	"github.com/deis/deis/client/controller/client"
@@ -18,6 +19,7 @@ const passwdExpected string = `{"username":"test","password":"old","new_password
 const regenAllExpected string = `{"all":true}`
 const regenUserExpected string = `{"username":"test"}`
 const cancelUserExpected string = `{"username":"foo"}`
+const cancelAdminExpected string = `{"username":"admin"}`
 
 type fakeHTTPServer struct {
 	regenBodyEmpty    bool
@@ -131,7 +133,12 @@ func (f *fakeHTTPServer) ServeHTTP(res http.ResponseWriter, req *http.Request) {
 			res.Write(nil)
 		}
 
-		if string(body) == cancelUserExpected && !f.cancelUsername {
+		if string(body) == cancelAdminExpected && !f.cancelUsername {
+			f.cancelUsername = true
+			res.WriteHeader(http.StatusConflict)
+			res.Write(nil)
+			return
+		} else if string(body) == cancelUserExpected && !f.cancelUsername {
 			f.cancelUsername = true
 			res.WriteHeader(http.StatusNoContent)
 			res.Write(nil)
@@ -251,6 +258,31 @@ func TestDelete(t *testing.T) {
 	}
 }
 
+func TestDeleteUserApp(t *testing.T) {
+	t.Parallel()
+
+	handler := fakeHTTPServer{cancelUsername: false, cancelEmpty: false}
+	server := httptest.NewServer(&handler)
+	defer server.Close()
+
+	u, err := url.Parse(server.URL)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	httpClient := client.CreateHTTPClient(false)
+	client := client.Client{HTTPClient: httpClient, ControllerURL: *u}
+
+	err = Delete(&client, "admin")
+	// should be a 409 Conflict
+
+	expected := fmt.Errorf("\n%s %s\n\n", "409", "Conflict")
+	if reflect.DeepEqual(err, expected) == false {
+		t.Errorf("got '%s' but expected '%s'", err, expected)
+	}
+}
+
 func TestRegenerate(t *testing.T) {
 	t.Parallel()
 
diff --git a/controller/api/tests/test_auth.py b/controller/api/tests/test_auth.py
--- a/controller/api/tests/test_auth.py
+++ b/controller/api/tests/test_auth.py
@@ -221,6 +221,19 @@ def test_cancel(self):
                                       content_type='application/json',
                                       HTTP_AUTHORIZATION='token {}'.format(self.admin_token))
         self.assertEqual(response.status_code, 204)
+        # user can not be deleted if it has an app attached to it
+        response = self.client.post(
+            '/v1/apps',
+            HTTP_AUTHORIZATION='token {}'.format(self.admin_token)
+        )
+        self.assertEqual(response.status_code, 201)
+        app_id = response.data['id']  # noqa
+        self.assertIn('id', response.data)
+
+        response = self.client.delete(url, json.dumps({'username': str(self.admin)}),
+                                      content_type='application/json',
+                                      HTTP_AUTHORIZATION='token {}'.format(self.admin_token))
+        self.assertEqual(response.status_code, 409)
 
     def test_passwd(self):
         """Test that a registered user can change the password."""