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
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user