Skip to content

Commit 12d83e3

Browse files
Implement advanced permissions (#661)
1 parent 90d0003 commit 12d83e3

File tree

15 files changed

+796
-299
lines changed

15 files changed

+796
-299
lines changed

admin-js/src/App.js

Lines changed: 81 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import {
66
NumberField, NumberInput,
77
ReferenceField, ReferenceInput,
88
ReferenceManyField,
9-
TextField, TextInput
9+
SelectInput,
10+
TextField, TextInput,
11+
WithRecord, required
1012
} from "react-admin";
13+
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
1114

1215
const _body = document.querySelector("body");
1316
const STATE = JSON.parse(_body.dataset.state);
@@ -94,33 +97,64 @@ function createFields(resource, name, permissions, display_only=false) {
9497
if ((display_only && !resource["display"].includes(field))
9598
|| !hasPermission(`${name}.${field}.view`, permissions))
9699
continue;
100+
97101
const C = COMPONENTS[state["type"]];
98102
if (C === undefined)
99103
throw Error(`Unknown component '${state["type"]}'`);
100104

105+
let c;
101106
if (state["props"]["children"]) {
102107
let child_fields = createFields({"fields": state["props"]["children"],
103108
"display": Object.keys(state["props"]["children"])},
104109
name, permissions);
105110
delete state["props"]["children"];
106-
components.push(<C source={field} {...state["props"]}><Datagrid>{child_fields}</Datagrid></C>);
111+
c = <C source={field} {...state["props"]}><Datagrid>{child_fields}</Datagrid></C>;
107112
} else {
108-
components.push(<C source={field} {...state["props"]} />);
113+
c = <C source={field} {...state["props"]} />;
109114
}
115+
// Show icon if user doesn't have permission to view this field (based on filters).
116+
components.push(<WithRecord label={state["props"]["label"] || field} render={
117+
(record) => hasPermission(`${name}.${field}.view`, permissions, record) ? c : <VisibilityOffIcon />
118+
} />);
110119
}
111120
return components;
112121
}
113122

