- 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
19 KiB
19 KiB
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→mainDé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
# 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
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
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
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
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
-- 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
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 :
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
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
editorau niveau workspace maisviewersur une page spécifique → il est viewer sur cette page - Si un utilisateur a
viewerau 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
# 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 ... :
<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
// 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 :
# 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
# 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
- Migrations DB —
page_permissions,collection_permissions,property_permissions,user_groups,group_members,permission_audit_log, colonnespermission_type app/services/permission_manager.py— extension complète (résolution héritage, cache, groupes)app/routers/permissions.py— endpoints CRUD permissions- Protection dans les routeurs existants (
board.py,collections.py,api.py,workspace.py) - UI — Page permissions panel (extension
page_editor.html) - UI — Collection permissions panel (extension
collections.py) - UI — Property visibility in views (
_database_table_scripts.html) - UI — Groups management (settings page)
- Audit log — tous les changements de permissions
- Tests — tous les scénarios de permissions
- Documentation —
/helpsection permissions granulaires - 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 à étendreapp/routers/collections.py— Protection de collections à étendreapp/routers/workspace.py— Gestion des membres existantsapp/db.py— Schéma de base de donnéesdocs/API_GUIDE_V6.md— Référence API v2 (endpoints permissions)ROADMAP.md— v6.0.0 Granular permissions item- Notion API Permissions
- Google ACL Model