From 99b0b827ab7b9fb0d96eede8fafcdb1bcb5f04e5 Mon Sep 17 00:00:00 2001 From: Colin Dellow Date: Sat, 4 Feb 2023 15:51:53 -0500 Subject: [PATCH] what even is this --- README.md | 29 +++++++++++++- datasette_current_actor/__init__.py | 59 +++++++++++++++++++++++++++++ setup.py | 2 +- tests/plugins/auth.py | 5 +++ tests/test_current_actor.py | 14 +++++++ 5 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 tests/plugins/auth.py diff --git a/README.md b/README.md index f78870d..109bf04 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Tests](https://github.com/cldellow/datasette-current-actor/workflows/Test/badge.svg)](https://github.com/cldellow/datasette-current-actor/actions?query=workflow%3ATest) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/cldellow/datasette-current-actor/blob/main/LICENSE) -Adds a current_actor() function to SQLite that show's the current actor's ID. +Adds a `current_actor()` function to SQLite that show's the current actor's ID. ## Installation @@ -15,7 +15,32 @@ Install this plugin in the same environment as Datasette. ## Usage -Usage instructions go here. +### Boring mode + +`SELECT current_actor()` returns `NULL` if there's no currently-logged in actor, +or the id of the actor. + +You could put this in a canned query to provide limited access to tables. + +### Ludicrous mode + +SQLite is _flexible_. It turns out you can refer to functions that don't exist +when issuing DDL statements. As long as they exist when they're needed, it all +works out. + +#### Auditing + +Create a trigger on a table that sets the `last_edited_by` column to +`current_actor()`. + +#### Row-level security + +Restrict what rows users can see: + +```sql +CREATE VIEW rls AS +SELECT * FROM sensitive_data WHERE owner = current_actor() +``` ## Development diff --git a/datasette_current_actor/__init__.py b/datasette_current_actor/__init__.py index 399cf9f..b3b4749 100644 --- a/datasette_current_actor/__init__.py +++ b/datasette_current_actor/__init__.py @@ -1 +1,60 @@ from datasette import hookimpl +from datasette.database import Database +import asyncio +import threading + +# Adds a current_actor() function to SQLite that shows the current actor's +# ID. + +original_execute_fn = Database.execute_fn +actor = threading.local() + +async def patched_execute_fn(self, fn): + task = asyncio.current_task() + + scope = None if not hasattr(task, '_dux_request') else task._dux_request.scope + + def wrapped_fn(conn): + if scope and 'actor' in scope and scope['actor'] and 'id' in scope['actor']: + actor.actor = scope['actor']['id'] + else: + actor.actor = None + rv = fn(conn) + actor.actor = None + return rv + + return await original_execute_fn(self, wrapped_fn) + +Database.execute_fn = patched_execute_fn +@hookimpl +def prepare_connection(conn): + try: + if getattr(conn, 'engine') == 'duckdb': + return + except AttributeError: + pass + + def fn(): + return actor.actor + + conn.create_function( + "current_actor", 0, fn + ) + +# We always register an actor_from_request hook so that our +# actor_from_request hookwrapper is guaranteed to fire +# on every request. +@hookimpl +def actor_from_request(datasette, request): + return None + _id = None + if 'x-me' in request.headers: + _id = request.headers['x-me'] + return {'id': _id} + +@hookimpl(specname='actor_from_request', hookwrapper=True) +def sniff_actor_from_request(datasette, request): + asyncio.current_task()._dux_request = request + + # all corresponding hookimpls are invoked here + outcome = yield diff --git a/setup.py b/setup.py index 49363cc..5fe9be0 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,6 @@ def get_long_description(): packages=["datasette_current_actor"], entry_points={"datasette": ["current_actor = datasette_current_actor"]}, install_requires=["datasette"], - extras_require={"test": ["pytest", "pytest-asyncio"]}, + extras_require={"test": ["pytest", "pytest-asyncio", "pytest-watch"]}, python_requires=">=3.7", ) diff --git a/tests/plugins/auth.py b/tests/plugins/auth.py new file mode 100644 index 0000000..40025cb --- /dev/null +++ b/tests/plugins/auth.py @@ -0,0 +1,5 @@ +from datasette import hookimpl + +@hookimpl +def actor_from_request(request): + return {'id': 'someuser'} diff --git a/tests/test_current_actor.py b/tests/test_current_actor.py index 500aa37..c3dd362 100644 --- a/tests/test_current_actor.py +++ b/tests/test_current_actor.py @@ -9,3 +9,17 @@ async def test_plugin_is_installed(): assert response.status_code == 200 installed_plugins = {p["name"] for p in response.json()} assert "datasette-current-actor" in installed_plugins + +@pytest.mark.asyncio +async def test_current_actor_none(): + datasette = Datasette(memory=True) + response = await datasette.client.get("/_memory.json?sql=select+current_actor()+as+actor&_shape=array") + assert response.status_code == 200 + assert response.json() == [{'actor': None}] + +@pytest.mark.asyncio +async def test_current_actor_none(): + datasette = Datasette(memory=True, plugins_dir='./tests/plugins/') + response = await datasette.client.get("/_memory.json?sql=select+current_actor()+as+actor&_shape=array") + assert response.status_code == 200 + assert response.json() == [{'actor': 'someuser'}]