Repository: Kinto/kinto

Issue:
Add creation of buckets in permissions endpoint
Currently with these settings:

```ini
kinto.experimental_permissions_endpoint = true
kinto.bucket_create_principals = system.Everyone  # or whatever 
```

I should see the permission to create buckets in http://localhost:8888/v1/permissions

Something like:
```js
{
  "data": [
    {
      "uri": "/buckets",
      "resource_name": "bucket",
      "permissions": [
        "create",
      ],
    }
  ]
}
```

This is because creating bucket is a specific case: it does not involve parents (buckets are top level) and does not involve the permission backend (permission can only be set in `.ini` settings).
Currently we only iterate on existing buckets https://github.com/Kinto/kinto/blob/bf60689ae016a31b8c5bb1955397be46d7b4acca/kinto/views/permissions.py#L91

Would allow to fix https://github.com/Kinto/kinto-admin/issues/401


Discussion:
Hi there,

Really enjoying this project so far!

I haven't worked on a bigger project like this before, but would like to try and take care of this issue.

My first thought was adding a new `{'/bucket':'write'}` item to `perms_by_object_uri` if `{'bucket_create_principals':'system.Everyone'}`. However, I wonder whether that's a clean solution?

Thanks!
Thanks for your interest in the project!

The issue is just for the creation, it should just add the entry with permissions create that I put in my comment.

So what we want is to check that the current user has a least one principals in common with the ones mentioned in the `kinto.bucket_create_principals` setting.

If I understand the current code correctly, the function `allowed_from_settings()` already returns an entry when this is the case: (`{"":  ["bucket:create"]}`).

But then when we build the `perms_by_object_uri` we never look at it :(

https://github.com/Kinto/kinto/blob/110ace2ea0688c3abfdec8e8f471a540a502fba1/kinto/views/permissions.py#L87-L91

So it should be enough to do something like this (*untested*):

```py
bucket_creation = from_settings.get("")
if bucket_creation:
     perms_by_object_uri["/buckets"] = bucket_creation

allowed_resources = {'bucket', 'collection', 'group'} & set(from_settings.keys())
if allowed_resources:
   ....
```

You should a write a unit test in `test_views_permissions` and make sure you obtain the desired behaviour:

```
$ py.test tests/test_views_permissions.py
```
Thanks for the detailed response!

It looks like `allowed_resources` in the following snippet will always evaluate to false as `from_settings` is `{'': 'bucket:create'}`, so there will be no intersection.

https://github.com/Kinto/kinto/blob/110ace2ea0688c3abfdec8e8f471a540a502fba1/kinto/views/permissions.py#L87

Secondly, I'm getting a key error on `bucket:create` on this line:

https://github.com/Kinto/kinto/blob/110ace2ea0688c3abfdec8e8f471a540a502fba1/kinto/views/permissions.py#L123

I've been digging through the permissions a bit and don't see a `bucket:create` key in `perms_descending_tree`, which seems like it would fix the problem.

In any case, I'll keep going through this and hopefully I'm not too off base here :)

Thanks!

> It looks like allowed_resources in the following snippet will always evaluate to false as from_settings is {'': 'bucket:create'}, so there will be no intersection.

Yes that's why I was proposing to put it before looking at `allowed_resources` (bucket creation is not linked to any parent resource, as opposed to records creation, collections, groups... etc.)

> I've been digging through the permissions a bit and don't see a bucket:create key in perms_descending_tree, which seems like it would fix the problem.

Yes indeed, and if I look at `PERMISSIONS_INHERITANCE_TREE` (where the `perms_descending_tree` is built from) there seems to be everything we need:

https://github.com/Kinto/kinto/blob/110ace2ea0688c3abfdec8e8f471a540a502fba1/kinto/authorization.py#L26-L28

Enjoy!
May I ask, what is the reason for seeing a "create" permission vs a "bucket:create" permission in the response from http://localhost:8888/v1/permissions? Since it is a top-level object, it needs an unbound permission?

For example, this is working:

```
'bucket:create': {
	'': ['bucket:create', 'create']
}
```

However, it responds with:

```
{
	"uri": "/buckets",
	"resource_name": "bucket",
	"permissions": [
		"bucket:create",
	],
}
```

Finally, I'm assuming it is desired behavior that with only `kinto.experimental_permissions_endpoint = true`, that an authenticated user should see the create permissions on the /buckets uri?

I'm asking because it's failing several tests in `test_views_permissions.py` so I just wanted to make sure before rewriting them and adding my own :)
I think you already went far enough for me to get lost :) 

First, let's agree on how it should look like.

Currently, this is how it comes out on `GET /permissions` for a single bucket:

