Files
FabledScribe/src/fabledassistant/services/access.py
T
bvandeusen c7ef709633 Multi-user sharing, groups, and in-app notifications
Backend:
- Alembic migration 0025: groups, group_memberships, project_shares,
  note_shares, notifications tables
- models: Group, GroupMembership, ProjectShare, NoteShare, Notification
- services/access.py: permission resolution (viewer/editor/admin/owner)
- services/groups.py + routes/groups.py: full group CRUD + membership
- services/sharing.py + routes/shares.py: project/note sharing API
- services/notifications.py: in-app notification create/list/mark-read
- routes/in_app_notifications.py: GET/POST notification endpoints
- routes/users.py: user search endpoint
- services/projects.py + services/notes.py: *_for_user variants
- routes updated to use *_for_user on get/list; adds permission field
- app.py: register all new blueprints

Frontend:
- api/client.ts: ShareEntry, GroupEntry, NotificationEntry types + helpers
- stores/notifications.ts: Pinia store with polling
- NotificationBell.vue: bell icon + badge + dropdown toggle + 60s poll
- NotificationsPanel.vue: unread notification list with mark-all-read
- ShareDialog.vue: teleport modal for sharing projects/notes with users/groups
- SharedWithMeView.vue: /shared route listing shared projects and notes
- AppHeader: NotificationBell, Shared nav link
- ProjectView, NoteViewerView, TaskViewerView: Share button + ShareDialog
- SettingsView: Groups admin tab with create/delete groups + member mgmt
- router: /shared route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 15:37:00 -04:00

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 fabledassistant.models import async_session
from fabledassistant.models.group import GroupMembership
from fabledassistant.models.note import Note
from fabledassistant.models.project import Project
from fabledassistant.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")