diff --git a/CHANGELOG.md b/CHANGELOG.md
index adc3c2e..54e955e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,46 @@
# Changelog - FlowDeck
+## v6.1.0 (2026-09-19) — Granular Permissions : page / collection / property ACL + groupes + audit
+
+> Permissions fines héritables : chaque page / database / propriété peut être restreinte à des utilisateurs ou groupes explicites. L'héritage suit la chaîne page → collection → workspace (moindre privilège), avec bypass owner/admin et audit complet.
+
+### Base de données (migration 18)
+
+- Tables `user_groups` (workspace-scoped, UNIQUE(name)), `group_members` (N-ary), `page_permissions`, `collection_permissions`, `property_permissions` (user_id XOR group_id, CHECK, UNIQUE), `permission_audit_log`.
+- Colonnes `pages.permission_type`, `collections.permission_type`, `collection_pages.permission_type` (`inherit|restricted|private`, défaut `inherit`).
+- Index `idx_pp_page`, `idx_pp_user`, `idx_cp_collection`, `idx_propp_prop`, `idx_perm_audit_res`, `idx_gm_*`.
+
+### PermissionManager (`app/services/permission_manager.py`)
+
+- Résolution héritage + moindre privilège : grant explicite sur page > grant collection > rôle workspace ; `restricted`/`private` sans grant → 404.
+- Méthodes page : `get_page_permission()`, `can_view/edit/comment/manag` ; collection : `get_collection_permission()`, `can_view/edit/manag` ; propriété : `can_view/edit_property()`, `get_visible_properties()`.
+- Groupes : `create_group()`, `add/remove_user_from_group()`, `get_groups_for_workspace()`, `get_group_members()`, `is_workspace_admin()`, `user_group_ids()`.
+- Helpers : `_explicit_grant_role()` (best rank user+groups), `_collection_role()`, `_property_*`, `_owns_workspace()`, `_is_admin()`.
+- Cache 60 s (`_cached()`) + `invalidate()` appelé après chaque grant/revoke/type_change.
+- `log_permission_change()` (audit, never-throw).
+
+### API (`app/routers/permissions.py` — `prefix /api/v2`)
+
+- Pages : `GET /pages/{id}/permissions` (grants + mine + type + can_manage), `GET /pages/{id}/permissions/mine`, `POST /pages/{id}/permissions` (user_id|group_id + role viewer/commenter/editor/owner), `POST /pages/{id}/permissions/batch`, `DELETE /pages/{id}/permissions/{perm_id}`, `POST /pages/{id}/permission-type`.
+- Collections : `GET /collections/{id}/permissions`, `POST /collections/{id}/permissions`, `DELETE /collections/{id}/permissions/{perm_id}`, `POST /collections/{id}/permission-type`, `GET /collections/{id}/properties/visible` (split visible/hidden).
+- Properties : `GET /collections/{cid}/properties/{pid}/permissions`, `POST …/permissions` (role viewer|editor), `DELETE …/permissions/{perm_id}` (extra_cols collection_id).
+- Groupes : `GET /groups?workspace_id`, `POST /groups`, `PUT /groups/{id}`, `DELETE /groups/{id}`, `GET/POST /groups/{id}/members`, `DELETE /groups/{id}/members/{uid}` (workspace owner/admin only).
+- Access pickers + audit : `GET /users?workspace_id&q`, `GET /audit/permissions?limit` (owner/admin, 500 max).
+- Validation : 401 sans session, 403 sans can_manage, 400 rôle invalide / cible manquante, 404 user/group/property inconnu.
+
+### Guards existants
+
+- `board.py` : `GET /board/api/pages/{id}` (404 si !can_view_page), `PUT /board/api/pages/{id}` (404 si non-view, 403 si viewer/commenter, 423 si locked reste prioritaire).
+- `collections.py` : `DELETE /db/api/{id}` (404 si !can_view_collection, 403 si !can_manage), `GET /db/pages/{id}/api` (404), `PUT/DELETE /db/pages/{id}/api` (403), `GET /db/{id}/properties/api` filtré par visible, `_require_view/_require_edit` helpers avec `_session_user()` (pas de fallback admin sur null session).
+
+### UI (backend-ready)
+
+- Endpoints prêts pour panneau Permissions de l'éditeur (icône 🔒), panneau collection, masquage colonnes via `visible`/`hidden`, et gestion des groupes en Settings. Les grants sont consommés par `can_view_property` / `get_visible_properties` côté vues.
+
+### Tests
+
+- `tests/test_v60_granular_permissions.py` — **21 tests** : inherit allow + edit 403, restricted hidden (owner 200 / member 404), grant viewer unlock + viewer cannot edit, editor can edit but not manage, revoke 404, private + admin override, mine + batch, permission-type via API, restricted collection hidden (API 404 + HTML 302), collection viewer 403 create/delete, owner delete, collection editor create pages, property visibility, property hidden, property grant 403, group grant inherits + listing shows group, group removal revokes, groups listing/members + non-owner 403, audit log (grant+type_change, member 403), endpoints 401, validation 404/400. Suite 21/21 verte.
+
## v6.0.0 (2026-09-18) — PWA : Progressive Web App, offline support
> FlowDeck devient une PWA installable et utilisable hors ligne : le shell est
diff --git a/ROADMAP.md b/ROADMAP.md
index f7e6829..b022907 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -714,11 +714,26 @@ Détails livrés :
---
+## v6.1.0 — Granular Permissions ✅ (2026-09-19)
+
+> **Objectif** : page-level, collection-level & property-level ACL + groupes + audit. **COMPLETED**.
+
+- [x] **Page permissions** — modes `inherit|restricted|private` (`pages.permission_type`, `collections.permission_type`, `collection_pages.permission_type`), grants explicites user/group (`page_permissions` role viewer/commenter/editor/owner), héritage page→collection→workspace, 404 masqué pour non-grantees, owner/admin bypass
+- [x] **Collection permissions** — `collection_permissions` + permission_type, `/db/{id}` et `/db/{id}/api` masqués (404→302), création page et delete collection gatés (viewer 403), editor bypass
+- [x] **Property-level visibility** — `property_permissions` (viewer|editor), `GET /db/{id}/properties/api` filtré par `get_visible_properties()`, `GET /api/v2/collections/{id}/properties/visible` (visible/hidden), grant = owner collection uniquement
+- [x] **Groupes réutilisables** — tables `user_groups` + `group_members` (workspace-scoped, UNIQUE(name)), CRUD `/api/v2/groups` + `/groups/{id}/members`, grant par `group_id` (page/collection/property), retrait membre révoque l'accès
+- [x] **API** — `app/routers/permissions.py` : pages (list/mine, grant, batch, revoke, permission-type), collections (list/grant/revoke/type + visible), properties (list/grant/revoke), groups (list/create/update/delete + members), users picker, audit `GET /api/v2/audit/permissions`
+- [x] **Guards** — `board.py` (`GET/PUT /board/api/pages/{id}`) + `collections.py` (`delete collection`, `GET/PUT/DELETE page`, `properties`), 403/404 conformes, is_admin/owner bypass + `_session_user()` (no admin fallback)
+- [x] **PermissionManager** — `app/services/permission_manager.py` étendu : `_explicit_grant_role()` (best rank user+groups), `get_page/collection_permission()`, `can_view/edit_page|collection`, `can_view/edit_property()`, `get_visible_properties()`, groups, `log_permission_change()`, cache 60s + `invalidate()`
+- [x] **Audit** — table `permission_audit_log` (resource_type, action grant/revoke/type_change/group_*), index, log sur tous les mutateurs, `GET /api/v2/audit/permissions` (owner/admin only, limit 500)
+- [x] **Migration 18** — `migrations.py` : création 6 tables + 3 colonnes `permission_type` + indexes (idempotent)
+- [x] **Tests** — `tests/test_v60_granular_permissions.py` **21 tests** (inherit/restricted/private, grant viewer/editor, revoke, batch, type via API, collection restricted+grant, property visibility/hidden, group inherits + revoke, audit, auth 401, validation 400/404)
+
## v6.0.0 — Pro (futur)
- [x] **PWA** — Progressive Web App, offline support ✅ (livré) — [📄 Conception détaillée](/docs/V6_PWA_Progressive_Web_App.md)
+- [x] **Granular permissions** — page-level, property-level access control ✅ (livré v6.1.0) — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
- [ ] **SSO/SAML** — enterprise authentication — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md)
-- [ ] **Granular permissions** — page-level, property-level access control — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
- [ ] **Web Clipper** — extension navigateur — [📄 Conception détaillée](/docs/V6_Web_Clipper.md)
- [ ] **API publique complète** — REST API documentée (OpenAPI) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 Référence des features v6](/docs/API_GUIDE_V6.md#11-documents-de-conception-détaillée-v600)
- [ ] **Realtime editing (production)** — voir **v5.13.0** (curseurs + présence déjà avancés ici) ; reste en v6 : conflits avancés, édition large échelle
@@ -768,4 +783,4 @@ Quality DB views, Agent IA Palette → Realtime + E
DB avancée, redo, drag&drop, bookmark, avancée
Calendrier, AI duplicate) lightbox…) (Pt.2)
-*Dernière mise à jour: 2026-09-18 — **v6.0.0 PWA (offline support) COMPLETED** (manifest, service worker, IndexedDB, sync `/api/v2/sync/*`, conflits, UI offline, tests + E2E). Reste: v6.0.0 Pro (SSO/SAML, permissions granulaires, Web Clipper, API publique, realtime production)*
+*Dernière mise à jour: 2026-09-19 — **v6.1.0 Granular Permissions COMPLETED** (page/collection/property ACL, groupes, audit, 21 tests). Reste: v6.0.0 Pro (SSO/SAML, Web Clipper, API publique, realtime production)*
diff --git a/VERSION b/VERSION
index 09b254e..dfda3e0 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-6.0.0
+6.1.0
diff --git a/app/main.py b/app/main.py
index 2c838c2..a7624b8 100644
--- a/app/main.py
+++ b/app/main.py
@@ -45,6 +45,7 @@ from app.routers.github_routes import router as github_router
from app.routers.imports import page_router as import_page_router
from app.routers.imports import router as imports_router
from app.routers.notifications import router as notifications_router
+from app.routers.permissions import router as permissions_router
from app.routers.realtime import router as realtime_router
from app.services.webhook_outbound import init_webhook_tables
@@ -108,7 +109,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
- version="6.0.0",
+ version="6.1.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
@@ -151,6 +152,7 @@ app.include_router(onboarding.router)
app.include_router(sync.router)
app.include_router(imports_router)
app.include_router(import_page_router)
+app.include_router(permissions_router)
app.mount("/static", StaticFiles(directory="static"), name="static")
diff --git a/app/migrations.py b/app/migrations.py
index f3a0e82..ec3ea2a 100644
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -666,6 +666,129 @@ def _migration_offline_sync_queue(conn: sqlite3.Connection) -> None:
)
+@register(18, "v6.0.0: granular permissions (page/collection/property ACL + groups)")
+def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
+ """v6.0.0 — Granular permissions (page-level, collection-level,
+ property-level access control + reusable user groups).
+
+ ``user_groups`` — named groups scoped to a workspace.
+ ``group_members`` — users inside a group (N-ary join).
+ ``page_permissions`` — explicit grants for block-editor pages
+ (``pages`` table). user_id XOR group_id.
+ ``collection_permissions`` — explicit grants for databases.
+ ``property_permissions`` — explicit viewer/editor grants per property.
+ ``permission_audit_log`` — immutable trail of every grant/revoke.
+ ``pages.permission_type`` / ``collections.permission_type`` — access
+ mode: 'inherit' (default, follows the
+ workspace/collection chain) | 'restricted'
+ | 'private' (explicit grants only).
+ """
+ conn.execute(
+ """CREATE TABLE IF NOT EXISTS user_groups (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(workspace_id, name)
+ )"""
+ )
+ conn.execute(
+ """CREATE TABLE IF NOT EXISTS group_members (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(group_id, user_id)
+ )"""
+ )
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_gm_group ON group_members(group_id)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_gm_user ON group_members(user_id)")
+
+ conn.execute(
+ """CREATE TABLE IF NOT EXISTS page_permissions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
+ role TEXT NOT NULL, -- viewer | commenter | editor | owner
+ grant_type TEXT NOT NULL DEFAULT 'explicit', -- explicit | group
+ granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
+ UNIQUE(page_id, user_id, group_id)
+ )"""
+ )
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_pp_page ON page_permissions(page_id, role)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_pp_user ON page_permissions(user_id)")
+
+ conn.execute(
+ """CREATE TABLE IF NOT EXISTS collection_permissions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
+ role TEXT NOT NULL, -- viewer | commenter | editor | owner
+ grant_type TEXT NOT NULL DEFAULT 'explicit',
+ granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
+ UNIQUE(collection_id, user_id, group_id)
+ )"""
+ )
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_cp_collection ON collection_permissions(collection_id, role)")
+
+ conn.execute(
+ """CREATE TABLE IF NOT EXISTS property_permissions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
+ property_id INTEGER NOT NULL REFERENCES collection_properties(id) ON DELETE CASCADE,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
+ role TEXT NOT NULL, -- viewer | editor
+ grant_type TEXT NOT NULL DEFAULT 'explicit',
+ granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
+ UNIQUE(collection_id, property_id, user_id, group_id)
+ )"""
+ )
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_propp_prop ON property_permissions(property_id, role)")
+
+ conn.execute(
+ """CREATE TABLE IF NOT EXISTS permission_audit_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ resource_type TEXT NOT NULL, -- page | collection | property | group
+ resource_id INTEGER NOT NULL,
+ action TEXT NOT NULL, -- grant | revoke | type_change | group_create | group_delete | member_add | member_remove
+ target_user_id INTEGER,
+ target_group_id INTEGER,
+ old_role TEXT,
+ new_role TEXT,
+ performed_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ ip_address TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )"""
+ )
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_perm_audit_res "
+ "ON permission_audit_log(resource_type, resource_id, created_at)"
+ )
+
+ for table in ("pages", "collection_pages"):
+ cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
+ if "permission_type" not in cols:
+ conn.execute(
+ f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
+ )
+ _ccols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
+ if "permission_type" not in _ccols:
+ conn.execute(
+ "ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
+ )
+
+
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
"""Add ``sync_version`` to ``table`` if it is not already present."""
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
diff --git a/app/routers/board.py b/app/routers/board.py
index 27f9c46..4d797dc 100644
--- a/app/routers/board.py
+++ b/app/routers/board.py
@@ -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("
Page not found
", status_code=404)
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
diff --git a/app/routers/collections.py b/app/routers/collections.py
index 99a02e4..90d1551 100644
--- a/app/routers/collections.py
+++ b/app/routers/collections.py
@@ -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:
diff --git a/app/routers/permissions.py b/app/routers/permissions.py
new file mode 100644
index 0000000..276ad58
--- /dev/null
+++ b/app/routers/permissions.py
@@ -0,0 +1,525 @@
+"""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]}
diff --git a/app/services/permission_manager.py b/app/services/permission_manager.py
index e6d7576..ca03099 100644
--- a/app/services/permission_manager.py
+++ b/app/services/permission_manager.py
@@ -1,12 +1,27 @@
-"""FlowDeck — Agent permission guard (v4.10.0).
+"""FlowDeck — Permission manager: workspace roles + granular ACL (v6.0.0).
-The agent always acts with *at most* the permissions of the invoking user
-(Notion Agent principle). This manager resolves the user's role in the active
-workspace and gates tool execution before any write reaches the database.
+Two layers:
+
+1. **Workspace roles** (v4.10.0, agent guard): every user has a single role in
+ each workspace (owner > owner-membership > editor > commenter > viewer).
+ The FlowDeck Agent always acts with *at most* the permissions of the
+ invoking user (Notion Agent principle).
+
+2. **Granular permissions** (v6.0.0): explicit page / collection / property
+ grants plus reusable user groups. Resolution follows the least-privilege
+ rule — an explicit grant on a resource overrides the inherited chain
+ (page → collection → workspace), while ``restricted`` / ``private``
+ resources deny access unless a grant (or the workspace owner / admin)
+ applies.
+
+Resolution results are cached for 60 s to keep the hot paths (sidebar, view
+rendering, route guards) < 10 ms per check; ``PermissionManager.invalidate()``
+drops the cache after any grant/revoke/type change.
"""
from __future__ import annotations
import logging
+import time
from fastapi import HTTPException
@@ -19,6 +34,12 @@ READ_ROLES = {"viewer", "commenter", "editor", "admin", "owner"}
WRITE_ROLES = {"editor", "admin", "owner"}
DESTRUCTIVE_ROLES = {"admin", "owner"}
+# Granular resource roles (ranked, least → most privileged).
+_GRANULAR_ROLES = ("viewer", "commenter", "editor", "owner")
+_ROLE_RANK = {role: i for i, role in enumerate(_GRANULAR_ROLES)}
+_PROPERTY_ROLES = ("viewer", "editor")
+_PROPERTY_RANK = {"viewer": 0, "editor": 1}
+
# Tools that mutate state and therefore require at least an editor role.
WRITE_TOOLS = {
"create_collection", "create_view", "create_page", "update_page",
@@ -35,10 +56,27 @@ DESTRUCTIVE_TOOLS = {
class PermissionManager:
- """Resolves workspace role and gates agent tool calls."""
+ """Resolves workspace role and gates agent + granular ACL checks."""
- def __init__(self, user_id: int):
+ def __init__(self, user_id: int, is_admin: bool = False):
self.user_id = user_id
+ self._is_admin_override = bool(is_admin)
+ self._cache: dict[str, tuple[float, object]] = {}
+
+ # ── Cache helpers ──
+
+ def _cached(self, key: str, ttl: float, fn):
+ now = time.monotonic()
+ hit = self._cache.get(key)
+ if hit and now - hit[0] < ttl:
+ return hit[1]
+ val = fn()
+ self._cache[key] = (now, val)
+ return val
+
+ def invalidate(self) -> None:
+ """Drop the resolution cache after a grant/revoke/type change."""
+ self._cache.clear()
# ── Role resolution ──
@@ -100,3 +138,315 @@ class PermissionManager:
# A viewer can always read; editor can read+write.
if role not in READ_ROLES:
raise HTTPException(status_code=403, detail="User has no access to this workspace")
+
+ # ═══════════════════════════════════════════════════════════════════════
+ # Granular permissions (v6.0.0)
+ # ═══════════════════════════════════════════════════════════════════════
+
+ def _is_admin(self, conn) -> bool:
+ if self._is_admin_override:
+ return True
+ row = conn.execute(
+ "SELECT is_admin FROM users WHERE id=?", (self.user_id,)
+ ).fetchone()
+ return bool(row and row["is_admin"])
+
+ def _owns_workspace(self, conn, workspace_id: int | None) -> bool:
+ if workspace_id is None:
+ # No workspace → single-user semantics: the actor is the owner.
+ return True
+ row = conn.execute(
+ "SELECT id FROM workspaces WHERE id=? AND owner_id=?",
+ (workspace_id, self.user_id),
+ ).fetchone()
+ return bool(row)
+
+ def user_group_ids(self, conn) -> list[int]:
+ return [
+ r["group_id"]
+ for r in conn.execute(
+ "SELECT group_id FROM group_members WHERE user_id=?", (self.user_id,)
+ ).fetchall()
+ ]
+
+ def _explicit_grant_role(self, conn, table: str, fk: str, resource_id: int,
+ role_rank: dict[str, int] | None = None) -> str | None:
+ """Most-privileged explicit role on ``table`` for the user / groups."""
+ rank = role_rank or _ROLE_RANK
+ groups = self.user_group_ids(conn)
+ if groups:
+ placeholders = ", ".join("?" * len(groups))
+ rows = conn.execute(
+ f"SELECT role FROM {table} WHERE {fk}=? "
+ f"AND (user_id=? OR group_id IN ({placeholders}))",
+ (resource_id, self.user_id, *groups),
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ f"SELECT role FROM {table} WHERE {fk}=? AND user_id=?",
+ (resource_id, self.user_id),
+ ).fetchall()
+ best = max((rank.get(r["role"], -1) for r in rows), default=-1)
+ if best < 0:
+ return None
+ rev = {rank[k]: k for k in rank}
+ return rev[best]
+
+ # ── Page-level ──
+
+ def get_page_permission(self, page_id: int) -> str | None:
+ """Effective page role for ``self.user_id`` (least privilege).
+
+ Chain: explicit page grant > explicit collection grant > workspace
+ role. ``restricted`` / ``private`` pages ignore the inherited chain.
+ Returns ``None`` when the user must not see the page at all.
+ """
+
+ def _resolve() -> str | None:
+ with get_conn() as conn:
+ page = conn.execute(
+ "SELECT permission_type, workspace_id, collection_id FROM pages WHERE id=?",
+ (page_id,),
+ ).fetchone()
+ if not page:
+ return None
+ if self._is_admin(conn) or self._owns_workspace(conn, page["workspace_id"]):
+ return "owner"
+ explicit = self._explicit_grant_role(
+ conn, "page_permissions", "page_id", page_id
+ )
+ if explicit:
+ return explicit
+ ptype = page["permission_type"] or "inherit"
+ if ptype in ("restricted", "private"):
+ return None
+ if page["collection_id"]:
+ coll_role = self._collection_role(conn, page["collection_id"])
+ if coll_role:
+ return coll_role
+ return self.role_in_workspace(page["workspace_id"])
+ return self._cached(f"page:{page_id}", 60, _resolve)
+
+ def can_view_page(self, page_id: int) -> bool:
+ return self.get_page_permission(page_id) is not None
+
+ def can_edit_page(self, page_id: int) -> bool:
+ role = self.get_page_permission(page_id)
+ return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["editor"])
+
+ def can_comment_page(self, page_id: int) -> bool:
+ role = self.get_page_permission(page_id)
+ return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["commenter"])
+
+ def can_manage_page_permissions(self, page_id: int) -> bool:
+ role = self.get_page_permission(page_id)
+ return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["owner"])
+
+ # ── Collection-level ──
+
+ def _collection_role(self, conn, collection_id: int) -> str | None:
+ coll = conn.execute(
+ "SELECT permission_type, workspace_id FROM collections WHERE id=?",
+ (collection_id,),
+ ).fetchone()
+ if not coll:
+ return None
+ if self._is_admin(conn) or self._owns_workspace(conn, coll["workspace_id"]):
+ return "owner"
+ explicit = self._explicit_grant_role(
+ conn, "collection_permissions", "collection_id", collection_id
+ )
+ if explicit:
+ return explicit
+ ptype = coll["permission_type"] or "inherit"
+ if ptype in ("restricted", "private"):
+ return None
+ return self.role_in_workspace(coll["workspace_id"])
+
+ def get_collection_permission(self, collection_id: int) -> str | None:
+ def _resolve() -> str | None:
+ with get_conn() as conn:
+ return self._collection_role(conn, collection_id)
+ return self._cached(f"collection:{collection_id}", 60, _resolve)
+
+ def can_view_collection(self, collection_id: int) -> bool:
+ return self.get_collection_permission(collection_id) is not None
+
+ def can_edit_collection(self, collection_id: int) -> bool:
+ role = self.get_collection_permission(collection_id)
+ return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["editor"])
+
+ def can_manage_collection_permissions(self, collection_id: int) -> bool:
+ role = self.get_collection_permission(collection_id)
+ return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["owner"])
+
+ # ── Property-level ──
+
+ def _property_grants_exist(self, conn, property_id: int) -> bool:
+ row = conn.execute(
+ "SELECT 1 FROM property_permissions WHERE property_id=? LIMIT 1",
+ (property_id,),
+ ).fetchone()
+ return row is not None
+
+ def _has_property_grant(self, conn, property_id: int, min_rank: int) -> bool:
+ groups = self.user_group_ids(conn)
+ if groups:
+ placeholders = ", ".join("?" * len(groups))
+ rows = conn.execute(
+ f"SELECT role FROM property_permissions WHERE property_id=? "
+ f"AND (user_id=? OR group_id IN ({placeholders}))",
+ (property_id, self.user_id, *groups),
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ "SELECT role FROM property_permissions WHERE property_id=? AND user_id=?",
+ (property_id, self.user_id),
+ ).fetchall()
+ return any(_PROPERTY_RANK.get(r["role"], -1) >= min_rank for r in rows)
+
+ def can_view_property(self, collection_id: int, property_id: int) -> bool:
+ """A property is visible unless it carries explicit grants excluding
+ the user; without any grant it inherits from the collection. Collection
+ owners/admins always see every property."""
+ if not self.can_view_collection(collection_id):
+ return False
+ return self._cached(
+ f"prop:{property_id}", 60, lambda: self._property_visible(collection_id, property_id)
+ )
+
+ def _collection_workspace_id(self, conn, collection_id: int) -> int | None:
+ row = conn.execute(
+ "SELECT workspace_id FROM collections WHERE id=?", (collection_id,)
+ ).fetchone()
+ return row["workspace_id"] if row else None
+
+ def _property_visible(self, collection_id: int, property_id: int) -> bool:
+ with get_conn() as conn:
+ workspace_id = self._collection_workspace_id(conn, collection_id)
+ if self._is_admin(conn) or self._owns_workspace(conn, workspace_id):
+ return True
+ if self.can_manage_collection_permissions(collection_id):
+ return True
+ if not self._property_grants_exist(conn, property_id):
+ return True
+ return self._has_property_grant(conn, property_id, _PROPERTY_RANK["viewer"])
+
+ def can_edit_property(self, collection_id: int, property_id: int) -> bool:
+ if not self.can_edit_collection(collection_id):
+ return False
+ with get_conn() as conn:
+ workspace_id = self._collection_workspace_id(conn, collection_id)
+ if self._is_admin(conn) or self._owns_workspace(conn, workspace_id):
+ return True
+ if self.can_manage_collection_permissions(collection_id):
+ return True
+ if not self._property_grants_exist(conn, property_id):
+ return True
+ return self._has_property_grant(conn, property_id, _PROPERTY_RANK["editor"])
+
+ def get_visible_properties(self, collection_id: int) -> list[int]:
+ def _resolve() -> list[int]:
+ with get_conn() as conn:
+ props = conn.execute(
+ "SELECT id FROM collection_properties WHERE collection_id=?",
+ (collection_id,),
+ ).fetchall()
+ return [p["id"] for p in props if self.can_view_property(collection_id, p["id"])]
+ return self._cached(f"visible_props:{collection_id}", 60, _resolve)
+
+ # ── Groups ──
+
+ def is_workspace_admin(self, workspace_id: int | None) -> bool:
+ with get_conn() as conn:
+ return self._is_admin(conn) or self._owns_workspace(conn, workspace_id)
+
+ def create_group(self, workspace_id: int | None, name: str,
+ description: str = "", created_by: int | None = None) -> int:
+ if not self.is_workspace_admin(workspace_id):
+ raise HTTPException(403, "Only a workspace owner or admin can create groups")
+ if not name.strip():
+ raise HTTPException(400, "name is required")
+ with get_conn() as conn:
+ dupe = conn.execute(
+ "SELECT id FROM user_groups WHERE workspace_id IS ? AND name=?",
+ (workspace_id, name.strip()),
+ ).fetchone()
+ if dupe:
+ raise HTTPException(400, "A group with this name already exists")
+ cur = conn.execute(
+ "INSERT INTO user_groups (workspace_id, name, description, created_by) "
+ "VALUES (?, ?, ?, ?)",
+ (workspace_id, name.strip(), description or "", created_by),
+ )
+ conn.commit()
+ return cur.lastrowid
+
+ def add_user_to_group(self, group_id: int, user_id: int) -> None:
+ with get_conn() as conn:
+ group = conn.execute(
+ "SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
+ ).fetchone()
+ if not group:
+ raise HTTPException(404, "Group not found")
+ conn.execute(
+ "INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)",
+ (group_id, user_id),
+ )
+ conn.commit()
+
+ def remove_user_from_group(self, group_id: int, user_id: int) -> None:
+ with get_conn() as conn:
+ conn.execute(
+ "DELETE FROM group_members WHERE group_id=? AND user_id=?",
+ (group_id, user_id),
+ )
+ conn.commit()
+
+ def delete_group(self, group_id: int) -> None:
+ with get_conn() as conn:
+ conn.execute("DELETE FROM user_groups WHERE id=?", (group_id,))
+ conn.commit()
+
+ def get_groups_for_workspace(self, workspace_id: int | None) -> list[dict]:
+ with get_conn() as conn:
+ rows = conn.execute(
+ """SELECT g.id, g.name, g.description, g.created_by, g.created_at,
+ (SELECT COUNT(*) FROM group_members m WHERE m.group_id=g.id) AS member_count
+ FROM user_groups g WHERE g.workspace_id IS ? ORDER BY g.name""",
+ (workspace_id,),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_group_members(self, group_id: int) -> list[dict]:
+ with get_conn() as conn:
+ rows = conn.execute(
+ """SELECT u.id, u.login, u.full_name, u.email, m.joined_at
+ FROM group_members m JOIN users u ON u.id=m.user_id
+ WHERE m.group_id=? ORDER BY u.login""",
+ (group_id,),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ # ── Audit log ──
+
+ def log_permission_change(self, resource_type: str, resource_id: int, action: str,
+ target_user_id: int | None = None,
+ target_group_id: int | None = None,
+ old_role: str | None = None,
+ new_role: str | None = None,
+ ip_address: str = "") -> None:
+ """Write one immutable audit row for a permission change."""
+ try:
+ with get_conn() as conn:
+ conn.execute(
+ """INSERT INTO permission_audit_log
+ (resource_type, resource_id, action, target_user_id, target_group_id,
+ old_role, new_role, performed_by, ip_address)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (resource_type, resource_id, action, target_user_id, target_group_id,
+ old_role, new_role, self.user_id, ip_address),
+ )
+ conn.commit()
+ except Exception as exc: # audit must never break the caller
+ logger.warning("permission audit log failed: %s", exc)
diff --git a/docs/V6_Granular_Permissions.md b/docs/V6_Granular_Permissions.md
new file mode 100644
index 0000000..b90501a
--- /dev/null
+++ b/docs/V6_Granular_Permissions.md
@@ -0,0 +1,518 @@
+# V6.0.0 — Permissions Granulaires : Page-level & Property-level Access Control
+
+> **Statut** : Conception détaillée — v6.0.0
+> **Date** : 2026-09-15
+> **Route** : `feat/v6-permissions` → `develop` → `main`
+> **Dépendances** : v4.0.0 Accounts & Integrations + v2.0 Multi-utilisateurs (workspaces, workspace_members)
+
+---
+
+## 1. Vision & objectifs
+
+Étendre le modèle de permissions existant (workspace-level avec rôles owner/admin/editor/commenter/viewer) vers des permissions **plus fines** :
+
+- **Page-level** : contrôler qui peut voir, éditer, commenter une page spécifique
+- **Property-level** : contrôler quels utilisateurs peuvent voir ou modifier des propriétés spécifiques
+- **Collection-level** : permissions héritables ou individuelles sur une collection/database
+
+### Objectifs
+
+| Critère | Cible |
+|---------|-------|
+| Héritage | Page-level → Collection-level → Workspace-level |
+| Performance | Vérification de permission < 10 ms (cache) |
+| Compatibilité | Tous les systèmes existants continuent de fonctionner |
+| UI | Indicateurs clairs des permissions (icônes, masquage) |
+| Audit | Log complet de tous les changements de permissions |
+
+---
+
+## 2. Modèle de permissions étendu
+
+### 2.1 Hiérarchie des permissions
+
+```
+Workspace (owner/admin/editor/commenter/viewer)
+ └── Collection (hérite du workspace + override possible)
+ └── Page (hérite de la collection + override possible)
+ └── Block (hérite de la page + override possible)
+ └── Property (hérite de la page/collection + override possible)
+```
+
+### 2.2 Matrice des permissions
+
+| Permission | workspace | collection | page | property |
+|------------|-----------|------------|------|----------|
+| **View** | ✓ viewer+ | ✓ viewer+ | ✓ viewer+ | ✓ viewer+ |
+| **Create** | ✓ editor+ | ✓ editor+ | ✓ editor+ | N/A |
+| **Edit** | ✓ editor+ | ✓ editor+ | ✓ editor+ | ✓ editor+ |
+| **Comment** | ✓ commenter+ | ✓ commenter+ | ✓ commenter+ | N/A |
+| **Delete** | ✓ admin+ | ✓ admin+ | ✓ admin+ | ✓ admin+ |
+| **Manage permissions** | ✓ admin+ | ✓ admin+ | ✓ admin+ | ✓ admin+ |
+| **Share** | ✓ editor+ | ✓ editor+ | ✓ editor+ | N/A |
+| **Lock** | ✓ admin+ | ✓ admin+ | ✓ admin+ | N/A |
+
+### 2.3 Rôles par ressource
+
+```python
+# Rôles disponibles par ressource (au-delà du workspace)
+RESOURCE_ROLES = {
+ "page": {
+ "viewer": "Peut lire la page",
+ "commenter": "Peut lire + commenter",
+ "editor": "Peut lire + éditer",
+ "owner": "Peut tout faire + gérer les permissions",
+ },
+ "property": {
+ "viewer": "Peut lire la propriété",
+ "editor": "Peut modifier la propriété",
+ },
+}
+```
+
+---
+
+## 3. Tables de base de données
+
+### 3.1 Nouveau table `page_permissions`
+
+```sql
+CREATE TABLE page_permissions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ -- Soit user_id, soit group_id, soit role (pour rôle global)
+ group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
+ role TEXT NOT NULL, -- 'viewer', 'commenter', 'editor', 'owner'
+ grant_type TEXT NOT NULL DEFAULT 'explicit',
+ -- 'explicit' (direct user), 'group', 'workspace_role'
+ inherited BOOLEAN NOT NULL DEFAULT 0,
+ -- True = hérité de la collection, pas de permission explicite
+ granted_by INTEGER REFERENCES users(id),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(page_id, user_id, group_id) -- Une permission par utilisateur/groupe
+);
+
+CREATE INDEX idx_pp_page ON page_permissions(page_id, role);
+CREATE INDEX idx_pp_user ON page_permissions(user_id);
+```
+
+### 3.2 Nouveau table `collection_permissions`
+
+```sql
+CREATE TABLE collection_permissions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
+ role TEXT NOT NULL,
+ grant_type TEXT NOT NULL DEFAULT 'explicit',
+ inherited BOOLEAN NOT NULL DEFAULT 0,
+ granted_by INTEGER REFERENCES users(id),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(collection_id, user_id, group_id)
+);
+
+CREATE INDEX idx_cp_collection ON collection_permissions(collection_id, role);
+```
+
+### 3.3 Nouveau table `property_permissions`
+
+```sql
+CREATE TABLE property_permissions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
+ property_id INTEGER NOT NULL REFERENCES collection_properties(id) ON DELETE CASCADE,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
+ role TEXT NOT NULL, -- 'viewer', 'editor'
+ grant_type TEXT NOT NULL DEFAULT 'explicit',
+ inherited BOOLEAN NOT NULL DEFAULT 0,
+ granted_by INTEGER REFERENCES users(id),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(collection_id, property_id, user_id, group_id)
+);
+```
+
+### 3.4 Nouveau table `user_groups`
+
+```sql
+CREATE TABLE user_groups (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ description TEXT DEFAULT '',
+ created_by INTEGER REFERENCES users(id),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(workspace_id, name)
+);
+
+CREATE TABLE group_members (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(group_id, user_id)
+);
+```
+
+### 3.5 Extension des tables existantes
+
+```sql
+-- Ajout de colonnes de permission sur les tables principales
+ALTER TABLE collection_pages ADD COLUMN permission_type TEXT DEFAULT 'inherit';
+-- 'inherit' (default) | 'restricted' | 'private'
+
+ALTER TABLE collections ADD COLUMN permission_type TEXT DEFAULT 'inherit';
+-- 'inherit' | 'restricted' | 'private'
+```
+
+### 3.6 Table d'audit
+
+```sql
+CREATE TABLE permission_audit_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ resource_type TEXT NOT NULL, -- 'page', 'collection', 'property', 'group'
+ resource_id INTEGER NOT NULL,
+ action TEXT NOT NULL, -- 'grant', 'revoke', 'inherit', 'inherit_override'
+ target_user_id INTEGER,
+ target_group_id INTEGER,
+ old_role TEXT,
+ new_role TEXT,
+ performed_by INTEGER REFERENCES users(id),
+ ip_address TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX idx_paudit_resource ON permission_audit_log(resource_type, resource_id, created_at);
+```
+
+---
+
+## 4. Endpoints API
+
+### 4.1 Routeur `app/routers/permissions.py`
+
+```
+GET /api/v2/pages/{id}/permissions — Lister les permissions d'une page
+POST /api/v2/pages/{id}/permissions — Accorder une permission
+DELETE /api/v2/pages/{id}/permissions/{id} — Révoquer une permission
+POST /api/v2/pages/{id}/permissions/batch — Batch de permissions
+GET /api/v2/collections/{id}/permissions — Lister les permissions d'une collection
+POST /api/v2/collections/{id}/permissions — Accorder une permission
+DELETE /api/v2/collections/{id}/permissions/{id} — Révoquer
+GET /api/v2/properties/{id}/permissions — Lister les permissions d'une propriété
+POST /api/v2/properties/{id}/permissions — Accorder
+DELETE /api/v2/properties/{id}/permissions/{id} — Révoquer
+GET /api/v2/groups — Lister les groupes du workspace
+POST /api/v2/groups — Créer un groupe
+PUT /api/v2/groups/{id} — Mettre à jour un groupe
+DELETE /api/v2/groups/{id} — Supprimer un groupe
+POST /api/v2/groups/{id}/members — Ajouter un membre au groupe
+DELETE /api/v2/groups/{id}/members/{uid} — Retirer un membre
+GET /api/v2/audit/permissions — Historique des changements de permissions
+```
+
+### 4.2 Service `app/services/permission_manager.py` — Extension
+
+Le `PermissionManager` existant est étendu :
+
+```python
+class PermissionManager:
+ """Granular permission resolution — héritage workspace → collection → page → property."""
+
+ # ── Méthodes existantes (conservées) ──
+ def role_in_workspace(self, workspace_id: int) -> str: ...
+ def can_read(self, workspace_id: int) -> bool: ...
+ def can_write(self, workspace_id: int) -> bool: ...
+
+ # ── Méthodes nouvelles — Page-level ──
+
+ def get_page_permission(self, page_id: int, user_id: int) -> str:
+ """Retourne le rôle effectif pour une page ('viewer', 'commenter', 'editor', 'owner', None).
+ Résout l'héritage : page → collection → workspace."""
+
+ def can_view_page(self, page_id: int, user_id: int) -> bool:
+ """Peut voir la page ?"""
+
+ def can_edit_page(self, page_id: int, user_id: int) -> bool:
+ """Peut éditer la page ?"""
+
+ def can_comment_page(self, page_id: int, user_id: int) -> bool:
+ """Peut commenter la page ?"""
+
+ def can_manage_page_permissions(self, page_id: int, user_id: int) -> bool:
+ """Peut gérer les permissions de la page ?"""
+
+ # ── Méthodes nouvelles — Collection-level ──
+
+ def get_collection_permission(self, collection_id: int, user_id: int) -> str:
+ """Rôle effectif pour une collection."""
+
+ def can_view_collection(self, collection_id: int, user_id: int) -> bool: ...
+ def can_edit_collection(self, collection_id: int, user_id: int) -> bool: ...
+
+ # ── Méthodes nouvelles — Property-level ──
+
+ def can_view_property(self, collection_id: int, property_id: int, user_id: int) -> bool:
+ """Peut voir cette propriété ?"""
+
+ def can_edit_property(self, collection_id: int, property_id: int, user_id: int) -> bool:
+ """Peut éditer cette propriété ?"""
+
+ def get_visible_properties(self, collection_id: int, user_id: int) -> list[int]:
+ """Retourne la liste des IDs de propriétés visibles pour l'utilisateur."""
+
+ # ── Méthodes nouvelles — Groups ──
+
+ def create_group(self, workspace_id: int, name: str, created_by: int) -> int: ...
+ def add_user_to_group(self, group_id: int, user_id: int, added_by: int) -> None: ...
+ def get_groups_for_workspace(self, workspace_id: int) -> list[dict]: ...
+
+ # ── Méthodes nouvelles — Audit ──
+
+ def log_permission_change(self, resource_type: str, resource_id: int,
+ action: str, target_user_id: int | None,
+ target_group_id: int | None,
+ old_role: str | None, new_role: str | None,
+ performed_by: int) -> None: ...
+
+ # ── Méthodes nouvelles — Résolution rapide ──
+
+ def _resolve_page_effective_role(self, page_id: int, user_id: int) -> str:
+ """Résout le rôle effectif en parcourant la chaîne d'héritage.
+ Cache le résultat pendant 60s."""
+```
+
+---
+
+## 5. Logique de résolution des permissions
+
+### 5.1 Algorithme de résolution
+
+```python
+def resolve_effective_permission(resource_type: str, resource_id: int, user_id: int) -> str:
+ """
+ Résout la permission effective en parcourant la chaîne d'héritage.
+ Règle : la permission la plus restrictive prévaut (principe du moindre privilège).
+ """
+
+ if resource_type == "page":
+ # 1. Vérifier page_permissions directe
+ direct = query_page_permission(page_id, user_id)
+ if direct and not direct.inherited:
+ return direct.role
+
+ # 2. Remonter à la collection
+ collection_id = get_page_collection(page_id)
+ coll_perm = query_collection_permission(collection_id, user_id)
+ if coll_perm and not coll_perm.inherited:
+ return coll_perm.role
+
+ # 3. Remonter au workspace
+ ws_id = get_collection_workspace(collection_id)
+ return get_workspace_role(ws_id, user_id)
+
+ elif resource_type == "property":
+ # Même logique : property → collection → workspace
+ ...
+
+ # Fallback
+ return "viewer" # Tout utilisateur authentifié a au moins le rôle viewer
+```
+
+### 5.2 Principe du moindre privilège
+
+- Si un utilisateur a `editor` au niveau workspace mais `viewer` sur une page spécifique → il est **viewer** sur cette page
+- Si un utilisateur a `viewer` au niveau workspace et aucune permission explicite sur la page → il est **viewer** sur cette page
+- Les permissions explicites de page **écrasent** toujours l'héritage
+
+### 5.3 Cache de résolution
+
+```python
+# Cache LRU pour les résolutions de permissions (60s TTL)
+from functools import lru_cache
+
+class PermissionResolver:
+ @lru_cache(maxsize=1024)
+ def cached_resolve(self, page_id: int, user_id: int, timestamp: int) -> str:
+ # Le timestamp force l'invalidation du cache toutes les 60s
+ return self.resolve_effective_permission(...)
+```
+
+---
+
+## 6. Interface utilisateur
+
+### 6.1 Page editor — Bouton de permissions
+
+Dans `page_editor.html`, un nouveau bouton **"Permissions"** (icône 🔒) dans le menu `...` :
+
+```html
+
+
Permissions de la page
+
+
+
Accès direct
+
+
+ | Utilisateur/Groupe |
+ Rôle |
+ Actions |
+
+
+
+ |
+
+
+ |
+ |
+
+
+
+
+
+
+
+
Groupes
+
+
+
+
+
+
+
+
+
+```
+
+### 6.2 Collection-level permissions
+
+Dans `collections.py` router et `_database_table_scripts.html` :
+- Nouveau bouton **"Permissions"** dans le menu de la collection
+- Panneau latéral avec la même logique que les pages
+- Indication visuelle : icône 🔒 sur les pages en "restricted"
+
+### 6.3 Property-level — Masquage dans les vues
+
+```javascript
+// Dans la vue table/kanban/gallery :
+// Les propriétés restreintes sont masquées ou verrouillées
+if (!canViewProperty(propertyId, currentUser)) {
+ // Masquer la colonne entière
+ // OU afficher une placeholder "🔒 Restricted"
+}
+```
+
+### 6.4 Indicateurs visuels
+
+| Situation | Indicateur |
+|-----------|-----------|
+| Page restreinte (permission différente du workspace) | Icône 🔒 dans le sidebar à côté du titre |
+| Propriété masquée | Colonne invisible ou placeholder 🔒 |
+| Utilisateur sans accès | Page affiche "Vous n'avez pas accès à cette page" |
+| Propriété en lecture seule | Champ désactivé avec tooltip "Permissions insuffisantes" |
+
+---
+
+## 7. Endpoints existants — Modifications nécessaires
+
+### 7.1 Protection dans les routeurs existants
+
+Les routeurs suivants doivent intégrer les vérifications de permissions granulaires :
+
+```python
+# board.py — pages
+@router.get("/board/api/pages/{id}")
+async def get_page(id: int, request: Request):
+ user = get_current_user(request)
+ if not permission_manager.can_view_page(id, user["id"]):
+ raise HTTPException(404, "Page not found") # 404 et non 403 pour la sécurité
+ ...
+
+# collections.py — pages de collection
+@router.post("/db/{id}/pages/api")
+async def create_page(id: int, ...):
+ user = get_current_user(request)
+ if not permission_manager.can_edit_collection(id, user["id"]):
+ raise HTTPException(403, "Insufficient permissions")
+ ...
+```
+
+### 7.2 Propriétés visibles dans les vues
+
+```python
+# Dans les vues (table, board, calendar, etc.) :
+def get_visible_collection_data(collection_id, user_id):
+ """Retourne seulement les propriétés visibles pour l'utilisateur."""
+ visible_props = permission_manager.get_visible_properties(collection_id, user_id)
+ # Filtrer la réponse API et le rendu HTML
+```
+
+---
+
+## 8. Tests
+
+| Test | Description | Outil |
+|------|-------------|-------|
+| Page permission grant | Accorder viewer à un utilisateur → il peut voir | Unit test |
+| Page permission revoke | Révoquer → 404 sur la page | Unit test |
+| Inheritance | Workspace editor + page viewer → viewer sur la page | Unit test |
+| Property hide | Propriété restreinte → colonne masquée | Integration test |
+| Group permissions | Groupe editor → tous les membres editor | Unit test |
+| Batch permissions | 10+ permissions en un POST | Unit test |
+| Audit log | Chaque changement de permission logué | Unit test |
+| Performance | 1000 utilisateurs, résolution < 10ms | Benchmark |
+| Conflict | Deux admins modifient les mêmes permissions → dernière écriture gagne | Integration test |
+| Public page | Page partagée publiquement ignore les restrictions | Unit test |
+
+---
+
+## 9. Checklist d'implémentation
+
+1. **Migrations DB** — `page_permissions`, `collection_permissions`, `property_permissions`, `user_groups`, `group_members`, `permission_audit_log`, colonnes `permission_type`
+2. **`app/services/permission_manager.py`** — extension complète (résolution héritage, cache, groupes)
+3. **`app/routers/permissions.py`** — endpoints CRUD permissions
+4. **Protection dans les routeurs existants** (`board.py`, `collections.py`, `api.py`, `workspace.py`)
+5. **UI — Page permissions panel** (extension `page_editor.html`)
+6. **UI — Collection permissions panel** (extension `collections.py`)
+7. **UI — Property visibility in views** (`_database_table_scripts.html`)
+8. **UI — Groups management** (settings page)
+9. **Audit log** — tous les changements de permissions
+10. **Tests** — tous les scénarios de permissions
+11. **Documentation** — `/help` section permissions granulaires
+12. **Performance** — cache LRU + index DB optimaux
+
+---
+
+## 10. Performance
+
+| Opération | Sans cache | Avec cache (LRU 60s) |
+|-----------|-----------|---------------------|
+| Résolution permission page | ~15 ms (3 requêtes DB) | ~0.5 ms |
+| Liste des propriétés visibles | ~20 ms | ~1 ms |
+| Vérification page dans sidebar | ~10 ms | ~0.3 ms |
+| Batch permissions (10 items) | ~50 ms | ~5 ms |
+
+---
+
+## 11. Références
+
+- `app/services/permission_manager.py` — PermissionManager existant (base)
+- `app/routers/board.py` — Protection de pages à étendre
+- `app/routers/collections.py` — Protection de collections à étendre
+- `app/routers/workspace.py` — Gestion des membres existants
+- `app/db.py` — Schéma de base de données
+- `docs/API_GUIDE_V6.md` — Référence API v2 (endpoints permissions)
+- `ROADMAP.md` — v6.0.0 Granular permissions item
+- [Notion API Permissions](https://developers.notion.com/docs/permissions)
+- [Google ACL Model](https://cloud.google.com/iam/docs/overview)
diff --git a/tests/test_v60_granular_permissions.py b/tests/test_v60_granular_permissions.py
new file mode 100644
index 0000000..05af4f6
--- /dev/null
+++ b/tests/test_v60_granular_permissions.py
@@ -0,0 +1,538 @@
+"""FlowDeck — v6.0.0 Granular permissions tests.
+
+Covers the page / collection / property ACL: permission types (inherit,
+restricted, private), explicit grants (user + group), inheritance, property
+visibility, user groups and the audit log.
+"""
+
+from app.auth.session import SessionManager
+
+# ═══════════════ helpers ═══════════════
+
+
+def _token(user_id, login="user", is_admin=0, full_name="User"):
+ return SessionManager.create_session(
+ {"id": user_id, "login": login, "full_name": full_name, "is_admin": is_admin}
+ )
+
+
+def _as(client, user_id, login="user", is_admin=0):
+ client.cookies.set("flowdeck_session", _token(user_id, login, is_admin))
+
+
+def _insert_user(login, email, full_name):
+ from app.db import get_conn
+ with get_conn() as conn:
+ cur = conn.execute(
+ "INSERT INTO users (login, email, full_name, is_active) VALUES (?,?,?,1)",
+ (login, email, full_name),
+ )
+ conn.commit()
+ return cur.lastrowid
+
+
+def _insert_workspace(owner_id, name="WS"):
+ from app.db import get_conn
+ with get_conn() as conn:
+ cur = conn.execute(
+ "INSERT INTO workspaces (name, owner_id) VALUES (?,?)", (name, owner_id)
+ )
+ conn.commit()
+ return cur.lastrowid
+
+
+def _insert_page(workspace_id, title="Page", collection_id=None):
+ from app.db import get_conn
+ with get_conn() as conn:
+ cur = conn.execute(
+ "INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
+ "parent_section, collection_id) VALUES (?,?,?,'','blocks','Private',?)",
+ ("ws", workspace_id, title, collection_id),
+ )
+ conn.commit()
+ return cur.lastrowid
+
+
+def _insert_collection(workspace_id, name="DB"):
+ from app.db import get_conn
+ with get_conn() as conn:
+ cur = conn.execute(
+ "INSERT INTO collections (name, schema_json, workspace_id) VALUES (?,'[]',?)",
+ (name, workspace_id),
+ )
+ conn.commit()
+ return cur.lastrowid
+
+
+def _insert_property(collection_id, name="Field"):
+ from app.db import get_conn
+ with get_conn() as conn:
+ cur = conn.execute(
+ "INSERT INTO collection_properties (collection_id, name, prop_type, position) "
+ "VALUES (?,?,'text',0)",
+ (collection_id, name),
+ )
+ conn.commit()
+ return cur.lastrowid
+
+
+def _set_page_type(page_id, ptype):
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("UPDATE pages SET permission_type=? WHERE id=?", (ptype, page_id))
+ conn.commit()
+
+
+def _set_collection_type(collection_id, ptype):
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute(
+ "UPDATE collections SET permission_type=? WHERE id=?", (ptype, collection_id)
+ )
+ conn.commit()
+
+
+def _scope(owner_id, member_id=None):
+ """Return (workspace_id, page_id, owner_id, member_id) with a fresh setup."""
+ ws = _insert_workspace(owner_id)
+ page = _insert_page(ws)
+ if member_id:
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute(
+ "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,'viewer')",
+ (ws, member_id),
+ )
+ conn.commit()
+ return ws, page, owner_id, member_id
+
+
+# ═══════════════ page permission types ═══════════════
+
+
+def test_inherit_allows_workspace_viewer(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ member = _insert_user("member", "m@t.dev", "Member")
+ _, page, _, _ = _scope(owner, member)
+
+ _as(client, member, "member")
+ r = client.get(f"/board/api/pages/{page}")
+ print("DEBUG body:", r.status_code, r.text)
+ assert r.status_code == 200, r.text
+
+ # Inherited viewer cannot edit.
+ resp = client.put(f"/board/api/pages/{page}?title=Sneak")
+ assert resp.status_code == 403, resp.text
+
+
+def test_restricted_page_hidden_for_non_grantees(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ member = _insert_user("member", "m@t.dev", "Member")
+ ws, page, _, _ = _scope(owner, member)
+ _set_page_type(page, "restricted")
+
+ # Owner still sees it.
+ _as(client, owner, "owner")
+ assert client.get(f"/board/api/pages/{page}").status_code == 200
+
+ # Workspace member WITHOUT a grant → hidden (404).
+ _as(client, member, "member")
+ assert client.get(f"/board/api/pages/{page}").status_code == 404
+ assert client.put(f"/board/api/pages/{page}?title=x").status_code == 404
+
+
+def test_grant_viewer_unlocks_restricted_page(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ member = _insert_user("member", "m@t.dev", "Member")
+ _, page, _, _ = _scope(owner, member)
+ _set_page_type(page, "restricted")
+
+ _as(client, owner, "owner")
+ grant = client.post(
+ f"/api/v2/pages/{page}/permissions",
+ json={"user_id": member, "role": "viewer"},
+ )
+ assert grant.status_code == 200, grant.text
+
+ _as(client, member, "member")
+ assert client.get(f"/board/api/pages/{page}").status_code == 200
+ # Viewer role cannot edit.
+ assert client.put(f"/board/api/pages/{page}?title=x").status_code == 403
+
+
+def test_grant_editor_allows_edit_but_not_manage(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ member = _insert_user("member", "m@t.dev", "Member")
+ _, page, _, _ = _scope(owner, member)
+
+ _as(client, owner, "owner")
+ client.post(
+ f"/api/v2/pages/{page}/permissions",
+ json={"user_id": member, "role": "editor"},
+ )
+
+ _as(client, member, "member")
+ assert client.get(f"/board/api/pages/{page}").status_code == 200
+ assert client.put(f"/board/api/pages/{page}?title=Edited").status_code == 200
+ # Editor is not an owner → cannot grant more permissions.
+ resp = client.post(
+ f"/api/v2/pages/{page}/permissions",
+ json={"user_id": owner, "role": "viewer"},
+ )
+ assert resp.status_code == 403
+
+
+def test_revoke_removes_access(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ member = _insert_user("member", "m@t.dev", "Member")
+ _, page, _, _ = _scope(owner, member)
+ _set_page_type(page, "restricted")
+
+ _as(client, owner, "owner")
+ grant = client.post(
+ f"/api/v2/pages/{page}/permissions",
+ json={"user_id": member, "role": "viewer"},
+ )
+ perm_id = grant.json()["id"]
+ revoke = client.delete(f"/api/v2/pages/{page}/permissions/{perm_id}")
+ assert revoke.status_code == 200
+
+ _as(client, member, "member")
+ assert client.get(f"/board/api/pages/{page}").status_code == 404
+
+
+def test_private_page_admin_override(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ member = _insert_user("member", "m@t.dev", "Member")
+ _, page, _, _ = _scope(owner, member)
+ _set_page_type(page, "private")
+
+ _as(client, member, "member")
+ assert client.get(f"/board/api/pages/{page}").status_code == 404
+
+ # A global admin can always access.
+ _as(client, member, "member", is_admin=1)
+ assert client.get(f"/board/api/pages/{page}").status_code == 200
+
+
+def test_mine_endpoint_and_batch_grant(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ member = _insert_user("member", "m@t.dev", "Member")
+ _, page, _, _ = _scope(owner, member)
+ _set_page_type(page, "restricted")
+
+ _as(client, owner, "owner")
+ mine = client.get(f"/api/v2/pages/{page}/permissions/mine")
+ assert mine.status_code == 200 and mine.json()["role"] == "owner"
+
+ batch = client.post(
+ f"/api/v2/pages/{page}/permissions/batch",
+ json={"grants": [{"user_id": member, "role": "viewer"}]},
+ )
+ assert batch.status_code == 200 and len(batch.json()["granted"]) == 1
+
+ _as(client, member, "member")
+ mine = client.get(f"/api/v2/pages/{page}/permissions/mine")
+ assert mine.json()["role"] == "viewer"
+ assert client.get(f"/board/api/pages/{page}").status_code == 200
+
+
+def test_page_permission_type_via_api(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ _, page, _, _ = _scope(owner)
+ _as(client, owner, "owner")
+ resp = client.post(
+ f"/api/v2/pages/{page}/permission-type",
+ json={"permission_type": "restricted"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["permission_type"] == "restricted"
+ # Listing reflects the type.
+ listing = client.get(f"/api/v2/pages/{page}/permissions").json()
+ assert listing["permission_type"] == "restricted"
+
+
+# ═══════════════ collection permissions ═══════════════
+
+
+def test_restricted_collection_hidden_for_member(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ member = _insert_user("member", "m@t.dev", "Member")
+ ws = _insert_workspace(owner)
+ coll = _insert_collection(ws)
+ _set_collection_type(coll, "restricted")
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute(
+ "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,'viewer')",
+ (ws, member),
+ )
+ conn.commit()
+
+ _as(client, member, "member")
+ assert client.get(f"/db/{coll}/api").status_code == 404
+ # HTML pages 404→redirect to /workspaces (main.py `not_found_handler`);
+ # a restricted collection is therefore hidden, not served.
+ assert client.get(f"/db/{coll}", follow_redirects=False).status_code == 302
+
+ # Owner sees it.
+ _as(client, owner, "owner")
+ assert client.get(f"/db/{coll}/api").status_code == 200
+
+
+def test_collection_grant_allows_view_then_edit_gate(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ member = _insert_user("member", "m@t.dev", "Member")
+ ws = _insert_workspace(owner)
+ coll = _insert_collection(ws)
+ _set_collection_type(coll, "restricted")
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,'viewer')",
+ (ws, member))
+ conn.commit()
+
+ _as(client, member, "member")
+ # Without grant → 404.
+ assert client.post(f"/db/{coll}/pages/api", json={"title": "New"}).status_code == 404
+
+ _as(client, owner, "owner")
+ grant = client.post(
+ f"/api/v2/collections/{coll}/permissions",
+ json={"user_id": member, "role": "viewer"},
+ )
+ assert grant.status_code == 200, grant.text
+
+ _as(client, member, "member")
+ # Viewer can read the collection…
+ assert client.get(f"/db/{coll}/api").status_code == 200
+ # …but cannot create pages.
+ assert client.post(f"/db/{coll}/pages/api", json={"title": "New"}).status_code == 403
+ # …and cannot delete the collection.
+ assert client.delete(f"/db/api/{coll}").status_code == 403
+
+
+def test_owner_can_delete_collection(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ ws = _insert_workspace(owner)
+ coll = _insert_collection(ws)
+ _as(client, owner, "owner")
+ assert client.delete(f"/db/api/{coll}").status_code == 200
+
+
+def test_collection_editor_can_create_pages(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ member = _insert_user("member", "m@t.dev", "Member")
+ ws = _insert_workspace(owner)
+ coll = _insert_collection(ws)
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,'editor')",
+ (ws, member))
+ conn.commit()
+
+ _as(client, member, "member")
+ resp = client.post(f"/db/{coll}/pages/api", json={"title": "New"})
+ assert resp.status_code == 200, resp.text
+
+
+# ═══════════════ property visibility ═══════════════
+
+
+def test_property_permissions_visibility(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ other = _insert_user("other", "o@t.dev", "Other")
+ ws = _insert_workspace(owner)
+ coll = _insert_collection(ws)
+ p_open = _insert_property(coll, "Open")
+ p_secret = _insert_property(coll, "Secret")
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,'viewer')",
+ (ws, other))
+ conn.commit()
+
+ # Owner grants view on the Secret property to `other`.
+ _as(client, owner, "owner")
+ grant = client.post(
+ f"/api/v2/collections/{coll}/properties/{p_secret}/permissions",
+ json={"user_id": other, "role": "viewer"},
+ )
+ assert grant.status_code == 200, grant.text
+
+ _as(client, other, "other")
+ props = client.get(f"/db/{coll}/properties/api").json()["properties"]
+ ids = {p["id"] for p in props}
+ # Open property (no grants) visible; Secret visible because granted.
+ assert p_open in ids and p_secret in ids
+ # Can view but not edit the Secret property.
+ visible = client.get(f"/api/v2/collections/{coll}/properties/visible").json()
+ assert p_secret in visible["visible"]
+
+
+def test_property_hidden_for_non_grantee(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ snoop = _insert_user("snoop", "s@t.dev", "Snoop")
+ other = _insert_user("other", "o@t.dev", "Other")
+ ws = _insert_workspace(owner)
+ coll = _insert_collection(ws)
+ p_secret = _insert_property(coll, "Secret")
+ from app.db import get_conn
+ with get_conn() as conn:
+ for uid, role in ((snoop, "viewer"), (other, "viewer")):
+ conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
+ (ws, uid, role))
+ conn.commit()
+
+ # Only `other` is granted — `snoop` has no grant.
+ _as(client, owner, "owner")
+ client.post(
+ f"/api/v2/collections/{coll}/properties/{p_secret}/permissions",
+ json={"user_id": other, "role": "viewer"},
+ )
+
+ _as(client, snoop, "snoop")
+ props = client.get(f"/db/{coll}/properties/api").json()["properties"]
+ assert all(p["id"] != p_secret for p in props)
+ visible = client.get(f"/api/v2/collections/{coll}/properties/visible").json()
+ assert p_secret in visible["hidden"]
+
+ # Owner still sees everything.
+ _as(client, owner, "owner")
+ props = client.get(f"/db/{coll}/properties/api").json()["properties"]
+ assert {p["id"] for p in props} == {p_secret}
+
+
+def test_property_grant_requires_collection_owner(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ member = _insert_user("member", "m@t.dev", "Member")
+ ws = _insert_workspace(owner)
+ coll = _insert_collection(ws)
+ p = _insert_property(coll)
+ from app.db import get_conn
+ with get_conn() as conn:
+ conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,'editor')",
+ (ws, member))
+ conn.commit()
+
+ _as(client, member, "member")
+ resp = client.post(
+ f"/api/v2/collections/{coll}/properties/{p}/permissions",
+ json={"user_id": member, "role": "viewer"},
+ )
+ assert resp.status_code == 403
+
+
+# ═══════════════ groups ═══════════════
+
+
+def test_group_grant_inherits_to_members(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ member = _insert_user("member", "m@t.dev", "Member")
+ ws, page, _, _ = _scope(owner, member)
+ _set_page_type(page, "restricted")
+
+ _as(client, owner, "owner")
+ gid = client.post(
+ "/api/v2/groups", json={"name": "Editors", "workspace_id": ws}
+ ).json()["id"]
+ assert client.post(f"/api/v2/groups/{gid}/members", json={"user_id": member}).status_code == 200
+ assert client.post(
+ f"/api/v2/pages/{page}/permissions",
+ json={"group_id": gid, "role": "viewer"},
+ ).status_code == 200
+
+ _as(client, member, "member")
+ assert client.get(f"/board/api/pages/{page}").status_code == 200
+
+ # Listing shows the group grant on the page.
+ _as(client, owner, "owner")
+ grants = client.get(f"/api/v2/pages/{page}/permissions").json()["permissions"]
+ assert any(g["kind"] == "group" and g["name"] == "Editors" for g in grants)
+
+
+def test_group_member_removal_revokes_group_access(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ member = _insert_user("member", "m@t.dev", "Member")
+ ws, page, _, _ = _scope(owner, member)
+ _set_page_type(page, "restricted")
+
+ _as(client, owner, "owner")
+ gid = client.post(
+ "/api/v2/groups", json={"name": "G", "workspace_id": ws}
+ ).json()["id"]
+ client.post(f"/api/v2/groups/{gid}/members", json={"user_id": member})
+ client.post(f"/api/v2/pages/{page}/permissions", json={"group_id": gid, "role": "viewer"})
+
+ _as(client, member, "member")
+ assert client.get(f"/board/api/pages/{page}").status_code == 200
+
+ _as(client, owner, "owner")
+ assert client.delete(f"/api/v2/groups/{gid}/members/{member}").status_code == 200
+
+ _as(client, member, "member")
+ assert client.get(f"/board/api/pages/{page}").status_code == 404
+
+
+def test_groups_listing_and_members(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ member = _insert_user("member", "m@t.dev", "Member")
+ ws = _insert_workspace(owner)
+ _as(client, owner, "owner")
+ gid = client.post(
+ "/api/v2/groups", json={"name": "Team", "workspace_id": ws}
+ ).json()["id"]
+ client.post(f"/api/v2/groups/{gid}/members", json={"user_id": member})
+
+ groups = client.get(f"/api/v2/groups?workspace_id={ws}").json()["groups"]
+ assert len(groups) == 1 and groups[0]["member_count"] == 1
+ members = client.get(f"/api/v2/groups/{gid}/members").json()["members"]
+ assert members[0]["id"] == member
+
+ # Non-owner cannot add members.
+ _as(client, member, "member")
+ resp = client.post(f"/api/v2/groups/{gid}/members", json={"user_id": owner})
+ assert resp.status_code == 403
+
+
+# ═══════════════ audit log + auth ═══════════════
+
+
+def test_audit_log_records_changes(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ member = _insert_user("member", "m@t.dev", "Member")
+ _, page, _, _ = _scope(owner, member)
+
+ _as(client, owner, "owner")
+ client.post(f"/api/v2/pages/{page}/permissions", json={"user_id": member, "role": "viewer"})
+ client.post(f"/api/v2/pages/{page}/permission-type", json={"permission_type": "restricted"})
+
+ events = client.get("/api/v2/audit/permissions").json()["events"]
+ actions = [e["action"] for e in events]
+ assert "grant" in actions and "type_change" in actions
+ # Only the owner workspace can view the log.
+ _as(client, member, "member")
+ assert client.get("/api/v2/audit/permissions").status_code == 403
+
+
+def test_permissions_endpoints_require_auth(client):
+ assert client.get("/api/v2/pages/1/permissions").status_code == 401
+ assert client.post("/api/v2/pages/1/permissions", json={"user_id": 2, "role": "viewer"}).status_code == 401
+ assert client.get("/api/v2/groups").status_code == 401
+ assert client.get("/api/v2/audit/permissions").status_code == 401
+
+
+def test_grant_requires_valid_user_or_group(client):
+ owner = _insert_user("owner", "owner@t.dev", "Owner")
+ _, page, _, _ = _scope(owner)
+ _as(client, owner, "owner")
+
+ # Unknown target user.
+ resp = client.post(f"/api/v2/pages/{page}/permissions", json={"user_id": 99999, "role": "viewer"})
+ assert resp.status_code == 404
+ # Unknown group.
+ resp = client.post(f"/api/v2/pages/{page}/permissions", json={"group_id": 99999, "role": "viewer"})
+ assert resp.status_code == 404
+ # Invalid role.
+ resp = client.post(f"/api/v2/pages/{page}/permissions", json={"user_id": owner, "role": "boss"})
+ assert resp.status_code == 400