Skip to content

Commit a783d5f

Browse files
committed
feat: Refactor permission management to use permission IDs instead of table-based permissions, enhancing user permission handling and UI integration
1 parent 824ff91 commit a783d5f

11 files changed

Lines changed: 192 additions & 253 deletions

File tree

fastapi_admin_kit/admin/builtin_models.py

Lines changed: 49 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,8 @@
77

88

99
async def flush_pending_perm_ops(request):
10-
"""Execute any pending direct-permission writes on the request's session.
11-
12-
Called after ``after_create`` / ``after_update`` so the ops run on the
13-
same session (and thus the same SQLite connection) as the main request.
14-
"""
15-
from sqlalchemy import text
16-
17-
from fastapi_admin_kit.db import get_db_session
18-
19-
ops = getattr(request.state, "_admin_perm_pending_ops", None)
20-
if not ops:
21-
return
22-
request.state._admin_perm_pending_ops = []
23-
session = get_db_session(request)
24-
if session is None:
25-
return
26-
for sql_str, params in ops:
27-
await session.execute(text(sql_str), params)
10+
"""No-op — direct permissions are now saved immediately."""
11+
pass
2812

2913

3014
def _get_table_names() -> list[str]:
@@ -115,66 +99,72 @@ def after_update(self, obj, request=None):
11599
self._save_direct_permissions_after_commit(obj, perm_data, request)
116100

117101
def _save_direct_permissions_after_commit(self, obj, perm_data, request):
118-
delete_sql = "DELETE FROM admin_user_permissions WHERE user_id = :uid"
119-
insert_sql = (
120-
"INSERT INTO admin_user_permissions"
121-
" (user_id, table_name, can_view, can_create, can_edit, can_delete)"
122-
" VALUES (:uid, :tn, :cv, :cc, :ce, :cd)"
123-
)
124-
125-
ops = []
126-
ops.append((delete_sql, {"uid": obj.id}))
127-
128-
for table_name, perms in perm_data.items():
129-
if not any(perms.get(a) for a in ["view", "create", "edit", "delete"]):
130-
continue
131-
ops.append(
132-
(
133-
insert_sql,
134-
{
135-
"uid": obj.id,
136-
"tn": table_name,
137-
"cv": 1 if perms.get("view") else 0,
138-
"cc": 1 if perms.get("create") else 0,
139-
"ce": 1 if perms.get("edit") else 0,
140-
"cd": 1 if perms.get("delete") else 0,
141-
},
142-
)
143-
)
102+
"""Save direct user permissions after the user object is committed.
103+
104+
perm_data: list of permission IDs, e.g. [1, 3, 5]
105+
"""
106+
import asyncio
107+
108+
from sqlalchemy import delete
109+
110+
from fastapi_admin_kit.auth.models import UserPermission
111+
112+
perm_ids = perm_data if isinstance(perm_data, list) else []
113+
114+
async def _do_save():
115+
from fastapi_admin_kit.db import get_db_session
116+
117+
session = get_db_session(request)
118+
# Delete existing direct permissions for this user
119+
await session.execute(delete(UserPermission).where(UserPermission.user_id == obj.id))
144120

145-
if not hasattr(request.state, "_admin_perm_pending_ops"):
146-
request.state._admin_perm_pending_ops = []
147-
request.state._admin_perm_pending_ops.extend(ops)
121+
# Insert new direct permissions
122+
for perm_id in perm_ids:
123+
up = UserPermission(user_id=obj.id, permission_id=perm_id)
124+
session.add(up)
125+
126+
await session.commit()
127+
128+
try:
129+
loop = asyncio.get_event_loop()
130+
if loop.is_running():
131+
asyncio.ensure_future(_do_save())
132+
else:
133+
loop.run_until_complete(_do_save())
134+
except RuntimeError:
135+
asyncio.run(_do_save())
148136

149137
async def get_form_context(self, context, obj=None, request=None):
150138
"""Load direct permissions for the user being edited."""
151139
from sqlalchemy import select
152140

153-
from fastapi_admin_kit.auth.models import UserPermission
141+
from fastapi_admin_kit.auth.models import Permission, UserPermission
154142
from fastapi_admin_kit.db import get_db_session
155143

156-
perm_data = {}
144+
perm_data = []
157145
if obj is not None and request is not None:
158146
try:
159147
session = get_db_session(request)
160148
result = await session.execute(
161-
select(UserPermission).where(UserPermission.user_id == obj.id)
149+
select(UserPermission, Permission)
150+
.join(Permission, UserPermission.permission_id == Permission.id)
151+
.where(UserPermission.user_id == obj.id)
162152
)
163-
for p in result.scalars():
164-
perm_data[p.table_name] = {
165-
"_label": p.table_name,
166-
"view": p.can_view,
167-
"create": p.can_create,
168-
"edit": p.can_edit,
169-
"delete": p.can_delete,
170-
}
153+
for up, perm in result:
154+
perm_data.append(
155+
{
156+
"id": perm.id,
157+
"name": perm.name,
158+
"table_name": perm.table_name,
159+
}
160+
)
171161
except Exception as exc:
172162
import logging
173163

174164
logging.getLogger(__name__).debug("Permission load failed: %s", exc)
175165

176166
context["perm_data"] = perm_data
177-
context["search_url"] = "/admin/tables/search"
167+
context["search_url"] = "/admin/permissions/search"
178168
return context
179169

180170
def process_form_data(self, data, request=None):

