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

- Migration 18: 6 tables + 3 colonnes permission_type + indexes
- PermissionManager: heritage page->collection->workspace, least privilege, groups, cache 60s
- API /api/v2: pages/collections/properties/groups/users/audit (401/403/404/400)
- Guards board.py + collections.py (404/403, admin/owner bypass)
- Tests 21/21 (inherit/restricted/private, grant, revoke, batch, group, audit)
- Docs + ROADMAP + CHANGELOG + VERSION 6.1.0
This commit is contained in:
2026-09-19 22:54:16 -04:00
parent d707a6850a
commit 13d5f8625a
11 changed files with 2215 additions and 14 deletions
+123
View File
@@ -666,6 +666,129 @@ def _migration_offline_sync_queue(conn: sqlite3.Connection) -> None:
)
@register(18, "v6.0.0: granular permissions (page/collection/property ACL + groups)")
def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
"""v6.0.0 — Granular permissions (page-level, collection-level,
property-level access control + reusable user groups).
``user_groups`` — named groups scoped to a workspace.
``group_members`` — users inside a group (N-ary join).
``page_permissions`` — explicit grants for block-editor pages
(``pages`` table). user_id XOR group_id.
``collection_permissions`` — explicit grants for databases.
``property_permissions`` — explicit viewer/editor grants per property.
``permission_audit_log`` — immutable trail of every grant/revoke.
``pages.permission_type`` / ``collections.permission_type`` — access
mode: 'inherit' (default, follows the
workspace/collection chain) | 'restricted'
| 'private' (explicit grants only).
"""
conn.execute(
"""CREATE TABLE IF NOT EXISTS user_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(workspace_id, name)
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS group_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(group_id, user_id)
)"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_gm_group ON group_members(group_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_gm_user ON group_members(user_id)")
conn.execute(
"""CREATE TABLE IF NOT EXISTS page_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
role TEXT NOT NULL, -- viewer | commenter | editor | owner
grant_type TEXT NOT NULL DEFAULT 'explicit', -- explicit | group
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
UNIQUE(page_id, user_id, group_id)
)"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_pp_page ON page_permissions(page_id, role)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_pp_user ON page_permissions(user_id)")
conn.execute(
"""CREATE TABLE IF NOT EXISTS collection_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
role TEXT NOT NULL, -- viewer | commenter | editor | owner
grant_type TEXT NOT NULL DEFAULT 'explicit',
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
UNIQUE(collection_id, user_id, group_id)
)"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_cp_collection ON collection_permissions(collection_id, role)")
conn.execute(
"""CREATE TABLE IF NOT EXISTS property_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
property_id INTEGER NOT NULL REFERENCES collection_properties(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
role TEXT NOT NULL, -- viewer | editor
grant_type TEXT NOT NULL DEFAULT 'explicit',
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
UNIQUE(collection_id, property_id, user_id, group_id)
)"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_propp_prop ON property_permissions(property_id, role)")
conn.execute(
"""CREATE TABLE IF NOT EXISTS permission_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_type TEXT NOT NULL, -- page | collection | property | group
resource_id INTEGER NOT NULL,
action TEXT NOT NULL, -- grant | revoke | type_change | group_create | group_delete | member_add | member_remove
target_user_id INTEGER,
target_group_id INTEGER,
old_role TEXT,
new_role TEXT,
performed_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
ip_address TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_perm_audit_res "
"ON permission_audit_log(resource_type, resource_id, created_at)"
)
for table in ("pages", "collection_pages"):
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
if "permission_type" not in cols:
conn.execute(
f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
)
_ccols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
if "permission_type" not in _ccols:
conn.execute(
"ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
)
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
"""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()}