Repository: Kinto/kinto

Issue:
500 when creating a new account with a POST and forgetting to put the ID
```
  File "kinto/plugins/accounts/views.py", line 112, in process_record
    if new[self.model.id_field] != self.request.selected_userid:
KeyError: 'id'
```


Discussion:

Resolving patch:
diff --git a/kinto/plugins/accounts/__init__.py b/kinto/plugins/accounts/__init__.py
--- a/kinto/plugins/accounts/__init__.py
+++ b/kinto/plugins/accounts/__init__.py
@@ -26,3 +26,24 @@ def includeme(config):
             error_msg = ("'basicauth' should not be mentioned before 'account' "
                          "in 'multiauth.policies' setting.")
             raise ConfigurationError(error_msg)
+
+    # We assume anyone in account_create_principals is to create
+    # accounts for other people.
+    # No one can create accounts for other people unless they are an
+    # "admin", defined as someone matching account_write_principals.
+    # Therefore any account that is in account_create_principals
+    # should be in account_write_principals too.
+    creators = set(settings.get('account_create_principals', '').split())
+    admins = set(settings.get('account_write_principals', '').split())
+    cant_create_anything = creators.difference(admins)
+    # system.Everyone isn't an account.
+    cant_create_anything.discard('system.Everyone')
+    if cant_create_anything:
+        message = ('Configuration has some principals in account_create_principals '
+                   'but not in account_write_principals. These principals will only be '
+                   'able to create their own accounts. This may not be what you want.\n'
+                   'If you want these users to be able to create accounts for other users, '
+                   'add them to account_write_principals.\n'
+                   'Affected users: {}'.format(list(cant_create_anything)))
+
+        raise ConfigurationError(message)
diff --git a/kinto/plugins/accounts/views.py b/kinto/plugins/accounts/views.py
--- a/kinto/plugins/accounts/views.py
+++ b/kinto/plugins/accounts/views.py
@@ -27,6 +27,12 @@ def _extract_posted_body_id(request):
         raise http_error(httpexceptions.HTTPUnauthorized(), error=error_msg)
 
 
+class AccountIdGenerator(NameGenerator):
+    """Allow @ signs in account IDs."""
+
+    regexp = r'^[a-zA-Z0-9][.@a-zA-Z0-9_-]*$'
+
+
 class AccountSchema(resource.ResourceSchema):
     password = colander.SchemaNode(colander.String())
 
@@ -55,7 +61,7 @@ def __init__(self, request, context):
     @reify
     def id_generator(self):
         # This generator is used for ID validation.
-        return NameGenerator()
+        return AccountIdGenerator()
 
     def get_parent_id(self, request):
         # The whole challenge here is that we want to isolate what
@@ -108,6 +114,14 @@ def process_record(self, new, old=None):
         if self.context.is_administrator or self.context.is_anonymous:
             return new
 
+        # Do not let accounts be created without usernames.
+        if self.model.id_field not in new:
+            error_details = {
+                'name': 'data.id',
+                'description': 'Accounts must have an ID.',
+            }
+            raise_invalid(self.request, **error_details)
+
         # Otherwise, we force the id to match the authenticated username.
         if new[self.model.id_field] != self.request.selected_userid:
             error_details = {


Test patch:
diff --git a/tests/plugins/test_accounts.py b/tests/plugins/test_accounts.py
--- a/tests/plugins/test_accounts.py
+++ b/tests/plugins/test_accounts.py
@@ -60,6 +60,10 @@ def test_password_field_is_mandatory(self):
     def test_id_field_is_mandatory(self):
         self.app.post_json('/accounts', {'data': {'password': 'pass'}}, status=400)
 
+    def test_id_can_be_email(self):
+        self.app.put_json('/accounts/alice@example.com', {'data': {'password': '123456'}},
+                          status=201)
+
     def test_account_can_have_metadata(self):
         resp = self.app.post_json('/accounts',
                                   {'data': {'id': 'me', 'password': 'bouh', 'age': 42}},
@@ -269,6 +273,26 @@ def test_user_created_by_admin_can_delete_her_record(self):
         self.app.delete('/accounts/alice', headers=get_user_headers('alice', 'bouh'))
 
 
+class CheckAdminCreateTest(AccountsWebTest):
+    def test_raise_if_create_but_no_write(self):
+        with self.assertRaises(ConfigurationError):
+            self.make_app({'account_create_principals': 'account:admin'})
+
+
+class CreateOtherUserTest(AccountsWebTest):
+    def setUp(self):
+        self.app.put_json('/accounts/bob', {'data': {'password': '123456'}}, status=201)
+        self.bob_headers = get_user_headers('bob', '123456')
+
+    def test_create_other_id_is_still_required(self):
+        self.app.post_json('/accounts', {'data': {'password': 'azerty'}}, status=400,
+                           headers=self.bob_headers)
+
+    def test_create_other_forbidden_without_write(self):
+        self.app.put_json('/accounts/alice', {'data': {'password': 'azerty'}}, status=400,
+                          headers=self.bob_headers)
+
+
 class WithBasicAuthTest(AccountsWebTest):
 
     @classmethod