Files
flowdeck/app/routers/permissions.py
T
bruno 13d5f8625a
FlowDeck CI / lint (push) Failing after 1m8s
FlowDeck CI / test (push) Failing after 8m5s
FlowDeck CI / docker (push) Skipped
feat: v6.1.0 granular permissions (page/collection/property ACL + groups + audit)
- Migration 18: 6 tables + 3 colonnes permission_type + indexes
- PermissionManager: heritage page->collection->workspace, least privilege, groups, cache 60s
- API /api/v2: pages/collections/properties/groups/users/audit (401/403/404/400)
- Guards board.py + collections.py (404/403, admin/owner bypass)
- Tests 21/21 (inherit/restricted/private, grant, revoke, batch, group, audit)
- Docs + ROADMAP + CHANGELOG + VERSION 6.1.0
2026-09-19 22:54:16 -04:00

526 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, 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")
async 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")
async 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")
async def grant_page_permission(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")
body = await request.json()
return _grant_common(request, pm, "page", page_id, body,
"page_permissions", "page_id", PAGE_ROLES)
@router.post("/pages/{page_id}/permissions/batch")
async def batch_page_permissions(page_id: int, request: Request):
"""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")
body = await request.json()
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}")
async 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")
async 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")
async def grant_collection_permission(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")
body = await request.json()
return _grant_common(request, pm, "collection", collection_id, body,
"collection_permissions", "collection_id", COLLECTION_ROLES)
@router.delete("/collections/{collection_id}/permissions/{perm_id}")
async 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")
async 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")
async 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")
async def grant_property_permission(collection_id: int, property_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")
body = await request.json()
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}")
async 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")
async 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")
async def create_group(request: Request):
pm = _pm(request)
body = await request.json()
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}")
async def update_group(group_id: int, request: Request):
pm = _pm(request)
body = await request.json()
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}")
async 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")
async 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")
async def add_group_member(group_id: int, request: Request):
pm = _pm(request)
body = await request.json()
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}")
async 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")
async 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")
async 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]}