```js        
        {
            "bucket_id": "bid",
            "id": "bid",
            "permissions": [
                "write",
                "read",
                "collection:create",
                "group:create"
            ],
            "resource_name": "bucket",
            "uri": "/buckets/bid"
        }
```
So we can see that we have `"uri": "/buckets/bid"` and the perm `collection:create`. 
We don't have `"uri": "/buckets/bid/collections"` and the perm `create`.

So I think it would be consistent to have `"uri": "/"` and the perm `bucket:create`, instead of `"uri": "/buckets"` and the perm `create` or `"uri": "/buckets"` and the perm `bucket:create`.

What do you think?

Once you define well what we expect, you may have to touch several unit tests to match the new behavior yes :)

Also, don't hesitate to open a pull-request with your work-in-progress, so that we can iterate on real code ;)

Thanks for being so thorough!
Awesome, thanks for the direction and the suggestion of a work-in-progress pull-request.

My thought on the response mirrors yours; I think it makes sense for a `bucket:create` perm and `"uri": "/buckets"` (partially because there's already a home view mapped to /).

I'll make some finalizations and submit soon.

Resolving patch:
diff --git a/kinto/views/permissions.py b/kinto/views/permissions.py
--- a/kinto/views/permissions.py
+++ b/kinto/views/permissions.py
@@ -82,6 +82,14 @@ def get_records(self, filters=None, sorting=None, pagination_rules=None,
         # Check settings for every allowed resources.
         from_settings = allowed_from_settings(self.request.registry.settings, principals)
 
+        # Add additional resources and permissions defined in settings/plugins
+        for root_perm in from_settings.get('', []):
+            resource_name, _ = root_perm.split(':')
+            uri = core_utils.strip_uri_prefix(
+                    self.request.route_path('{0}-collection'.format(resource_name)))
+            perms_by_object_uri[uri] = {root_perm}
+            perms_descending_tree[resource_name].update({root_perm: {resource_name: {root_perm}}})
+
         # Expand permissions obtained from backend with the object URIs that
         # correspond to permissions allowed from settings.
         allowed_resources = {'bucket', 'collection', 'group'} & set(from_settings.keys())
@@ -114,8 +122,10 @@ def get_records(self, filters=None, sorting=None, pagination_rules=None,
                 # Skip permissions entries that are not linked to an object URI
                 continue
 
-            # For consistency with event payloads, prefix id with resource name
-            matchdict[resource_name + '_id'] = matchdict.get('id')
+            # For consistency with event payloads, if resource has an id,
+            # prefix it with its resource name
+            if "id" in matchdict:
+                matchdict[resource_name + '_id'] = matchdict['id']
 
             # Expand implicit permissions using descending tree.
             permissions = set(perms)


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
@@ -233,6 +233,7 @@ def test_cannot_obtain_unknown_account(self):
 
 
 class PermissionsEndpointTest(AccountsWebTest):
+
     @classmethod
     def get_app_settings(cls, extras=None):
         settings = super().get_app_settings(extras)
@@ -252,7 +253,53 @@ def test_permissions_endpoint_is_compatible_with_accounts_plugin(self):
         assert uris == ['/buckets/a/collections/b/records/c',
                         '/buckets/a/collections/b',
                         '/buckets/a',
-                        '/accounts/alice']
+                        '/buckets',
+                        '/accounts/alice',
+                        '/accounts']
+
+    def test_account_create_read_write_permissions_(self):
+        resp = self.app.get('/permissions', headers=self.headers)
+        buckets = resp.json['data']
+        accounts = buckets[4]
+        account = buckets[5]
+        accounts['permissions'].sort()
+        self.assertEqual(accounts, {
+            'account_id': 'alice',
+            'id': 'alice',
+            'permissions': ['read', 'write'],
+            'resource_name': 'account',
+            'uri': '/accounts/alice'})
+        self.assertEqual(account, {
+            'permissions': ['account:create'],
+            'resource_name': 'account',
+            'uri': '/accounts'})
+
+
+class PermissionsEndpointTestUnauthenticatedCreatePermission(AccountsWebTest):
+
+    @classmethod
+    def get_app_settings(cls, extras=None):
+        settings = super().get_app_settings(extras)
+        settings['experimental_permissions_endpoint'] = 'True'
+        settings['bucket_create_principals'] = 'system.Everyone'
+        return settings
+
+    def setUp(self):
+        self.everyone_headers = get_user_headers('')
+
+    def test_permissions_endpoint_is_compatible_with_accounts_plugin(self):
+        resp = self.app.get('/permissions', headers=self.everyone_headers)
+        buckets = resp.json['data']
+        bucket_create = buckets[0]
+        account_create = buckets[1]
+        self.assertEqual(bucket_create, {
+            'permissions': ['bucket:create'],
+            'resource_name': 'bucket',
+            'uri': '/buckets'})
+        self.assertEqual(account_create, {
+            'permissions': ['account:create'],
+            'resource_name': 'account',
+            'uri': '/accounts'})
 
 
 class AdminTest(AccountsWebTest):
diff --git a/tests/test_views_permissions.py b/tests/test_views_permissions.py
--- a/tests/test_views_permissions.py
+++ b/tests/test_views_permissions.py
@@ -19,6 +19,59 @@ def get_app_settings(cls, extras=None):
         return settings
 
 
+class PermissionsUnauthenticatedViewTest(BaseWebTest, unittest.TestCase):
+
+    @classmethod
+    def get_app_settings(cls, extras=None):
+        settings = super().get_app_settings(extras)
+        settings['experimental_permissions_endpoint'] = 'True'
+        settings['bucket_create_principals'] = 'system.Everyone'
+        return settings
+
+    def setUp(self):
+        super().setUp()
+        self.everyone_headers = get_user_headers('')
+        self.app.put_json('/buckets/beers', MINIMALIST_BUCKET,
+                          headers=self.everyone_headers)
+        self.app.put_json('/buckets/beers/collections/barley',
+                          MINIMALIST_COLLECTION,
+                          headers=self.everyone_headers)
+        self.app.put_json('/buckets/beers/groups/amateurs',
+                          MINIMALIST_GROUP,
+                          headers=self.everyone_headers)
+        self.app.put_json('/buckets/beers/collections/barley/records/{}'.format(RECORD_ID),
+                          MINIMALIST_RECORD,
+                          headers=self.everyone_headers)
+
+        # Other user.
+        self.app.put_json('/buckets/water', MINIMALIST_BUCKET,
+                          headers=get_user_headers('alice'))
+
+    def test_permissions_list_entries_for_everyone(self):
+        resp = self.app.get('/permissions', headers=self.everyone_headers)
+        buckets = resp.json['data']
+        self.assertEqual(len(buckets), 5)
+
+    def test_bucket_create_permission_exists(self):
+        resp = self.app.get('/permissions', headers=self.everyone_headers)
+        buckets = resp.json['data']
+        toplevel_bucket = buckets[4]
+        self.assertEqual(toplevel_bucket, {
+            'permissions': ['bucket:create'],
+            'resource_name': 'bucket',
+            'uri': '/buckets'})
+
+    def test_object_details_are_provided(self):
+        resp = self.app.get('/permissions', headers=self.everyone_headers)
+        entries = resp.json['data']
+        for entry in entries:
+            if entry['resource_name'] == 'record':
+                record_permission = entry
+        self.assertEqual(record_permission['record_id'], RECORD_ID)
+        self.assertEqual(record_permission['collection_id'], 'barley')
+        self.assertEqual(record_permission['bucket_id'], 'beers')
+
+
 class EntriesTest(PermissionsViewTest):
 
     def setUp(self):
@@ -42,7 +95,7 @@ def setUp(self):
     def test_permissions_list_entries_for_current_principal(self):
         resp = self.app.get('/permissions', headers=self.headers)
         permissions = resp.json['data']
-        self.assertEqual(len(permissions), 4)
+        self.assertEqual(len(permissions), 5)
 
     def test_permissions_can_be_listed_anonymously(self):
         self.app.patch_json('/buckets/beers/collections/barley',
@@ -77,7 +130,7 @@ def test_permissions_list_can_be_filtered(self):
         resp = self.app.get('/permissions?in_resource_name=bucket,collection',
                             headers=self.headers)
         permissions = resp.json['data']
-        self.assertEqual(len(permissions), 2)
+        self.assertEqual(len(permissions), 3)
 
     def test_filtering_with_unknown_field_is_not_supported(self):
         self.app.get('/permissions?movie=bourne',
@@ -92,7 +145,7 @@ def test_permissions_fields_can_be_selected(self):
     def test_permissions_list_can_be_paginated(self):
         resp = self.app.get('/permissions?_limit=2',
                             headers=self.headers)
-        self.assertEqual(resp.headers['Total-Records'], '4')
+        self.assertEqual(resp.headers['Total-Records'], '5')
         self.assertIn('Next-Page', resp.headers)
         self.assertEqual(len(resp.json['data']), 2)
 
@@ -168,6 +221,8 @@ def test_permissions_granted_via_groups_are_listed(self):
     def test_permissions_inherited_are_not_listed(self):
         resp = self.app.get('/permissions', headers=self.admin_headers)
         collections = [e for e in resp.json['data']
+                       # top level '/buckets' does not have an id
+                       if e['uri'] != '/buckets'
                        if e['bucket_id'] == 'beers' and e['resource_name'] == 'collection']
         self.assertEqual(len(collections), 0)
 
@@ -261,4 +316,4 @@ def setUp(self):
 
     def test_deleted_objects_are_not_listed(self):
         resp = self.app.get('/permissions', headers=self.headers)
-        self.assertEqual(len(resp.json['data']), 0)
+        self.assertEqual(len(resp.json['data']), 1)