Repository: deis/deis

Issue:
App namespace < 4 characters fails with Docker registry
reported by @nathansamson

Found another problem:

Apparantly docker registry namespace names should be 4 characters or more. Since we are using the username of the owner of the app the namespace name might not be long enough.
2 Solutions:

force deis usernames to have the same limitations as a docker namespace which is something along the lines of [a-Z0-9_]{4,}.
Convert an invalid docker namespae name (eg. dev) to one that is valid (eg __dev).
EDIT:
2014/02/05 13:36:32 Invalid namespace name (), only [a-z0-9_] are allowed, size between 4 and 30

After creating a user with a long enough username it still failed after publishing the image in the registry.

Logs are here https://gist.github.com/nathansamson/8821195



Discussion:
Note the last 2 lines (sentence + logs) are a different issue than the rest of the bug report. (openign a new issue for it)

Just got hit by this myself. I'd vote for forcing usernames to have the same rules as docker namespaces. It's a reasonable rule and means there's consistency.

If it is just the user name at issue, we can force this to be 4 chars. Let's pin down where this is in the go code so we can ensure we're matching any limits they have.

Let's follow the spec on their site for usernames on the registry: http://docs.docker.io/en/latest/api/registry_index_spec/#create-a-user-index

Docker doc moved here: http://docs.docker.io/en/latest/reference/api/registry_index_spec/#create-a-user-index

I'm working on this now at https://github.com/opdemand/deis/tree/add-custom-user-auth

I just hit this issue: https://github.com/lukaszb/django-guardian/issues/179

What's our use of django-guardian in our application? Can we pull that dependency out, or does our system rely on that package?

```
><> git grep guardian
api/admin.py:from guardian.admin import GuardedModelAdmin
api/migrations/0002_drop_djcelery.py:    # Deleting content_types raises an error if guardian isn't yet installed.
api/migrations/0002_drop_djcelery.py:        ('guardian', '0005_auto__chg_field_groupobjectpermission_object_pk__chg_field_userobjectp'),
api/migrations/0003_drop_socialaccount.py:        ('guardian', '0005_auto__chg_field_groupobjectpermission_object_pk__chg_field_userobjectp'),
api/models.py:from guardian.shortcuts import get_users_with_perms
api/views.py:from guardian.shortcuts import assign_perm
api/views.py:from guardian.shortcuts import get_objects_for_user
api/views.py:from guardian.shortcuts import get_users_with_perms
api/views.py:from guardian.shortcuts import remove_perm
deis/settings.py:    'guardian',
deis/settings.py:    "guardian.backends.ObjectPermissionBackend",
dev_requirements.txt:django-guardian==1.1.1
docs/docs_requirements.txt:django-guardian==1.1.1
requirements.txt:django-guardian==1.1.1
```

@mboersma mentioned on IRC that we use `django-allauth` as our Django apps for registration, authentication, etc. In that case, it should be as easy as setting `ACCOUNT_USERNAME_MIN_LENGTH = 4` in `deis/settings.py`...

So it turns out that we use two different registration strategies, which is causing some inconsistency between the web interface and the CLI. The website is using `django-allauth` to authenticate and register a user, while the CLI is using Django REST Framework's strategy to register users. Note the two different endpoints:

```
http://deis-controller.local:8000/accounts/signup/
http://deis-controller.local:8000/api/auth/register/
```

There is also some business logic that we introduced into Django REST Framework's view classes: https://github.com/opdemand/deis/blob/master/api/views.py#L138-L142

I propose that we switch to **one** centralized authentication system across the entire controller, rather than trying to integrate and shoehorn 4 different libraries for each use case. I say we gut out `django-allauth` and just use Django REST Framework for authentication, registration et al. We'd also need to gut out `django-guardian` due to the issue listed above so that we have complete control over the `User` model.

The reason why I would prefer that we use Django REST Framework's auth system is because they are tied in very close to Django's default auth systems which makes it naturally pluggable, their framework is stupendously documented, and the community is amazing for support.

Thinking about it, there's probably a lot that we can do to refactor our "god" `api` module...

What are your thoughts on refactoring the authentication system, @mboersma and @gabrtv?

Since we don't allow actual registration via the web (only login/logout), there is effectively only the one registration endpoint: the CLI / REST Framework one. So to solve the "turn off registration" issue we could be pragmatic and just build on the current...um...hybrid approach.

Early on, we thought we would leverage much more from django-allauth, and having done the "auth via GitHub" thing by hand once, we thought getting Facebook and friends OAuth would be a huge win, but it seems unlikely we need it now. I would be psyched to toss it and unify our registration/auth code, modulo the usual caveats about rewriting working code, etc.

We do need django-guardian around however to implement row-level permissions, used by `deis sharing` for access control to formations/apps.

FYI, `django-guardian` just patched this issue in https://github.com/lukaszb/django-guardian/commit/a6d3948da8d1f93f121a0f495a38473d99bcfd53, so we can upgrade to v1.2 to get this fix.


Resolving patch:
diff --git a/api/exceptions.py b/api/exceptions.py
--- a/api/exceptions.py
+++ b/api/exceptions.py
@@ -8,29 +8,38 @@
 from rest_framework import status
 
 
