diff --git a/datasette/app.py b/datasette/app.py index c3d802a4..b53144d1 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -40,7 +40,7 @@ from .views.special import ( PermissionsDebugView, MessagesDebugView, ) -from .views.table import TableView, TableInsertView +from .views.table import TableView, TableInsertView, TableDropView from .views.row import RowView from .renderer import json_renderer from .url_builder import Urls @@ -1276,6 +1276,10 @@ class Datasette: TableInsertView.as_view(self), r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/insert$", ) + add_route( + TableDropView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/drop$", + ) return [ # Compile any strings to regular expressions ((re.compile(pattern) if isinstance(pattern, str) else pattern), view) diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index 151ba2b5..3c781317 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -9,7 +9,7 @@ import time @hookimpl(tryfirst=True) def permission_allowed(datasette, actor, action, resource): async def inner(): - if action in ("permissions-debug", "debug-menu", "insert-row"): + if action in ("permissions-debug", "debug-menu", "insert-row", "drop-table"): if actor and actor.get("id") == "root": return True elif action == "view-instance": diff --git a/datasette/views/table.py b/datasette/views/table.py index fd203036..1e3d566e 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -40,6 +40,10 @@ LINK_WITH_LABEL = ( LINK_WITH_VALUE = '{id}' +def _error(messages, status=400): + return Response.json({"ok": False, "errors": messages}, status=status) + + class Row: def __init__(self, cells): self.cells = cells @@ -1147,9 +1151,6 @@ class TableInsertView(BaseView): return rows, errors, extra async def post(self, request): - def _error(messages, status=400): - return Response.json({"ok": False, "errors": messages}, status=status) - database_route = tilde_decode(request.url_vars["database"]) try: db = self.ds.get_database(route=database_route) @@ -1181,7 +1182,8 @@ class TableInsertView(BaseView): rowids.append(table.insert(row).last_rowid) return list( table.rows_where( - "rowid in ({})".format(",".join("?" for _ in rowids)), rowids + "rowid in ({})".format(",".join("?" for _ in rowids)), + rowids, ) ) else: @@ -1192,3 +1194,33 @@ class TableInsertView(BaseView): if should_return: result["inserted"] = rows return Response.json(result, status=201) + + +class TableDropView(BaseView): + name = "table-drop" + + def __init__(self, datasette): + self.ds = datasette + + async def post(self, request): + database_route = tilde_decode(request.url_vars["database"]) + try: + db = self.ds.get_database(route=database_route) + except KeyError: + return _error(["Database not found: {}".format(database_route)], 404) + database_name = db.name + table_name = tilde_decode(request.url_vars["table"]) + # Table must exist + db = self.ds.get_database(database_name) + if not await db.table_exists(table_name): + return _error(["Table not found: {}".format(table_name)], 404) + if not await self.ds.permission_allowed( + request.actor, "drop-table", resource=(database_name, table_name) + ): + return _error(["Permission denied"], 403) + # Drop table + def drop_table(conn): + sqlite_utils.Database(conn)[table_name].drop() + + await db.execute_write_fn(drop_table) + return Response.json({"ok": True}, status=200) diff --git a/docs/authentication.rst b/docs/authentication.rst index 233a50d2..e0796bc8 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -559,6 +559,18 @@ Actor is allowed to insert rows into a table. Default *deny*. +.. _permissions_drop_table: + +drop-table +---------- + +Actor is allowed to drop a database table. + +``resource`` - tuple: (string, string) + The name of the database, then the name of the table + +Default *deny*. + .. _permissions_execute_sql: execute-sql diff --git a/docs/json_api.rst b/docs/json_api.rst index 01558c23..cf7eaefc 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -539,3 +539,20 @@ To return the newly inserted rows, add the ``"return_rows": true`` key to the re } This will return the same ``"inserted"`` key as the single row example above. There is a small performance penalty for using this option. + +.. _TableDropView: + +Dropping tables +~~~~~~~~~~~~~~~ + +To drop a table, make a ``POST`` to ``//
/-/drop``. This requires the :ref:`permissions_drop_table` permission. + +:: + + POST //
/-/drop + Content-Type: application/json + Authorization: Bearer dstok_ + +If successful, this will return a ``200`` status code and a ``{"ok": true}`` response body. + +Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error. diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 4a5a58aa..79f905f7 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -16,6 +16,14 @@ def ds_write(tmp_path_factory): db.close() +def write_token(ds, actor_id="root"): + return "dstok_{}".format( + ds.sign( + {"a": actor_id, "token": "dstok", "t": int(time.time())}, namespace="token" + ) + ) + + @pytest.mark.asyncio async def test_write_row(ds_write): token = write_token(ds_write) @@ -188,9 +196,39 @@ async def test_write_row_errors( assert response.json()["errors"] == expected_errors -def write_token(ds): - return "dstok_{}".format( - ds.sign( - {"a": "root", "token": "dstok", "t": int(time.time())}, namespace="token" - ) +@pytest.mark.asyncio +@pytest.mark.parametrize("scenario", ("no_token", "no_perm", "bad_table", "has_perm")) +async def test_drop_table(ds_write, scenario): + if scenario == "no_token": + token = "bad_token" + elif scenario == "no_perm": + token = write_token(ds_write, actor_id="not-root") + else: + token = write_token(ds_write) + should_work = scenario == "has_perm" + + path = "/data/{}/-/drop".format("docs" if scenario != "bad_table" else "bad_table") + response = await ds_write.client.post( + path, + headers={ + "Authorization": "Bearer {}".format(token), + "Content-Type": "application/json", + }, ) + if should_work: + assert response.status_code == 200 + assert response.json() == {"ok": True} + assert (await ds_write.client.get("/data/docs")).status_code == 404 + else: + assert ( + response.status_code == 403 + if scenario in ("no_token", "bad_token") + else 404 + ) + assert response.json()["ok"] is False + assert ( + response.json()["errors"] == ["Permission denied"] + if scenario == "no_token" + else ["Table not found: bad_table"] + ) + assert (await ds_write.client.get("/data/docs")).status_code == 200