sharing.forget_principal has existed since shares did, documented as the thing that stops a recycled id inheriting somebody's grant, and was called by nobody. Deleting a group left every grant naming it; deleting an account left both the grants to it and the grants of its own work -- that second half is the one nothing else could catch, since their rows cascade and the shares of those rows have nothing to cascade from. Both now run before the delete, while the rows are still findable, and a deleted resource forgets its own. library.share defaulted to False, which meant sharing shipped documented as done and unreachable: the panel only renders for somebody holding it, so out of the box nobody could share anything and nothing said why. It is on. The panel itself was checkboxes inside the resource's *save form*, listing every group and every account on the instance, unpaginated, on every detail page -- and a tick only took effect if you also saved the resource. It is its own routes now: search, one grant per POST, the panel re-rendered from what is stored. Anything already shared stays listed whatever the search says, or removing a grant would mean searching for the name it was given to. Reports join the shareable set and memories still do not: a finished piece of work is the thing somebody most wants to hand over, and a record about a person is not content to pass round. reports.visible became sharing.visible_to, which is the one line its own docstring predicted. Two things fell out: `owned` beside `get`, because sharing grants reading and deleting is the owner's alone; and reading somebody else's report no longer clears their unread dot. Permissions gained the answer to "what can this person actually do?" -- explain() is resolve()'s working shown rather than thrown away, naming admin, the baseline, or the groups that granted each one. That is the simulation the union rule exists to make unnecessary, and until now the only way to get it was to open every group and read the grids by eye. Users and groups are list-plus-detail, and membership is edited from one side: it was on both, and a full-form POST from either overwrote what the other had shown. Read and write are split for notes, memory and skills -- checked on the tool's declared risk, after the gate so it can only narrow, and defaulting on. Quotas are the union rule applied to numbers, with the corner that makes it interesting: zero means "no limit" and wins outright, or a group saying unlimited would count for less than one saying a million. Absent means "no opinion". _narrower folds a group's ceiling with the instance's and is deliberately not min, for the same reason. Five axes, enforced where each is knowable -- before a reply is built, before a second one starts, on an agent reply's clock, before a minute of GPU, and beside the helper cap -- and usage is recorded even for a reply that was stopped or errored, because an endpoint charges either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
177 lines
7.0 KiB
Python
177 lines
7.0 KiB
Python
"""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)
|