-class BuildNodeError(APIException):
+class AbstractDeisException(APIException):
     """
-    Indicates a problem in building or bootstrapping a node.
+    Abstract class in which all Deis Exceptions and Errors should extend.
 
-    This exception is subclassed from rest_framework's APIException so it
-    isn't reported as "500 SERVER ERROR."
+    This exception is subclassed from rest_framework's APIException so that
+    subclasses can change the status code to something different than
+    "500 SERVER ERROR."
     """
 
-    status_code = status.HTTP_401_UNAUTHORIZED
-
     def __init__(self, detail=None):
         self.detail = detail
 
+    class Meta:
+        abstract = True
 
-class BuildFormationError(APIException):
-    """
-    Indicates a problem in creating a formation.
 
-    This exception is subclassed from rest_framework's APIException so it
-    isn't reported as "500 SERVER ERROR."
+class BuildNodeError(AbstractDeisException):
     """
+    Indicates a problem in building or bootstrapping a node.
+    """
+    status_code = status.HTTP_401_UNAUTHORIZED
+
 
+class BuildFormationError(AbstractDeisException):
+    """
+    Indicates a problem in creating a formation.
+    """
     status_code = status.HTTP_400_BAD_REQUEST
 
-    def __init__(self, detail=None):
-        self.detail = detail
+
+class UserRegistrationException(AbstractDeisException):
+    """
+    Indicates that there was a problem registering the user.
+    """
+    status_code = status.HTTP_400_BAD_REQUEST
diff --git a/api/views.py b/api/views.py
--- a/api/views.py
+++ b/api/views.py
@@ -24,7 +24,7 @@
 from rest_framework.response import Response
 
 from api import docker, models, serializers
-from .exceptions import BuildFormationError
+from .exceptions import BuildFormationError, UserRegistrationException
 
 from deis import settings
 
@@ -149,6 +149,9 @@ def pre_save(self, obj):
         obj.is_active = True
         obj.email = User.objects.normalize_email(obj.email)
         obj.set_password(obj.password)
+        # FIXME: move this business logic to the model
+        if len(obj.username) < 4:
+            raise UserRegistrationException('username must be >= 4 characters in length')
         # Make this first signup an admin / superuser
         if not User.objects.filter(is_superuser=True).exists():
             obj.is_superuser = obj.is_staff = True


Test patch:
diff --git a/api/tests/test_auth.py b/api/tests/test_auth.py
--- a/api/tests/test_auth.py
+++ b/api/tests/test_auth.py
@@ -56,6 +56,10 @@ def test_auth(self):
         self.assertFalse(response.data['is_staff'])
         self.assertTrue(
             self.client.login(username=username, password=password))
+        # test with len(username) < 4
+        submit['username'] = 'new'
+        response = self.client.post(url, json.dumps(submit), content_type='application/json')
+        self.assertEqual(response.status_code, 400)
         # test for default objects
         url = '/api/providers'
         response = self.client.get(url)
diff --git a/api/tests/test_perm.py b/api/tests/test_perm.py
--- a/api/tests/test_perm.py
+++ b/api/tests/test_perm.py
@@ -70,7 +70,7 @@ def test_list(self):
 
     def test_create(self):
         submit = {
-            'username': 'one',
+            'username': 'first',
             'password': 'password',
             'email': 'autotest@deis.io',
         }
@@ -79,7 +79,7 @@ def test_create(self):
         self.assertEqual(response.status_code, 201)
         self.assertTrue(response.data['is_superuser'])
         submit = {
-            'username': 'two',
+            'username': 'second',
             'password': 'password',
             'email': 'autotest@deis.io',
         }
@@ -88,20 +88,20 @@ def test_create(self):
         self.assertEqual(response.status_code, 201)
         self.assertFalse(response.data['is_superuser'])
         self.assertTrue(
-            self.client.login(username='one', password='password'))
+            self.client.login(username='first', password='password'))
         # grant user 2 the superuser perm
         url = '/api/admin/perms'
-        body = {'username': 'two'}
+        body = {'username': 'second'}
         response = self.client.post(url, json.dumps(body), content_type='application/json')
         self.assertEqual(response.status_code, 201)
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(len(response.data['results']), 2)
-        self.assertIn('two', str(response.data['results']))
+        self.assertIn('second', str(response.data['results']))
 
     def test_delete(self):
         submit = {
-            'username': 'uno',
+            'username': 'first',
             'password': 'password',
             'email': 'autotest@deis.io',
         }
@@ -110,7 +110,7 @@ def test_delete(self):
         self.assertEqual(response.status_code, 201)
         self.assertTrue(response.data['is_superuser'])
         submit = {
-            'username': 'dos',
+            'username': 'second',
             'password': 'password',
             'email': 'autotest@deis.io',
         }
@@ -119,14 +119,14 @@ def test_delete(self):
         self.assertEqual(response.status_code, 201)
         self.assertFalse(response.data['is_superuser'])
         self.assertTrue(
-            self.client.login(username='uno', password='password'))
+            self.client.login(username='first', password='password'))
         # grant user 2 the superuser perm
         url = '/api/admin/perms'
-        body = {'username': 'dos'}
+        body = {'username': 'second'}
         response = self.client.post(url, json.dumps(body), content_type='application/json')
         self.assertEqual(response.status_code, 201)
         # revoke the superuser perm
-        response = self.client.delete(url + '/dos')
+        response = self.client.delete(url + '/second')
         self.assertEqual(response.status_code, 204)
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200)