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
+3 -1
View File
@@ -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")
+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()}
+31 -1
View File
@@ -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:
+62 -3
View File
@@ -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:
+525
View File
@@ -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]}
+356 -6
View File
@@ -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)