"""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]}