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

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 → 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

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

# 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

  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
  • Google ACL Model