-
-
Notifications
You must be signed in to change notification settings - Fork 690
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
API tokens with view-table but not view-database/view-instance cannot access the table #2102
Comments
I think I made this decision because I was thinking about default deny: obviously if a user has been denied access to a database. It doesn't make sense that they could access tables within it. But now that I am spending more time with authentication tokens, which default to denying everything, except for the things that you have explicitly listed, this policy, no longer makes as much sense. |
Relevant code: Lines 822 to 855 in 0f7192b
|
I tried some code spelunking and came across d6e03b0 which says:
Refs: Which suggests that my initial design decision wasn't what appears to be implemented today. Needs more investigation. |
This might only be an issue with the code that checks datasette/datasette/default_permissions.py Lines 185 to 222 in 0f7192b
Added in bcc781f - refs: |
Here's that crucial comment:
So that's why I implemented it like this. The goal here is to be able to issue a token which can't do anything more than the actor it is associated with, but CAN be configured to do less. So I think the solution here is for the I'm not sure that's going to work though - would that mean that granting Maybe that's OK: if you have Also, do |
Confirmed that this is an issue with regular Datasette signed tokens as well. I created one on https://latest.datasette.io/-/create-token with these details: {
"_r": {
"r": {
"fixtures": {
"sortable": [
"vt"
]
}
}
},
"a": "root",
"d": 3600,
"t": 1689614483
} Run like this:
Returned an HTML Forbidden page: <!DOCTYPE html>
<html>
<head>
<title>Forbidden</title>
<link rel="stylesheet" href="/-/static/app.css?d59929">
... Same token againts {
"actor": {
"id": "root",
"token": "dstok",
"_r": {
"r": {
"fixtures": {
"sortable": [
"vt"
]
}
}
},
"token_expires": 1689618083
}
} Reminder - |
I think I've figured out the problem here. The question being asked is "can this actor access this resource, which is within this database within this instance". The answer to this question needs to consider the full set of questions at once - yes they can access within this instance IF they have access to the specified table and that's the table being asked about. But the questions are currently being asked independently, which means the plugin hook acting on So I think I may need to redesign the plugin hook to always see the full hierarchy of checks, not just a single check at a time. |
This is the hook in question: datasette/datasette/hookspecs.py Lines 108 to 110 in bdf59eb
|
Built this new test: @pytest.mark.asyncio
async def test_view_table_token_can_access_table(perms_ds):
actor = {
"id": "restricted-token",
"token": "dstok",
# Restricted to just view-table on perms_ds_two/t1
"_r": {"r": {"perms_ds_two": {"t1": ["vt"]}}},
}
cookies = {"ds_actor": perms_ds.client.actor_cookie(actor)}
response = await perms_ds.client.get("/perms_ds_two/t1.json", cookies=cookies)
assert response.status_code == 200 The test fails. Running it with
|
Idea: |
There might be an easier way to solve this. Here's some permission checks that run when hitting
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. |
I applied a fun trick to help test this out: 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 With that in place I can try this, with a token that has view-instance and view-database and view-table: 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: datasette fixtures.db --get '/fixtures/facetable.json' --actor '{
"_r": {
"r": {
"fixtures": {
"facetable": [
"vt"
]
}
}
},
"a": "user"
}' |
On first test this seems to work! 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. |
Also need to confirm that permissions like |
With that fix in place, this works: 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: datasette fixtures.db --get '/fixtures/searchable.json' --actor '{
"_r": {
"r": {
"fixtures": {
"facetable": [
"vt"
]
}
}
},
"a": "user"
}' |
I'm going to implement this in a branch to make it easier to test out. |
Also includes a prototype implementation of --actor option from #2153 which I'm using for testing this.
I tested this out against a Datasette Cloud instance. I created a restricted token and tested it like this: curl -H "Authorization: Bearer $TOKEN" \
'https://$INSTANCE/-/actor.json' | jq {
"actor": {
"id": "245",
"token": "dsatok",
"token_id": 2,
"_r": {
"r": {
"data": {
"all_stocks": [
"vt"
]
}
}
}
}
} It can access the curl -H "Authorization: Bearer $TOKEN" \
'https://$INSTANCE/data/all_stocks.json?_size=1' | jq {
"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: curl -H "Authorization: Bearer $TOKEN" \
'https://$INSTANCE/data.json?_size=1' {
"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 curl -H "Authorization: Bearer $TOKEN" \
'https://$INSTANCE/.json?_size=1' {
"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
}
} |
So what's needed to finish this is:
|
Here's an existing relevant test: datasette/tests/test_permissions.py Lines 616 to 666 in 2e28258
It's not quite right for this new set of tests though, since they need to be exercising actual endpoints ( |
I want to test "for this set of restrictions, does a GET/POST to this path return 200 or 403"? |
Originally posted by @simonw in simonw/datasette-auth-tokens#7 (comment)
The text was updated successfully, but these errors were encountered: