4 passes (283 → 93 routes async sur 667 = 86 % hors loop, avant 61 %) :
A. RACINE AUTH — `get_current_user` (auth/session.py) était `async def`
SANS aucun await (cookie decode = synchrone) ; idem ses clones :
`agent._current_user_id/_workspace_id/_current_admin` (34 sites) et
`sso._require_admin` (corps 0 await, 6 sites) → `def` +
47 `await` supprimés. Piège : 3 call sites passaient par l'alias `gcu`
(grep littéral aveugle) — 8 tests en échec → corrigés.
B. Re-scan : 19 routes devenues SANS await → `def` (agent 8, sso 5,
web_clipper 3, projects 2, auth 1…).
C/D. 155 routes dont les seuls awaits = `request.json()` / événements :
- try/except `body = {}` → `Body(default={})` (même tolérance)
- try/except `raise HTTPException(400)` → `Body(...)` REQUIS
(422 FastAPI — aucun test ne couvrait le 400)
- forme conditionnelle `request.json() if content-type else {}`
(54 sites) → défaut `{}` (sans corps = `{}` dans les 2 cas)
- `await fire_*` → `run_event_sync(...)` ; imports `Body` /
`run_event_sync` ajoutés aux routers convertis
Reste async (93, justifié) : form/upload/file (22), réseau gitea/llm/oidc,
`_json_body` (9), 2 JSON inline en argument, 1 fallback logique
(capture_frontend_error), 1 lecture conditionnelle (web_clipper), mixtes.
suite **1089/1089** · ruff OK · docs à jour
519 lines
22 KiB
Python
519 lines
22 KiB
Python
"""FlowDeck — v6.0.0 Granular permissions API (page/collection/property ACL).
|
|
|
|
Backend for the page-editor "Permissions" panel, database property visibility
|
|
and user-group management. Grants are stored in ``page_permissions`` /
|
|
``collection_permissions`` / ``property_permissions``; every mutation is logged
|
|
into ``permission_audit_log`` for the admin audit view.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Body, HTTPException, Request
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
from app.services.permission_manager import PermissionManager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["permissions"], prefix="/api/v2")
|
|
|
|
|
|
PAGE_ROLES = ("viewer", "commenter", "editor", "owner")
|
|
COLLECTION_ROLES = ("viewer", "commenter", "editor", "owner")
|
|
PROPERTY_ROLES = ("viewer", "editor")
|
|
PERMISSION_TYPES = ("inherit", "restricted", "private")
|
|
|
|
|
|
def _require_user(request: Request) -> dict:
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user or not user.get("id"):
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
return user
|
|
|
|
|
|
def _pm(request: Request) -> PermissionManager:
|
|
return PermissionManager(_require_user(request)["id"])
|
|
|
|
|
|
def _client_ip(request: Request) -> str:
|
|
try:
|
|
return request.client.host if request.client else ""
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _perm_list(conn, table: str, fk: str, resource_id: int) -> list[dict]:
|
|
rows = conn.execute(
|
|
f"""SELECT p.*,
|
|
u.login AS user_login, u.full_name AS user_name,
|
|
g.name AS group_name
|
|
FROM {table} p
|
|
LEFT JOIN users u ON u.id = p.user_id
|
|
LEFT JOIN user_groups g ON g.id = p.group_id
|
|
WHERE p.{fk}=? ORDER BY p.id""",
|
|
(resource_id,),
|
|
).fetchall()
|
|
out = []
|
|
for r in rows:
|
|
d = dict(r)
|
|
if d.get("user_id"):
|
|
d["name"] = d["user_name"] or d["user_login"] or f"User #{d['user_id']}"
|
|
d["kind"] = "user"
|
|
else:
|
|
d["name"] = d["group_name"] or f"Group #{d['group_id']}"
|
|
d["kind"] = "group"
|
|
out.append(d)
|
|
return out
|
|
|
|
|
|
def _grant_common(request: Request, pm: PermissionManager, resource_type: str,
|
|
resource_id: int, body: dict, table: str, fk: str,
|
|
allowed_roles: tuple[str, ...],
|
|
extra_cols: dict | None = None) -> dict:
|
|
user_id = body.get("user_id")
|
|
group_id = body.get("group_id")
|
|
role = (body.get("role") or "").strip()
|
|
if role not in allowed_roles:
|
|
raise HTTPException(400, f"role must be one of {', '.join(allowed_roles)}")
|
|
if not user_id and not group_id:
|
|
raise HTTPException(400, "Provide either user_id or group_id")
|
|
if user_id and not isinstance(user_id, int):
|
|
raise HTTPException(400, "user_id must be an integer")
|
|
if group_id and not isinstance(group_id, int):
|
|
raise HTTPException(400, "group_id must be an integer")
|
|
actor = _require_user(request)["id"]
|
|
with get_conn() as conn:
|
|
if user_id:
|
|
exists = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
|
if not exists:
|
|
raise HTTPException(404, "User not found")
|
|
if group_id:
|
|
exists = conn.execute("SELECT id FROM user_groups WHERE id=?", (group_id,)).fetchone()
|
|
if not exists:
|
|
raise HTTPException(404, "Group not found")
|
|
existing = conn.execute(
|
|
f"SELECT id, role FROM {table} WHERE {fk}=? AND user_id IS ? AND group_id IS ?",
|
|
(resource_id, user_id, group_id),
|
|
).fetchone()
|
|
if existing:
|
|
conn.execute(f"UPDATE {table} SET role=? WHERE id=?",
|
|
(role, existing["id"]))
|
|
old_role = existing["role"]
|
|
perm_id = existing["id"]
|
|
else:
|
|
cols = [fk, "user_id", "group_id", "role", "granted_by"]
|
|
vals: list = [resource_id, user_id, group_id, role, actor]
|
|
for col, val in (extra_cols or {}).items():
|
|
cols.append(col)
|
|
vals.append(val)
|
|
placeholders = ", ".join("?" for _ in cols)
|
|
cur = conn.execute(
|
|
f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders})",
|
|
tuple(vals),
|
|
)
|
|
perm_id = cur.lastrowid
|
|
old_role = None
|
|
conn.commit()
|
|
pm.invalidate()
|
|
pm.log_permission_change(resource_type, resource_id, "grant",
|
|
target_user_id=user_id, target_group_id=group_id,
|
|
old_role=old_role, new_role=role, ip_address=_client_ip(request))
|
|
return {"status": "ok", "id": perm_id, "role": role, "user_id": user_id, "group_id": group_id}
|
|
|
|
|
|
def _revoke_common(request: Request, pm: PermissionManager, resource_type: str,
|
|
resource_id: int, table: str, fk: str, perm_id: int) -> dict:
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
f"SELECT user_id, group_id, role FROM {table} WHERE id=? AND {fk}=?",
|
|
(perm_id, resource_id),
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Permission not found")
|
|
conn.execute(f"DELETE FROM {table} WHERE id=?", (perm_id,))
|
|
conn.commit()
|
|
pm.invalidate()
|
|
pm.log_permission_change(resource_type, resource_id, "revoke",
|
|
target_user_id=row["user_id"], target_group_id=row["group_id"],
|
|
old_role=row["role"], new_role=None, ip_address=_client_ip(request))
|
|
return {"status": "revoked"}
|
|
|
|
|
|
def _set_permission_type(request: Request, pm: PermissionManager, resource_type: str,
|
|
resource_id: int, table: str, body: dict) -> dict:
|
|
ptype = (body.get("permission_type") or "").strip()
|
|
if ptype not in PERMISSION_TYPES:
|
|
raise HTTPException(400, f"permission_type must be one of {', '.join(PERMISSION_TYPES)}")
|
|
with get_conn() as conn:
|
|
conn.execute(f"UPDATE {table} SET permission_type=? WHERE id=?", (ptype, resource_id))
|
|
conn.commit()
|
|
pm.invalidate()
|
|
pm.log_permission_change(resource_type, resource_id, "type_change",
|
|
new_role=ptype, ip_address=_client_ip(request))
|
|
return {"status": "ok", "permission_type": ptype}
|
|
|
|
|
|
# ═══════════════ Page permissions ═══════════════
|
|
|
|
|
|
@router.get("/pages/{page_id}/permissions")
|
|
def list_page_permissions(page_id: int, request: Request):
|
|
"""List explicit page grants + the caller's effective role."""
|
|
pm = _pm(request)
|
|
if not pm.can_view_page(page_id):
|
|
raise HTTPException(404, "Page not found")
|
|
with get_conn() as conn:
|
|
grants = _perm_list(conn, "page_permissions", "page_id", page_id)
|
|
return {
|
|
"permissions": grants,
|
|
"mine": pm.get_page_permission(page_id),
|
|
"permission_type": _page_type(page_id),
|
|
"can_manage": pm.can_manage_page_permissions(page_id),
|
|
}
|
|
|
|
|
|
@router.get("/pages/{page_id}/permissions/mine")
|
|
def my_page_permission(page_id: int, request: Request):
|
|
"""Effective role of the current user on a page (UI gating)."""
|
|
pm = _pm(request)
|
|
if not pm.can_view_page(page_id):
|
|
raise HTTPException(404, "Page not found")
|
|
return {
|
|
"role": pm.get_page_permission(page_id),
|
|
"can_edit": pm.can_edit_page(page_id),
|
|
"can_comment": pm.can_comment_page(page_id),
|
|
"can_manage": pm.can_manage_page_permissions(page_id),
|
|
"permission_type": _page_type(page_id),
|
|
}
|
|
|
|
|
|
def _page_type(page_id: int) -> str:
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT permission_type FROM pages WHERE id=?", (page_id,)
|
|
).fetchone()
|
|
return (row["permission_type"] if row else "inherit") or "inherit"
|
|
|
|
|
|
@router.post("/pages/{page_id}/permissions")
|
|
def grant_page_permission(page_id: int, request: Request, body: dict = Body(default={})):
|
|
pm = _pm(request)
|
|
if not pm.can_manage_page_permissions(page_id):
|
|
raise HTTPException(403, "Only a page owner can manage its permissions")
|
|
return _grant_common(request, pm, "page", page_id, body,
|
|
"page_permissions", "page_id", PAGE_ROLES)
|
|
|
|
|
|
@router.post("/pages/{page_id}/permissions/batch")
|
|
def batch_page_permissions(page_id: int, request: Request, body: dict = Body(default={})):
|
|
"""Grant several permissions in one call: {grants: [{user_id|group_id, role}, ...]}."""
|
|
pm = _pm(request)
|
|
if not pm.can_manage_page_permissions(page_id):
|
|
raise HTTPException(403, "Only a page owner can manage its permissions")
|
|
grants = body.get("grants") or []
|
|
if not isinstance(grants, list) or not grants:
|
|
raise HTTPException(400, "grants must be a non-empty list")
|
|
results = []
|
|
for g in grants:
|
|
results.append(_grant_common(request, pm, "page", page_id, g,
|
|
"page_permissions", "page_id", PAGE_ROLES))
|
|
return {"status": "ok", "granted": results}
|
|
|
|
|
|
@router.delete("/pages/{page_id}/permissions/{perm_id}")
|
|
def revoke_page_permission(page_id: int, perm_id: int, request: Request):
|
|
pm = _pm(request)
|
|
if not pm.can_manage_page_permissions(page_id):
|
|
raise HTTPException(403, "Only a page owner can manage its permissions")
|
|
return _revoke_common(request, pm, "page", page_id, "page_permissions", "page_id", perm_id)
|
|
|
|
|
|
@router.post("/pages/{page_id}/permission-type")
|
|
async def set_page_permission_type(page_id: int, request: Request):
|
|
pm = _pm(request)
|
|
if not pm.can_manage_page_permissions(page_id):
|
|
raise HTTPException(403, "Only a page owner can manage its permissions")
|
|
return _set_permission_type(request, pm, "page", page_id, "pages", await request.json())
|
|
|
|
|
|
# ═══════════════ Collection permissions ═══════════════
|
|
|
|
|
|
@router.get("/collections/{collection_id}/permissions")
|
|
def list_collection_permissions(collection_id: int, request: Request):
|
|
pm = _pm(request)
|
|
if not pm.can_view_collection(collection_id):
|
|
raise HTTPException(404, "Collection not found")
|
|
with get_conn() as conn:
|
|
grants = _perm_list(conn, "collection_permissions", "collection_id", collection_id)
|
|
return {
|
|
"permissions": grants,
|
|
"mine": pm.get_collection_permission(collection_id),
|
|
"permission_type": _collection_type(collection_id),
|
|
"can_manage": pm.can_manage_collection_permissions(collection_id),
|
|
}
|
|
|
|
|
|
def _collection_type(collection_id: int) -> str:
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT permission_type FROM collections WHERE id=?", (collection_id,)
|
|
).fetchone()
|
|
return (row["permission_type"] if row else "inherit") or "inherit"
|
|
|
|
|
|
@router.post("/collections/{collection_id}/permissions")
|
|
def grant_collection_permission(collection_id: int, request: Request, body: dict = Body(default={})):
|
|
pm = _pm(request)
|
|
if not pm.can_manage_collection_permissions(collection_id):
|
|
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
|
return _grant_common(request, pm, "collection", collection_id, body,
|
|
"collection_permissions", "collection_id", COLLECTION_ROLES)
|
|
|
|
|
|
@router.delete("/collections/{collection_id}/permissions/{perm_id}")
|
|
def revoke_collection_permission(collection_id: int, perm_id: int, request: Request):
|
|
pm = _pm(request)
|
|
if not pm.can_manage_collection_permissions(collection_id):
|
|
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
|
return _revoke_common(request, pm, "collection", collection_id,
|
|
"collection_permissions", "collection_id", perm_id)
|
|
|
|
|
|
@router.post("/collections/{collection_id}/permission-type")
|
|
async def set_collection_permission_type(collection_id: int, request: Request):
|
|
pm = _pm(request)
|
|
if not pm.can_manage_collection_permissions(collection_id):
|
|
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
|
return _set_permission_type(request, pm, "collection", collection_id,
|
|
"collections", await request.json())
|
|
|
|
|
|
@router.get("/collections/{collection_id}/properties/visible")
|
|
def visible_properties(collection_id: int, request: Request):
|
|
"""Split property ids into visible / hidden for the current user."""
|
|
pm = _pm(request)
|
|
if not pm.can_view_collection(collection_id):
|
|
raise HTTPException(404, "Collection not found")
|
|
visible = pm.get_visible_properties(collection_id)
|
|
with get_conn() as conn:
|
|
all_ids = [r["id"] for r in conn.execute(
|
|
"SELECT id FROM collection_properties WHERE collection_id=?",
|
|
(collection_id,),
|
|
).fetchall()]
|
|
return {
|
|
"visible": visible,
|
|
"hidden": [pid for pid in all_ids if pid not in visible],
|
|
"can_edit": pm.can_edit_collection(collection_id),
|
|
}
|
|
|
|
|
|
# ═══════════════ Property permissions ═══════════════
|
|
|
|
|
|
@router.get("/collections/{collection_id}/properties/{property_id}/permissions")
|
|
def list_property_permissions(collection_id: int, property_id: int, request: Request):
|
|
pm = _pm(request)
|
|
if not pm.can_view_collection(collection_id):
|
|
raise HTTPException(404, "Collection not found")
|
|
with get_conn() as conn:
|
|
grants = _perm_list(conn, "property_permissions", "property_id", property_id)
|
|
return {
|
|
"permissions": grants,
|
|
"mine_view": pm.can_view_property(collection_id, property_id),
|
|
"mine_edit": pm.can_edit_property(collection_id, property_id),
|
|
"can_manage": pm.can_manage_collection_permissions(collection_id),
|
|
}
|
|
|
|
|
|
@router.post("/collections/{collection_id}/properties/{property_id}/permissions")
|
|
def grant_property_permission(collection_id: int, property_id: int, request: Request, body: dict = Body(default={})):
|
|
pm = _pm(request)
|
|
if not pm.can_manage_collection_permissions(collection_id):
|
|
raise HTTPException(403, "Only a collection owner can manage property permissions")
|
|
with get_conn() as conn:
|
|
prop = conn.execute(
|
|
"SELECT id FROM collection_properties WHERE id=? AND collection_id=?",
|
|
(property_id, collection_id),
|
|
).fetchone()
|
|
if not prop:
|
|
raise HTTPException(404, "Property not found")
|
|
return _grant_common(request, pm, "property", property_id, body,
|
|
"property_permissions", "property_id", PROPERTY_ROLES,
|
|
extra_cols={"collection_id": collection_id})
|
|
|
|
|
|
@router.delete("/collections/{collection_id}/properties/{property_id}/permissions/{perm_id}")
|
|
def revoke_property_permission(collection_id: int, property_id: int,
|
|
perm_id: int, request: Request):
|
|
pm = _pm(request)
|
|
if not pm.can_manage_collection_permissions(collection_id):
|
|
raise HTTPException(403, "Only a collection owner can manage property permissions")
|
|
return _revoke_common(request, pm, "property", property_id,
|
|
"property_permissions", "property_id", perm_id)
|
|
|
|
|
|
# ═══════════════ Groups ═══════════════
|
|
|
|
|
|
@router.get("/groups")
|
|
def list_groups(request: Request, workspace_id: int | None = None):
|
|
user = _require_user(request)
|
|
pm = PermissionManager(user["id"])
|
|
return {"groups": pm.get_groups_for_workspace(workspace_id)}
|
|
|
|
|
|
@router.post("/groups")
|
|
def create_group(request: Request, body: dict = Body(default={})):
|
|
pm = _pm(request)
|
|
ws_id = body.get("workspace_id")
|
|
gid = pm.create_group(ws_id, body.get("name") or "", body.get("description") or "",
|
|
created_by=pm.user_id)
|
|
pm.log_permission_change("group", gid, "group_create",
|
|
target_group_id=gid, new_role="",
|
|
ip_address=_client_ip(request))
|
|
return {"status": "ok", "id": gid}
|
|
|
|
|
|
@router.put("/groups/{group_id}")
|
|
def update_group(group_id: int, request: Request, body: dict = Body(default={})):
|
|
pm = _pm(request)
|
|
name = (body.get("name") or "").strip()
|
|
if not name:
|
|
raise HTTPException(400, "name is required")
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Group not found")
|
|
if not pm.is_workspace_admin(row["workspace_id"]):
|
|
raise HTTPException(403, "Only a workspace owner or admin can edit groups")
|
|
conn.execute(
|
|
"UPDATE user_groups SET name=?, description=? WHERE id=?",
|
|
(name, body.get("description") or "", group_id),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.delete("/groups/{group_id}")
|
|
def delete_group(group_id: int, request: Request):
|
|
pm = _pm(request)
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Group not found")
|
|
if not pm.is_workspace_admin(row["workspace_id"]):
|
|
raise HTTPException(403, "Only a workspace owner or admin can delete groups")
|
|
pm.delete_group(group_id)
|
|
pm.log_permission_change("group", group_id, "group_delete",
|
|
target_group_id=group_id, ip_address=_client_ip(request))
|
|
return {"status": "deleted"}
|
|
|
|
|
|
@router.get("/groups/{group_id}/members")
|
|
def list_group_members(group_id: int, request: Request):
|
|
user = _require_user(request)
|
|
return {"members": PermissionManager(user["id"]).get_group_members(group_id)}
|
|
|
|
|
|
@router.post("/groups/{group_id}/members")
|
|
def add_group_member(group_id: int, request: Request, body: dict = Body(default={})):
|
|
pm = _pm(request)
|
|
user_id = body.get("user_id")
|
|
if not user_id or not isinstance(user_id, int):
|
|
raise HTTPException(400, "user_id is required")
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Group not found")
|
|
if not pm.is_workspace_admin(row["workspace_id"]):
|
|
raise HTTPException(403, "Only a workspace owner or admin can manage groups")
|
|
pm.add_user_to_group(group_id, user_id)
|
|
pm.invalidate()
|
|
pm.log_permission_change("group", group_id, "member_add",
|
|
target_user_id=user_id, target_group_id=group_id,
|
|
ip_address=_client_ip(request))
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.delete("/groups/{group_id}/members/{user_id}")
|
|
def remove_group_member(group_id: int, user_id: int, request: Request):
|
|
pm = _pm(request)
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Group not found")
|
|
if not pm.is_workspace_admin(row["workspace_id"]):
|
|
raise HTTPException(403, "Only a workspace owner or admin can manage groups")
|
|
pm.remove_user_from_group(group_id, user_id)
|
|
pm.invalidate()
|
|
pm.log_permission_change("group", group_id, "member_remove",
|
|
target_user_id=user_id, target_group_id=group_id,
|
|
ip_address=_client_ip(request))
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ═══════════════ Users (access pickers) + audit ═══════════════
|
|
|
|
|
|
@router.get("/users")
|
|
def list_users(request: Request, workspace_id: int | None = None, q: str = ""):
|
|
"""Workspace members (+ admins) for the grant pickers."""
|
|
_require_user(request)
|
|
q = (q or "").strip().lower()
|
|
with get_conn() as conn:
|
|
if workspace_id:
|
|
rows = conn.execute(
|
|
"""SELECT DISTINCT u.id, u.login, u.full_name, u.email, u.avatar_color
|
|
FROM users u
|
|
LEFT JOIN workspace_members wm ON wm.user_id=u.id AND wm.workspace_id=?
|
|
WHERE u.is_admin=1 OR wm.id IS NOT NULL
|
|
ORDER BY u.login""",
|
|
(workspace_id,),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT id, login, full_name, email, avatar_color FROM users ORDER BY login"
|
|
).fetchall()
|
|
users = []
|
|
for r in rows:
|
|
d = dict(r)
|
|
if q and q not in (d["login"].lower(), d["full_name"].lower(),
|
|
d["email"].lower()):
|
|
continue
|
|
users.append({"id": d["id"], "login": d["login"], "name": d["full_name"] or d["login"],
|
|
"email": d["email"], "avatar_color": d["avatar_color"]})
|
|
return {"users": users}
|
|
|
|
|
|
@router.get("/audit/permissions")
|
|
def permission_audit(request: Request, limit: int = 100):
|
|
"""Full permission change history — workspace owner/admin only."""
|
|
user = _require_user(request)
|
|
uid = user["id"]
|
|
is_admin = bool(user.get("is_admin"))
|
|
limit = max(1, min(int(limit), 500))
|
|
with get_conn() as conn:
|
|
if not is_admin:
|
|
owned = conn.execute(
|
|
"SELECT id FROM workspaces WHERE owner_id=?", (uid,)
|
|
).fetchall()
|
|
if not owned:
|
|
raise HTTPException(403, "Only a workspace owner or admin can view the audit log")
|
|
rows = conn.execute(
|
|
"""SELECT a.*, u.login AS actor_login
|
|
FROM permission_audit_log a LEFT JOIN users u ON u.id=a.performed_by
|
|
ORDER BY a.created_at DESC, a.id DESC LIMIT ?""",
|
|
(limit,),
|
|
).fetchall()
|
|
return {"events": [dict(r) for r in rows]}
|