From 13d5f8625a493f429f9e625bace63c93637dc7c8 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 19 Sep 2026 22:54:16 -0400 Subject: [PATCH] feat: v6.1.0 granular permissions (page/collection/property ACL + groups + audit) - Migration 18: 6 tables + 3 colonnes permission_type + indexes - PermissionManager: heritage page->collection->workspace, least privilege, groups, cache 60s - API /api/v2: pages/collections/properties/groups/users/audit (401/403/404/400) - Guards board.py + collections.py (404/403, admin/owner bypass) - Tests 21/21 (inherit/restricted/private, grant, revoke, batch, group, audit) - Docs + ROADMAP + CHANGELOG + VERSION 6.1.0 --- CHANGELOG.md | 41 ++ ROADMAP.md | 19 +- VERSION | 2 +- app/main.py | 4 +- app/migrations.py | 123 ++++++ app/routers/board.py | 32 +- app/routers/collections.py | 65 ++- app/routers/permissions.py | 525 ++++++++++++++++++++++++ app/services/permission_manager.py | 362 ++++++++++++++++- docs/V6_Granular_Permissions.md | 518 ++++++++++++++++++++++++ tests/test_v60_granular_permissions.py | 538 +++++++++++++++++++++++++ 11 files changed, 2215 insertions(+), 14 deletions(-) create mode 100644 app/routers/permissions.py create mode 100644 docs/V6_Granular_Permissions.md create mode 100644 tests/test_v60_granular_permissions.py 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/GroupeRôleActions
+ +
+ +
+

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