CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 20s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 27s
Milestone #254 step 1 (#2286). A design system becomes a record Scribe holds rather than prose in a rulebook: a named set of tokens with an OPTIONAL parent, so a family system carries the house style and an app system carries only what it changes. Answering "what does this app alter?" is then `list its tokens` — nothing to compute. `parent_id` is the whole model. It replaces both an `always_on` flag (a family system is one with no parent) and a subscription join table (a project points at ONE system; the chain supplies the rest) — less schema than the rulebook shape it mirrors. Two decisions the task left open, settled here: - **Token values are JSONB keyed by mode**, not `value_light`/`value_dark` columns. The deciding argument was not flexibility, it was ambiguity: in a child system an unset mode means "inherit", in a root it means "not mode-dependent", and as columns both are NULL and the resolver cannot tell them apart. As a map, resolution is `{**parent, **child}` at every level with no special case for roots. Against it: queryability — but nothing filters tokens by value in SQL, so that buys a query no caller makes. - **`group_name` is free text, no CHECK enum.** Groupings are each design system's own vocabulary; a whitelist would bake one install's kit into the schema. No CHECK is introduced anywhere, so rule #36 does not fire. The cascade lives in `services/design_cascade.py` as pure functions over a `{id: parent_id}` map, importing nothing — which is what lets both the service and `access.py` use it without a cycle, and lets a test state a whole hierarchy in one literal. Cycles are refused on WRITE by walking up from the proposed parent (the cheap direction), and survived on READ by a visited-set, because a loop from a direct DB edit must truncate rather than hang. ACL (rule #78) is deliberately asymmetric: owning a system grants write, reaching one through a project you can see grants READ ONLY. An editor on a shared project must not be able to rewrite the family system every other project in that family resolves through. Also renames `services/design_system.py` -> `design_rulebook_import.py`. It is the #251 prose extractor, whose role is already scheduled to become a one-shot importer (#2288), and leaving it one character away from the new `design_systems.py` was a trap for every later session. Rule #115 throughout: nothing seeds a system or implies a default. An install with zero design systems is ordinary, not degraded.
441 lines
16 KiB
Python
441 lines
16 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 or_, select
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.design_system import DesignSystem
|
|
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
|
|
from scribe.models.user import User
|
|
from scribe.services.design_cascade import ancestry
|
|
|
|
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")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Design-system permissions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def get_design_system_permission(
|
|
user_id: int, design_system_id: int
|
|
) -> str | None:
|
|
"""Effective permission on a design system, or None.
|
|
|
|
Two ways in, and the asymmetry between them is the point:
|
|
|
|
- **Owning it** grants "owner" — full read and write.
|
|
- **Reaching it through a project you can see** grants "viewer", and only
|
|
ever "viewer". Being an editor on a shared project must NOT confer the
|
|
right to rewrite the family system that project inherits from: one
|
|
project's collaborator would be editing tokens every other project in
|
|
the family resolves through. Editing a design system stays the owner's
|
|
act, and it is the same reasoning that keeps a rulebook owner-scoped.
|
|
|
|
Reachability follows the parent chain UPWARD. Rendering a project's UI means
|
|
resolving its whole chain, so read access to a system implies read access to
|
|
its ancestors — otherwise a shared project would resolve to a truncated
|
|
cascade and silently render with the wrong values.
|
|
"""
|
|
async with async_session() as session:
|
|
system = await session.get(DesignSystem, design_system_id)
|
|
if system is None or system.deleted_at is not None:
|
|
return None
|
|
if system.owner_user_id == user_id:
|
|
return "owner"
|
|
|
|
shared_project_ids = select(ProjectShare.project_id).where(
|
|
or_(
|
|
ProjectShare.shared_with_user_id == user_id,
|
|
ProjectShare.shared_with_group_id.in_(_my_group_ids(user_id)),
|
|
)
|
|
)
|
|
entry_points = set(
|
|
(
|
|
await session.execute(
|
|
select(Project.design_system_id).where(
|
|
Project.design_system_id.is_not(None),
|
|
Project.deleted_at.is_(None),
|
|
or_(
|
|
Project.user_id == user_id,
|
|
Project.id.in_(shared_project_ids),
|
|
),
|
|
)
|
|
)
|
|
).scalars().all()
|
|
)
|
|
if not entry_points:
|
|
return None
|
|
|
|
# One narrow query for the whole forest's shape. Design systems are a
|
|
# handful of rows per install — a family and one per app — so walking
|
|
# from each entry point in memory beats a recursive CTE per check.
|
|
parents = dict(
|
|
(
|
|
await session.execute(
|
|
select(DesignSystem.id, DesignSystem.parent_id).where(
|
|
DesignSystem.deleted_at.is_(None)
|
|
)
|
|
)
|
|
).all()
|
|
)
|
|
|
|
for entry in entry_points:
|
|
if design_system_id in ancestry(entry, parents):
|
|
return "viewer"
|
|
return None
|
|
|
|
|
|
async def can_read_design_system(user_id: int, design_system_id: int) -> bool:
|
|
return (await get_design_system_permission(user_id, design_system_id)) is not None
|
|
|
|
|
|
async def can_write_design_system(user_id: int, design_system_id: int) -> bool:
|
|
perm = await get_design_system_permission(user_id, design_system_id)
|
|
return perm in ("editor", "admin", "owner")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Set-based visibility (for LIST queries)
|
|
#
|
|
# Two scopes, deliberately different (decision note 2094):
|
|
#
|
|
# readable_* — everything the ACL permits, including a record reachable ONLY
|
|
# through a direct or group note share. For EXPLICIT acts: a
|
|
# search the caller typed, or a fetch by id.
|
|
# browsable_* — the caller's own records plus anything in a project they have
|
|
# access to. For PASSIVE surfaces: browse lists, facet counts,
|
|
# the process→skill manifest.
|
|
#
|
|
# The split is a trust boundary, not an optimisation. Anything that appears
|
|
# unasked — in your own list, your own counts, or as a skill installed on your
|
|
# machine — reads as material you endorsed. A one-off record someone shared with
|
|
# you has not earned that standing, so it waits until you go looking for it.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def notes_visibility_clause(user_id: int, scope: str = "own"):
|
|
"""The one place a retrieval declares how far it may see.
|
|
|
|
Every path that returns notes picks a scope by what KIND of act it is:
|
|
|
|
"own" — the caller's records only. For machinery whose answer must not
|
|
depend on other people: the near-duplicate gate can't block a
|
|
create because a stranger wrote something similar, and can't
|
|
point at a record the caller cannot edit.
|
|
"browse" — own + project-reachable. For passive surfaces, where an
|
|
unrequested record would read as endorsed.
|
|
"read" — everything the ACL permits. For explicit acts: a typed search,
|
|
a fetch by id.
|
|
|
|
Defaults to the narrowest, so a new caller that forgets to choose is wrong in
|
|
the safe direction.
|
|
"""
|
|
if scope == "own":
|
|
return Note.user_id == user_id
|
|
if scope == "browse":
|
|
return browsable_notes_clause(user_id)
|
|
if scope == "read":
|
|
return readable_notes_clause(user_id)
|
|
raise ValueError(f"unknown note scope {scope!r} (own | browse | read)")
|
|
|
|
|
|
async def owner_names_for(user_ids: set[int]) -> dict[int, str]:
|
|
"""{user_id: username} in one query. Empty input costs nothing.
|
|
|
|
Fails soft: on a lookup error the caller gets no names and renders "another
|
|
user" instead. Losing an attribution is a cosmetic downgrade, and the part
|
|
that matters — that the record ISN'T the caller's — comes from comparing
|
|
owner ids, not from this. Failing the whole search over a username would be
|
|
the worse trade.
|
|
"""
|
|
if not user_ids:
|
|
return {}
|
|
try:
|
|
async with async_session() as session:
|
|
rows = (
|
|
await session.execute(
|
|
select(User.id, User.username).where(User.id.in_(user_ids))
|
|
)
|
|
).all()
|
|
return {uid: name for uid, name in rows}
|
|
except Exception:
|
|
logger.warning("Owner-name lookup failed; falling back to unnamed "
|
|
"attribution", exc_info=True)
|
|
return {}
|
|
|
|
|
|
def _my_group_ids(user_id: int):
|
|
"""The caller's group ids as a SUBQUERY, not a fetched list.
|
|
|
|
Keeping it in SQL is what makes the clause builders below pure functions —
|
|
no session, no await, nothing for a caller's unit test to mock — and it folds
|
|
the membership lookup into the one statement the caller was already running.
|
|
"""
|
|
return select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
|
|
|
|
|
|
def readable_notes_clause(user_id: int):
|
|
"""A SQLAlchemy WHERE predicate matching every note this user may READ.
|
|
|
|
`get_note_permission` answers "may I read THIS note?" one row at a time,
|
|
which a list query can't use — checking per row is O(n) round-trips. This is
|
|
the same resolution expressed as set membership so it can go straight into a
|
|
`select(Note).where(...)`:
|
|
|
|
1. ownership
|
|
2. a direct note share
|
|
3. a group note share
|
|
4. inheritance from a shared project
|
|
|
|
Keep the two in step: a rule added here belongs in `get_note_permission`
|
|
too, or a note becomes findable but not openable (or the reverse — which is
|
|
the bug this was written to fix).
|
|
"""
|
|
groups = _my_group_ids(user_id)
|
|
|
|
shared_note_ids = select(NoteShare.note_id).where(
|
|
or_(
|
|
NoteShare.shared_with_user_id == user_id,
|
|
NoteShare.shared_with_group_id.in_(groups),
|
|
)
|
|
)
|
|
shared_project_ids = select(ProjectShare.project_id).where(
|
|
or_(
|
|
ProjectShare.shared_with_user_id == user_id,
|
|
ProjectShare.shared_with_group_id.in_(groups),
|
|
)
|
|
)
|
|
|
|
return or_(
|
|
Note.user_id == user_id,
|
|
Note.id.in_(shared_note_ids),
|
|
Note.project_id.in_(shared_project_ids),
|
|
)
|
|
|
|
|
|
async def describe_provenance(user_id: int, note) -> dict:
|
|
"""Provenance for a record being handed to a caller: `{}` when it's theirs,
|
|
otherwise `{"shared": True, "owner": <username>, "permission": <perm>}`.
|
|
|
|
Anything reaching an agent or a UI from another person has to say so. A
|
|
shared record is one person's suggestion, not a standard the caller adopted,
|
|
and without a marker the two are indistinguishable — the reader would assume
|
|
their own past self wrote it and treat it as settled practice.
|
|
"""
|
|
if note is None or note.user_id == user_id:
|
|
return {}
|
|
async with async_session() as session:
|
|
owner = await session.get(User, note.user_id)
|
|
return {
|
|
"shared": True,
|
|
"owner": owner.username if owner else None,
|
|
"permission": await get_note_permission(user_id, note.id),
|
|
}
|
|
|
|
|
|
async def label_shared_items(user_id: int, items: list[dict]) -> list[dict]:
|
|
"""Mark the entries in a list payload that belong to someone else.
|
|
|
|
Each foreign item gains `shared: True` and `owner: <username>`; the caller's
|
|
own items are left untouched so an all-mine list stays noise-free. Usernames
|
|
are resolved in one query per distinct owner, not one per row.
|
|
|
|
Lists are where provenance matters most: an unmarked row in your own list
|
|
reads as something you recorded and vetted.
|
|
"""
|
|
foreign = {
|
|
it["user_id"] for it in items
|
|
if it.get("user_id") is not None and it["user_id"] != user_id
|
|
}
|
|
if not foreign:
|
|
return items
|
|
names = await owner_names_for(foreign)
|
|
for it in items:
|
|
owner_id = it.get("user_id")
|
|
if owner_id is not None and owner_id != user_id:
|
|
it["shared"] = True
|
|
it["owner"] = names.get(owner_id)
|
|
return items
|
|
|
|
|
|
def browsable_notes_clause(user_id: int):
|
|
"""A WHERE predicate for PASSIVE surfaces: the caller's own notes, plus
|
|
notes in a project they can reach (owned or shared).
|
|
|
|
Deliberately narrower than `readable_notes_clause` — it omits records the
|
|
caller can read *only* via a direct or group note share. Those stay
|
|
search-only, so a one-off someone handed them never drifts into a browse
|
|
list, a facet count, or the skill manifest as though it were their own.
|
|
|
|
Project membership is the seam because it's a standing, mutual context: if
|
|
you're on the project, its content is your working material. A note with no
|
|
project that you don't own therefore never matches — `NULL IN (...)` is not
|
|
true — which is exactly the intent.
|
|
"""
|
|
shared_project_ids = select(ProjectShare.project_id).where(
|
|
or_(
|
|
ProjectShare.shared_with_user_id == user_id,
|
|
ProjectShare.shared_with_group_id.in_(_my_group_ids(user_id)),
|
|
)
|
|
)
|
|
owned_project_ids = select(Project.id).where(Project.user_id == user_id)
|
|
|
|
return or_(
|
|
Note.user_id == user_id,
|
|
Note.project_id.in_(shared_project_ids),
|
|
Note.project_id.in_(owned_project_ids),
|
|
)
|