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
This commit is contained in:
+3
-1
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user