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

- Migration 18: 6 tables + 3 colonnes permission_type + indexes
- PermissionManager: heritage page->collection->workspace, least privilege, groups, cache 60s
- API /api/v2: pages/collections/properties/groups/users/audit (401/403/404/400)
- Guards board.py + collections.py (404/403, admin/owner bypass)
- Tests 21/21 (inherit/restricted/private, grant, revoke, batch, group, audit)
- Docs + ROADMAP + CHANGELOG + VERSION 6.1.0
This commit is contained in:
2026-09-19 22:54:16 -04:00
parent d707a6850a
commit 13d5f8625a
11 changed files with 2215 additions and 14 deletions
+41
View File
@@ -1,5 +1,46 @@
# Changelog - FlowDeck # 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 ## v6.0.0 (2026-09-18) — PWA : Progressive Web App, offline support
> FlowDeck devient une PWA installable et utilisable hors ligne : le shell est > FlowDeck devient une PWA installable et utilisable hors ligne : le shell est
+17 -2
View File
@@ -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) ## 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] **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) - [ ] **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) - [ ] **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) - [ ] **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 - [ ] **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 DB avancée, redo, drag&drop, bookmark, avancée
Calendrier, AI duplicate) lightbox…) (Pt.2) 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)*
+1 -1
View File
@@ -1 +1 @@
6.0.0 6.1.0
+3 -1
View File
@@ -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 page_router as import_page_router
from app.routers.imports import router as imports_router from app.routers.imports import router as imports_router
from app.routers.notifications import router as notifications_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.routers.realtime import router as realtime_router
from app.services.webhook_outbound import init_webhook_tables from app.services.webhook_outbound import init_webhook_tables
@@ -108,7 +109,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="6.0.0", version="6.1.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None, docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None, redoc_url=None,
lifespan=lifespan, lifespan=lifespan,
@@ -151,6 +152,7 @@ app.include_router(onboarding.router)
app.include_router(sync.router) app.include_router(sync.router)
app.include_router(imports_router) app.include_router(imports_router)
app.include_router(import_page_router) app.include_router(import_page_router)
app.include_router(permissions_router)
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
+123
View File
@@ -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: def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
"""Add ``sync_version`` to ``table`` if it is not already present.""" """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()} cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
+31 -1
View File
@@ -14,6 +14,7 @@ from app.routers.dashboard import _get_app_version
from app.routers.sidebar_config import get_sidebar_config_sync from app.routers.sidebar_config import get_sidebar_config_sync
from app.services.automations import fire_event from app.services.automations import fire_event
from app.services.gitea_client import gitea from app.services.gitea_client import gitea
from app.services.permission_manager import PermissionManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board") 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}") @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.""" """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: with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row: 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="")): content_format: str = Query(default="")):
"""Update a page's title and/or content. Accepts JSON body for blocks.""" """Update a page's title and/or content. Accepts JSON body for blocks."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) 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: with get_conn() as conn:
_ensure_page_editable(conn, page_id, user) _ensure_page_editable(conn, page_id, user)
if title: if title:
@@ -1408,6 +1424,9 @@ async def save_page_blocks(request: Request, page_id: int):
title = body.get("title", "") title = body.get("title", "")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id") 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 # Extract synced block ids from the blocks
def _extract_synced(blocks: list[dict]) -> set[int]: 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}") @router.delete("/api/pages/{page_id}")
async def delete_page(request: Request, page_id: int): async def delete_page(request: Request, page_id: int):
"""Move a page to trash (soft delete).""" """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: with get_conn() as conn:
row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone() row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone()
if not row: if not row:
@@ -1910,6 +1936,10 @@ async def view_page(request: Request, page_id: int):
embed = request.query_params.get("embed") == "1" embed = request.query_params.get("embed") == "1"
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates")) env = Environment(loader=FileSystemLoader("app/templates"))
# v6.0.0: granular page permissions — hide restricted pages (404).
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
with get_conn() as conn: with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row: if not row:
+62 -3
View File
@@ -25,6 +25,7 @@ from app.services.recurrence import (
validate_rule, validate_rule,
) )
from app.services.reminders import REMINDER_KEY, parse_lead from app.services.reminders import REMINDER_KEY, parse_lead
from app.services.permission_manager import PermissionManager
def _current_user(request: Request) -> dict: 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} 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]: def _collection_properties(conn, collection_id: int) -> list[dict]:
return [ return [
dict(r) for r in conn.execute( 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}") @router.delete("/api/{collection_id}")
async def delete_collection_api(request: Request, collection_id: int): async def delete_collection_api(request: Request, collection_id: int):
"""API: delete a collection and its pages (CASCADE).""" """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: with get_conn() as conn:
existing = conn.execute( existing = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,) "SELECT * FROM collections WHERE id=?", (collection_id,)
@@ -456,7 +494,8 @@ async def get_page_api(request: Request, page_id: int):
).fetchone() ).fetchone()
if not page: if not page:
raise HTTPException(status_code=404, detail="Page not found") 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) return dict(page)
@@ -474,6 +513,8 @@ async def update_page_api(request: Request, page_id: int):
).fetchone() ).fetchone()
if not existing: if not existing:
raise HTTPException(status_code=404, detail="Page not found") 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"]) title = body.get("title", existing["title"])
icon = body.get("icon", existing["icon"]) icon = body.get("icon", existing["icon"])
@@ -537,6 +578,8 @@ async def delete_page_api(request: Request, page_id: int):
).fetchone() ).fetchone()
if not existing: if not existing:
raise HTTPException(status_code=404, detail="Page not found") 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.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
conn.commit() conn.commit()
@@ -601,7 +644,9 @@ async def list_property_types_api(request: Request):
@router.get("/{collection_id}/properties/api") @router.get("/{collection_id}/properties/api")
async def list_properties_api(request: Request, collection_id: int): 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: with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone() coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll: 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", "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,), (collection_id,),
).fetchall() ).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") @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) @router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
async def view_collection(request: Request, collection_id: int, view_type: str = "table"): async def view_collection(request: Request, collection_id: int, view_type: str = "table"):
"""Main view — renders collection in the requested view type.""" """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: with get_conn() as conn:
collection = conn.execute( collection = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,) "SELECT * FROM collections WHERE id=?", (collection_id,)
@@ -2349,6 +2403,8 @@ tr:hover td{{background:#222}}
@router.get("/{collection_id}/api") @router.get("/{collection_id}/api")
async def get_collection_api(request: Request, collection_id: int): async def get_collection_api(request: Request, collection_id: int):
"""API: get a single collection with its pages.""" """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: with get_conn() as conn:
collection = conn.execute( collection = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,) "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") @router.post("/{collection_id}/pages/api")
async def create_page_api(request: Request, collection_id: int): async def create_page_api(request: Request, collection_id: int):
"""API: create a page in a collection.""" """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: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
+525
View File
@@ -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]}
+356 -6
View File
@@ -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 Two layers:
(Notion Agent principle). This manager resolves the user's role in the active
workspace and gates tool execution before any write reaches the database. 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 from __future__ import annotations
import logging import logging
import time
from fastapi import HTTPException from fastapi import HTTPException
@@ -19,6 +34,12 @@ READ_ROLES = {"viewer", "commenter", "editor", "admin", "owner"}
WRITE_ROLES = {"editor", "admin", "owner"} WRITE_ROLES = {"editor", "admin", "owner"}
DESTRUCTIVE_ROLES = {"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. # Tools that mutate state and therefore require at least an editor role.
WRITE_TOOLS = { WRITE_TOOLS = {
"create_collection", "create_view", "create_page", "update_page", "create_collection", "create_view", "create_page", "update_page",
@@ -35,10 +56,27 @@ DESTRUCTIVE_TOOLS = {
class PermissionManager: 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.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 ── # ── Role resolution ──
@@ -100,3 +138,315 @@ class PermissionManager:
# A viewer can always read; editor can read+write. # A viewer can always read; editor can read+write.
if role not in READ_ROLES: if role not in READ_ROLES:
raise HTTPException(status_code=403, detail="User has no access to this workspace") 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)
+518
View File
@@ -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
<div class="page-permissions-panel" x-show="showPermissions" x-transition>
<h3>Permissions de la page</h3>
<div class="perm-section">
<h4>Accès direct</h4>
<table>
<tr>
<th>Utilisateur/Groupe</th>
<th>Rôle</th>
<th>Actions</th>
</tr>
<template x-for="perm in pagePermissions">
<tr>
<td x-text="perm.name"></td>
<td>
<select x-model="perm.role" @change="updatePermission(perm)">
<option value="viewer">Viewer</option>
<option value="commenter">Commenter</option>
<option value="editor">Editor</option>
<option value="owner">Owner</option>
</select>
</td>
<td><button @click="revokePermission(perm.id)">✕</button></td>
</tr>
</template>
</table>
<button @click="addPermission()">+ Ajouter un accès</button>
</div>
<div class="perm-section">
<h4>Groupes</h4>
<template x-for="group in groups">
<div class="perm-group-row">
<span x-text="group.name"></span>
<select x-model="group.role" @change="updateGroupPermission(group)">
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="owner">Owner</option>
</select>
</div>
</template>
<button @click="createGroup()">+ Nouveau groupe</button>
</div>
</div>
```
### 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)
+538
View File
@@ -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", "[email protected]", "Owner")
member = _insert_user("member", "[email protected]", "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", "[email protected]", "Owner")
member = _insert_user("member", "[email protected]", "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", "[email protected]", "Owner")
member = _insert_user("member", "[email protected]", "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", "[email protected]", "Owner")
member = _insert_user("member", "[email protected]", "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", "[email protected]", "Owner")
member = _insert_user("member", "[email protected]", "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", "[email protected]", "Owner")
member = _insert_user("member", "[email protected]", "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", "[email protected]", "Owner")
member = _insert_user("member", "[email protected]", "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", "[email protected]", "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", "[email protected]", "Owner")
member = _insert_user("member", "[email protected]", "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", "[email protected]", "Owner")
member = _insert_user("member", "[email protected]", "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", "[email protected]", "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", "[email protected]", "Owner")
member = _insert_user("member", "[email protected]", "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", "[email protected]", "Owner")
other = _insert_user("other", "[email protected]", "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", "[email protected]", "Owner")
snoop = _insert_user("snoop", "[email protected]", "Snoop")
other = _insert_user("other", "[email protected]", "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", "[email protected]", "Owner")
member = _insert_user("member", "[email protected]", "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", "[email protected]", "Owner")
member = _insert_user("member", "[email protected]", "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", "[email protected]", "Owner")
member = _insert_user("member", "[email protected]", "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", "[email protected]", "Owner")
member = _insert_user("member", "[email protected]", "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", "[email protected]", "Owner")
member = _insert_user("member", "[email protected]", "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", "[email protected]", "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