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
+62 -3
View File
@@ -25,6 +25,7 @@ from app.services.recurrence import (
validate_rule,
)
from app.services.reminders import REMINDER_KEY, parse_lead
from app.services.permission_manager import PermissionManager
def _current_user(request: Request) -> dict:
@@ -33,6 +34,36 @@ def _current_user(request: Request) -> dict:
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
def _session_user(request: Request) -> dict | None:
"""Resolve the session user WITHOUT the admin fallback (for ACL checks)."""
s = request.cookies.get("flowdeck_session", "")
user = SessionManager.decode_session(s)
return user if user and user.get("id") else None
def _require_view(collection_id: int, user: dict | None) -> None:
"""Return None when a user may view the collection, else raise 404.
A missing/userless session keeps the legacy single-user behaviour (owner on
un-workspaced collections); explicit ``restricted`` / ``private`` collections
are hidden for non-owners unless granted.
"""
if not user:
return
pm = PermissionManager(user["id"])
if not pm.can_view_collection(collection_id):
raise HTTPException(status_code=404, detail="Collection not found")
def _require_edit(collection_id: int, user: dict | None) -> None:
"""Raise 403 when the user may not edit pages in the collection."""
if not user:
return
pm = PermissionManager(user["id"])
if not pm.can_edit_collection(collection_id):
raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
def _collection_properties(conn, collection_id: int) -> list[dict]:
return [
dict(r) for r in conn.execute(
@@ -291,6 +322,13 @@ async def update_collection_api(request: Request, collection_id: int):
@router.delete("/api/{collection_id}")
async def delete_collection_api(request: Request, collection_id: int):
"""API: delete a collection and its pages (CASCADE)."""
# v6.0.0: granular collection permissions — owner/admin only.
user = _session_user(request)
_require_view(collection_id, user)
if user:
pm = PermissionManager(user["id"])
if not pm.can_manage_collection_permissions(collection_id):
raise HTTPException(status_code=403, detail="Only a collection owner can delete it")
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
@@ -456,7 +494,8 @@ async def get_page_api(request: Request, page_id: int):
).fetchone()
if not page:
raise HTTPException(status_code=404, detail="Page not found")
# v6.0.0: granular collection/page permissions.
_require_view(page["collection_id"], _session_user(request))
return dict(page)
@@ -474,6 +513,8 @@ async def update_page_api(request: Request, page_id: int):
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Page not found")
# v6.0.0: granular collection/page permissions.
_require_edit(existing["collection_id"], _session_user(request))
title = body.get("title", existing["title"])
icon = body.get("icon", existing["icon"])
@@ -537,6 +578,8 @@ async def delete_page_api(request: Request, page_id: int):
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Page not found")
# v6.0.0: granular collection/page permissions.
_require_edit(existing["collection_id"], _session_user(request))
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
conn.commit()
@@ -601,7 +644,9 @@ async def list_property_types_api(request: Request):
@router.get("/{collection_id}/properties/api")
async def list_properties_api(request: Request, collection_id: int):
"""API: list all properties for a collection."""
"""API: list all properties visible to the current user."""
user = _session_user(request)
_require_view(collection_id, user)
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
@@ -610,7 +655,14 @@ async def list_properties_api(request: Request, collection_id: int):
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
return {"properties": [dict(r) for r in rows]}
props = [dict(r) for r in rows]
# v6.0.0: property-level visibility — owners/editors see everything, other
# users only the properties explicitly granted or left open.
if user:
pm = PermissionManager(user["id"])
visible = pm.get_visible_properties(collection_id)
props = [p for p in props if p["id"] in visible]
return {"properties": props}
@router.get("/{collection_id}/members/api")
@@ -1793,6 +1845,8 @@ async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
async def view_collection(request: Request, collection_id: int, view_type: str = "table"):
"""Main view — renders collection in the requested view type."""
# v6.0.0: granular collection permissions — hide restricted collections.
_require_view(collection_id, _session_user(request))
with get_conn() as conn:
collection = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
@@ -2349,6 +2403,8 @@ tr:hover td{{background:#222}}
@router.get("/{collection_id}/api")
async def get_collection_api(request: Request, collection_id: int):
"""API: get a single collection with its pages."""
# v6.0.0: granular collection permissions — hide restricted collections.
_require_view(collection_id, _session_user(request))
with get_conn() as conn:
collection = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
@@ -2375,6 +2431,9 @@ async def get_collection_api(request: Request, collection_id: int):
@router.post("/{collection_id}/pages/api")
async def create_page_api(request: Request, collection_id: int):
"""API: create a page in a collection."""
# v6.0.0: granular collection permissions — viewer/commenter cannot create.
_require_view(collection_id, _session_user(request))
_require_edit(collection_id, _session_user(request))
try:
body = await request.json()
except Exception: