Grants that outlive what they name, and a rule you can read

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>
This commit is contained in:
Jaroslav Beneš
2026-08-06 16:48:14 +02:00
co-authored by Claude Opus 5
parent 20bb569b00
commit 1b8c9f948c
31 changed files with 2226 additions and 377 deletions
+138 -12
View File
@@ -10,11 +10,21 @@ from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import AdminUser, Db
from lembas.db.models import ROLE_ADMIN, ROLE_PENDING, ROLE_USER, Group, Model, User
from lembas.db.models import (
PRINCIPAL_GROUP,
PRINCIPAL_USER,
ROLE_ADMIN,
ROLE_PENDING,
ROLE_USER,
Group,
Model,
User,
)
from lembas.security import permissions
from lembas.security.passwords import hash_password, validate_password
from lembas.security.sessions import revoke_all_for_user
from lembas.services import settings_store
from lembas.services import settings_store, sharing
from lembas.services import usage as usage_service
from lembas.web.templating import render
log = logging.getLogger(__name__)
@@ -54,22 +64,71 @@ def _would_orphan_the_instance(db: DBSession, user: User) -> bool:
# --- Users -------------------------------------------------------------------
# List plus detail, which is the shape this codebase already mandates for admin
# lists and the one `/admin/models` follows. The single page it replaces
# rendered a full form per account *and* a membership grid, and edited that
# membership from the opposite side to `/admin/groups` -- so a full-form POST
# from either overwrote what the other had just shown.
#
# Membership is now edited from **one** side, the group's. A user's page links
# to their groups and does not offer to change them, because two controls
# writing one value is how each becomes the answer to "why did my change not
# stick?".
PAGE_SIZE = 25
@router.get("/users")
async def users_page(request: Request, db: Db, user: AdminUser, q: str = "", saved: str = ""):
async def users_page(
request: Request, db: Db, user: AdminUser, q: str = "", saved: str = "", page: int = 1
):
query = select(User).order_by(User.created_at)
if q.strip():
pattern = f"%{q.strip()}%"
query = query.where(or_(User.name.ilike(pattern), User.email.ilike(pattern)))
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
page = min(max(1, page), pages)
rows = list(db.scalars(query.offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE)))
return render(
request,
"admin/users.html",
{
"users": list(db.scalars(query)),
"groups": list(db.scalars(select(Group).order_by(Group.name))),
"users": rows,
"usage": {row.id: usage_service.summary(db, row) for row in rows},
"roles": ROLES,
"q": q,
"saved": saved,
"pager": {"page": page, "pages": pages, "total": total},
"admin_count": _admin_count(db),
},
)
@router.get("/users/{user_id}")
async def user_detail(request: Request, db: Db, user: AdminUser, user_id: str, saved: str = ""):
"""One account, and the answer to "what can this person actually do?".
That answer is `permissions.explain`, which is `resolve`'s working shown
rather than thrown away. Read-only on purpose: every one of those switches
is set somewhere else -- the baseline, or a named group -- and a control here
would be a third place to change one thing.
"""
target = _user(db, user_id)
return render(
request,
"admin/user_detail.html",
{
"target": target,
"roles": ROLES,
"explained": permissions.explain(db, target),
"permission_groups": permissions.permission_groups(),
"limits": permissions.limits_for(db, target),
"limit_defs": permissions.LIMIT_DEFS,
"usage": usage_service.summary(db, target),
"models": permissions.models_visible_to(db, target),
"saved": saved,
"admin_count": _admin_count(db),
},
)
@@ -114,8 +173,13 @@ async def update_user(
name: str = Form(...),
role: str = Form(ROLE_USER),
active: bool = Form(False),
group_ids: list[str] = Form(default=[]),
) -> Response:
"""Name, role and whether the account is active. **Not membership.**
That moved to the group's page. It used to be here as well, and a full-form
POST from either side overwrote whatever the other had -- two controls, one
value, and no answer to which one wins.
"""
target = _user(db, user_id)
losing_admin = target.role == ROLE_ADMIN and (role != ROLE_ADMIN or not active)
@@ -128,7 +192,6 @@ async def update_user(
target.name = name.strip()[:120] or target.name
target.role = role if role in ROLES else target.role
target.active = active
target.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))
# A deactivated or demoted user must lose their live sessions immediately,
# otherwise the change only takes effect when their cookie happens to expire.
@@ -137,7 +200,9 @@ async def update_user(
db.commit()
log.info("%s updated account %s (role=%s active=%s)", user.email, target.email, role, active)
return RedirectResponse(f"/admin/users?saved=Saved+{target.email}.", status_code=303)
return RedirectResponse(
f"/admin/users/{target.id}?saved=Saved+{target.email}.", status_code=303
)
@router.post("/users/{user_id}/password")
@@ -146,7 +211,7 @@ async def reset_password(
) -> Response:
target = _user(db, user_id)
if (problem := validate_password(password)) is not None:
return RedirectResponse(f"/admin/users?saved={problem}", status_code=303)
return RedirectResponse(f"/admin/users/{user_id}?saved={problem}", status_code=303)
target.password_hash = hash_password(password)
db.commit()
@@ -155,7 +220,7 @@ async def reset_password(
revoke_all_for_user(db, target)
log.info("%s reset the password for %s", user.email, target.email)
return RedirectResponse(
f"/admin/users?saved=Password+reset+for+{target.email}.+Sessions+revoked.",
f"/admin/users/{target.id}?saved=Password+reset.+Sessions+revoked.",
status_code=303,
)
@@ -175,6 +240,15 @@ async def delete_user(db: Db, user: AdminUser, user_id: str) -> Response:
email = target.email
# Chats and folders cascade; that is the point of deleting an account.
#
# Shares do not, and never did. `Share.principal_id` and
# `Share.resource_id` both point at one of several tables depending on a
# sibling column, which SQLite cannot express as a foreign key -- so a
# deleted account left behind every grant *to* it and every grant *of* its
# own work. Both halves, and both before the delete, while the rows are
# still there to be found.
sharing.forget_owner(db, target.id)
sharing.forget_principal(db, PRINCIPAL_USER, target.id)
db.delete(target)
db.commit()
log.info("%s deleted account %s", user.email, email)
@@ -182,17 +256,42 @@ async def delete_user(db: Db, user: AdminUser, user_id: str) -> Response:
# --- Groups ------------------------------------------------------------------
# The same list-plus-detail shape. The old page rendered every group's full
# permission grid, every member and every model on one screen, which is fine for
# two groups and unreadable at ten.
@router.get("/groups")
async def groups_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
groups = list(db.scalars(select(Group).order_by(Group.name)))
return render(
request,
"admin/groups.html",
{
"groups": list(db.scalars(select(Group).order_by(Group.name))),
"groups": groups,
"granted": {
group.id: sum(1 for on in (group.permissions_json or {}).values() if on)
for group in groups
},
"permission_groups": permissions.permission_groups(),
"baseline": permissions.baseline_permissions(db),
"saved": saved,
},
)
@router.get("/groups/{group_id}")
async def group_detail(request: Request, db: Db, user: AdminUser, group_id: str, saved: str = ""):
group = _group(db, group_id)
return render(
request,
"admin/group_detail.html",
{
"group": group,
"users": list(db.scalars(select(User).order_by(User.name))),
"models": list(db.scalars(select(Model).order_by(Model.position, Model.model_id))),
"permission_groups": permissions.permission_groups(),
"baseline": permissions.baseline_permissions(db),
"limit_defs": permissions.LIMIT_DEFS,
"limits": group.limits_json or {},
"saved": saved,
},
)
@@ -216,6 +315,7 @@ async def create_group(db: Db, user: AdminUser, name: str = Form(...)) -> Respon
@router.post("/groups/{group_id}")
async def update_group(
request: Request,
db: Db,
user: AdminUser,
group_id: str,
@@ -226,6 +326,7 @@ async def update_group(
model_ids: list[str] = Form(default=[]),
) -> Response:
group = _group(db, group_id)
form = await request.form()
group.name = name.strip()[:120] or group.name
group.description = description.strip()[:1000]
@@ -235,9 +336,26 @@ async def update_group(
group.users = list(db.scalars(select(User).where(User.id.in_(user_ids or []))))
group.models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or []))))
# Quotas. Only what was submitted and could be read as a number is stored, so
# a blank box means "this group has no opinion" and contributes nothing to
# the resolution -- which is what `limits_for` needs in order to tell it
# apart from a deliberate zero, and zero here means *no limit*.
wanted: dict[str, int] = {}
for key in permissions.LIMIT_KEYS:
raw = str(form.get(f"limit_{key}") or "").strip()
if not raw:
continue
try:
wanted[key] = max(0, int(raw))
except ValueError:
continue
group.limits_json = wanted
db.commit()
log.info("%s updated group %s", user.email, group.name)
return RedirectResponse(f"/admin/groups?saved=Saved+{group.name}.", status_code=303)
return RedirectResponse(
f"/admin/groups/{group.id}?saved=Saved+{group.name}.", status_code=303
)
@router.post("/groups/{group_id}/delete")
@@ -245,8 +363,16 @@ async def delete_group(db: Db, user: AdminUser, group_id: str) -> Response:
group = _group(db, group_id)
name = group.name
# Members and model links go with it; the users themselves are untouched.
#
# Every share naming this group goes too. Nothing cascades -- see
# `sharing.forget_principal` -- so a deleted group left its grants behind,
# and a group id is a random hex string that nothing reissues today and
# nothing promises not to reissue tomorrow.
dropped = sharing.forget_principal(db, PRINCIPAL_GROUP, group.id)
db.delete(group)
db.commit()
if dropped:
log.info("dropped %d share(s) naming group %s", dropped, name)
log.info("%s deleted group %s", user.email, name)
return RedirectResponse(f"/admin/groups?saved=Deleted+{name}.", status_code=303)
+42
View File
@@ -975,6 +975,36 @@ def _note_rewind(chat: Chat) -> None:
chat.rewound_at = datetime.now(UTC)
def _too_many_replies(db: DBSession, chat: Chat, user: User) -> str:
"""Why this account may not start another reply right now, or "".
In-process, and that is exact rather than approximate only because this
application runs one worker -- see the first known limit in PLAN.md. With
several, this becomes a guess, and a quota that is a guess should be a
number in the database instead. Stated here rather than discovered.
"""
from lembas.security import permissions
ceiling = permissions.limit(db, user, "concurrent_replies")
if ceiling <= 0:
return ""
mine = {
row[0]
for row in db.execute(select(Chat.id).where(Chat.user_id == user.id)).all()
}
running = sum(
1
for chat_id in mine
if chat_id != chat.id and generation_service.running_for(chat_id) is not None
)
if running < ceiling:
return ""
return (
f"You already have {running} repl{'y' if running == 1 else 'ies'} being "
f"written, which is this account's limit. Wait for one to finish."
)
def _send(
request: Request,
db: Db,
@@ -997,6 +1027,18 @@ def _send(
prefixes of it, with Stop pointing at whichever bubble came first in the
document.
"""
# How many of *this account's* chats are already writing. Checked here and
# not inside `generation`, because this is where there is somebody to tell:
# a schedule firing or a finished job waking a chat has nobody at the
# keyboard, and refusing those would be a quota silently eating work an
# administrator set up on purpose.
#
# This chat's own reply does not count against it -- a second message here
# is queued rather than sent, a few lines down, and that path is what the
# queue is for.
if busy := _too_many_replies(db, chat, user):
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, busy)
if queued := _reply_in_flight(db, chat):
waiting = db.scalar(
select(func.count())
+73 -37
View File
@@ -23,10 +23,7 @@ from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.api.pages import sidebar_context
from lembas.db.models import (
AUTHOR_USER,
PRINCIPAL_GROUP,
PRINCIPAL_USER,
Document,
Group,
KnowledgeBase,
Note,
Skill,
@@ -61,32 +58,21 @@ def _page(db: DBSession, query, page: int):
return rows, {"page": page, "pages": pages, "total": total}
def _shared_context(db: DBSession, user: User, resource) -> dict:
"""Everything the share panel on a detail page needs."""
grants = sharing.grants_for(db, resource)
def _shared_context(db: DBSession, user: User, resource, kind: str) -> dict:
"""What the share placeholder needs, which is now three facts.
The panel itself is fetched from `api/sharing.py`, so the names, the search
and the grants are no longer built here -- and neither is a query for every
account on the instance on every detail page.
"""
return {
"can_share": permissions.has(db, user, "library.share"),
"groups": list(db.scalars(select(Group).order_by(Group.name))),
"people": list(
db.scalars(select(User).where(User.id != user.id).order_by(User.name))
),
"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],
"is_owner": resource.owner_id == user.id,
"share_kind": kind,
"share_id": resource.id,
}
def _apply_shares(db: DBSession, user: User, resource, form) -> None:
if not permissions.has(db, user, "library.share") or resource.owner_id != user.id:
return
sharing.set_grants(
db,
resource,
user_ids=form.getlist("share_user"),
group_ids=form.getlist("share_group"),
)
# --- Shell -------------------------------------------------------------------
@router.get("/library")
async def library_home(user: RequiredUser):
@@ -98,9 +84,21 @@ async def library_home(user: RequiredUser):
# before /library/knowledge/{base_id}, or "document" is parsed as a base id.
# FastAPI matches in registration order and this has bitten before.
@router.get("/library/knowledge")
async def knowledge_list(request: Request, db: Db, user: RequiredUser, error: str = ""):
"""The bases, not the documents. A library is a set of places first."""
bases = list(db.scalars(documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)))
async def knowledge_list(
request: Request, db: Db, user: RequiredUser, error: str = "", shared: bool = False
):
"""The bases, not the documents. A library is a set of places first.
`shared=1` narrows to bases other people have given this reader — the same
filter the notes and skills lists carry, and the one that makes "what have
people shared with me?" a question with an answer.
"""
query = (
select(KnowledgeBase).where(sharing.only_shared(KnowledgeBase, user))
if shared
else documents_service.visible_bases(db, user)
)
bases = list(db.scalars(query.order_by(KnowledgeBase.name)))
counts = {
base.id: db.scalar(
select(func.count()).select_from(Document).where(Document.base_id == base.id)
@@ -115,6 +113,7 @@ async def knowledge_list(request: Request, db: Db, user: RequiredUser, error: st
"section": "knowledge",
"bases": bases,
"counts": counts,
"shared": shared,
"error": error,
**sidebar_context(db, user),
},
@@ -200,7 +199,7 @@ async def base_detail(
"documents": rows,
"q": q,
"pager": pager,
**_shared_context(db, user, base),
**_shared_context(db, user, base, "base"),
**sidebar_context(db, user),
},
)
@@ -220,7 +219,6 @@ async def update_base(request: Request, db: Db, user: RequiredUser, base_id: str
base.name = name
base.description = str(form.get("description", "")).strip()[:2000]
db.commit()
_apply_shares(db, user, base, form)
return RedirectResponse(
f"/library/knowledge/{base.id}", status_code=status.HTTP_303_SEE_OTHER
)
@@ -345,16 +343,34 @@ async def document_content(db: Db, user: RequiredUser, document_id: str) -> Resp
# --- Notes -------------------------------------------------------------------
@router.get("/library/notes")
async def notes_list(request: Request, db: Db, user: RequiredUser, q: str = "", page: int = 1):
async def notes_list(
request: Request,
db: Db,
user: RequiredUser,
q: str = "",
page: int = 1,
shared: bool = False,
):
"""`shared=1` narrows to what other people have given this reader.
A separate view rather than a badge in the mixed list. A badge answers "is
this mine?" for a row already on screen; the question somebody has is "what
have people given me?", which a mixed list of two hundred cannot answer.
Searching inside it is deliberately left out -- the search path returns
ranked ids and re-filtering them by owner would silently shorten the page.
"""
if q.strip():
rows = notes_service.search(
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q)
)
pager = {"page": 1, "pages": 1, "total": len(rows)}
else:
rows, pager = _page(
db, notes_service.visible(db, user).order_by(Note.updated_at.desc()), page
query = (
select(Note).where(sharing.only_shared(Note, user))
if shared
else notes_service.visible(db, user)
)
rows, pager = _page(db, query.order_by(Note.updated_at.desc()), page)
return render(
request,
"library/notes.html",
@@ -362,6 +378,7 @@ async def notes_list(request: Request, db: Db, user: RequiredUser, q: str = "",
"section": "notes",
"notes": rows,
"q": q,
"shared": shared,
"pager": pager,
**sidebar_context(db, user),
},
@@ -389,7 +406,7 @@ async def note_detail(request: Request, db: Db, user: RequiredUser, note_id: str
"section": "notes",
"note": note,
"body_html": render_markdown(note.body),
**_shared_context(db, user, note),
**_shared_context(db, user, note, "note"),
**sidebar_context(db, user),
},
)
@@ -413,7 +430,6 @@ async def update_note(request: Request, db: Db, user: RequiredUser, note_id: str
form = await request.form()
notes_service.update(db, note, title=str(form.get("title", "")), body=str(form.get("body", "")))
_apply_shares(db, user, note, form)
return RedirectResponse(f"/library/notes/{note.id}", status_code=status.HTTP_303_SEE_OTHER)
@@ -428,14 +444,34 @@ async def delete_note(db: Db, user: RequiredUser, note_id: str) -> Response:
# --- Skills ------------------------------------------------------------------
@router.get("/library/skills")
async def skills_list(request: Request, db: Db, user: RequiredUser, q: str = "", page: int = 1):
async def skills_list(
request: Request,
db: Db,
user: RequiredUser,
q: str = "",
page: int = 1,
shared: bool = False,
):
"""`shared=1` narrows to what other people have given this reader.
A separate view rather than a badge in the mixed list. A badge answers "is
this mine?" for a row already on screen; the question somebody has is "what
have people given me?", which a mixed list of two hundred cannot answer.
Searching inside it is deliberately left out -- the search path returns
ranked ids and re-filtering them by owner would silently shorten the page.
"""
if q.strip():
rows = skills_service.search(
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q)
)
pager = {"page": 1, "pages": 1, "total": len(rows)}
else:
rows, pager = _page(db, skills_service.visible(db, user).order_by(Skill.name), page)
query = (
select(Skill).where(sharing.only_shared(Skill, user))
if shared
else skills_service.visible(db, user)
)
rows, pager = _page(db, query.order_by(Skill.name), page)
return render(
request,
"library/skills.html",
@@ -443,6 +479,7 @@ async def skills_list(request: Request, db: Db, user: RequiredUser, q: str = "",
"section": "skills",
"skills": rows,
"q": q,
"shared": shared,
"pager": pager,
**sidebar_context(db, user),
},
@@ -470,7 +507,7 @@ async def skill_detail(request: Request, db: Db, user: RequiredUser, skill_id: s
"section": "skills",
"skill": skill,
"revisions": skill.revisions,
**_shared_context(db, user, skill),
**_shared_context(db, user, skill, "skill"),
**sidebar_context(db, user),
},
)
@@ -511,7 +548,6 @@ async def update_skill(request: Request, db: Db, user: RequiredUser, skill_id: s
author=AUTHOR_USER,
note="edited by hand",
)
_apply_shares(db, user, skill, form)
return RedirectResponse(f"/library/skills/{skill.id}", status_code=status.HTTP_303_SEE_OTHER)
+38 -4
View File
@@ -18,12 +18,15 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response
from sqlalchemy import select
from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.api.library import PAGE_SIZE, _page
from lembas.api.pages import sidebar_context
from lembas.db.models import Report
from lembas.security import permissions
from lembas.services import reports as reports_service
from lembas.services import sharing
from lembas.services.library import retrieval
from lembas.services.markdown import render_markdown
from lembas.web.templating import render
@@ -34,7 +37,20 @@ router = APIRouter(dependencies=[Depends(require_permission("reports.use"))], ta
@router.get("/reports")
async def reports_list(request: Request, db: Db, user: RequiredUser, q: str = "", page: int = 1):
async def reports_list(
request: Request,
db: Db,
user: RequiredUser,
q: str = "",
page: int = 1,
shared: bool = False,
):
"""`shared=1` narrows to reports other people have shared with this reader.
Reports became shareable at the same time as this filter appeared, and the
two arrived together on purpose: a feed that quietly grew somebody else's
work with no way to see only theirs is worse than one that never grew.
"""
if q.strip():
rows = reports_service.search(
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q)
@@ -42,7 +58,13 @@ async def reports_list(request: Request, db: Db, user: RequiredUser, q: str = ""
pager = {"page": 1, "pages": 1, "total": len(rows)}
else:
rows, pager = _page(
db, reports_service.visible(user).order_by(Report.created_at.desc()), page
db,
(
select(Report).where(sharing.only_shared(Report, user))
if shared
else reports_service.visible(user)
).order_by(Report.created_at.desc()),
page,
)
return render(
request,
@@ -51,6 +73,7 @@ async def reports_list(request: Request, db: Db, user: RequiredUser, q: str = ""
"section": "reports",
"reports": rows,
"q": q,
"shared": shared,
"pager": pager,
**sidebar_context(db, user),
},
@@ -65,7 +88,12 @@ async def report_detail(request: Request, db: Db, user: RequiredUser, report_id:
# Opening one is what reading it means. Done before rendering so the dot on
# the way in and the dot on the way back to the list agree -- the poller
# would otherwise re-announce a report the reader is looking at.
reports_service.mark_read(db, report)
#
# Only the owner's own reading counts. `unread` is the owner's dot, and
# somebody a report was shared with opening it would otherwise clear a
# notification meant for a person who has not seen it.
if report.owner_id == user.id:
reports_service.mark_read(db, report)
return render(
request,
"reports/detail.html",
@@ -74,6 +102,10 @@ async def report_detail(request: Request, db: Db, user: RequiredUser, report_id:
"report": report,
# Model output, through the one path allowed to emit HTML.
"body_html": render_markdown(report.body),
"can_share": permissions.has(db, user, "library.share"),
"is_owner": report.owner_id == user.id,
"share_kind": "report",
"share_id": report.id,
**sidebar_context(db, user),
},
)
@@ -81,7 +113,9 @@ async def report_detail(request: Request, db: Db, user: RequiredUser, report_id:
@router.post("/api/reports/{report_id}/delete")
async def delete_report(db: Db, user: RequiredUser, report_id: str) -> Response:
report = reports_service.get(db, report_id, user)
# `owned`, not `get`: sharing grants reading, so being able to see a report
# is not being able to delete it out from under the person who filed it.
report = reports_service.owned(db, report_id, user)
if report is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That report is not available.")
reports_service.delete(db, report)
+176
View File
@@ -0,0 +1,176 @@
"""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)