Skip to content

Commit 66b3328

Browse files
Add permission_for() to create permisisions programatically (#697)
1 parent f5247fd commit 66b3328

3 files changed

Lines changed: 112 additions & 19 deletions

File tree

aiohttp_admin/backends/sqlalchemy.py

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import asyncio
2+
import json
23
import logging
34
import operator
45
import sys
6+
from collections.abc import Sequence
57
from types import MappingProxyType
6-
from typing import Any, Callable, Coroutine, Iterator, Type, TypeVar, Union
8+
from typing import Any, Callable, Coroutine, Iterator, Literal, Optional, TypeVar, Union
79

810
import sqlalchemy as sa
911
from aiohttp import web
1012
from sqlalchemy.ext.asyncio import AsyncEngine
11-
from sqlalchemy.orm import DeclarativeBase
13+
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
1214
from sqlalchemy.sql.roles import ExpressionElementRole
1315

1416
from .abc import (
@@ -22,6 +24,9 @@
2224

2325
_P = ParamSpec("_P")
2426
_T = TypeVar("_T")
27+
_FValues = Union[bool, int, str]
28+
_Filters = dict[Union[sa.Column[object], QueryableAttribute[Any]],
29+
Union[_FValues, Sequence[_FValues]]]
2530

2631
logger = logging.getLogger(__name__)
2732

@@ -53,6 +58,58 @@ async def inner(*args: _P.args, **kwargs: _P.kwargs) -> _T:
5358
return inner
5459

5560

61+
def permission_for(sa_obj: Union[sa.Table, type[DeclarativeBase],
62+
sa.Column[object], QueryableAttribute[Any]],
63+
perm_type: Literal["view", "edit", "add", "delete", "*"] = "*",
64+
*, filters: Optional[_Filters] = None, negated: bool = False) -> str:
65+
"""Returns a permission string for the given sa_obj.
66+
67+
Args:
68+
sa_obj: A SQLAlchemy object to grant permission to (table/model/column/attribute).
69+
perm_type: The type of permission to grant acces to.
70+
filters: Filters to restrict the permisson to (can't be used with negated).
71+
e.g. {User.type: "admin", User.active: True} only permits access if
72+
`User.type == "admin" and User.active`.
73+
{Post.type: ("news", "sports")} only permits access if
74+
`Post.type in ("news", "sports")`.
75+
negated: True if result should restrict access from sa_obj.
76+
"""
77+
if filters and negated:
78+
raise ValueError("Can't use filters on negated permissions.")
79+
if perm_type not in {"view", "edit", "add", "delete", "*"}:
80+
raise ValueError(f"Invalid perm_type: '{perm_type}'")
81+
82+
field = None
83+
if isinstance(sa_obj, sa.Table):
84+
table = sa_obj
85+
elif isinstance(sa_obj, (sa.Column, QueryableAttribute)):
86+
table = sa_obj.table
87+
field = sa_obj.name
88+
else:
89+
if not isinstance(sa_obj.__table__, sa.Table):
90+
raise ValueError("Non-table mappings are not supported.")
91+
table = sa_obj.__table__
92+
p = "{}admin.{}".format("~" if negated else "", table.name)
93+
94+
if field:
95+
p = f"{p}.{field}"
96+
97+
p = f"{p}.{perm_type}"
98+
99+
if filters:
100+
for col, value in filters.items():
101+
if col.table is not table:
102+
raise ValueError("Filter key not an attribute/column of sa_obj.")
103+
# Sequences should be treated as multiple filter values for that key.
104+
if not isinstance(value, Sequence) or isinstance(value, str):
105+
value = (value,)
106+
for v in value:
107+
v = json.dumps(v)
108+
p += f"|{col.name}={v}"
109+
110+
return p
111+
112+
56113
def create_filters(columns: sa.ColumnCollection[str, sa.Column[object]],
57114
filters: dict[str, object]) -> Iterator[ExpressionElementRole[Any]]:
58115
return (columns[k].in_(v) if isinstance(v, list)
@@ -61,7 +118,7 @@ def create_filters(columns: sa.ColumnCollection[str, sa.Column[object]],
61118

62119

63120
class SAResource(AbstractAdminResource):
64-
def __init__(self, db: AsyncEngine, model_or_table: Union[sa.Table, Type[DeclarativeBase]]):
121+
def __init__(self, db: AsyncEngine, model_or_table: Union[sa.Table, type[DeclarativeBase]]):
65122
if isinstance(model_or_table, sa.Table):
66123
table = model_or_table
67124
else:

examples/permissions.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import aiohttp_admin
1717
from _models import Base, Simple, SimpleParent
1818
from aiohttp_admin import Permissions, UserDetails
19-
from aiohttp_admin.backends.sqlalchemy import SAResource
19+
from aiohttp_admin.backends.sqlalchemy import SAResource, permission_for as p
2020

2121

2222
class User(Base):
@@ -48,14 +48,14 @@ async def create_app() -> web.Application:
4848
await conn.run_sync(Base.metadata.create_all)
4949
async with session.begin() as sess:
5050
sess.add(Simple(num=5, value="first"))
51-
p = Simple(num=82, optional_num=12, value="with child")
52-
sess.add(p)
51+
p_simple = Simple(num=82, optional_num=12, value="with child")
52+
sess.add(p_simple)
5353
sess.add(Simple(num=5, value="second"))
5454
sess.add(Simple(num=5, value="3"))
5555
sess.add(Simple(num=5, optional_num=42, value="4"))
5656
sess.add(Simple(num=5, value="5"))
5757
async with session.begin() as sess:
58-
sess.add(SimpleParent(id=p.id, date=datetime(2023, 2, 13, 19, 4)))
58+
sess.add(SimpleParent(id=p_simple.id, date=datetime(2023, 2, 13, 19, 4)))
5959

6060
app = web.Application()
6161
app["db"] = session
@@ -86,17 +86,19 @@ async def create_app() -> web.Application:
8686
sess.add(User(username="delete", permissions=json.dumps(
8787
(Permissions.view, Permissions.delete))))
8888
users = {
89-
"simple": ("admin.simple.*",),
90-
"mixed": ("admin.simple.view", "admin.simple.edit", "admin.parent.view"),
91-
"negated": ("admin.*", "~admin.parent.*", "~admin.simple.edit"),
92-
"field": ("admin.*", "~admin.simple.optional_num.*"),
93-
"field_edit": ("admin.*", "~admin.simple.optional_num.edit"),
94-
"filter": ("admin.*", "admin.simple.*|num=5"),
95-
"filter_edit": ("admin.*", "admin.simple.edit|num=5"),
96-
"filter_add": ("admin.*", "admin.simple.add|num=5"),
97-
"filter_delete": ("admin.*", "admin.simple.delete|num=5"),
98-
"filter_field": ("admin.*", "admin.simple.optional_num.*|num=5"),
99-
"filter_field_edit": ("admin.*", "admin.simple.optional_num.edit|num=5")
89+
"simple": (p(Simple),),
90+
"mixed": (p(Simple, "view"), p(Simple, "edit"), p(SimpleParent, "view")),
91+
"negated": (Permissions.all, p(SimpleParent, negated=True),
92+
p(Simple, "edit", negated=True)),
93+
"field": (Permissions.all, p(Simple.optional_num, negated=True)),
94+
"field_edit": (Permissions.all, p(Simple.optional_num, "edit", negated=True)),
95+
"filter": (Permissions.all, p(Simple, filters={Simple.num: 5})),
96+
"filter_edit": (Permissions.all, p(Simple, "edit", filters={Simple.num: 5})),
97+
"filter_add": (Permissions.all, p(Simple, "add", filters={Simple.num: 5})),
98+
"filter_delete": (Permissions.all, p(Simple, "delete", filters={Simple.num: 5})),
99+
"filter_field": (Permissions.all, p(Simple.optional_num, filters={Simple.num: 5})),
100+
"filter_field_edit": (Permissions.all, p(Simple.optional_num, "edit",
101+
filters={Simple.num: 5}))
100102
}
101103
for name, permissions in users.items():
102104
if any(admin["permission_re"].fullmatch(p) is None for p in permissions):

tests/test_backends_sqlalchemy.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import aiohttp_admin
1313
from _auth import check_credentials
14-
from aiohttp_admin.backends.sqlalchemy import SAResource
14+
from aiohttp_admin.backends.sqlalchemy import SAResource, permission_for
1515

1616
_Login = Callable[[TestClient], Awaitable[dict[str, str]]]
1717

@@ -285,3 +285,37 @@ class TestModel(base): # type: ignore[misc,valid-type]
285285
assert resp.status == 200
286286
assert await resp.json() == {"data": {"id": 2, "date": "2024-05-09",
287287
"time": "2020-11-12 03:04:05"}}
288+
289+
290+
def test_permission_for(base: type[DeclarativeBase]) -> None:
291+
class M(base): # type: ignore[misc,valid-type]
292+
__tablename__ = "test"
293+
id: Mapped[int] = mapped_column(primary_key=True)
294+
cat: Mapped[int]
295+
val: Mapped[str]
296+
297+
t = M.__table__
298+
299+
assert permission_for(M) == "admin.test.*"
300+
assert permission_for(M, "view") == "admin.test.view"
301+
assert permission_for(M, "add", negated=True) == "~admin.test.add"
302+
assert permission_for(M.cat, "edit") == "admin.test.cat.edit"
303+
assert permission_for(t.c["val"], "*", negated=True) == "~admin.test.val.*"
304+
assert permission_for(M, filters={M.cat: 5, M.val: "Foo"}) == 'admin.test.*|cat=5|val="Foo"'
305+
assert permission_for(
306+
t, "delete", filters={t.c["val"]: "bar"}) == 'admin.test.delete|val="bar"'
307+
assert permission_for(M.val, filters={M.id: (3, 4)}) == "admin.test.val.*|id=3|id=4"
308+
assert permission_for(
309+
M.cat, "edit", filters={M.cat: [1, 5]}) == "admin.test.cat.edit|cat=1|cat=5"
310+
311+
with pytest.raises(ValueError, match="Can't use filters on negated"):
312+
permission_for(M, filters={M.id: 1}, negated=True)
313+
with pytest.raises(ValueError, match="foo"):
314+
permission_for(M, "foo") # type: ignore[arg-type]
315+
316+
class Wrong(base): # type: ignore[misc,valid-type]
317+
__tablename__ = "wrong"
318+
id: Mapped[int] = mapped_column(primary_key=True)
319+
320+
with pytest.raises(ValueError, match="not an attribute"):
321+
permission_for(M, filters={Wrong.id: 1})

0 commit comments

Comments
 (0)