114-
function createInputs(resource, name, perm_type, permissions, create=false) {
123+
function createInputs(resource, name, perm_type, permissions) {
115124
let components = [];
125+
const resource_filters = getFilters(name, perm_type, permissions);
116126
for (const [field, state] of Object.entries(resource["inputs"])) {
117-
if ((create && !state["show_create"])
127+
if ((perm_type === "add" && !state["show_create"])
118128
|| !hasPermission(`${name}.${field}.${perm_type}`, permissions))
119129
continue;
120-
const C = COMPONENTS[state["type"]];
121-
if (C === undefined)
122-
throw Error(`Unknown component '${state["type"]}'`);
123-
components.push(<C source={field} {...state["props"]} />);
130+
131+
const fvalues = resource_filters[field];
132+
if (fvalues !== undefined) {
133+
// If there are filters for the resource-level permission which depend on
134+
// this field, then restrict the input options to the allowed values.
135+
const disabled = fvalues.length <= 1;
136+
const nullable = fvalues.indexOf(null);
137+
if (nullable > -1)
138+
fvalues.splice(nullable, 1);
139+
let choices = [];
140+
for (let v of fvalues)
141+
choices.push({"id": v, "name": v});
142+
components.push(
143+
<SelectInput source={field} choices={choices} defaultValue={nullable < 0 && fvalues[0]}
144+
validate={nullable < 0 && required()} disabled={disabled} />);
145+
} else {
146+
const C = COMPONENTS[state["type"]];
147+
if (C === undefined)
148+
throw Error(`Unknown component '${state["type"]}'`);
149+
const c = <C source={field} {...state["props"]} />;
150+
if (perm_type === "edit")
151+
// Don't render if filters disallow editing this field.
152+
components.push(<WithRecord render={
153+
(record) => hasPermission(`${name}.${field}.${perm_type}`, permissions, record) && c
154+
} />);
155+
else
156+
components.push(c);
157+
}
124158
}
125159
return components;
126160
}
@@ -129,7 +163,7 @@ const AiohttpList = (resource, name, permissions) => (
129163
<List filters={createInputs(resource, name, "view", permissions)}>
130164
<Datagrid rowClick="show">
131165
{createFields(resource, name, permissions, true)}
132-
{hasPermission(`${name}.edit`, permissions) && <EditButton />}
166+
<WithRecord render={(record) => hasPermission(`${name}.edit`, permissions, record) && <EditButton />} />
133167
</Datagrid>
134168
</List>
135169
);
@@ -153,35 +187,54 @@ const AiohttpEdit = (resource, name, permissions) => (
153187
const AiohttpCreate = (resource, name, permissions) => (
154188
<Create>
155189
<SimpleForm>
156-
{createInputs(resource, name, "add", permissions, true)}
190+
{createInputs(resource, name, "add", permissions)}
157191
</SimpleForm>
158192
</Create>
159193
);
160194

161-
function hasPermission(p, permissions) {
195+
/** Return any filters for a given permission. */
196+
function getFilters(name, perm_type, permissions) {
197+
let filters = permissions[`admin.${name}.${perm_type}`];
198+
if (filters !== undefined)
199+
return filters;
200+
filters = permissions[`admin.${name}.*`];
201+
return filters || {};
202+
}
203+
204+
/** Return true if a user has the given permission.
205+
206+
A record can be passed as the context parameter in order to check permission filters
207+
against the current record.
208+
*/
209+
function hasPermission(p, permissions, context=null) {
162210
const parts = ["admin", ...p.split(".")];
163211
const type = parts.pop();
164212

165213
// Negative permissions.
166-
for (let i=1; i < parts.length+1; ++i) {
167-
let perm = [...parts.slice(0, i), type].join(".");
168-
if (permissions["~" + perm] !== undefined)
169-
return false;
170-
171-
let wildcard = [...parts.slice(0, i), "*"].join(".");
172-
if (permissions["~" + wildcard] !== undefined)
173-
return false;
214+
for (let i=parts.length; i > 0; --i) {
215+
for (let t of [type, "*"]) {
216+
let perm = [...parts.slice(0, i), t].join(".");
217+
if (permissions["~" + perm] !== undefined)
218+
return false;
219+
}
174220
}
175221

176222
// Positive permissions.
177-
for (let i=1; i < parts.length+1; ++i) {
178-
let perm = [...parts.slice(0, i), type].join(".");
179-
if (permissions[perm] !== undefined)
180-
return true;
181-
182-
let wildcard = [...parts.slice(0, i), "*"].join(".");
183-
if (permissions[wildcard] !== undefined)
184-
return true;
223+
for (let i=parts.length; i > 0; --i) {
224+
for (let t of [type, "*"]) {
225+
let perm = [...parts.slice(0, i), t].join(".");
226+
if (permissions[perm] !== undefined) {
227+
if (!context)
228+
return true;
229+
230+
let filters = permissions[perm];
231+
for (let attr of Object.keys(filters)) {
232+
if (!filters[attr].includes(context[attr]))
233+
return false;
234+
}
235+
return true;
236+
}
237+
}
185238
}
186239
return false;
187240
}

aiohttp_admin/__init__.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,14 @@
55
import aiohttp_session
66
from aiohttp import web
77
from aiohttp.typedefs import Handler
8-
from aiohttp_security import AbstractAuthorizationPolicy
98
from aiohttp_session.cookie_storage import EncryptedCookieStorage
109
from pydantic import ValidationError, parse_obj_as
1110

1211
from .routes import setup_resources, setup_routes
13-
from .security import Permissions, TokenIdentityPolicy, has_permission
12+
from .security import AdminAuthorizationPolicy, Permissions, TokenIdentityPolicy
1413
from .types import Schema, UserDetails
1514

16-
__all__ = ("Permissions", "Schema", "UserDetails", "has_permission", "setup")
15+
__all__ = ("Permissions", "Schema", "UserDetails", "setup")
1716
__version__ = "0.1.0a0"
1817

1918

@@ -25,8 +24,8 @@ async def pydantic_middleware(request: web.Request, handler: Handler) -> web.Str
2524
raise web.HTTPBadRequest(text=e.json(), content_type="application/json")
2625

2726

28-
def setup(app: web.Application, schema: Schema, auth_policy: AbstractAuthorizationPolicy, # type: ignore[no-any-unimported] # noqa: B950
29-
*, path: str = "/admin", secret: Optional[bytes] = None) -> web.Application:
27+
def setup(app: web.Application, schema: Schema, *, path: str = "/admin",
28+
secret: Optional[bytes] = None) -> web.Application:
3029
"""Initialize the admin.
3130
3231
Args:
@@ -75,6 +74,7 @@ def value(r: web.RouteDef) -> tuple[str, str]:
7574
admin.middlewares.append(pydantic_middleware)
7675
admin.on_startup.append(on_startup)
7776
admin["check_credentials"] = schema["security"]["check_credentials"]
77+
admin["identity_callback"] = schema["security"].get("identity_callback")
7878
admin["state"] = {"view": schema.get("view", {})}
7979

8080
max_age = schema["security"].get("max_age")
@@ -83,7 +83,7 @@ def value(r: web.RouteDef) -> tuple[str, str]:
8383
secret, max_age=max_age, httponly=True, samesite="Strict", secure=secure)
8484
identity_policy = TokenIdentityPolicy(storage._fernet, schema)
8585
aiohttp_session.setup(admin, storage)
86-
aiohttp_security.setup(admin, identity_policy, auth_policy)
86+
aiohttp_security.setup(admin, identity_policy, AdminAuthorizationPolicy(schema))
8787

8888
setup_routes(admin)
8989
setup_resources(admin, schema)

aiohttp_admin/backends/abc.py

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1+
import asyncio
12
import json
23
from abc import ABC, abstractmethod
34
from datetime import datetime
45
from enum import Enum
56
from functools import cached_property, partial
6-
from typing import Any, Literal, TypedDict, Union
7+
from typing import Any, Literal, Optional, TypedDict, Union
78

89
from aiohttp import web
9-
from aiohttp_security import check_permission, permits
10+
from aiohttp_security import authorized_userid, check_permission, permits
1011
from pydantic import Json, parse_obj_as
1112

13+
from ..security import permissions_as_dict
14+
from ..types import FieldState, InputState
15+
1216
Record = dict[str, object]
1317

1418

@@ -25,16 +29,6 @@ def default(self, o: object) -> Any:
2529
json_response = partial(web.json_response, dumps=partial(json.dumps, cls=Encoder))
2630

2731

28-
class FieldState(TypedDict):
29-
type: str
30-
props: dict[str, object]
31-
32-
33-
class InputState(FieldState):
34-
# Whether to show this input in the create form.
35-
show_create: bool
36-
37-
3832
class _Pagination(TypedDict):
3933
page: int
4034
perPage: int
@@ -89,10 +83,11 @@ class AbstractAdminResource(ABC):
8983
repr_field: str
9084

9185
async def filter_by_permissions(self, request: web.Request, perm_type: str,
92-
record: Record) -> Record:
86+
record: Record, original: Optional[Record] = None) -> Record:
9387
"""Return a filtered record containing permissible fields only."""
9488
return {k: v for k, v in record.items()
95-
if await permits(request, f"admin.{self.name}.{k}.{perm_type}")}
89+
if await permits(request, f"admin.{self.name}.{k}.{perm_type}",
90+
context=original or record)}
9691

9792
@abstractmethod
9893
async def get_list(self, params: GetListParams) -> tuple[list[Record], int]:
@@ -128,15 +123,29 @@ async def _get_list(self, request: web.Request) -> web.Response:
128123
await check_permission(request, f"admin.{self.name}.view")
129124
query = parse_obj_as(GetListParams, request.query)
130125

126+
# Add filters from advanced permissions.
127+
if request.app["identity_callback"]:
128+
identity = await authorized_userid(request)
129+
user_details = await request.app["identity_callback"](identity)
130+
permissions = permissions_as_dict(user_details["permissions"])
131+
filters = permissions.get(f"admin.{self.name}.view",
132+
permissions.get(f"admin.{self.name}.*", {}))
133+
for k, v in filters.items():
134+
query["filter"][k] = v
135+
131136
results, total = await self.get_list(query)
132137
results = [await self.filter_by_permissions(request, "view", r) for r in results]
138+
results = [r for r in results if await permits(request, f"admin.{self.name}.view",
139+
context=r)]
133140
return json_response({"data": results, "total": total})
134141

135142
async def _get_one(self, request: web.Request) -> web.Response:
136143
await check_permission(request, f"admin.{self.name}.view")
137144
query = parse_obj_as(GetOneParams, request.query)
138145

139146
result = await self.get_one(query)
147+
if not await permits(request, f"admin.{self.name}.view", context=result):
148+
raise web.HTTPForbidden()
140149
result = await self.filter_by_permissions(request, "view", result)
141150
return json_response({"data": result})
142151

@@ -145,14 +154,17 @@ async def _get_many(self, request: web.Request) -> web.Response:
145154
query = parse_obj_as(GetManyParams, request.query)
146155

147156
results = await self.get_many(query)
148-
results = [await self.filter_by_permissions(request, "view", r) for r in results]
157+
results = [await self.filter_by_permissions(request, "view", r) for r in results
158+
if await permits(request, f"admin.{self.name}.view", context=r)]
149159
return json_response({"data": results})
150160

151161
async def _create(self, request: web.Request) -> web.Response:
152-
await check_permission(request, f"admin.{self.name}.add")
153162
query = parse_obj_as(CreateParams, request.query)
154-
for k in query["data"]:
155-
await check_permission(request, f"admin.{self.name}.{k}.add")
163+
await check_permission(request, f"admin.{self.name}.add", context=query["data"])
164+
for k, v in query["data"].items():
165+
if v is not None:
166+
await check_permission(request, f"admin.{self.name}.{k}.add",
167+
context=query["data"])
156168

157169
result = await self.create(query)
158170
result = await self.filter_by_permissions(request, "view", result)
@@ -161,8 +173,22 @@ async def _create(self, request: web.Request) -> web.Response:
161173
async def _update(self, request: web.Request) -> web.Response:
162174
await check_permission(request, f"admin.{self.name}.edit")
163175
query = parse_obj_as(UpdateParams, request.query)
164-
# Filter because react-admin still sends fields without an input component.
165-
query["data"] = await self.filter_by_permissions(request, "edit", query["data"])
176+
177+
# Check original record is allowed by permission filters.
178+
original = await self.get_one({"id": query["id"]})
179+
if not await permits(request, f"admin.{self.name}.edit", context=original):
180+
raise web.HTTPForbidden()
181+
182+
# Filter rather than forbid because react-admin still sends fields without an
183+
# input component. The query may not be the complete dict though, so we must
184+
# pass original for testing.
185+
query["data"] = await self.filter_by_permissions(request, "edit", query["data"], original)
186+
# Check new values are allowed by permission filters.
187+
if not await permits(request, f"admin.{self.name}.edit", context=query["data"]):
188+
raise web.HTTPForbidden()
189+
190+
if not query["data"]:
191+
raise web.HTTPBadRequest(reason="No allowed fields to change.")
166192

167193
result = await self.update(query)
168194
result = await self.filter_by_permissions(request, "view", result)
@@ -172,6 +198,10 @@ async def _delete(self, request: web.Request) -> web.Response:
172198
await check_permission(request, f"admin.{self.name}.delete")
173199
query = parse_obj_as(DeleteParams, request.query)
174200

201+
original = await self.get_one({"id": query["id"]})
202+
if not await permits(request, f"admin.{self.name}.delete", context=original):
203+
raise web.HTTPForbidden()
204+
175205
result = await self.delete(query)
176206
result = await self.filter_by_permissions(request, "view", result)
177207
return json_response({"data": result})
@@ -180,6 +210,12 @@ async def _delete_many(self, request: web.Request) -> web.Response:
180210
await check_permission(request, f"admin.{self.name}.delete")
181211
query = parse_obj_as(DeleteManyParams, request.query)
182212

213+
originals = await self.get_many(query)
214+
allowed = await asyncio.gather(*(permits(request, f"admin.{self.name}.delete",
215+
context=r) for r in originals))
216+
if not all(allowed):
217+
raise web.HTTPForbidden()
218+
183219
ids = await self.delete_many(query)
184220
return json_response({"data": ids})
185221

aiohttp_admin/backends/sqlalchemy.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727

2828
def create_filters(columns: sa.ColumnCollection[str, sa.Column[object]],
2929
filters: dict[str, object]) -> Iterator[ExpressionElementRole[Any]]:
30-
return (columns[k].ilike(f"%{v}%") if isinstance(v, str) else columns[k] == v
30+
return (columns[k].in_(v) if isinstance(v, list)
31+
else columns[k].ilike(f"%{v}%") if isinstance(v, str) else columns[k] == v
3132
for k, v in filters.items())
3233

3334

0 commit comments

Comments
 (0)