Skip to content

Commit

Permalink
Dynamic groups feature - needs tests and docs, refs #2
Browse files Browse the repository at this point in the history
  • Loading branch information
simonw committed Aug 30, 2024
1 parent 87102b1 commit 91dc5f8
Showing 1 changed file with 121 additions and 1 deletion.
122 changes: 121 additions & 1 deletion datasette_acl/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from datasette import hookimpl, Response
from datasette.utils import actor_matches_allow
import time
import json

CREATE_TABLES_SQL = """
create table if not exists acl_resources (
Expand All @@ -23,7 +26,7 @@
actor_id text,
group_id integer,
primary key (actor_id, group_id),
foreign key (group_id) references groups(id)
foreign key (group_id) references acl_groups(id)
);
create table if not exists acl (
Expand Down Expand Up @@ -71,6 +74,40 @@
and action_id = (select id from target_action)
"""

EXPECTED_GROUPS_SQL = """
with expected_groups as (
select value as group_name
from json_each(:expected_groups_json)
),
dynamic_groups as (
select value as group_name
from json_each(:dynamic_groups)
),
actual_groups as (
select g.name as group_name
from acl_groups g
join acl_actor_groups ug on g.id = ug.group_id
where ug.actor_id = :actor_id
)
select
'should-add' as status,
eg.group_name
from expected_groups eg
where eg.group_name not in (select group_name from actual_groups)
union all
select
'should-remove' as status,
ag.group_name
from actual_groups ag
where ag.group_name not in (select group_name from expected_groups)
and ag.group_name in (select group_name from dynamic_groups)
union all
select
'current' as status,
group_name
from actual_groups
"""


@hookimpl
def startup(datasette):
Expand All @@ -88,6 +125,88 @@ async def inner():
return inner


class OneSecondCache:
def __init__(self):
self.cache = {}

def set(self, key, value):
self.cache[key] = (value, time.monotonic() + 1)

def get(self, key):
if key in self.cache:
value, expiration_time = self.cache[key]
if time.monotonic() < expiration_time:
return value
else:
del self.cache[key]
return None

def clear_expired(self):
current_time = time.monotonic()
self.cache = {k: v for k, v in self.cache.items() if v[1] > current_time}


one_second_cache = OneSecondCache()


async def update_dynamic_groups(datasette, actor):
if one_second_cache.get(actor["id"]):
# Don't do this more than once a second per actor
return
one_second_cache.set(actor["id"], 1)
config = datasette.plugin_config("datasette-acl")
groups = config.get("dynamic-groups")
if not groups:
return
# Figure out the groups the user should be in
should_have_groups = set(
group_name
for group_name, allow_block in groups.items()
if actor_matches_allow(actor, allow_block)
)
db = datasette.get_internal_database()
result = await db.execute(
EXPECTED_GROUPS_SQL,
{
"actor_id": actor["id"],
"expected_groups_json": json.dumps(list(should_have_groups)),
"dynamic_groups": json.dumps(list(groups.keys())),
},
)
should_add = []
should_remove = []
for row in result.rows:
if row["status"] == "should-add":
should_add.append(row["group_name"])
elif row["status"] == "should-remove":
should_remove.append(row["group_name"])
# Add/remove groups as needed
for group_name in should_add:
# Make sure the group exists
await db.execute_write(
"insert or ignore into acl_groups (name) VALUES (:name);",
{"name": group_name},
)
await db.execute_write(
"""
insert into acl_actor_groups (
actor_id, group_id
) values (
:actor_id, (select id from acl_groups where name = :group_name)
)""",
{"actor_id": actor["id"], "group_name": group_name},
)
for group_name in should_remove:
await db.execute_write(
"""
delete from acl_actor_groups
where actor_id = :actor_id
and group_id = (select id from acl_groups where name = :group_name)
""",
{"actor_id": actor["id"], "group_name": group_name},
)


@hookimpl
def permission_allowed(datasette, actor, action, resource):
if not resource or len(resource) != 2:
Expand All @@ -96,6 +215,7 @@ def permission_allowed(datasette, actor, action, resource):
async def inner():
if not actor:
return False
await update_dynamic_groups(datasette, actor)
db = datasette.get_internal_database()
result = await db.execute(
ACL_RESOURCE_PAIR_SQL,
Expand Down

0 comments on commit 91dc5f8

Please sign in to comment.