c7ef709633
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>
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 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")
|