feat: v6.1.0 granular permissions (page/collection/property ACL + groups + audit)
FlowDeck CI / lint (push) Failing after 1m8s
FlowDeck CI / test (push) Failing after 8m5s
FlowDeck CI / docker (push) Skipped

- 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
This commit is contained in:
2026-09-19 22:54:16 -04:00
parent d707a6850a
commit 13d5f8625a
11 changed files with 2215 additions and 14 deletions
+31 -1
View File
@@ -14,6 +14,7 @@ from app.routers.dashboard import _get_app_version
from app.routers.sidebar_config import get_sidebar_config_sync
from app.services.automations import fire_event
from app.services.gitea_client import gitea
from app.services.permission_manager import PermissionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
@@ -1362,8 +1363,14 @@ async def create_page(request: Request, title: str = Query(default=""),
@router.get("/api/pages/{page_id}")
async def get_page(page_id: int):
async def get_page(request: Request, page_id: int):
"""Get a Markdown page."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
# No session → legacy single-user behaviour (matches collections `_require_view`).
if user and user.get("id"):
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
raise HTTPException(404, "Page not found")
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
@@ -1377,6 +1384,15 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
content_format: str = Query(default="")):
"""Update a page's title and/or content. Accepts JSON body for blocks."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(403, "Authentication required")
# v6.0.0: granular page permissions — hidden pages 404 (not 403); a visible
# page the caller cannot edit yields 403.
pm = PermissionManager(user["id"], bool(user.get("is_admin")))
if not pm.can_view_page(page_id):
raise HTTPException(404, "Page not found")
if not pm.can_edit_page(page_id):
raise HTTPException(403, "You don't have edit access to this page")
with get_conn() as conn:
_ensure_page_editable(conn, page_id, user)
if title:
@@ -1408,6 +1424,9 @@ async def save_page_blocks(request: Request, page_id: int):
title = body.get("title", "")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id")
# No session → legacy single-user behaviour; otherwise enforce edit rights.
if uid and not PermissionManager(uid).can_edit_page(page_id):
raise HTTPException(403, "You don't have edit access to this page")
# Extract synced block ids from the blocks
def _extract_synced(blocks: list[dict]) -> set[int]:
@@ -1892,6 +1911,13 @@ async def move_page(request: Request, page_id: int):
@router.delete("/api/pages/{page_id}")
async def delete_page(request: Request, page_id: int):
"""Move a page to trash (soft delete)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id")
if not uid:
raise HTTPException(403, "Authentication required")
# v6.0.0: granular page permissions — need at least edit access to trash.
if not PermissionManager(uid).can_edit_page(page_id):
raise HTTPException(403, "You don't have edit access to this page")
with get_conn() as conn:
row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone()
if not row:
@@ -1910,6 +1936,10 @@ async def view_page(request: Request, page_id: int):
embed = request.query_params.get("embed") == "1"
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
# v6.0.0: granular page permissions — hide restricted pages (404).
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row: