b255a0f90e
Renames src/fabledassistant -> src/scribe and all imports, plus the default DB name and DB user/password (fabled -> scribe) in config + compose. 952 refs / 154 files. Reverses the old 'internal name stays fabledassistant' convention. Code-only: live databases are still physically named 'fabledassistant'. Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git host (fabledsword), MCP (fabled-git) and the image name (fabledscribe) are intentionally unchanged. ruff check src/ clean locally; CI (typecheck + pytest) is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
164 lines
5.3 KiB
Python
164 lines
5.3 KiB
Python
"""Access control service.
|
|
|
|
Single source of truth for permission resolution on projects and notes.
|
|
All human-facing routes that should honour shares call these functions.
|
|
LLM tool routes continue to use owner-scoped service functions directly.
|
|
|
|
Permission rank: owner > admin > editor > viewer
|
|
"""
|
|
|
|
import logging
|
|
|
|
from sqlalchemy import select
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.group import GroupMembership
|
|
from scribe.models.note import Note
|
|
from scribe.models.project import Project
|
|
from scribe.models.share import NoteShare, ProjectShare
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PERMISSION_RANK: dict[str, int] = {
|
|
"viewer": 1,
|
|
"editor": 2,
|
|
"admin": 3,
|
|
"owner": 4,
|
|
}
|
|
|
|
|
|
def _higher(a: str | None, b: str | None) -> str | None:
|
|
"""Return the higher-ranked permission, or None if both are None."""
|
|
if a is None:
|
|
return b
|
|
if b is None:
|
|
return a
|
|
return a if PERMISSION_RANK[a] >= PERMISSION_RANK[b] else b
|
|
|
|
|
|
async def _user_group_ids(session, user_id: int) -> list[int]:
|
|
rows = (
|
|
await session.execute(
|
|
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
|
|
)
|
|
).scalars().all()
|
|
return list(rows)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Project permissions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def get_project_permission(user_id: int, project_id: int) -> str | None:
|
|
"""Return the effective permission string for user on project, or None."""
|
|
async with async_session() as session:
|
|
project = await session.get(Project, project_id)
|
|
if project is None:
|
|
return None
|
|
if project.user_id == user_id:
|
|
return "owner"
|
|
|
|
# Direct share
|
|
direct = (
|
|
await session.execute(
|
|
select(ProjectShare).where(
|
|
ProjectShare.project_id == project_id,
|
|
ProjectShare.shared_with_user_id == user_id,
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
# Group shares
|
|
group_ids = await _user_group_ids(session, user_id)
|
|
group_perm: str | None = None
|
|
if group_ids:
|
|
group_shares = (
|
|
await session.execute(
|
|
select(ProjectShare).where(
|
|
ProjectShare.project_id == project_id,
|
|
ProjectShare.shared_with_group_id.in_(group_ids),
|
|
)
|
|
)
|
|
).scalars().all()
|
|
for gs in group_shares:
|
|
group_perm = _higher(group_perm, gs.permission)
|
|
|
|
return _higher(direct.permission if direct else None, group_perm)
|
|
|
|
|
|
async def can_read_project(user_id: int, project_id: int) -> bool:
|
|
return (await get_project_permission(user_id, project_id)) is not None
|
|
|
|
|
|
async def can_write_project(user_id: int, project_id: int) -> bool:
|
|
perm = await get_project_permission(user_id, project_id)
|
|
return perm in ("editor", "admin", "owner")
|
|
|
|
|
|
async def can_admin_project(user_id: int, project_id: int) -> bool:
|
|
perm = await get_project_permission(user_id, project_id)
|
|
return perm in ("admin", "owner")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Note / task permissions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def get_note_permission(user_id: int, note_id: int) -> str | None:
|
|
"""Return the effective permission for user on a note/task, or None.
|
|
|
|
Resolution order:
|
|
1. Ownership
|
|
2. Direct note share
|
|
3. Group-based note share
|
|
4. Inherited from project share (if note belongs to a project)
|
|
Highest rank wins.
|
|
"""
|
|
async with async_session() as session:
|
|
note = await session.get(Note, note_id)
|
|
if note is None:
|
|
return None
|
|
if note.user_id == user_id:
|
|
return "owner"
|
|
|
|
direct = (
|
|
await session.execute(
|
|
select(NoteShare).where(
|
|
NoteShare.note_id == note_id,
|
|
NoteShare.shared_with_user_id == user_id,
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
group_ids = await _user_group_ids(session, user_id)
|
|
group_perm: str | None = None
|
|
if group_ids:
|
|
group_shares = (
|
|
await session.execute(
|
|
select(NoteShare).where(
|
|
NoteShare.note_id == note_id,
|
|
NoteShare.shared_with_group_id.in_(group_ids),
|
|
)
|
|
)
|
|
).scalars().all()
|
|
for gs in group_shares:
|
|
group_perm = _higher(group_perm, gs.permission)
|
|
|
|
note_perm = _higher(direct.permission if direct else None, group_perm)
|
|
|
|
# Inherit from project if note belongs to one
|
|
if note.project_id is not None:
|
|
project_perm = await get_project_permission(user_id, note.project_id)
|
|
note_perm = _higher(note_perm, project_perm)
|
|
|
|
return note_perm
|
|
|
|
|
|
async def can_read_note(user_id: int, note_id: int) -> bool:
|
|
return (await get_note_permission(user_id, note_id)) is not None
|
|
|
|
|
|
async def can_write_note(user_id: int, note_id: int) -> bool:
|
|
perm = await get_note_permission(user_id, note_id)
|
|
return perm in ("editor", "admin", "owner")
|