feat(design-systems): the model, the parent chain, and the guard on it
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
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.
This commit is contained in:
@@ -12,11 +12,13 @@ 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__)
|
||||
|
||||
@@ -164,6 +166,88 @@ async def can_write_note(user_id: int, note_id: int) -> bool:
|
||||
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)
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user