feat: v6.1.0 granular permissions (page/collection/property ACL + groups + audit)
- Migration 18: 6 tables + 3 colonnes permission_type + indexes - PermissionManager: heritage page->collection->workspace, least privilege, groups, cache 60s - API /api/v2: pages/collections/properties/groups/users/audit (401/403/404/400) - Guards board.py + collections.py (404/403, admin/owner bypass) - Tests 21/21 (inherit/restricted/private, grant, revoke, batch, group, audit) - Docs + ROADMAP + CHANGELOG + VERSION 6.1.0
This commit is contained in:
@@ -1,5 +1,46 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v6.1.0 (2026-09-19) — Granular Permissions : page / collection / property ACL + groupes + audit
|
||||
|
||||
> Permissions fines héritables : chaque page / database / propriété peut être restreinte à des utilisateurs ou groupes explicites. L'héritage suit la chaîne page → collection → workspace (moindre privilège), avec bypass owner/admin et audit complet.
|
||||
|
||||
### Base de données (migration 18)
|
||||
|
||||
- Tables `user_groups` (workspace-scoped, UNIQUE(name)), `group_members` (N-ary), `page_permissions`, `collection_permissions`, `property_permissions` (user_id XOR group_id, CHECK, UNIQUE), `permission_audit_log`.
|
||||
- Colonnes `pages.permission_type`, `collections.permission_type`, `collection_pages.permission_type` (`inherit|restricted|private`, défaut `inherit`).
|
||||
- Index `idx_pp_page`, `idx_pp_user`, `idx_cp_collection`, `idx_propp_prop`, `idx_perm_audit_res`, `idx_gm_*`.
|
||||
|
||||
### PermissionManager (`app/services/permission_manager.py`)
|
||||
|
||||
- Résolution héritage + moindre privilège : grant explicite sur page > grant collection > rôle workspace ; `restricted`/`private` sans grant → 404.
|
||||
- Méthodes page : `get_page_permission()`, `can_view/edit/comment/manag` ; collection : `get_collection_permission()`, `can_view/edit/manag` ; propriété : `can_view/edit_property()`, `get_visible_properties()`.
|
||||
- Groupes : `create_group()`, `add/remove_user_from_group()`, `get_groups_for_workspace()`, `get_group_members()`, `is_workspace_admin()`, `user_group_ids()`.
|
||||
- Helpers : `_explicit_grant_role()` (best rank user+groups), `_collection_role()`, `_property_*`, `_owns_workspace()`, `_is_admin()`.
|
||||
- Cache 60 s (`_cached()`) + `invalidate()` appelé après chaque grant/revoke/type_change.
|
||||
- `log_permission_change()` (audit, never-throw).
|
||||
|
||||
### API (`app/routers/permissions.py` — `prefix /api/v2`)
|
||||
|
||||
- Pages : `GET /pages/{id}/permissions` (grants + mine + type + can_manage), `GET /pages/{id}/permissions/mine`, `POST /pages/{id}/permissions` (user_id|group_id + role viewer/commenter/editor/owner), `POST /pages/{id}/permissions/batch`, `DELETE /pages/{id}/permissions/{perm_id}`, `POST /pages/{id}/permission-type`.
|
||||
- Collections : `GET /collections/{id}/permissions`, `POST /collections/{id}/permissions`, `DELETE /collections/{id}/permissions/{perm_id}`, `POST /collections/{id}/permission-type`, `GET /collections/{id}/properties/visible` (split visible/hidden).
|
||||
- Properties : `GET /collections/{cid}/properties/{pid}/permissions`, `POST …/permissions` (role viewer|editor), `DELETE …/permissions/{perm_id}` (extra_cols collection_id).
|
||||
- Groupes : `GET /groups?workspace_id`, `POST /groups`, `PUT /groups/{id}`, `DELETE /groups/{id}`, `GET/POST /groups/{id}/members`, `DELETE /groups/{id}/members/{uid}` (workspace owner/admin only).
|
||||
- Access pickers + audit : `GET /users?workspace_id&q`, `GET /audit/permissions?limit` (owner/admin, 500 max).
|
||||
- Validation : 401 sans session, 403 sans can_manage, 400 rôle invalide / cible manquante, 404 user/group/property inconnu.
|
||||
|
||||
### Guards existants
|
||||
|
||||
- `board.py` : `GET /board/api/pages/{id}` (404 si !can_view_page), `PUT /board/api/pages/{id}` (404 si non-view, 403 si viewer/commenter, 423 si locked reste prioritaire).
|
||||
- `collections.py` : `DELETE /db/api/{id}` (404 si !can_view_collection, 403 si !can_manage), `GET /db/pages/{id}/api` (404), `PUT/DELETE /db/pages/{id}/api` (403), `GET /db/{id}/properties/api` filtré par visible, `_require_view/_require_edit` helpers avec `_session_user()` (pas de fallback admin sur null session).
|
||||
|
||||
### UI (backend-ready)
|
||||
|
||||
- Endpoints prêts pour panneau Permissions de l'éditeur (icône 🔒), panneau collection, masquage colonnes via `visible`/`hidden`, et gestion des groupes en Settings. Les grants sont consommés par `can_view_property` / `get_visible_properties` côté vues.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v60_granular_permissions.py` — **21 tests** : inherit allow + edit 403, restricted hidden (owner 200 / member 404), grant viewer unlock + viewer cannot edit, editor can edit but not manage, revoke 404, private + admin override, mine + batch, permission-type via API, restricted collection hidden (API 404 + HTML 302), collection viewer 403 create/delete, owner delete, collection editor create pages, property visibility, property hidden, property grant 403, group grant inherits + listing shows group, group removal revokes, groups listing/members + non-owner 403, audit log (grant+type_change, member 403), endpoints 401, validation 404/400. Suite 21/21 verte.
|
||||
|
||||
## v6.0.0 (2026-09-18) — PWA : Progressive Web App, offline support
|
||||
|
||||
> FlowDeck devient une PWA installable et utilisable hors ligne : le shell est
|
||||
|
||||
+17
-2
@@ -714,11 +714,26 @@ Détails livrés :
|
||||
|
||||
---
|
||||
|
||||
## v6.1.0 — Granular Permissions ✅ (2026-09-19)
|
||||
|
||||
> **Objectif** : page-level, collection-level & property-level ACL + groupes + audit. **COMPLETED**.
|
||||
|
||||
- [x] **Page permissions** — modes `inherit|restricted|private` (`pages.permission_type`, `collections.permission_type`, `collection_pages.permission_type`), grants explicites user/group (`page_permissions` role viewer/commenter/editor/owner), héritage page→collection→workspace, 404 masqué pour non-grantees, owner/admin bypass
|
||||
- [x] **Collection permissions** — `collection_permissions` + permission_type, `/db/{id}` et `/db/{id}/api` masqués (404→302), création page et delete collection gatés (viewer 403), editor bypass
|
||||
- [x] **Property-level visibility** — `property_permissions` (viewer|editor), `GET /db/{id}/properties/api` filtré par `get_visible_properties()`, `GET /api/v2/collections/{id}/properties/visible` (visible/hidden), grant = owner collection uniquement
|
||||
- [x] **Groupes réutilisables** — tables `user_groups` + `group_members` (workspace-scoped, UNIQUE(name)), CRUD `/api/v2/groups` + `/groups/{id}/members`, grant par `group_id` (page/collection/property), retrait membre révoque l'accès
|
||||
- [x] **API** — `app/routers/permissions.py` : pages (list/mine, grant, batch, revoke, permission-type), collections (list/grant/revoke/type + visible), properties (list/grant/revoke), groups (list/create/update/delete + members), users picker, audit `GET /api/v2/audit/permissions`
|
||||
- [x] **Guards** — `board.py` (`GET/PUT /board/api/pages/{id}`) + `collections.py` (`delete collection`, `GET/PUT/DELETE page`, `properties`), 403/404 conformes, is_admin/owner bypass + `_session_user()` (no admin fallback)
|
||||
- [x] **PermissionManager** — `app/services/permission_manager.py` étendu : `_explicit_grant_role()` (best rank user+groups), `get_page/collection_permission()`, `can_view/edit_page|collection`, `can_view/edit_property()`, `get_visible_properties()`, groups, `log_permission_change()`, cache 60s + `invalidate()`
|
||||
- [x] **Audit** — table `permission_audit_log` (resource_type, action grant/revoke/type_change/group_*), index, log sur tous les mutateurs, `GET /api/v2/audit/permissions` (owner/admin only, limit 500)
|
||||
- [x] **Migration 18** — `migrations.py` : création 6 tables + 3 colonnes `permission_type` + indexes (idempotent)
|
||||
- [x] **Tests** — `tests/test_v60_granular_permissions.py` **21 tests** (inherit/restricted/private, grant viewer/editor, revoke, batch, type via API, collection restricted+grant, property visibility/hidden, group inherits + revoke, audit, auth 401, validation 400/404)
|
||||
|
||||
## v6.0.0 — Pro (futur)
|
||||
|
||||
- [x] **PWA** — Progressive Web App, offline support ✅ (livré) — [📄 Conception détaillée](/docs/V6_PWA_Progressive_Web_App.md)
|
||||
- [x] **Granular permissions** — page-level, property-level access control ✅ (livré v6.1.0) — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
|
||||
- [ ] **SSO/SAML** — enterprise authentication — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md)
|
||||
- [ ] **Granular permissions** — page-level, property-level access control — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
|
||||
- [ ] **Web Clipper** — extension navigateur — [📄 Conception détaillée](/docs/V6_Web_Clipper.md)
|
||||
- [ ] **API publique complète** — REST API documentée (OpenAPI) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 Référence des features v6](/docs/API_GUIDE_V6.md#11-documents-de-conception-détaillée-v600)
|
||||
- [ ] **Realtime editing (production)** — voir **v5.13.0** (curseurs + présence déjà avancés ici) ; reste en v6 : conflits avancés, édition large échelle
|
||||
@@ -768,4 +783,4 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
DB avancée, redo, drag&drop, bookmark, avancée
|
||||
Calendrier, AI duplicate) lightbox…) (Pt.2)
|
||||
|
||||
*Dernière mise à jour: 2026-09-18 — **v6.0.0 PWA (offline support) COMPLETED** (manifest, service worker, IndexedDB, sync `/api/v2/sync/*`, conflits, UI offline, tests + E2E). Reste: v6.0.0 Pro (SSO/SAML, permissions granulaires, Web Clipper, API publique, realtime production)*
|
||||
*Dernière mise à jour: 2026-09-19 — **v6.1.0 Granular Permissions COMPLETED** (page/collection/property ACL, groupes, audit, 21 tests). Reste: v6.0.0 Pro (SSO/SAML, Web Clipper, API publique, realtime production)*
|
||||
|
||||
+3
-1
@@ -45,6 +45,7 @@ from app.routers.github_routes import router as github_router
|
||||
from app.routers.imports import page_router as import_page_router
|
||||
from app.routers.imports import router as imports_router
|
||||
from app.routers.notifications import router as notifications_router
|
||||
from app.routers.permissions import router as permissions_router
|
||||
from app.routers.realtime import router as realtime_router
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
|
||||
@@ -108,7 +109,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="6.0.0",
|
||||
version="6.1.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
@@ -151,6 +152,7 @@ app.include_router(onboarding.router)
|
||||
app.include_router(sync.router)
|
||||
app.include_router(imports_router)
|
||||
app.include_router(import_page_router)
|
||||
app.include_router(permissions_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@@ -666,6 +666,129 @@ def _migration_offline_sync_queue(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
@register(18, "v6.0.0: granular permissions (page/collection/property ACL + groups)")
|
||||
def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — Granular permissions (page-level, collection-level,
|
||||
property-level access control + reusable user groups).
|
||||
|
||||
``user_groups`` — named groups scoped to a workspace.
|
||||
``group_members`` — users inside a group (N-ary join).
|
||||
``page_permissions`` — explicit grants for block-editor pages
|
||||
(``pages`` table). user_id XOR group_id.
|
||||
``collection_permissions`` — explicit grants for databases.
|
||||
``property_permissions`` — explicit viewer/editor grants per property.
|
||||
``permission_audit_log`` — immutable trail of every grant/revoke.
|
||||
``pages.permission_type`` / ``collections.permission_type`` — access
|
||||
mode: 'inherit' (default, follows the
|
||||
workspace/collection chain) | 'restricted'
|
||||
| 'private' (explicit grants only).
|
||||
"""
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS user_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(workspace_id, name)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS group_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(group_id, user_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_gm_group ON group_members(group_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_gm_user ON group_members(user_id)")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS page_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL, -- viewer | commenter | editor | owner
|
||||
grant_type TEXT NOT NULL DEFAULT 'explicit', -- explicit | group
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
|
||||
UNIQUE(page_id, user_id, group_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_pp_page ON page_permissions(page_id, role)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_pp_user ON page_permissions(user_id)")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS collection_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL, -- viewer | commenter | editor | owner
|
||||
grant_type TEXT NOT NULL DEFAULT 'explicit',
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
|
||||
UNIQUE(collection_id, user_id, group_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_cp_collection ON collection_permissions(collection_id, role)")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS property_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
property_id INTEGER NOT NULL REFERENCES collection_properties(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL, -- viewer | editor
|
||||
grant_type TEXT NOT NULL DEFAULT 'explicit',
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
|
||||
UNIQUE(collection_id, property_id, user_id, group_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_propp_prop ON property_permissions(property_id, role)")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS permission_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
resource_type TEXT NOT NULL, -- page | collection | property | group
|
||||
resource_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL, -- grant | revoke | type_change | group_create | group_delete | member_add | member_remove
|
||||
target_user_id INTEGER,
|
||||
target_group_id INTEGER,
|
||||
old_role TEXT,
|
||||
new_role TEXT,
|
||||
performed_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_perm_audit_res "
|
||||
"ON permission_audit_log(resource_type, resource_id, created_at)"
|
||||
)
|
||||
|
||||
for table in ("pages", "collection_pages"):
|
||||
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
if "permission_type" not in cols:
|
||||
conn.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
||||
)
|
||||
_ccols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
|
||||
if "permission_type" not in _ccols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
||||
)
|
||||
|
||||
|
||||
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
|
||||
"""Add ``sync_version`` to ``table`` if it is not already present."""
|
||||
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
|
||||
+31
-1
@@ -14,6 +14,7 @@ from app.routers.dashboard import _get_app_version
|
||||
from app.routers.sidebar_config import get_sidebar_config_sync
|
||||
from app.services.automations import fire_event
|
||||
from app.services.gitea_client import gitea
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
@@ -1362,8 +1363,14 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}")
|
||||
async def get_page(page_id: int):
|
||||
async def get_page(request: Request, page_id: int):
|
||||
"""Get a Markdown page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
# No session → legacy single-user behaviour (matches collections `_require_view`).
|
||||
if user and user.get("id"):
|
||||
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -1377,6 +1384,15 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
|
||||
content_format: str = Query(default="")):
|
||||
"""Update a page's title and/or content. Accepts JSON body for blocks."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(403, "Authentication required")
|
||||
# v6.0.0: granular page permissions — hidden pages 404 (not 403); a visible
|
||||
# page the caller cannot edit yields 403.
|
||||
pm = PermissionManager(user["id"], bool(user.get("is_admin")))
|
||||
if not pm.can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
if not pm.can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
with get_conn() as conn:
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
@@ -1408,6 +1424,9 @@ async def save_page_blocks(request: Request, page_id: int):
|
||||
title = body.get("title", "")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
# No session → legacy single-user behaviour; otherwise enforce edit rights.
|
||||
if uid and not PermissionManager(uid).can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
|
||||
# Extract synced block ids from the blocks
|
||||
def _extract_synced(blocks: list[dict]) -> set[int]:
|
||||
@@ -1892,6 +1911,13 @@ async def move_page(request: Request, page_id: int):
|
||||
@router.delete("/api/pages/{page_id}")
|
||||
async def delete_page(request: Request, page_id: int):
|
||||
"""Move a page to trash (soft delete)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
if not uid:
|
||||
raise HTTPException(403, "Authentication required")
|
||||
# v6.0.0: granular page permissions — need at least edit access to trash.
|
||||
if not PermissionManager(uid).can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -1910,6 +1936,10 @@ async def view_page(request: Request, page_id: int):
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
# v6.0.0: granular page permissions — hide restricted pages (404).
|
||||
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
|
||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.services.recurrence import (
|
||||
validate_rule,
|
||||
)
|
||||
from app.services.reminders import REMINDER_KEY, parse_lead
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
@@ -33,6 +34,36 @@ def _current_user(request: Request) -> dict:
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
|
||||
|
||||
def _session_user(request: Request) -> dict | None:
|
||||
"""Resolve the session user WITHOUT the admin fallback (for ACL checks)."""
|
||||
s = request.cookies.get("flowdeck_session", "")
|
||||
user = SessionManager.decode_session(s)
|
||||
return user if user and user.get("id") else None
|
||||
|
||||
|
||||
def _require_view(collection_id: int, user: dict | None) -> None:
|
||||
"""Return None when a user may view the collection, else raise 404.
|
||||
|
||||
A missing/userless session keeps the legacy single-user behaviour (owner on
|
||||
un-workspaced collections); explicit ``restricted`` / ``private`` collections
|
||||
are hidden for non-owners unless granted.
|
||||
"""
|
||||
if not user:
|
||||
return
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
|
||||
def _require_edit(collection_id: int, user: dict | None) -> None:
|
||||
"""Raise 403 when the user may not edit pages in the collection."""
|
||||
if not user:
|
||||
return
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_edit_collection(collection_id):
|
||||
raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
|
||||
|
||||
|
||||
def _collection_properties(conn, collection_id: int) -> list[dict]:
|
||||
return [
|
||||
dict(r) for r in conn.execute(
|
||||
@@ -291,6 +322,13 @@ async def update_collection_api(request: Request, collection_id: int):
|
||||
@router.delete("/api/{collection_id}")
|
||||
async def delete_collection_api(request: Request, collection_id: int):
|
||||
"""API: delete a collection and its pages (CASCADE)."""
|
||||
# v6.0.0: granular collection permissions — owner/admin only.
|
||||
user = _session_user(request)
|
||||
_require_view(collection_id, user)
|
||||
if user:
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(status_code=403, detail="Only a collection owner can delete it")
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
@@ -456,7 +494,8 @@ async def get_page_api(request: Request, page_id: int):
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_view(page["collection_id"], _session_user(request))
|
||||
return dict(page)
|
||||
|
||||
|
||||
@@ -474,6 +513,8 @@ async def update_page_api(request: Request, page_id: int):
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_edit(existing["collection_id"], _session_user(request))
|
||||
|
||||
title = body.get("title", existing["title"])
|
||||
icon = body.get("icon", existing["icon"])
|
||||
@@ -537,6 +578,8 @@ async def delete_page_api(request: Request, page_id: int):
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_edit(existing["collection_id"], _session_user(request))
|
||||
|
||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
@@ -601,7 +644,9 @@ async def list_property_types_api(request: Request):
|
||||
|
||||
@router.get("/{collection_id}/properties/api")
|
||||
async def list_properties_api(request: Request, collection_id: int):
|
||||
"""API: list all properties for a collection."""
|
||||
"""API: list all properties visible to the current user."""
|
||||
user = _session_user(request)
|
||||
_require_view(collection_id, user)
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
@@ -610,7 +655,14 @@ async def list_properties_api(request: Request, collection_id: int):
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return {"properties": [dict(r) for r in rows]}
|
||||
props = [dict(r) for r in rows]
|
||||
# v6.0.0: property-level visibility — owners/editors see everything, other
|
||||
# users only the properties explicitly granted or left open.
|
||||
if user:
|
||||
pm = PermissionManager(user["id"])
|
||||
visible = pm.get_visible_properties(collection_id)
|
||||
props = [p for p in props if p["id"] in visible]
|
||||
return {"properties": props}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/members/api")
|
||||
@@ -1793,6 +1845,8 @@ async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
|
||||
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
|
||||
async def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
||||
"""Main view — renders collection in the requested view type."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
@@ -2349,6 +2403,8 @@ tr:hover td{{background:#222}}
|
||||
@router.get("/{collection_id}/api")
|
||||
async def get_collection_api(request: Request, collection_id: int):
|
||||
"""API: get a single collection with its pages."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
@@ -2375,6 +2431,9 @@ async def get_collection_api(request: Request, collection_id: int):
|
||||
@router.post("/{collection_id}/pages/api")
|
||||
async def create_page_api(request: Request, collection_id: int):
|
||||
"""API: create a page in a collection."""
|
||||
# v6.0.0: granular collection permissions — viewer/commenter cannot create.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
|
||||
@@ -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]}
|
||||
@@ -1,12 +1,27 @@
|
||||
"""FlowDeck — Agent permission guard (v4.10.0).
|
||||
"""FlowDeck — Permission manager: workspace roles + granular ACL (v6.0.0).
|
||||
|
||||
The agent always acts with *at most* the permissions of the invoking user
|
||||
(Notion Agent principle). This manager resolves the user's role in the active
|
||||
workspace and gates tool execution before any write reaches the database.
|
||||
Two layers:
|
||||
|
||||
1. **Workspace roles** (v4.10.0, agent guard): every user has a single role in
|
||||
each workspace (owner > owner-membership > editor > commenter > viewer).
|
||||
The FlowDeck Agent always acts with *at most* the permissions of the
|
||||
invoking user (Notion Agent principle).
|
||||
|
||||
2. **Granular permissions** (v6.0.0): explicit page / collection / property
|
||||
grants plus reusable user groups. Resolution follows the least-privilege
|
||||
rule — an explicit grant on a resource overrides the inherited chain
|
||||
(page → collection → workspace), while ``restricted`` / ``private``
|
||||
resources deny access unless a grant (or the workspace owner / admin)
|
||||
applies.
|
||||
|
||||
Resolution results are cached for 60 s to keep the hot paths (sidebar, view
|
||||
rendering, route guards) < 10 ms per check; ``PermissionManager.invalidate()``
|
||||
drops the cache after any grant/revoke/type change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -19,6 +34,12 @@ READ_ROLES = {"viewer", "commenter", "editor", "admin", "owner"}
|
||||
WRITE_ROLES = {"editor", "admin", "owner"}
|
||||
DESTRUCTIVE_ROLES = {"admin", "owner"}
|
||||
|
||||
# Granular resource roles (ranked, least → most privileged).
|
||||
_GRANULAR_ROLES = ("viewer", "commenter", "editor", "owner")
|
||||
_ROLE_RANK = {role: i for i, role in enumerate(_GRANULAR_ROLES)}
|
||||
_PROPERTY_ROLES = ("viewer", "editor")
|
||||
_PROPERTY_RANK = {"viewer": 0, "editor": 1}
|
||||
|
||||
# Tools that mutate state and therefore require at least an editor role.
|
||||
WRITE_TOOLS = {
|
||||
"create_collection", "create_view", "create_page", "update_page",
|
||||
@@ -35,10 +56,27 @@ DESTRUCTIVE_TOOLS = {
|
||||
|
||||
|
||||
class PermissionManager:
|
||||
"""Resolves workspace role and gates agent tool calls."""
|
||||
"""Resolves workspace role and gates agent + granular ACL checks."""
|
||||
|
||||
def __init__(self, user_id: int):
|
||||
def __init__(self, user_id: int, is_admin: bool = False):
|
||||
self.user_id = user_id
|
||||
self._is_admin_override = bool(is_admin)
|
||||
self._cache: dict[str, tuple[float, object]] = {}
|
||||
|
||||
# ── Cache helpers ──
|
||||
|
||||
def _cached(self, key: str, ttl: float, fn):
|
||||
now = time.monotonic()
|
||||
hit = self._cache.get(key)
|
||||
if hit and now - hit[0] < ttl:
|
||||
return hit[1]
|
||||
val = fn()
|
||||
self._cache[key] = (now, val)
|
||||
return val
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Drop the resolution cache after a grant/revoke/type change."""
|
||||
self._cache.clear()
|
||||
|
||||
# ── Role resolution ──
|
||||
|
||||
@@ -100,3 +138,315 @@ class PermissionManager:
|
||||
# A viewer can always read; editor can read+write.
|
||||
if role not in READ_ROLES:
|
||||
raise HTTPException(status_code=403, detail="User has no access to this workspace")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Granular permissions (v6.0.0)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _is_admin(self, conn) -> bool:
|
||||
if self._is_admin_override:
|
||||
return True
|
||||
row = conn.execute(
|
||||
"SELECT is_admin FROM users WHERE id=?", (self.user_id,)
|
||||
).fetchone()
|
||||
return bool(row and row["is_admin"])
|
||||
|
||||
def _owns_workspace(self, conn, workspace_id: int | None) -> bool:
|
||||
if workspace_id is None:
|
||||
# No workspace → single-user semantics: the actor is the owner.
|
||||
return True
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(workspace_id, self.user_id),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
def user_group_ids(self, conn) -> list[int]:
|
||||
return [
|
||||
r["group_id"]
|
||||
for r in conn.execute(
|
||||
"SELECT group_id FROM group_members WHERE user_id=?", (self.user_id,)
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
def _explicit_grant_role(self, conn, table: str, fk: str, resource_id: int,
|
||||
role_rank: dict[str, int] | None = None) -> str | None:
|
||||
"""Most-privileged explicit role on ``table`` for the user / groups."""
|
||||
rank = role_rank or _ROLE_RANK
|
||||
groups = self.user_group_ids(conn)
|
||||
if groups:
|
||||
placeholders = ", ".join("?" * len(groups))
|
||||
rows = conn.execute(
|
||||
f"SELECT role FROM {table} WHERE {fk}=? "
|
||||
f"AND (user_id=? OR group_id IN ({placeholders}))",
|
||||
(resource_id, self.user_id, *groups),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
f"SELECT role FROM {table} WHERE {fk}=? AND user_id=?",
|
||||
(resource_id, self.user_id),
|
||||
).fetchall()
|
||||
best = max((rank.get(r["role"], -1) for r in rows), default=-1)
|
||||
if best < 0:
|
||||
return None
|
||||
rev = {rank[k]: k for k in rank}
|
||||
return rev[best]
|
||||
|
||||
# ── Page-level ──
|
||||
|
||||
def get_page_permission(self, page_id: int) -> str | None:
|
||||
"""Effective page role for ``self.user_id`` (least privilege).
|
||||
|
||||
Chain: explicit page grant > explicit collection grant > workspace
|
||||
role. ``restricted`` / ``private`` pages ignore the inherited chain.
|
||||
Returns ``None`` when the user must not see the page at all.
|
||||
"""
|
||||
|
||||
def _resolve() -> str | None:
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT permission_type, workspace_id, collection_id FROM pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not page:
|
||||
return None
|
||||
if self._is_admin(conn) or self._owns_workspace(conn, page["workspace_id"]):
|
||||
return "owner"
|
||||
explicit = self._explicit_grant_role(
|
||||
conn, "page_permissions", "page_id", page_id
|
||||
)
|
||||
if explicit:
|
||||
return explicit
|
||||
ptype = page["permission_type"] or "inherit"
|
||||
if ptype in ("restricted", "private"):
|
||||
return None
|
||||
if page["collection_id"]:
|
||||
coll_role = self._collection_role(conn, page["collection_id"])
|
||||
if coll_role:
|
||||
return coll_role
|
||||
return self.role_in_workspace(page["workspace_id"])
|
||||
return self._cached(f"page:{page_id}", 60, _resolve)
|
||||
|
||||
def can_view_page(self, page_id: int) -> bool:
|
||||
return self.get_page_permission(page_id) is not None
|
||||
|
||||
def can_edit_page(self, page_id: int) -> bool:
|
||||
role = self.get_page_permission(page_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["editor"])
|
||||
|
||||
def can_comment_page(self, page_id: int) -> bool:
|
||||
role = self.get_page_permission(page_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["commenter"])
|
||||
|
||||
def can_manage_page_permissions(self, page_id: int) -> bool:
|
||||
role = self.get_page_permission(page_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["owner"])
|
||||
|
||||
# ── Collection-level ──
|
||||
|
||||
def _collection_role(self, conn, collection_id: int) -> str | None:
|
||||
coll = conn.execute(
|
||||
"SELECT permission_type, workspace_id FROM collections WHERE id=?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
if not coll:
|
||||
return None
|
||||
if self._is_admin(conn) or self._owns_workspace(conn, coll["workspace_id"]):
|
||||
return "owner"
|
||||
explicit = self._explicit_grant_role(
|
||||
conn, "collection_permissions", "collection_id", collection_id
|
||||
)
|
||||
if explicit:
|
||||
return explicit
|
||||
ptype = coll["permission_type"] or "inherit"
|
||||
if ptype in ("restricted", "private"):
|
||||
return None
|
||||
return self.role_in_workspace(coll["workspace_id"])
|
||||
|
||||
def get_collection_permission(self, collection_id: int) -> str | None:
|
||||
def _resolve() -> str | None:
|
||||
with get_conn() as conn:
|
||||
return self._collection_role(conn, collection_id)
|
||||
return self._cached(f"collection:{collection_id}", 60, _resolve)
|
||||
|
||||
def can_view_collection(self, collection_id: int) -> bool:
|
||||
return self.get_collection_permission(collection_id) is not None
|
||||
|
||||
def can_edit_collection(self, collection_id: int) -> bool:
|
||||
role = self.get_collection_permission(collection_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["editor"])
|
||||
|
||||
def can_manage_collection_permissions(self, collection_id: int) -> bool:
|
||||
role = self.get_collection_permission(collection_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["owner"])
|
||||
|
||||
# ── Property-level ──
|
||||
|
||||
def _property_grants_exist(self, conn, property_id: int) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM property_permissions WHERE property_id=? LIMIT 1",
|
||||
(property_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def _has_property_grant(self, conn, property_id: int, min_rank: int) -> bool:
|
||||
groups = self.user_group_ids(conn)
|
||||
if groups:
|
||||
placeholders = ", ".join("?" * len(groups))
|
||||
rows = conn.execute(
|
||||
f"SELECT role FROM property_permissions WHERE property_id=? "
|
||||
f"AND (user_id=? OR group_id IN ({placeholders}))",
|
||||
(property_id, self.user_id, *groups),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT role FROM property_permissions WHERE property_id=? AND user_id=?",
|
||||
(property_id, self.user_id),
|
||||
).fetchall()
|
||||
return any(_PROPERTY_RANK.get(r["role"], -1) >= min_rank for r in rows)
|
||||
|
||||
def can_view_property(self, collection_id: int, property_id: int) -> bool:
|
||||
"""A property is visible unless it carries explicit grants excluding
|
||||
the user; without any grant it inherits from the collection. Collection
|
||||
owners/admins always see every property."""
|
||||
if not self.can_view_collection(collection_id):
|
||||
return False
|
||||
return self._cached(
|
||||
f"prop:{property_id}", 60, lambda: self._property_visible(collection_id, property_id)
|
||||
)
|
||||
|
||||
def _collection_workspace_id(self, conn, collection_id: int) -> int | None:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
return row["workspace_id"] if row else None
|
||||
|
||||
def _property_visible(self, collection_id: int, property_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
workspace_id = self._collection_workspace_id(conn, collection_id)
|
||||
if self._is_admin(conn) or self._owns_workspace(conn, workspace_id):
|
||||
return True
|
||||
if self.can_manage_collection_permissions(collection_id):
|
||||
return True
|
||||
if not self._property_grants_exist(conn, property_id):
|
||||
return True
|
||||
return self._has_property_grant(conn, property_id, _PROPERTY_RANK["viewer"])
|
||||
|
||||
def can_edit_property(self, collection_id: int, property_id: int) -> bool:
|
||||
if not self.can_edit_collection(collection_id):
|
||||
return False
|
||||
with get_conn() as conn:
|
||||
workspace_id = self._collection_workspace_id(conn, collection_id)
|
||||
if self._is_admin(conn) or self._owns_workspace(conn, workspace_id):
|
||||
return True
|
||||
if self.can_manage_collection_permissions(collection_id):
|
||||
return True
|
||||
if not self._property_grants_exist(conn, property_id):
|
||||
return True
|
||||
return self._has_property_grant(conn, property_id, _PROPERTY_RANK["editor"])
|
||||
|
||||
def get_visible_properties(self, collection_id: int) -> list[int]:
|
||||
def _resolve() -> list[int]:
|
||||
with get_conn() as conn:
|
||||
props = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return [p["id"] for p in props if self.can_view_property(collection_id, p["id"])]
|
||||
return self._cached(f"visible_props:{collection_id}", 60, _resolve)
|
||||
|
||||
# ── Groups ──
|
||||
|
||||
def is_workspace_admin(self, workspace_id: int | None) -> bool:
|
||||
with get_conn() as conn:
|
||||
return self._is_admin(conn) or self._owns_workspace(conn, workspace_id)
|
||||
|
||||
def create_group(self, workspace_id: int | None, name: str,
|
||||
description: str = "", created_by: int | None = None) -> int:
|
||||
if not self.is_workspace_admin(workspace_id):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can create groups")
|
||||
if not name.strip():
|
||||
raise HTTPException(400, "name is required")
|
||||
with get_conn() as conn:
|
||||
dupe = conn.execute(
|
||||
"SELECT id FROM user_groups WHERE workspace_id IS ? AND name=?",
|
||||
(workspace_id, name.strip()),
|
||||
).fetchone()
|
||||
if dupe:
|
||||
raise HTTPException(400, "A group with this name already exists")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO user_groups (workspace_id, name, description, created_by) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(workspace_id, name.strip(), description or "", created_by),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
def add_user_to_group(self, group_id: int, user_id: int) -> None:
|
||||
with get_conn() as conn:
|
||||
group = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not group:
|
||||
raise HTTPException(404, "Group not found")
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)",
|
||||
(group_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def remove_user_from_group(self, group_id: int, user_id: int) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM group_members WHERE group_id=? AND user_id=?",
|
||||
(group_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def delete_group(self, group_id: int) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM user_groups WHERE id=?", (group_id,))
|
||||
conn.commit()
|
||||
|
||||
def get_groups_for_workspace(self, workspace_id: int | None) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT g.id, g.name, g.description, g.created_by, g.created_at,
|
||||
(SELECT COUNT(*) FROM group_members m WHERE m.group_id=g.id) AS member_count
|
||||
FROM user_groups g WHERE g.workspace_id IS ? ORDER BY g.name""",
|
||||
(workspace_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_group_members(self, group_id: int) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT u.id, u.login, u.full_name, u.email, m.joined_at
|
||||
FROM group_members m JOIN users u ON u.id=m.user_id
|
||||
WHERE m.group_id=? ORDER BY u.login""",
|
||||
(group_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── Audit log ──
|
||||
|
||||
def log_permission_change(self, resource_type: str, resource_id: int, action: str,
|
||||
target_user_id: int | None = None,
|
||||
target_group_id: int | None = None,
|
||||
old_role: str | None = None,
|
||||
new_role: str | None = None,
|
||||
ip_address: str = "") -> None:
|
||||
"""Write one immutable audit row for a permission change."""
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO permission_audit_log
|
||||
(resource_type, resource_id, action, target_user_id, target_group_id,
|
||||
old_role, new_role, performed_by, ip_address)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(resource_type, resource_id, action, target_user_id, target_group_id,
|
||||
old_role, new_role, self.user_id, ip_address),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc: # audit must never break the caller
|
||||
logger.warning("permission audit log failed: %s", exc)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user