Files
flowdeck/docs/V6_Granular_Permissions.md
bruno 13d5f8625a
FlowDeck CI / lint (push) Failing after 1m8s
FlowDeck CI / test (push) Failing after 8m5s
FlowDeck CI / docker (push) Skipped
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
2026-09-19 22:54:16 -04:00

519 lines
19 KiB
Markdown

# 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)