html_url,issue_url,id,node_id,user,created_at,updated_at,author_association,body,reactions,issue,performed_via_github_app
https://github.com/simonw/datasette/issues/2102#issuecomment-1691842259,https://api.github.com/repos/simonw/datasette/issues/2102,1691842259,IC_kwDOBm6k_c5k13bT,9599,2023-08-24T14:55:54Z,2023-08-24T14:55:54Z,OWNER,"So what's needed to finish this is:
- Tests that demonstrate that nothing is revealed that shouldn't be by tokens restricted in this way
- Similar tests for other permissions like `create-table` that check that they work (and don't also need `view-instance` etc).
- Documentation","{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",1805076818,
https://github.com/simonw/datasette/issues/2102#issuecomment-1691824713,https://api.github.com/repos/simonw/datasette/issues/2102,1691824713,IC_kwDOBm6k_c5k1zJJ,9599,2023-08-24T14:45:49Z,2023-08-24T14:45:49Z,OWNER,"I tested this out against a Datasette Cloud instance. I created a restricted token and tested it like this:
```bash
curl -H ""Authorization: Bearer $TOKEN"" \
  'https://$INSTANCE/-/actor.json' | jq
```
```json
{
  ""actor"": {
    ""id"": ""245"",
    ""token"": ""dsatok"",
    ""token_id"": 2,
    ""_r"": {
      ""r"": {
        ""data"": {
          ""all_stocks"": [
            ""vt""
          ]
        }
      }
    }
  }
}
```
It can access the `all_stocks` demo table:
```bash
curl -H ""Authorization: Bearer $TOKEN"" \
  'https://$INSTANCE/data/all_stocks.json?_size=1' | jq
```
```json
{
  ""ok"": true,
  ""next"": ""1"",
  ""rows"": [
    {
      ""rowid"": 1,
      ""Date"": ""2013-01-02"",
      ""Open"": 79.12,
      ""High"": 79.29,
      ""Low"": 77.38,
      ""Close"": 78.43,
      ""Volume"": 140124866,
      ""Name"": ""AAPL""
    }
  ],
  ""truncated"": false
}
```
Accessing the database returns just information about that table, even though other tables exist:
```bash
curl -H ""Authorization: Bearer $TOKEN"" \
  'https://$INSTANCE/data.json?_size=1'
```
```json
{
  ""database"": ""data"",
  ""private"": true,
  ""path"": ""/data"",
  ""size"": 3796992,
  ""tables"": [
    {
      ""name"": ""all_stocks"",
      ""columns"": [
        ""Date"",
        ""Open"",
        ""High"",
        ""Low"",
        ""Close"",
        ""Volume"",
        ""Name""
      ],
      ""primary_keys"": [],
      ""count"": 8813,
      ""hidden"": false,
      ""fts_table"": null,
      ""foreign_keys"": {
        ""incoming"": [],
        ""outgoing"": []
      },
      ""private"": true
    }
  ],
  ""hidden_count"": 0,
  ""views"": [],
  ""queries"": [],
  ""allow_execute_sql"": false,
  ""table_columns"": {}
}
```
And hitting the top-level `/.json` thing does the same - it reveals that table but not any of the other tables or databases:
```bash
curl -H ""Authorization: Bearer $TOKEN"" \
  'https://$INSTANCE/.json?_size=1'
```
```json
{
  ""data"": {
    ""name"": ""data"",
    ""hash"": null,
    ""color"": ""8d777f"",
    ""path"": ""/data"",
    ""tables_and_views_truncated"": [
      {
        ""name"": ""all_stocks"",
        ""columns"": [
          ""Date"",
          ""Open"",
          ""High"",
          ""Low"",
          ""Close"",
          ""Volume"",
          ""Name""
        ],
        ""primary_keys"": [],
        ""count"": null,
        ""hidden"": false,
        ""fts_table"": null,
        ""num_relationships_for_sorting"": 0,
        ""private"": false
      }
    ],
    ""tables_and_views_more"": false,
    ""tables_count"": 1,
    ""table_rows_sum"": 0,
    ""show_table_row_counts"": false,
    ""hidden_table_rows_sum"": 0,
    ""hidden_tables_count"": 0,
    ""views_count"": 0,
    ""private"": false
  }
}
```","{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",1805076818,
https://github.com/simonw/datasette/issues/2102#issuecomment-1691758168,https://api.github.com/repos/simonw/datasette/issues/2102,1691758168,IC_kwDOBm6k_c5k1i5Y,9599,2023-08-24T14:09:45Z,2023-08-24T14:09:45Z,OWNER,I'm going to implement this in a branch to make it easier to test out.,"{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",1805076818,
https://github.com/simonw/datasette/issues/2102#issuecomment-1691045051,https://api.github.com/repos/simonw/datasette/issues/2102,1691045051,IC_kwDOBm6k_c5ky0y7,9599,2023-08-24T05:51:59Z,2023-08-24T05:51:59Z,OWNER,"With that fix in place, this works:
```bash
datasette fixtures.db --get '/fixtures/facetable.json' --actor '{
    ""_r"": {
        ""r"": {
            ""fixtures"": {
                ""facetable"": [
                    ""vt""
                ]
            }
        }
    },
    ""a"": ""user""
}'
```
But this fails, because it's for a table not explicitly listed:
```bash
datasette fixtures.db --get '/fixtures/searchable.json' --actor '{
    ""_r"": {
        ""r"": {
            ""fixtures"": {
                ""facetable"": [
                    ""vt""
                ]
            }
        }
    },
    ""a"": ""user""
}'
```","{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",1805076818,
https://github.com/simonw/datasette/issues/2102#issuecomment-1691044283,https://api.github.com/repos/simonw/datasette/issues/2102,1691044283,IC_kwDOBm6k_c5ky0m7,9599,2023-08-24T05:51:02Z,2023-08-24T05:51:02Z,OWNER,"Also need to confirm that permissions like `insert-row`, `delete-row`, `create-table` etc don't also need special cases to ensure they get through the `view-instance` etc checks, if those exist for those actions.","{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",1805076818,
https://github.com/simonw/datasette/issues/2102#issuecomment-1691043475,https://api.github.com/repos/simonw/datasette/issues/2102,1691043475,IC_kwDOBm6k_c5ky0aT,9599,2023-08-24T05:50:04Z,2023-08-24T05:50:04Z,OWNER,"On first test this seems to work!

```diff
diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py
index 63a66c3c..9303dac8 100644
--- a/datasette/default_permissions.py
+++ b/datasette/default_permissions.py
@@ -187,6 +187,30 @@ def permission_allowed_actor_restrictions(datasette, actor, action, resource):
         return None
     _r = actor.get(""_r"")
 
+    # Special case for view-instance: it's allowed if there are any view-database
+    # or view-table permissions defined
+    if action == ""view-instance"":
+        database_rules = _r.get(""d"") or {}
+        for rules in database_rules.values():
+            if ""vd"" in rules or ""view-database"" in rules:
+                return None
+        # Now check resources
+        resource_rules = _r.get(""r"") or {}
+        for _database, resources in resource_rules.items():
+            for rules in resources.values():
+                if ""vt"" in rules or ""view-table"" in rules:
+                    return None
+
+    # Special case for view-database: it's allowed if there are any view-table permissions
+    # defined within that database
+    if action == ""view-database"":
+        database_name = resource
+        resource_rules = _r.get(""r"") or {}
+        resources_in_database = resource_rules.get(database_name) or {}
+        for rules in resources_in_database.values():
+            if ""vt"" in rules or ""view-table"" in rules:
+                return None
+
     # Does this action have an abbreviation?
     to_check = {action}
     permission = datasette.permissions.get(action)
```
Needs a LOT of testing to make sure what it's doing is sensible though.","{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",1805076818,
https://github.com/simonw/datasette/issues/2102#issuecomment-1691037971,https://api.github.com/repos/simonw/datasette/issues/2102,1691037971,IC_kwDOBm6k_c5kyzET,9599,2023-08-24T05:42:47Z,2023-08-24T05:42:47Z,OWNER,"I applied a fun trick to help test this out:
```diff
diff --git a/datasette/cli.py b/datasette/cli.py
index 58f89c1c..830f47ef 100644
--- a/datasette/cli.py
+++ b/datasette/cli.py
@@ -445,6 +445,10 @@ def uninstall(packages, yes):
     ""--token"",
     help=""API token to send with --get requests"",
 )
+@click.option(
+    ""--actor"",
+    help=""Actor to use for --get requests"",
+)
 @click.option(""--version-note"", help=""Additional note to show on /-/versions"")
 @click.option(""--help-settings"", is_flag=True, help=""Show available settings"")
 @click.option(""--pdb"", is_flag=True, help=""Launch debugger on any errors"")
@@ -499,6 +503,7 @@ def serve(
     root,
     get,
     token,
+    actor,
     version_note,
     help_settings,
     pdb,
@@ -611,7 +616,10 @@ def serve(
         headers = {}
         if token:
             headers[""Authorization""] = ""Bearer {}"".format(token)
-        response = client.get(get, headers=headers)
+        cookies = {}
+        if actor:
+            cookies[""ds_actor""] = client.actor_cookie(json.loads(actor))
+        response = client.get(get, headers=headers, cookies=cookies)
         click.echo(response.text)
         exit_code = 0 if response.status == 200 else 1
         sys.exit(exit_code)
```
This adds a `--actor` option to `datasette ... --get /path` which makes it easy to test an API endpoint using a fake actor with a set of `_r` restrictions.

With that in place I can try this, with a token that has view-instance and view-database and view-table:
```bash
datasette fixtures.db --get '/fixtures/facetable.json' --actor '{
    ""_r"": {
        ""a"": [
            ""vi""
        ],
        ""d"": {
            ""fixtures"": [
                ""vd""
            ]
        },
        ""r"": {
            ""fixtures"": {
                ""facetable"": [
                    ""vt""
                ]
            }
        }
    },
    ""a"": ""user""
}'
```
Or this, with a token that just has view-table but is missing the view-database and view-instance:
```bash
datasette fixtures.db --get '/fixtures/facetable.json' --actor '{
    ""_r"": {
        ""r"": {
            ""fixtures"": {
                ""facetable"": [
                    ""vt""
                ]
            }
        }
    },
    ""a"": ""user""
}'
```","{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",1805076818,
https://github.com/simonw/datasette/issues/2102#issuecomment-1691036559,https://api.github.com/repos/simonw/datasette/issues/2102,1691036559,IC_kwDOBm6k_c5kyyuP,9599,2023-08-24T05:40:53Z,2023-08-24T05:40:53Z,OWNER,"There might be an easier way to solve this. Here's some permission checks that run when hitting `/fixtures/facetable.json`:

```
permission_allowed: action=view-table, resource=('fixtures', 'facetable'), actor={'_r': {'a': ['vi'], 'd': {'fixtures': ['vd']}, 'r': {'fixtures': {'facetable': ['vt']}}}, 'a': 'user'}

  File ""/datasette/views/table.py"", line 727, in table_view_traced
    view_data = await table_view_data(

  File ""/datasette/views/table.py"", line 875, in table_view_data
    visible, private = await datasette.check_visibility(

  File ""/datasette/app.py"", line 890, in check_visibility
    await self.ensure_permissions(actor, permissions)

permission_allowed: action=view-database, resource=fixtures, actor={'_r': {'a': ['vi'], 'd': {'fixtures': ['vd']}, 'r': {'fixtures': {'facetable': ['vt']}}}, 'a': 'user'}

  File ""/datasette/views/table.py"", line 727, in table_view_traced
    view_data = await table_view_data(

  File ""/datasette/views/table.py"", line 875, in table_view_data
    visible, private = await datasette.check_visibility(

  File ""/datasette/app.py"", line 890, in check_visibility
    await self.ensure_permissions(actor, permissions)

permission_allowed: action=view-instance, resource=<None>, actor={'_r': {'a': ['vi'], 'd': {'fixtures': ['vd']}, 'r': {'fixtures': {'facetable': ['vt']}}}, 'a': 'user'}

  File ""/datasette/views/table.py"", line 727, in table_view_traced
    view_data = await table_view_data(

  File ""/datasette/views/table.py"", line 875, in table_view_data
    visible, private = await datasette.check_visibility(

  File ""/datasette/app.py"", line 890, in check_visibility
    await self.ensure_permissions(actor, permissions)
```
That's with a token that has the view instance, view database and view table permissions required.

But... what if the restrictions logic said that if you have view-table you automatically also get view-database and view-instance?

Would that actually let people do anything they shouldn't be able to do? I don't think it would even let them see a list of tables that they weren't allowed to visit, so it might be OK.

I'll try that and see how it works.","{""total_count"": 0, ""+1"": 0, ""-1"": 0, ""laugh"": 0, ""hooray"": 0, ""confused"": 0, ""heart"": 0, ""rocket"": 0, ""eyes"": 0}",1805076818,