/
default_permissions.py
337 lines (314 loc) · 11.9 KB
/
default_permissions.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
from datasette import hookimpl, Permission
from datasette.utils import actor_matches_allow
import itsdangerous
import time
@hookimpl
def register_permissions():
return (
# name, abbr, description, takes_database, takes_resource, default
Permission(
"view-instance", "vi", "View Datasette instance", False, False, True
),
Permission("view-database", "vd", "View database", True, False, True),
Permission(
"view-database-download", "vdd", "Download database file", True, False, True
),
Permission("view-table", "vt", "View table", True, True, True),
Permission("view-query", "vq", "View named query results", True, True, True),
Permission(
"execute-sql", "es", "Execute read-only SQL queries", True, False, True
),
Permission(
"permissions-debug",
"pd",
"Access permission debug tool",
False,
False,
False,
),
Permission("debug-menu", "dm", "View debug menu items", False, False, False),
# Write API permissions
Permission("insert-row", "ir", "Insert rows", True, True, False),
Permission("delete-row", "dr", "Delete rows", True, True, False),
Permission("update-row", "ur", "Update rows", True, True, False),
Permission("create-table", "ct", "Create tables", True, False, False),
Permission("drop-table", "dt", "Drop tables", True, True, False),
)
@hookimpl(tryfirst=True, specname="permission_allowed")
def permission_allowed_default(datasette, actor, action, resource):
async def inner():
# id=root gets some special permissions:
if action in (
"permissions-debug",
"debug-menu",
"insert-row",
"create-table",
"drop-table",
"delete-row",
"update-row",
):
if actor and actor.get("id") == "root":
return True
# Resolve metadata view permissions
if action in (
"view-instance",
"view-database",
"view-table",
"view-query",
"execute-sql",
):
result = await _resolve_metadata_view_permissions(
datasette, actor, action, resource
)
if result is not None:
return result
# Check custom permissions: blocks
result = await _resolve_metadata_permissions_blocks(
datasette, actor, action, resource
)
if result is not None:
return result
# --setting default_allow_sql
if action == "execute-sql" and not datasette.setting("default_allow_sql"):
return False
return inner
async def _resolve_metadata_permissions_blocks(datasette, actor, action, resource):
# Check custom permissions: blocks
metadata = datasette.metadata()
root_block = (metadata.get("permissions", None) or {}).get(action)
if root_block:
root_result = actor_matches_allow(actor, root_block)
if root_result is not None:
return root_result
# Now try database-specific blocks
if not resource:
return None
if isinstance(resource, str):
database = resource
else:
database = resource[0]
database_block = (
(metadata.get("databases", {}).get(database, {}).get("permissions", None)) or {}
).get(action)
if database_block:
database_result = actor_matches_allow(actor, database_block)
if database_result is not None:
return database_result
# Finally try table/query specific blocks
if not isinstance(resource, tuple):
return None
database, table_or_query = resource
table_block = (
(
metadata.get("databases", {})
.get(database, {})
.get("tables", {})
.get(table_or_query, {})
.get("permissions", None)
)
or {}
).get(action)
if table_block:
table_result = actor_matches_allow(actor, table_block)
if table_result is not None:
return table_result
# Finally the canned queries
query_block = (
(
metadata.get("databases", {})
.get(database, {})
.get("queries", {})
.get(table_or_query, {})
.get("permissions", None)
)
or {}
).get(action)
if query_block:
query_result = actor_matches_allow(actor, query_block)
if query_result is not None:
return query_result
return None
async def _resolve_metadata_view_permissions(datasette, actor, action, resource):
if action == "view-instance":
allow = datasette.metadata("allow")
if allow is not None:
return actor_matches_allow(actor, allow)
elif action == "view-database":
if resource == "_internal" and (actor is None or actor.get("id") != "root"):
return False
database_allow = datasette.metadata("allow", database=resource)
if database_allow is None:
return None
return actor_matches_allow(actor, database_allow)
elif action == "view-table":
database, table = resource
tables = datasette.metadata("tables", database=database) or {}
table_allow = (tables.get(table) or {}).get("allow")
if table_allow is None:
return None
return actor_matches_allow(actor, table_allow)
elif action == "view-query":
# Check if this query has a "allow" block in metadata
database, query_name = resource
query = await datasette.get_canned_query(database, query_name, actor)
assert query is not None
allow = query.get("allow")
if allow is None:
return None
return actor_matches_allow(actor, allow)
elif action == "execute-sql":
# Use allow_sql block from database block, or from top-level
database_allow_sql = datasette.metadata("allow_sql", database=resource)
if database_allow_sql is None:
database_allow_sql = datasette.metadata("allow_sql")
if database_allow_sql is None:
return None
return actor_matches_allow(actor, database_allow_sql)
@hookimpl(specname="permission_allowed")
def permission_allowed_actor_restrictions(datasette, actor, action, resource):
if actor is None:
return None
if "_r" not in actor:
# No restrictions, so we have no opinion
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":
all_rules = _r.get("a") or []
if (
"view-database" in all_rules
or "vd" in all_rules
or "view-table" in all_rules
or "vt" in all_rules
):
return None
database_rules = _r.get("d") or {}
for rules in database_rules.values():
if (
"vd" in rules
or "view-database" in rules
or "vt" in rules
or "view-table" 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
all_rules = _r.get("a") or []
if (
"view-database" in all_rules
or "vd" in all_rules
or "view-table" in all_rules
or "vt" in all_rules
):
return None
database_rules = (_r.get("d") or {}).get(database_name) or {}
if "vt" in database_rules or "view-table" in database_rules:
return None
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
if action == "view-table":
# Can view table if they have view-table in database or instance too
database_name = resource[0]
all_rules = _r.get("a") or []
if "view-table" in all_rules or "vt" in all_rules:
return None
database_rules = (_r.get("d") or {}).get(database_name) or {}
if "vt" in database_rules or "view-table" in database_rules:
return None
# Does this action have an abbreviation?
to_check = {action}
permission = datasette.permissions.get(action)
if permission and permission.abbr:
to_check.add(permission.abbr)
# If _r is defined then we use those to further restrict the actor
# Crucially, we only use this to say NO (return False) - we never
# use it to return YES (True) because that might over-ride other
# restrictions placed on this actor
all_allowed = _r.get("a")
if all_allowed is not None:
assert isinstance(all_allowed, list)
if to_check.intersection(all_allowed):
return None
# How about for the current database?
if isinstance(resource, str):
database_allowed = _r.get("d", {}).get(resource)
if database_allowed is not None:
assert isinstance(database_allowed, list)
if to_check.intersection(database_allowed):
return None
# Or the current table? That's any time the resource is (database, table)
if resource is not None and not isinstance(resource, str) and len(resource) == 2:
database, table = resource
table_allowed = _r.get("r", {}).get(database, {}).get(table)
# TODO: What should this do for canned queries?
if table_allowed is not None:
assert isinstance(table_allowed, list)
if to_check.intersection(table_allowed):
return None
# This action is not specifically allowed, so reject it
return False
@hookimpl
def actor_from_request(datasette, request):
prefix = "dstok_"
if not datasette.setting("allow_signed_tokens"):
return None
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
authorization = request.headers.get("authorization")
if not authorization:
return None
if not authorization.startswith("Bearer "):
return None
token = authorization[len("Bearer ") :]
if not token.startswith(prefix):
return None
token = token[len(prefix) :]
try:
decoded = datasette.unsign(token, namespace="token")
except itsdangerous.BadSignature:
return None
if "t" not in decoded:
# Missing timestamp
return None
created = decoded["t"]
if not isinstance(created, int):
# Invalid timestamp
return None
duration = decoded.get("d")
if duration is not None and not isinstance(duration, int):
# Invalid duration
return None
if (duration is None and max_signed_tokens_ttl) or (
duration is not None
and max_signed_tokens_ttl
and duration > max_signed_tokens_ttl
):
duration = max_signed_tokens_ttl
if duration:
if time.time() - created > duration:
# Expired
return None
actor = {"id": decoded["a"], "token": "dstok"}
if "_r" in decoded:
actor["_r"] = decoded["_r"]
if duration:
actor["token_expires"] = created + duration
return actor
@hookimpl
def skip_csrf(scope):
# Skip CSRF check for requests with content-type: application/json
if scope["type"] == "http":
headers = scope.get("headers") or {}
if dict(headers).get(b"content-type") == b"application/json":
return True