"""Giving somebody else access to one thing. Its own routes and its own fragment, rather than a block of checkboxes riding along with the resource's save form. Three reasons, in the order they bite: - **It rendered every group and every person on the instance, unpaginated, on every detail page.** That is fine for a household and unusable for anything else, and the page it is on has nothing to do with how many accounts exist. - **A share was only stored if the resource was saved.** Ticking a box and navigating away did nothing, silently, which is the shape of failure this codebase keeps cataloguing. - Sharing a *report* has no save form to ride along with at all. So: search, and each grant is its own POST. The fragment re-renders itself after every change, which is what keeps "who can see this" a thing you read rather than a thing you reconstruct from checkboxes. **Only the owner may reach any of it.** Somebody a thing was shared with cannot share it on -- that is what keeps "who can see this?" answerable by asking one person -- and the check is `sharing.can_write`, which is ownership and nothing else. """ from __future__ import annotations import logging from fastapi import APIRouter, Form, HTTPException, Request, Response, status from sqlalchemy import or_, select from lembas.api.deps import Db, RequiredUser from lembas.db.models import ( PRINCIPAL_GROUP, PRINCIPAL_USER, Group, KnowledgeBase, Note, Report, Skill, User, ) from lembas.security import permissions from lembas.services import sharing from lembas.web.templating import render log = logging.getLogger(__name__) router = APIRouter(prefix="/api/library/share", tags=["sharing"]) # What a URL may name, and what it resolves to. A fixed table rather than a # lookup by string on `sharing.RESOURCE_TYPES`, because that one maps class to # string and this needs the other direction -- and because a route segment is # request input, so the set of things it may name belongs written down. KINDS: dict[str, type] = { "base": KnowledgeBase, "note": Note, "skill": Skill, "report": Report, } # Candidates offered at once. Enough that a small instance never has to type # anything, few enough that a large one is not a page of names. MAX_CANDIDATES = 12 def _resource(db: Db, kind: str, resource_id: str, user: User): model = KINDS.get(kind) if model is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Not a shareable kind.") resource = db.get(model, resource_id) # Ownership, not readability. Being able to see a thing is not being able to # give it away, and the 404 rather than a 403 is deliberate: somebody who # cannot share it has no business learning whether it exists. if resource is None or not sharing.can_write(resource, user): raise HTTPException(status.HTTP_404_NOT_FOUND, "That is not yours to share.") return resource def _panel(request: Request, db: Db, user: User, kind: str, resource, q: str = "") -> Response: grants = sharing.grants_for(db, resource) shared_users = [g.principal_id for g in grants if g.principal_type == PRINCIPAL_USER] shared_groups = [g.principal_id for g in grants if g.principal_type == PRINCIPAL_GROUP] needle = q.strip() pattern = f"%{needle}%" group_query = select(Group).order_by(Group.name) people_query = select(User).where(User.id != user.id).order_by(User.name) if needle: group_query = group_query.where(Group.name.ilike(pattern)) people_query = people_query.where( or_(User.name.ilike(pattern), User.email.ilike(pattern)) ) # Anything already shared is shown whatever the search says, or the only way # to remove a grant would be to search for the name it was given to. groups = list(db.scalars(group_query.limit(MAX_CANDIDATES))) people = list(db.scalars(people_query.limit(MAX_CANDIDATES))) for existing in db.scalars(select(Group).where(Group.id.in_(shared_groups or [""]))): if existing.id not in {g.id for g in groups}: groups.insert(0, existing) for existing in db.scalars(select(User).where(User.id.in_(shared_users or [""]))): if existing.id not in {p.id for p in people}: people.insert(0, existing) return render( request, "library/_share_panel.html", { "kind": kind, "resource": resource, "q": needle, "groups": groups, "people": people, "shared_users": shared_users, "shared_groups": shared_groups, "share_count": len(grants), # Whether the lists were cut, so the panel can say "search for # somebody" rather than implying these are all the names there are. "truncated": len(people) >= MAX_CANDIDATES or len(groups) >= MAX_CANDIDATES, }, ) @router.get("/{kind}/{resource_id}") async def share_panel( request: Request, db: Db, user: RequiredUser, kind: str, resource_id: str, q: str = "" ) -> Response: resource = _resource(db, kind, resource_id, user) if not permissions.has(db, user, "library.share"): raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not share things.") return _panel(request, db, user, kind, resource, q) @router.post("/{kind}/{resource_id}") async def set_share( request: Request, db: Db, user: RequiredUser, kind: str, resource_id: str, principal_type: str = Form(""), principal_id: str = Form(""), on: bool = Form(False), q: str = Form(""), ) -> Response: """Add or remove one grant, and answer with the panel. One grant per request rather than a submitted set, because the set is what made the old panel need every name on the instance in front of you before you could change one of them. """ resource = _resource(db, kind, resource_id, user) if not permissions.has(db, user, "library.share"): raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not share things.") if principal_type not in (PRINCIPAL_USER, PRINCIPAL_GROUP): raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unknown principal.") grants = sharing.grants_for(db, resource) users = [g.principal_id for g in grants if g.principal_type == PRINCIPAL_USER] groups = [g.principal_id for g in grants if g.principal_type == PRINCIPAL_GROUP] target = users if principal_type == PRINCIPAL_USER else groups # Validated against what exists, so a crafted id cannot write a grant naming # nothing -- which would be invisible in the panel and unremovable from it. exists = db.get(User if principal_type == PRINCIPAL_USER else Group, principal_id) if on and exists is not None and principal_id not in target: target.append(principal_id) elif not on and principal_id in target: target.remove(principal_id) sharing.set_grants(db, resource, user_ids=users, group_ids=groups) log.info( "%s %s %s %s with %s", user.email, "shared" if on else "unshared", kind, resource_id, principal_id, ) return _panel(request, db, user, kind, resource, q)