fastapi_admin_kit/api/auth.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,11 @@ async def _build_user_permissions(user: Any, db_session: Any) -> dict[str, list[
9494
user_id = getattr(user, "id", None)
9595
if user_id is not None:
9696
result = await db_session.execute(
97-
select(UserPermission).where(UserPermission.user_id == user_id)
97+
select(UserPermission, Permission)
98+
.join(Permission, UserPermission.permission_id == Permission.id)
99+
.where(UserPermission.user_id == user_id)
98100
)
99-
for perm in result.scalars():
101+
for up, perm in result:
100102
actions = []
101103
if perm.can_view:
102104
actions.append("view")

fastapi_admin_kit/auth/mixins.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from sqlalchemy import Boolean, Column, String
88

99
if TYPE_CHECKING:
10-
pass
10+
from sqlalchemy.ext.asyncio import AsyncSession
1111

1212

1313
class AuthModelMixin:
@@ -42,6 +42,7 @@ class MyUser(AutoModelMixin, Base):
4242
- ``verify_password(password)`` → bool
4343
- ``hash_password(password)`` → str (classmethod)
4444
- ``set_hasher(hasher)`` classmethod
45+
- ``has_perm(perm_name, session)`` → bool (check permission by name)
4546
"""
4647

4748
_hasher: ClassVar[type | None] = None
@@ -83,5 +84,68 @@ def set_hasher(cls, hasher: type) -> None:
8384
"""Set the password hasher class for this model."""
8485
cls._hasher = hasher
8586

87+
async def has_perm(self, perm_name: str, session: AsyncSession) -> bool:
88+
"""Check if this user has a permission by name (e.g. 'products_view').
89+
90+
Returns True if any assigned role grants this permission,
91+
or if a direct user permission grants it. Superusers always return True.
92+
"""
93+
if self.is_superuser:
94+
return True
95+
96+
from sqlalchemy import select
97+
98+
from fastapi_admin_kit.auth.models import (
99+
Permission,
100+
UserPermission,
101+
admin_role_permissions,
102+
admin_user_roles,
103+
)
104+
105+
# Parse perm_name -> (table_name, action)
106+
# e.g. "products_view" -> ("products", "view")
107+
parts = perm_name.rsplit("_", 1)
108+
if len(parts) != 2:
109+
return False
110+
table_name, action = parts
111+
112+
attr = f"can_{action}"
113+
if attr not in ("can_view", "can_create", "can_edit", "can_delete"):
114+
return False
115+
116+
role_ids = self.role_ids
117+
if not role_ids and not self.id:
118+
return False
119+
120+
# Check role-based permissions
121+
if role_ids:
122+
result = await session.execute(
123+
select(Permission)
124+
.join(
125+
admin_role_permissions,
126+
Permission.id == admin_role_permissions.c.permission_id,
127+
)
128+
.join(
129+
admin_user_roles,
130+
admin_role_permissions.c.role_id == admin_user_roles.c.role_id,
131+
)
132+
.where(admin_user_roles.c.user_id == self.id)
133+
)
134+
for perm in result.scalars():
135+
if perm.table_name == table_name and getattr(perm, attr, False):
136+
return True
137+
138+
# Check direct user permissions
139+
result = await session.execute(
140+
select(Permission)
141+
.join(UserPermission, UserPermission.permission_id == Permission.id)
142+
.where(UserPermission.user_id == self.id)
143+
)
144+
for perm in result.scalars():
145+
if perm.table_name == table_name and getattr(perm, attr, False):
146+
return True
147+
148+
return False
149+
86150

87151
# Backward-compatible alias

fastapi_admin_kit/auth/models.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ class UserPermission(Base):
144144

145145
__tablename__ = "admin_user_permissions"
146146
__table_args__ = (
147-
UniqueConstraint("user_id", "table_name", name="uq_admin_user_perm_user_table"),
147+
UniqueConstraint("user_id", "permission_id", name="uq_admin_user_perm_user_perm"),
148148
)
149149

150150
id = Column(Integer, primary_key=True)
@@ -153,19 +153,22 @@ class UserPermission(Base):
153153
ForeignKey("admin_users.id", ondelete="CASCADE"),
154154
nullable=False,
155155
)
156-
table_name = Column(String(255), nullable=False)
157-
can_view = Column(Boolean, default=False)
158-
can_create = Column(Boolean, default=False)
159-
can_edit = Column(Boolean, default=False)
160-
can_delete = Column(Boolean, default=False)
156+
permission_id = Column(
157+
Integer,
158+
ForeignKey("admin_permissions.id", ondelete="CASCADE"),
159+
nullable=False,
160+
)
161161

162162
user = relationship("User", back_populates="direct_permissions")
163+
permission = relationship("Permission", backref="user_overrides")
163164

164165
def __str__(self) -> str:
165-
return f"{self.table_name} (user {self.user_id})"
166+
if self.permission:
167+
return f"{self.permission.name} (user {self.user_id})"
168+
return f"user {self.user_id}"
166169

167170
def __repr__(self) -> str:
168-
return f"<UserPermission user={self.user_id} table={self.table_name!r}>"
171+
return f"<UserPermission user={self.user_id} perm={self.permission_id}>"
169172

170173

171174
class RefreshToken(Base):

fastapi_admin_kit/auth/permissions.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,14 @@ async def _load_direct_permissions(self) -> dict[str, PermissionSet | None]:
9696

9797
from sqlalchemy import select
9898

99+
from fastapi_admin_kit.auth.models import Permission
100+
99101
result = await self.session.execute(
100-
select(UserPermission).where(UserPermission.user_id == self._user_id)
102+
select(UserPermission, Permission)
103+
.join(Permission, UserPermission.permission_id == Permission.id)
104+
.where(UserPermission.user_id == self._user_id)
101105
)
102-
for perm in result.scalars():
106+
for up, perm in result:
103107
table = perm.table_name
104108
if table not in self._direct_cache:
105109
self._direct_cache[table] = PermissionSet()

fastapi_admin_kit/auth/views.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ async def login_post(
113113
auth_backend: AuthBackend = request.app.state.admin_auth_backend
114114
login_field = request.app.state.admin_config.get("login_field", "email")
115115
user = await auth_backend.authenticate(username, password, session, login_field=login_field)
116-
print("render page", user)
117116
if user is not None:
118117
_login_rate_limiter.reset(client_ip)
119118
user.last_login = datetime.now(UTC)

fastapi_admin_kit/static/js/admin.js

Lines changed: 15 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -205,19 +205,18 @@ document.addEventListener('alpine:init', () => {
205205
/* ── Permission Widget ────────────────────────────────────────────── */
206206

207207
Alpine.data('permissionWidget', (searchUrl, initialPermData) => ({
208-
selectedTables: [],
208+
selectedPerms: [],
209209
searchQuery: '',
210210
results: [],
211211
open: false,
212212
_debounce: null,
213-
permData: {},
214-
expandedTable: null,
215213

216214
init() {
217-
this.permData = initialPermData || {};
218-
this.selectedTables = Object.keys(this.permData).map(k => ({
219-
id: k, label: this.permData[k]._label || k
220-
}));
215+
if (initialPermData && Array.isArray(initialPermData)) {
216+
this.selectedPerms = initialPermData.map(p => ({
217+
id: p.id, name: p.name, table_name: p.table_name
218+
}));
219+
}
221220
},
222221

223222
async search() {
@@ -229,58 +228,31 @@ document.addEventListener('alpine:init', () => {
229228
const resp = await fetch(url);
230229
if (resp.ok) {
231230
const all = await resp.json();
232-
const selected = new Set(this.selectedTables.map(t => t.id));
231+
const selected = new Set(this.selectedPerms.map(p => p.id));
233232
this.results = all.filter(r => !selected.has(r.id));
234233
}
235234
} catch (e) { console.error('Permission search error:', e); }
236235
}, 250);
237236
},
238237

239-
addTable(table) {
240-
if (!this.permData[table.id]) {
241-
this.permData[table.id] = {
242-
_label: table.label,
243-
view: false, create: false, edit: false, delete: false
244-
};
238+
addPerm(perm) {
239+
if (!this.selectedPerms.find(p => p.id === perm.id)) {
240+
this.selectedPerms.push({ id: perm.id, name: perm.name, table_name: perm.table_name });
245241
}
246-
this.selectedTables.push(table);
247242
this.searchQuery = '';
248243
this.results = [];
249-
this.expandedTable = table.id;
250-
},
251-
252-
removeTable(index) {
253-
const table = this.selectedTables[index];
254-
delete this.permData[table.id];
255-
this.selectedTables.splice(index, 1);
256-
if (this.expandedTable === table.id) this.expandedTable = null;
257-
},
258-
259-
toggleExpand(tableId) {
260-
this.expandedTable = this.expandedTable === tableId ? null : tableId;
261244
},
262245

263-
toggleAllActions(tableId, on) {
264-
this.permData[tableId].view = on;
265-
this.permData[tableId].create = on;
266-
this.permData[tableId].edit = on;
267-
this.permData[tableId].delete = on;
246+
removePerm(index) {
247+
this.selectedPerms.splice(index, 1);
268248
},
269249

270-
toggleAllTables(action, on) {
271-
for (const table of this.selectedTables) {
272-
if (this.permData[table.id]) {
273-
this.permData[table.id][action] = on;
274-
}
275-
}
250+
toggleAll(action, on) {
251+
// No-op — permissions are individual, no table grouping
276252
},
277253

278254
get serializedPermData() {
279-
const out = {};
280-
for (const [table, data] of Object.entries(this.permData)) {
281-
out[table] = { view: data.view, create: data.create, edit: data.edit, delete: data.delete };
282-
}
283-
return JSON.stringify(out);
255+
return JSON.stringify(this.selectedPerms.map(p => p.id));
284256
}
285257
}));
286258

0 commit comments

Comments
 (0)