From 9d7fb72bdb113d4d632bcdc8af80ba987cd56d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Thu, 6 Aug 2026 16:48:14 +0200 Subject: [PATCH] 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) --- CLAUDE.md | 12 +- PLAN.md | 37 ++- docs/notes/permissions-and-sharing.md | 143 +++++++++ src/lembas/__init__.py | 2 +- src/lembas/api/admin_users.py | 150 ++++++++- src/lembas/api/chats.py | 42 +++ src/lembas/api/library.py | 110 ++++--- src/lembas/api/reports.py | 42 ++- src/lembas/api/sharing.py | 176 +++++++++++ src/lembas/db/models/__init__.py | 4 + src/lembas/db/models/library.py | 7 + src/lembas/db/models/user.py | 63 +++- src/lembas/main.py | 2 + src/lembas/security/permissions.py | 179 ++++++++++- src/lembas/services/generation.py | 57 ++++ src/lembas/services/images/tool.py | 24 ++ src/lembas/services/reports.py | 33 +- src/lembas/services/sharing.py | 54 +++- src/lembas/services/subagent.py | 7 + src/lembas/services/tools.py | 16 + src/lembas/services/usage.py | 161 ++++++++++ .../web/templates/admin/group_detail.html | 141 +++++++++ src/lembas/web/templates/admin/groups.html | 153 +++------- .../web/templates/admin/user_detail.html | 203 ++++++++++++ src/lembas/web/templates/admin/users.html | 226 ++++++-------- src/lembas/web/templates/library/_share.html | 75 ++--- .../web/templates/library/_share_panel.html | 95 ++++++ .../web/templates/library/knowledge.html | 7 + src/lembas/web/templates/library/notes.html | 9 + src/lembas/web/templates/library/skills.html | 8 + src/lembas/web/templates/reports/detail.html | 10 + src/lembas/web/templates/reports/index.html | 8 + tests/test_quotas.py | 250 +++++++++++++++ tests/test_sharing_grants.py | 289 ++++++++++++++++++ 34 files changed, 2405 insertions(+), 390 deletions(-) create mode 100644 docs/notes/permissions-and-sharing.md create mode 100644 src/lembas/api/sharing.py create mode 100644 src/lembas/services/usage.py create mode 100644 src/lembas/web/templates/admin/group_detail.html create mode 100644 src/lembas/web/templates/admin/user_detail.html create mode 100644 src/lembas/web/templates/library/_share_panel.html create mode 100644 tests/test_quotas.py create mode 100644 tests/test_sharing_grants.py diff --git a/CLAUDE.md b/CLAUDE.md index 253442e..1169466 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused lembas secret-key # generate LEMBAS_SECRET_KEY lembas create-admin # create or promote an admin -pytest # 2024 tests, ~2min +pytest # 2062 tests, ~2min # PLAN.md tracks what is and is not built ruff check . # lint (line length 100) python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons; @@ -82,7 +82,9 @@ src/lembas/ folders.py folder CRUD admin.py connections + instance settings admin_models.py model ordering, defaults, images, access - admin_users.py users, groups, permissions + admin_users.py users, groups, permissions, quotas -- list plus detail, + with "what can this person actually do?" answered + sharing.py the share panel: search, and one grant per request admin_audio.py speech-to-text and text-to-speech endpoints admin_search.py web search provider and credentials admin_images.py the ComfyUI, and the workflow templates on it @@ -139,6 +141,8 @@ src/lembas/ wake.py starting a reply from outside a request -- one lock discipline, shared by finished jobs and by schedules sharing.py one visibility rule for every library store + usage.py what an account spent this month, and whether it may spend + more -- written even when the reply failed prompts.py every injected prompt fragment, and {{variables}} metrics.py tokens, context percentage and tokens/second tokens.py the chars/4 estimate, for endpoints that report none @@ -199,6 +203,10 @@ touching the code it names -- these are the same notes, not a summary. schedule something wrote a note and said it had). - `docs/notes/image-generation.md` -- the ComfyUI workflow with holes in it, what substitution walks, the review-and-retry loop, and how a failure reports itself. +- `docs/notes/permissions-and-sharing.md` -- the union rule shown rather than + thrown away, where read and write are split and why not everywhere, quotas as + the union rule applied to numbers (and why zero wins), where each is enforced, + and the three deletes that have to forget a share because nothing cascades. - `docs/notes/search-and-extraction.md` -- extraction limits as a snapshot, why reciprocal rank fusion and not a weight, how a record scores as its best chunk, why vectors from two models never meet, and the session event that notices a diff --git a/PLAN.md b/PLAN.md index c125339..6a4f9b5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -9,7 +9,7 @@ reasoning, tool calling with web search, custom HTTP tools and MCP servers, agent chats that work on a machine over SSH, a knowledge library, notes, memory and skills, speech in and out, image generation over ComfyUI, users and groups, model administration, installable as an app, reports, messages, and scheduled -work that runs on its own. 2024 tests, `ruff` clean. +work that runs on its own. 2062 tests, `ruff` clean. What remains before the first stable release is written out below, in phases, under [The road to 1.0.0](#the-road-to-100). @@ -521,15 +521,27 @@ seen working. it finishes ### Phase 6 — permissions, quotas and sharing (`0.9.6`) -- [ ] **"What can this user actually do?"** answered on screen, from the - resolution that already computes it -- [ ] Membership edited from one side; a searchable, paginated user list -- [ ] Reading and writing split within a gate where the difference matters -- [ ] **Quotas on a group**, resolved by maximum — the union rule applied to - numbers — and enforced where the existing budgets are -- [ ] Deleting a group or a user forgets its grants, which it never did -- [ ] Sharing as its own action with a search box, a shared-with-me filter, and - reports shareable. Sharing stays read-only +- [x] **"What can this user actually do?"** answered on screen, and *where each + permission came from* — `explain()` is the resolution's working shown + rather than thrown away, which is the simulation the union rule exists to + make unnecessary +- [x] List plus detail for users and groups; membership edited from **one** side, + since a full-form POST from either used to overwrite the other's view +- [x] Reading and writing split for the three gates where the difference is a + real decision — checked on the tool's risk, after the gate, defaulting on +- [x] **Quotas on a group**, resolved by maximum with **zero meaning no limit + and winning outright**, and enforced at the five places 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 +- [x] Usage recorded even for a reply that was stopped or failed, because an + endpoint charges either way and a quota a Stop button walks past is not one +- [x] **Deleting a group or a user forgets its grants, which it never did** — + both halves for an account, since their rows cascade and the shares of + those rows have nothing to cascade from +- [x] Sharing as its own action with a search box — one grant per request, stored + the moment it is made rather than when the resource happens to be saved +- [x] A "Shared with me" filter in all four listings, reports shareable, and + `library.share` on by default. Sharing stays read-only ### Phase 7 — packaging and updating (`0.9.7`) - [ ] **Docker**, with the data on a volume and a TLS proxy expected in front @@ -625,7 +637,10 @@ Recorded because each looks like an oversight until you know the reason. - **No JavaScript build step.** Browser libraries are hash-pinned and committed. A self-hosted tool should work offline and not report page views to a CDN. -- **Permissions union, never deny.** With denies, "why can this user not do X" +- **Permissions union, never deny**, and quotas resolved by maximum for the same + reason -- with the corner that zero means *no limit* and therefore wins, or + "unlimited" would count for less than a large number. With denies, "why can + this user not do X" cannot be answered without simulating every group. - **System prompts replace, never stack.** Two layers that disagree give the model contradictory instructions and nobody can tell which is losing. diff --git a/docs/notes/permissions-and-sharing.md b/docs/notes/permissions-and-sharing.md new file mode 100644 index 0000000..265c31d --- /dev/null +++ b/docs/notes/permissions-and-sharing.md @@ -0,0 +1,143 @@ +# Permissions, quotas and sharing + +Read this before touching `security/permissions.py`, `services/sharing.py`, +`services/usage.py`, or the admin user and group screens. + +## The union rule, and what it costs + +Permissions are a flat set of named booleans: a baseline, widened by each group. +**A group grants; it never denies.** That is a recorded decision and the reason +still holds — with denies, "why can this person not do X" needs a simulation of +every group they are in. + +`permissions.explain(db, user)` is `resolve`'s working *shown* rather than thrown +away: for each key, whether it is on and what granted it — "admin", "baseline", +or the names of the groups. The user detail page renders it read-only, because +every one of those switches is set somewhere else and a control there would be a +third place to change one thing. + +## Read and write, split for three gates + +`tools.notes` used to be one switch over five tools. Three gates now have a +second permission, `tools..write`, listed in `permissions.SPLIT_GATES`: +notes, memory, skills. + +It is checked in `resolve_tools`, not in `_family_allowed`, and that is not +tidiness: `_family_allowed` is given a *family* and this needs the *tool*, since +the whole point is that two tools in one family get different answers. It applies +**after** the gate, so it can only narrow what was already allowed, and all three +default on — an instance that never looks behaves exactly as it did. + +Not split everywhere. `web_search` has no write half; `report` is a write with no +read worth withholding; `agent` has modes, which are finer than a permission and +are per chat. A permission whose answer is always "the same as that one" is one +nobody should be asked about. + +## Quotas are the union rule applied to numbers + +`Group.limits_json`, resolved by `permissions.limits_for`. Five axes, because +they fail differently and a single "budget" would need an exchange rate between +a token and a minute of somebody's GPU. + +Three rules, and the third is the one that is easy to get wrong: + +1. **Maximum across groups** — a second group can only ever grant more. +2. **Absent contributes nothing** — a group with no opinion about tokens must not + silently make somebody unlimited. +3. **Zero means no limit and wins outright.** A plain maximum would make a group + saying "unlimited" count for less than one saying "a million" — the union rule + inverted for exactly the value somebody sets when they mean *stop limiting + this person*. + +The same asymmetry appears wherever a group's ceiling meets the instance's, so +`generation._narrower` is written once: it is not `min`, because a zero on either +side would win and turn "no opinion" into "no time at all". + +Administrators are unlimited, for the reason they hold every permission. + +### Where each is enforced, and why there + +| axis | where | why there | +|---|---|---| +| `monthly_tokens` | start of `generation._run` | knowable in advance; a reply that trailed off mid-sentence because a month ran out is the failure `_wrap_up` exists to prevent | +| `concurrent_replies` | `api/chats.py:_send` | the only place with somebody to tell — a schedule firing has nobody at the keyboard | +| `agent_seconds` | `_run`, narrowing `Limits` | the instance's ceiling already lives there | +| `images_per_day` | `images/tool.py:run` | before a minute of GPU is spent | +| `helpers_per_reply` | `subagent._run_subagent` | beside the instance's own per-reply cap | + +`concurrent_replies` is in-process, and that is exact **only because this +application runs one worker**. With several it becomes a guess, and a quota that +is a guess should be a number in the database instead. + +## Usage is recorded even when the reply failed + +`generation._persist` is the single writer for everything a reply produced, and +it records usage whether the reply finished, was stopped, or errored. An endpoint +charges for tokens it generated regardless of whether anybody wanted them, and a +quota that only counted happy paths is one a Stop button walks past. + +One row per user per period, UTC. Not the reader's timezone: a quota that reset +at a different instant for each member of a group is one nobody can reason about. +`usage.record` never raises — bookkeeping that broke a reply would be worse than +no bookkeeping. + +`images_today` is counted off `Attachment` rather than kept as a counter, because +there is a natural source of truth and a *daily* counter would need a second row +shape and a second reset. + +## Nothing cascades to a `Share` + +`Share.principal_id` points at a user *or* a group, and `resource_id` at one of +four tables, depending on a sibling column. SQLite cannot express either as a +foreign key, so **every delete has to say so explicitly**: + +- `delete_group` → `forget_principal(GROUP, id)` +- `delete_user` → `forget_owner(id)` **and** `forget_principal(USER, id)` +- deleting a resource → `forget_resource` + +`forget_principal` existed for exactly this and was called by nobody. +`forget_owner` is new and is the half nothing else could catch: their rows +cascade when the account goes, and the shares *of those rows* have nothing to +cascade from. Both run **before** the delete, while the rows are still findable. + +## Reports are shareable; memories are not + +A report is read once and never answered, so sharing it has none of the +two-editors problem that keeps writing off the table. A memory is a record *about +a person*, which is not content to hand round — that decision stands. + +`reports.visible` became `sharing.visible_to` — one line, which is what its own +docstring predicted. Two consequences that needed saying: + +- `reports.owned` exists beside `get`. Sharing grants **reading**, so deleting is + the owner's alone. Two functions rather than a flag, because a route that wants + one and calls the other is a bug you can see in the name. +- **Reading somebody else's report does not clear their dot.** `unread` is the + owner's notification, and a reader opening it would silence something meant for + a person who has not seen it. + +## The share panel is its own action + +It used to be 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 the resource happened to be saved afterwards. Now: + +- `api/sharing.py` serves the panel and takes **one grant per POST**, answering + with the panel again, so what is on screen is what is stored. +- It searches. Anything already shared stays listed whatever the search says, or + the only way to remove a grant would be to search for the name it was given to. +- A principal id that names nothing is refused — a crafted one would write a + grant invisible in the panel and unremovable from it. +- Only the owner may reach any of it, checked with `sharing.can_write` + (ownership, nothing else). A 404 rather than a 403: somebody who cannot share + it has no business learning whether it exists. + +`library.share` **defaults on** now. It was off, 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. + +## Sharing still grants reading only + +Recorded, and the reason still holds: two editors, no history, no merge. Writable +shares would touch `owned_by`, `can_write` and four places in `canvas.py`. Not +for 1.0. diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py index c7d74f3..39cc2f2 100644 --- a/src/lembas/__init__.py +++ b/src/lembas/__init__.py @@ -1,3 +1,3 @@ """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" -__version__ = "0.9.5" +__version__ = "0.9.6" diff --git a/src/lembas/api/admin_users.py b/src/lembas/api/admin_users.py index 0695d65..add11f2 100644 --- a/src/lembas/api/admin_users.py +++ b/src/lembas/api/admin_users.py @@ -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) diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index be04790..bf994f1 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -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()) diff --git a/src/lembas/api/library.py b/src/lembas/api/library.py index 9d26d09..053331f 100644 --- a/src/lembas/api/library.py +++ b/src/lembas/api/library.py @@ -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) diff --git a/src/lembas/api/reports.py b/src/lembas/api/reports.py index 004f4eb..471b7ea 100644 --- a/src/lembas/api/reports.py +++ b/src/lembas/api/reports.py @@ -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) diff --git a/src/lembas/api/sharing.py b/src/lembas/api/sharing.py new file mode 100644 index 0000000..096e51a --- /dev/null +++ b/src/lembas/api/sharing.py @@ -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) diff --git a/src/lembas/db/models/__init__.py b/src/lembas/db/models/__init__.py index c985a13..f564386 100644 --- a/src/lembas/db/models/__init__.py +++ b/src/lembas/db/models/__init__.py @@ -48,6 +48,7 @@ from lembas.db.models.library import ( PRINCIPAL_USER, RESOURCE_BASE, RESOURCE_NOTE, + RESOURCE_REPORT, RESOURCE_SKILL, SOURCE_LINK, SOURCE_UPLOAD, @@ -101,6 +102,7 @@ from lembas.db.models.user import ( Group, PushSubscription, Session, + Usage, User, user_groups, ) @@ -108,6 +110,7 @@ from lembas.db.models.user import ( __all__ = [ "AUTHOR_MODEL", "PushSubscription", + "Usage", "AUTH_KEY", "AUTH_METHODS", "AUTH_PASSWORD", @@ -126,6 +129,7 @@ __all__ = [ "PRINCIPAL_USER", "RESOURCE_BASE", "RESOURCE_NOTE", + "RESOURCE_REPORT", "RESOURCE_SKILL", "RESPONSE_JSON", "RESPONSE_MODES", diff --git a/src/lembas/db/models/library.py b/src/lembas/db/models/library.py index 8765b9d..cf69ba6 100644 --- a/src/lembas/db/models/library.py +++ b/src/lembas/db/models/library.py @@ -53,6 +53,13 @@ SOURCE_LINK = "link" RESOURCE_BASE = "base" RESOURCE_NOTE = "note" RESOURCE_SKILL = "skill" +# A report is shareable and a memory is not, and the line between them is the +# one already drawn elsewhere: a finished piece of work is exactly the thing +# somebody wants to hand over, and a record *about a person* is not content to +# pass round. The constant lives here beside the other three even though Report +# is not a library model, because `Share.resource_type` is one column and its +# vocabulary belongs in one place. +RESOURCE_REPORT = "report" PRINCIPAL_USER = "user" PRINCIPAL_GROUP = "group" diff --git a/src/lembas/db/models/user.py b/src/lembas/db/models/user.py index 08c9f31..dbf5c97 100644 --- a/src/lembas/db/models/user.py +++ b/src/lembas/db/models/user.py @@ -5,7 +5,18 @@ from __future__ import annotations from datetime import datetime from typing import TYPE_CHECKING, Any -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String, Table, Text +from sqlalchemy import ( + Boolean, + Column, + DateTime, + ForeignKey, + Index, + Integer, + String, + Table, + Text, + UniqueConstraint, +) from sqlalchemy.orm import Mapped, mapped_column, relationship from lembas.db.base import Base, Timestamps, UUIDPrimaryKey @@ -69,6 +80,16 @@ class Group(UUIDPrimaryKey, Timestamps, Base): # lembas.security.permissions. permissions_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + # What members of this group may spend. Resolved across a user's groups by + # **maximum**, which is the union rule applied to numbers: being in a second + # group can only ever grant more. Zero means "no limit" and therefore wins + # outright, because a group that says "unlimited" saying less than one that + # says "a million" would be the union rule inverted for one value. + # + # Absent keys mean the group has no opinion and contribute nothing. See + # security/permissions.py:limits_for. + limits_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + users: Mapped[list[User]] = relationship(secondary=user_groups, back_populates="groups") models: Mapped[list[Model]] = relationship( "Model", secondary="model_groups", back_populates="groups" @@ -149,3 +170,43 @@ class PushSubscription(UUIDPrimaryKey, Timestamps, Base): Index("ix_push_subscriptions_user_id", PushSubscription.user_id) + + +class Usage(UUIDPrimaryKey, Timestamps, Base): + """What one account spent in one period. + + A row per user per period rather than a row per reply. A per-reply ledger is + what somebody eventually wants for a bill; this exists to answer one + question on the request path -- "has this account used its month?" -- and + that question wants one indexed lookup, not a sum over ten thousand rows. + + `period` is a plain "YYYY-MM" string in **UTC**. Not the reader's timezone: + a quota that resets at a different instant for each member of a group is a + quota nobody can reason about, and the month boundary is not something + anybody experiences to the hour. + + Written by `generation._persist`, which is the single writer for everything + a reply produced, so a reply that is stopped or errors still records what it + spent -- an endpoint charges for tokens it generated whether or not the + reply was wanted. + """ + + __tablename__ = "usage" + __table_args__ = (UniqueConstraint("user_id", "period", name="uq_usage_user_period"),) + + user_id: Mapped[str] = mapped_column( + String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + period: Mapped[str] = mapped_column(String(7), nullable=False) + + prompt_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + completion_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + replies: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + # Counted separately because it is its own quota: one picture is a minute of + # somebody's GPU and no tokens at all, so a token budget says nothing about + # it. `images_today` on the resolved limits is the daily half; this is the + # month's running total, for the admin screen. + images: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + def __repr__(self) -> str: + return f"" diff --git a/src/lembas/main.py b/src/lembas/main.py index 036cff2..cdfa95d 100644 --- a/src/lembas/main.py +++ b/src/lembas/main.py @@ -41,6 +41,7 @@ from lembas.api import ( push, reports, schedules, + sharing, terminal, ) from lembas.api.deps import RedirectToLogin, is_htmx, login_redirect @@ -194,6 +195,7 @@ def create_app() -> FastAPI: app.include_router(reports.router) app.include_router(schedules.router) app.include_router(agents.router) + app.include_router(sharing.router) app.include_router(admin.router) app.include_router(admin_users.router) app.include_router(admin_models.router) diff --git a/src/lembas/security/permissions.py b/src/lembas/security/permissions.py index f76ba86..ae47701 100644 --- a/src/lembas/security/permissions.py +++ b/src/lembas/security/permissions.py @@ -225,9 +225,15 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = ( PermissionDef( "library.share", "Share library items", - "Give other people, or a group, access to their documents, notes and " - "skills. Sharing grants reading only.", - False, + "Give other people, or a group, access to their knowledge bases, notes, " + "skills and reports. Sharing grants reading only — never changing, and " + "never sharing on.", + # On. It was off, which meant sharing shipped documented as done and + # unreachable: the panel is only rendered for somebody who holds this, + # so out of the box nobody could share anything and nothing said why. + # An instance that wants it off can say so; one that never looked should + # get the feature it was told it had. + True, "Library", ), PermissionDef( @@ -260,8 +266,53 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = ( True, "Library", ), + # --- Reading and writing, split where the difference matters ------------ + # Three gates cover both, and for these three the two halves are genuinely + # different decisions: a model that may *read* somebody's notes and not add + # to them is a reasonable thing to want, and until now `tools.notes` was one + # switch over five tools. + # + # Not split for every gate. `tools.web_search` has no write half; `report` + # is a write with no read worth withholding; `agent` has modes, which are a + # finer instrument than a permission and are per chat. A permission that + # answers "the same as that one" is a permission nobody should be asked + # about -- the reasoning `schedule.use` already carries. + # + # **All three default on**, so an instance that never looks behaves exactly + # as it did: `_family_allowed` reads them only to *narrow* what the gate + # already allowed. + PermissionDef( + "tools.notes.write", + "Write notes", + "Let a model create, change and delete notes. Without it, it can still " + "search and read the ones that are there.", + True, + "Library", + ), + PermissionDef( + "tools.memory.write", + "Record memories", + "Let a model add and forget short facts about this person. Without it, " + "the memories it already has are still shown to it every turn.", + True, + "Library", + ), + PermissionDef( + "tools.skills.write", + "Write skills", + "Let a model write new skills and change existing ones. Without it, it " + "follows the skills that are there and cannot add to them — which is " + "the setting for an instance whose skills are curated by hand.", + True, + "Library", + ), ) +# Gates whose read and write halves are separate permissions. Keyed on the gate, +# with the permission derived as `tools..write`, so adding a fourth is one +# entry here and one PermissionDef above. +SPLIT_GATES = ("notes", "memory", "skills") + PERMISSION_KEYS = tuple(d.key for d in PERMISSION_DEFS) DEFAULT_PERMISSIONS = {d.key: d.default for d in PERMISSION_DEFS} @@ -304,6 +355,128 @@ def has(db: DBSession, user: User | None, key: str) -> bool: return resolve(db, user).get(key, False) +def explain(db: DBSession, user: User | None) -> dict[str, dict]: + """Every permission, whether this user has it, and **where it came from**. + + The question the admin screens could not answer. `resolve` has always + computed the union and thrown the working away, so "why can this person do + X?" meant opening every group they belong to and reading the grids by eye -- + which is exactly the simulation the union rule exists to avoid needing. + + `source` is "admin" (bypassing everything), "baseline", or the names of the + groups that granted it. A permission that is off has no source, because + nothing granted it -- there is no such thing as a deny here to point at. + """ + keys = PERMISSION_KEYS + if user is None: + return {key: {"on": False, "source": []} for key in keys} + if user.is_admin: + return {key: {"on": True, "source": ["admin"]} for key in keys} + + baseline = baseline_permissions(db) + out: dict[str, dict] = {} + for key in keys: + sources = ["baseline"] if baseline.get(key) else [] + sources += [ + group.name for group in user.groups if (group.permissions_json or {}).get(key) + ] + out[key] = {"on": bool(sources), "source": sources} + return out + + +# --- Quotas ------------------------------------------------------------------- +# What a group may raise, and what each number means. Every one of them is +# **zero for no limit**, which is the convention `max_completion_tokens` and +# `index_chars` already use here, and it is what makes "unlimited" sayable at all. +# +# Five axes rather than one, because they fail differently and a single "budget" +# would have to pick an exchange rate between a token and a minute of somebody's +# GPU. There isn't one. +LIMIT_DEFS: tuple[tuple[str, str, str], ...] = ( + ( + "monthly_tokens", + "Tokens a month", + "Prompt and completion together, across every chat, reset on the first " + "of the month. Reached, a reply says so before it spends anything " + "rather than stopping half way through.", + ), + ( + "concurrent_replies", + "Replies at once", + "How many of their chats may be writing at the same time. This is the " + "one that stops one person queueing every other person's work behind " + "them on a single endpoint.", + ), + ( + "agent_seconds", + "Longest agent reply", + "Seconds of wall clock for one reply in an agent chat, if lower than " + "the instance's own. Waiting for somebody to approve something does " + "not count.", + ), + ( + "images_per_day", + "Images a day", + "Each one is a minute of somebody's GPU and no tokens at all, so a " + "token budget says nothing about it.", + ), + ( + "helpers_per_reply", + "Helpers per reply", + "How many subagents one reply may send, if lower than the instance's " + "own.", + ), +) + +LIMIT_KEYS = tuple(key for key, _, _ in LIMIT_DEFS) + +# Nobody is limited until somebody says so. A quota that arrived with an upgrade +# and started refusing replies would be the worst possible way to introduce one. +NO_LIMITS: dict[str, int] = dict.fromkeys(LIMIT_KEYS, 0) + + +def limits_for(db: DBSession, user: User | None) -> dict[str, int]: + """What this user may spend, resolved across their groups. + + **By maximum**, which is the union rule applied to numbers: being in a second + group can only ever grant more, never less. That is the same promise the + permissions make, and having one of the two work the other way round is how + "why can this person not do X" stops being answerable. + + **Zero wins outright**, because zero means "no limit". Taking the plain + maximum would make a group saying "unlimited" count for less than one saying + "a million", which is the union rule inverted for exactly one value -- and it + is the value somebody sets when they mean *stop limiting this person*. + + An administrator is unlimited, for the reason `resolve` gives them every + permission: they can raise their own quota in two clicks, and pretending + otherwise is theatre. + """ + if user is None or user.is_admin: + return dict(NO_LIMITS) + + resolved = dict(NO_LIMITS) + for key in LIMIT_KEYS: + values = [] + for group in user.groups: + raw = (group.limits_json or {}).get(key) + if raw is None: + continue # no opinion, contributes nothing + try: + values.append(max(0, int(raw))) + except (TypeError, ValueError): + continue + if not values or 0 in values: + resolved[key] = 0 + else: + resolved[key] = max(values) + return resolved + + +def limit(db: DBSession, user: User | None, key: str) -> int: + return limits_for(db, user).get(key, 0) + + def models_visible_to(db: DBSession, user: User | None) -> list[Model]: """Models a user may start a chat with, in display order. diff --git a/src/lembas/services/generation.py b/src/lembas/services/generation.py index e02c97c..27a115e 100644 --- a/src/lembas/services/generation.py +++ b/src/lembas/services/generation.py @@ -28,6 +28,7 @@ from sqlalchemy import select from lembas.db.models import KIND_AGENT, ROLE_ASSISTANT, ROLE_USER, Chat, Message, User from lembas.db.session import session_scope +from lembas.security import permissions from lembas.services import canvas as canvas_service from lembas.services import chat as chat_service from lembas.services import compaction as compaction_service @@ -36,6 +37,7 @@ from lembas.services import metrics as metrics_service from lembas.services import prompts as prompts_service from lembas.services import push as push_service from lembas.services import tools as tools_service +from lembas.services import usage as usage_service from lembas.services.agent import policy as agent_policy from lembas.services.agent import session as agent_session from lembas.services.agent import tools as agent_tools @@ -437,6 +439,21 @@ async def shutdown() -> None: await task +def _narrower(instance: float, quota: int) -> float: + """The tighter of two ceilings, where **zero means no limit**. + + Not `min`: a zero on either side would win and turn "no opinion" into "no + time at all". Written once and used wherever a group's number meets the + instance's, because getting it wrong in one of those places is a limit that + silently stops working. + """ + if instance <= 0: + return float(quota) + if quota <= 0: + return float(instance) + return float(min(instance, quota)) + + async def _run(generation: Generation) -> None: """Produce one reply, then persist it. Never raises into the task. @@ -483,6 +500,16 @@ async def _run(generation: Generation) -> None: endpoint, model_id = chat_service.resolve_endpoint(db, chat) owner = db.get(User, chat.user_id) + # Before the request is built, not while it streams. Every other + # budget here can only be noticed part way through and so ends with + # `_wrap_up` asking for a final answer; this one is knowable in + # advance, and a reply that trails off because a month ran out mid + # sentence would be the failure `_wrap_up` exists to prevent. + over = usage_service.over_token_budget(db, owner) + if over: + generation.error = over + return + # Read while the session is open: everything below outlives it. # Resolved once, so that what the loop is allowed to *run* is the # same set the endpoint was *offered* -- not whatever happens to @@ -520,6 +547,10 @@ async def _run(generation: Generation) -> None: # vision model, a plain string to anything else, or the endpoint # rejects the whole request. vision = chat_service.model_supports(db, chat, "vision") + # Resolved while the session is open, like everything else here. + # Empty for an admin and for a user in no group, which is every + # instance that has not set one -- see permissions.limits_for. + quota = permissions.limits_for(db, owner) chat_rounds = settings_store.chat_rounds(db) # A helper's chat is bounded by its own number, not the instance's. # Only reached in an *ordinary* helper chat -- an agent one is sized @@ -532,6 +563,15 @@ async def _run(generation: Generation) -> None: nudge_enabled = bool(settings_store.agents(db).get("nudge_unfinished")) limits = tool_context.agent.limits if tool_context.agent else None + # A group's ceiling narrows the instance's, never widens it. `min` of + # two numbers where zero means "no limit" cannot be written as `min`: + # the zero would win and turn a group with no opinion into an unlimited + # one, so the two are folded by `_narrower`. + if limits is not None and quota.get("agent_seconds"): + limits = replace( + limits, + wall_seconds=_narrower(limits.wall_seconds, quota["agent_seconds"]), + ) # A ceiling, not a schedule -- the loop below ends the moment a round # produces no tool calls, which is the model saying it is done. Zero # means an ordinary chat has no ceiling either; `steps` is already a @@ -2083,6 +2123,23 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None: message.stopped = generation.stopped message.complete = True + # What this reply cost, against the account's month. Here because + # this is the single writer and it runs for a reply that finished, a + # reply that was stopped and a reply that errored alike -- an + # endpoint charges for tokens it generated whether or not anybody + # wanted them, and a quota that only counted happy paths is one a + # Stop button can walk past. `metrics.from_generation` is the one + # place the three figures are worked out, so this is the same + # arithmetic the bubble shows. + spent = metrics_service.from_generation(generation) + usage_service.record( + db, + chat.user_id, + prompt_tokens=spent.prompt_tokens, + completion_tokens=spent.completion_tokens, + images=len(generation.attachment_ids), + ) + if title and not chat.title_generated: chat.title = title chat.title_generated = True diff --git a/src/lembas/services/images/tool.py b/src/lembas/services/images/tool.py index a6feb00..ee3e9cf 100644 --- a/src/lembas/services/images/tool.py +++ b/src/lembas/services/images/tool.py @@ -403,6 +403,23 @@ async def _review( # --- The runner ---------------------------------------------------------------- +def _over_quota(context: ToolContext) -> str: + """Why this account may not draw another picture today, or "". + + Its own session, opened and closed before anything else: this runs before a + request that takes a minute, and holding a session across one is the trade + every long call in this codebase already refuses. + """ + from lembas.db.models import User + from lembas.db.session import session_scope + from lembas.services import usage as usage_service + + if not context.owner_id: + return "" + with session_scope() as db: + return usage_service.over_image_budget(db, db.get(User, context.owner_id)) + + async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome: """Generate one image, review it if there is anybody to ask, and keep one.""" from lembas.db.session import session_scope @@ -427,6 +444,13 @@ async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome: {**event, "status": "error", "error": "No chat."}, ) + # Before a minute of somebody's GPU is spent. Its own quota because it is + # its own cost: a picture is no tokens at all, so a token budget says + # nothing about how many of them one account may make. + over = _over_quota(context) + if over: + return ToolOutcome(over, {**event, "status": "error", "error": over}) + values = context.image_config or {} config = config_of(context) if not config.configured: diff --git a/src/lembas/services/reports.py b/src/lembas/services/reports.py index e122828..7205cd3 100644 --- a/src/lembas/services/reports.py +++ b/src/lembas/services/reports.py @@ -20,6 +20,7 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session as DBSession from lembas.db.models import CHUNK_REPORT, SOURCE_MANUAL, SOURCES, Report, User +from lembas.services import sharing from lembas.services.library import retrieval log = logging.getLogger(__name__) @@ -33,21 +34,35 @@ SNIPPET_CHARS = 400 def visible(user: User | None): - """Every report this person owns. + """Every report this person owns or has been shared. Takes no session because it builds a query rather than running one, and takes `None` to mean nobody so an unauthenticated caller gets an empty - result instead of an exception -- the same shape `sharing.visible_to` has, - so a later move to shared reports is a change of one line here. + result instead of an exception. + + It said "a later move to shared reports is a change of one line here", and + it was: `sharing.visible_to` is that line. Every listing, search and detail + page went through this already, which is what made the move safe. """ - if user is None: - return select(Report).where(Report.id.is_(None)) - return select(Report).where(Report.owner_id == user.id) + return select(Report).where(sharing.visible_to(Report, user)) def get(db: DBSession, report_id: str, user: User | None) -> Report | None: report = db.get(Report, report_id) - if report is None or user is None or report.owner_id != user.id: + if report is None or not sharing.can_read(db, report, user): + return None + return report + + +def owned(db: DBSession, report_id: str, user: User | None) -> Report | None: + """The same, but only when they own it. + + Sharing grants **reading**, so deleting and marking-as-read are the owner's + alone. Two functions rather than a flag, because a route that wants one and + calls the other is a bug you can see in the name. + """ + report = db.get(Report, report_id) + if report is None or not sharing.can_write(report, user): return None return report @@ -202,6 +217,10 @@ def mark_read(db: DBSession, report: Report) -> Report: def delete(db: DBSession, report: Report) -> None: + # Shares carry no foreign key to their resource, so nothing cascades and + # this has to be said. A grant left behind names a report that has gone -- + # harmless now and a grant to whoever next holds that id later. + sharing.forget_resource(db, report) db.delete(report) db.commit() diff --git a/src/lembas/services/sharing.py b/src/lembas/services/sharing.py index e885063..fcb7fa7 100644 --- a/src/lembas/services/sharing.py +++ b/src/lembas/services/sharing.py @@ -1,6 +1,6 @@ -"""Who may see a document, a note or a skill. +"""Who may see a knowledge base, a note, a skill or a report. -One rule, in one place, for all three: you can see a resource if you own it, if +One rule, in one place, for all four: you can see a resource if you own it, if it was shared with you by name, or if it was shared with a group you are in. Documents are deliberately absent from that list. They are shared through the @@ -25,7 +25,7 @@ from __future__ import annotations import logging from typing import Any -from sqlalchemy import ColumnElement, delete, or_, select +from sqlalchemy import ColumnElement, and_, delete, or_, select from sqlalchemy.orm import Session as DBSession from lembas.db.models import ( @@ -33,9 +33,11 @@ from lembas.db.models import ( PRINCIPAL_USER, RESOURCE_BASE, RESOURCE_NOTE, + RESOURCE_REPORT, RESOURCE_SKILL, KnowledgeBase, Note, + Report, Share, Skill, User, @@ -49,6 +51,12 @@ RESOURCE_TYPES: dict[Any, str] = { KnowledgeBase: RESOURCE_BASE, Note: RESOURCE_NOTE, Skill: RESOURCE_SKILL, + # A report joins the list and a memory still does not. A finished piece of + # work is the thing somebody most wants to hand over -- "here is what the + # Monday run found" -- and a report is read once and never answered, so + # sharing it has none of the two-editors problem that keeps writing off the + # table everywhere else here. + Report: RESOURCE_REPORT, } @@ -88,6 +96,19 @@ def visible_to(model: Any, user: User | None) -> ColumnElement[bool]: return or_(model.owner_id == user.id, model.id.in_(shared)) +def only_shared(model: Any, user: User | None) -> ColumnElement[bool]: + """Rows this user may see and does **not** own. + + The "Shared with me" filter. Worth having as its own listing rather than a + badge in the mixed one: a badge answers "is this mine?" for a row already on + screen, and the question somebody actually has is "what have people given + me?", which a mixed list of two hundred cannot answer at all. + """ + if user is None: + return model.id.is_(None) + return and_(visible_to(model, user), model.owner_id != user.id) + + def owned_by(model: Any, user: User | None) -> ColumnElement[bool]: """Rows this user may *change*. @@ -197,12 +218,39 @@ def forget_principal(db: DBSession, principal_type: str, principal_id: str) -> i return result.rowcount or 0 +def forget_owner(db: DBSession, owner_id: str) -> int: + """Drop every share of everything a departing account owned. + + Their rows cascade when the account goes; the shares of those rows do not, + because `Share.resource_id` has no foreign key to point at. Left behind, + they are grants naming resources that no longer exist -- harmless today, + and a grant to whoever next receives one of those ids if a future store + ever reuses them. + + Called *before* the delete, while the rows are still there to be found. + `forget_principal` is the other half and covers shares pointing *at* them. + """ + removed = 0 + for model in RESOURCE_TYPES: + owned = select(model.id).where(model.owner_id == owner_id) + result = db.execute( + delete(Share).where( + Share.resource_type == RESOURCE_TYPES[model], + Share.resource_id.in_(owned), + ) + ) + removed += result.rowcount or 0 + return removed + + __all__ = [ "can_read", "can_write", + "forget_owner", "forget_principal", "forget_resource", "grants_for", + "only_shared", "owned_by", "resource_type", "set_grants", diff --git a/src/lembas/services/subagent.py b/src/lembas/services/subagent.py index 7332c5c..4f5080d 100644 --- a/src/lembas/services/subagent.py +++ b/src/lembas/services/subagent.py @@ -76,6 +76,7 @@ from typing import TYPE_CHECKING, Any from lembas.db.models import KIND_AGENT, Chat, User from lembas.db.session import session_scope +from lembas.security import permissions from lembas.services import settings_store from lembas.services.agent import policy as agent_policy @@ -443,6 +444,12 @@ async def _run_subagent(context: ToolContext, args: dict[str, Any]) -> ToolOutco # rather than told it has run out of helpers, and the counter should # only move for a call that is about to spend one. values = settings_store.subagents(db) + # A group's ceiling narrows the instance's, never widens it. Zero on + # either side means "no opinion", so the two cannot be folded with + # `min` -- see generation._narrower for the same arithmetic. + allowance = permissions.limit(db, owner, "helpers_per_reply") + if allowance: + values = {**values, "max_per_reply": min(int(values["max_per_reply"]), allowance)} refusal = _budget(generation_service.running_for(parent_id), values) if refusal: return _error(refusal, task=task) diff --git a/src/lembas/services/tools.py b/src/lembas/services/tools.py index 8c2eda1..7feb2de 100644 --- a/src/lembas/services/tools.py +++ b/src/lembas/services/tools.py @@ -1604,6 +1604,21 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet: # instrument -- `git log` is a read whatever its risk class says. writes_off = scoped_writes_off(chat) + # Reading and writing, split for the three gates where the two are genuinely + # different decisions. A second check keyed on the tool's **risk**, applied + # after the gate rather than instead of it -- so it can only ever narrow + # what `_family_allowed` already allowed, and an instance that has never + # looked at it behaves exactly as it did, all three defaulting on. + # + # Here rather than in `_family_allowed` because that one is given a family + # and this needs the tool: the whole point is that two tools in one family + # get different answers. + def may_write(tool: ToolDef) -> bool: + gate = gate_of(tool.family) + if tool.risk != RISK_WRITE or gate not in permissions.SPLIT_GATES: + return True + return bool(allowed.get(f"tools.{gate}.write", True)) + return ToolSet( tuple( tool @@ -1619,6 +1634,7 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet: ) and gate_of(tool.family) not in off and not (writes_off and tool.risk == RISK_WRITE) + and may_write(tool) # Nothing to read and nothing to improve. Offering `skill_get` with # no skills is what makes a model spend a round looking one up and # being told it does not exist -- and `context.skills` already diff --git a/src/lembas/services/usage.py b/src/lembas/services/usage.py new file mode 100644 index 0000000..c72496f --- /dev/null +++ b/src/lembas/services/usage.py @@ -0,0 +1,161 @@ +"""What an account has spent, and whether it may spend more. + +A row per user per period, not per reply. A per-reply ledger is what somebody +eventually wants for a bill; this exists to answer one question on the request +path — "has this account used its month?" — and that wants one indexed lookup +rather than a sum over ten thousand rows. + +**Recorded even when the reply failed.** `generation._persist` is the single +writer for everything a reply produced, and it calls this whether the reply +finished, was stopped or errored: an endpoint charges for tokens it generated +regardless of whether anybody wanted them, and a quota that only counted happy +paths would be one somebody could avoid by pressing Stop. + +**Never raises.** A quota that broke a reply because its own bookkeeping failed +would be worse than no quota. Everything here is best-effort and logs. + +The period is UTC and the boundary is not the reader's midnight. A quota that +reset at a different instant for each member of a group is one nobody can reason +about, and nobody experiences a monthly allowance to the hour. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, date, datetime + +from sqlalchemy import func, select +from sqlalchemy.orm import Session as DBSession + +from lembas.db.models import Attachment, Usage, User + +log = logging.getLogger(__name__) + + +def period_of(moment: datetime | None = None) -> str: + return (moment or datetime.now(tz=UTC)).astimezone(UTC).strftime("%Y-%m") + + +def row_for(db: DBSession, user_id: str, *, period: str = "") -> Usage: + """This account's row for a period, made if it is not there yet.""" + period = period or period_of() + row = db.scalar(select(Usage).where(Usage.user_id == user_id, Usage.period == period)) + if row is None: + row = Usage(user_id=user_id, period=period) + db.add(row) + db.flush() + return row + + +def record( + db: DBSession, + user_id: str, + *, + prompt_tokens: int = 0, + completion_tokens: int = 0, + images: int = 0, + replies: int = 1, +) -> None: + """Add what one reply spent. Best-effort, and never raises.""" + if not user_id: + return + try: + row = row_for(db, user_id) + row.prompt_tokens += max(0, int(prompt_tokens)) + row.completion_tokens += max(0, int(completion_tokens)) + row.images += max(0, int(images)) + row.replies += max(0, int(replies)) + except Exception: # noqa: BLE001 - bookkeeping must never break a reply + log.debug("could not record usage for %s", user_id, exc_info=True) + + +def month_tokens(db: DBSession, user_id: str) -> int: + row = db.scalar(select(Usage).where(Usage.user_id == user_id, Usage.period == period_of())) + return int((row.prompt_tokens if row else 0) + (row.completion_tokens if row else 0)) + + +def images_today(db: DBSession, user_id: str) -> int: + """Pictures this account has made since midnight UTC. + + Counted off `Attachment` rather than kept as a counter, because there is a + natural source of truth and a *daily* counter would need a second row shape + and a second reset. The month's total on `Usage.images` is for the admin + screen, where a number that is a day stale costs nothing. + """ + start = datetime.combine(date.today(), datetime.min.time(), tzinfo=UTC) # noqa: DTZ011 + return int( + db.scalar( + select(func.count()) + .select_from(Attachment) + .where( + Attachment.user_id == user_id, + Attachment.kind == "image", + Attachment.created_at >= start, + ) + ) + or 0 + ) + + +# --- Asking permission ---------------------------------------------------------- +def over_token_budget(db: DBSession, user: User | None) -> str: + """Why this account may not start another reply, or "". + + Checked **before** a reply is built rather than while it streams. A quota + that stopped a reply half way through would leave the reader with an answer + that trails off, which is exactly what `_wrap_up` exists to prevent for every + other budget here -- and unlike those, this one is knowable in advance. + """ + from lembas.security import permissions + + if user is None: + return "" + ceiling = permissions.limit(db, user, "monthly_tokens") + if ceiling <= 0: + return "" + spent = month_tokens(db, user.id) + if spent < ceiling: + return "" + return ( + f"This account has used its {ceiling:,} tokens for the month " + f"({spent:,} so far). It will reset on the first." + ) + + +def over_image_budget(db: DBSession, user: User | None) -> str: + from lembas.security import permissions + + if user is None: + return "" + ceiling = permissions.limit(db, user, "images_per_day") + if ceiling <= 0: + return "" + made = images_today(db, user.id) + if made < ceiling: + return "" + return f"This account has made its {ceiling} image(s) for today." + + +def summary(db: DBSession, user: User) -> dict[str, int]: + """This month's figures, for the admin screen.""" + row = db.scalar(select(Usage).where(Usage.user_id == user.id, Usage.period == period_of())) + return { + "prompt_tokens": int(row.prompt_tokens if row else 0), + "completion_tokens": int(row.completion_tokens if row else 0), + "tokens": month_tokens(db, user.id), + "replies": int(row.replies if row else 0), + "images": int(row.images if row else 0), + "images_today": images_today(db, user.id), + } + + +__all__ = [ + "images_today", + "month_tokens", + "over_image_budget", + "over_token_budget", + "period_of", + "record", + "row_for", + "summary", +] diff --git a/src/lembas/web/templates/admin/group_detail.html b/src/lembas/web/templates/admin/group_detail.html new file mode 100644 index 0000000..753c18d --- /dev/null +++ b/src/lembas/web/templates/admin/group_detail.html @@ -0,0 +1,141 @@ +{% extends "admin/_layout.html" %} +{% from "_macros.html" import icon, model_avatar %} +{% set section = "groups" %} + +{% block title %}{{ group.name }} - Groups - {{ brand.name }}{% endblock %} +{% block heading %}{{ group.name }}{% endblock %} + +{% block admin_content %} +

+ {{ icon("chevron-left", "icon--sm") }} All groups + · A group only ever adds. Anything already in the baseline is shown + below as such, so a tick here that changes nothing looks like one. +

+ +{% if saved %} +
{{ icon("check", "icon--sm") }} {{ saved }}
+{% endif %} + +
+
+

Name

+
+ + +
+
+ + +
+
+ +
+

Permissions this group adds

+ {% for section_name, defs in permission_groups.items() %} +
+ {{ section_name }} + {% for definition in defs %} + + {% endfor %} +
+ {% endfor %} +
+ + {# + Quotas. Every one is zero-for-no-limit, and an *empty* box is different from + a zero: empty is "this group has no opinion" and contributes nothing to the + resolution, zero is "unlimited" and wins outright. Saying that here is the + only place somebody will read it. + #} +
+

Quotas

+

+ Resolved across a person's groups by maximum — the union + rule applied to numbers, so a second group can only grant more. + Leave a box empty for “no opinion”, and use + 0 for “no limit”, which beats any number another group + sets. Administrators are unlimited whatever is here. +

+
+ {% for key, label, description in limit_defs %} +
+ + +

{{ description }}

+
+ {% endfor %} +
+
+ + {# + Membership lives here and only here. It used to be on the user page as well, + and a full-form POST from either side overwrote what the other had shown. + #} +
+

Members

+

+ The one place membership is edited. A user's own page links here rather + than offering a second control for the same value. +

+
+ {% for person in users %} + + {% endfor %} +
+
+ +
+

Models this group unlocks

+

+ A model marked public is available to everyone; one that is not is + available to the groups named here. Model access is separate from + permissions — one says what somebody may do, the other what with. +

+
+ {% for model in models %} + + {% endfor %} +
+
+ +
+ +
+
+ +
+

Remove

+
+ +
+

+ Members keep their accounts and lose whatever this group granted them. Every + share naming this group goes too — nothing cascades to those, so they are + deleted explicitly. +

+
+{% endblock %} diff --git a/src/lembas/web/templates/admin/groups.html b/src/lembas/web/templates/admin/groups.html index 42f3259..4b3e9d5 100644 --- a/src/lembas/web/templates/admin/groups.html +++ b/src/lembas/web/templates/admin/groups.html @@ -1,5 +1,5 @@ {% extends "admin/_layout.html" %} -{% from "_macros.html" import icon, model_avatar %} +{% from "_macros.html" import icon %} {% set section = "groups" %} {% block title %}Groups & permissions - {{ brand.name }}{% endblock %} @@ -9,8 +9,9 @@

Permissions are a union: everyone starts with the baseline below, and each group they belong to can add more. A group never takes - something away, so being in a second group can only widen what someone can do. - Administrators bypass all of it. + something away, so being in a second group can only widen what someone can do — + which is what keeps “why can this person not do X?” answerable without + simulating every group they are in. Administrators bypass all of it.

{% if saved %} @@ -19,7 +20,7 @@

Baseline permissions

-

+

What every signed-in user can do before any group is considered. Turn something off here and grant it through a group to make it opt-in.

@@ -48,7 +49,39 @@ Groups {{ groups|length }} -
+{# + Rows, not a form each. The old page rendered every group's full permission + grid, every member and every model on one screen -- fine for two groups and + unreadable at ten, which is the list-plus-detail rule the model admin already + follows. +#} + + +
@@ -57,114 +90,4 @@
- -{% if not groups %} -
- {{ icon("users", "empty__mark") }} -

- No groups yet. Create one to grant extra permissions, or to restrict a model - to a subset of users. -

-
-{% endif %} - -{% for group in groups %} -
-
-
- {{ group.name }} - - {{ group.users|length }} member{{ '' if group.users|length == 1 else 's' }}, - {{ group.models|length }} model{{ '' if group.models|length == 1 else 's' }} - -
- -
- - -
- -
- - -
- -
- Grants -

- Anything already in the baseline stays on regardless — these only add. -

- {% for section_name, defs in permission_groups.items() %} - {% for definition in defs %} - - {% endfor %} - {% endfor %} -
- -
- Members - {% if users %} -
- {% for account in users %} - - {% endfor %} -
- {% else %} -

No users yet.

- {% endif %} -
- -
- Model access -

- Models marked “available to everyone” are reachable regardless. These - grant access to the restricted ones. -

- {% if models %} -
- {% for model in models %} - - {% endfor %} -
- {% else %} -

No models yet.

- {% endif %} -
- -
- -
-
- - -
-{% endfor %} {% endblock %} diff --git a/src/lembas/web/templates/admin/user_detail.html b/src/lembas/web/templates/admin/user_detail.html new file mode 100644 index 0000000..6cd0537 --- /dev/null +++ b/src/lembas/web/templates/admin/user_detail.html @@ -0,0 +1,203 @@ +{% extends "admin/_layout.html" %} +{% from "_macros.html" import icon %} +{% set section = "users" %} + +{% block title %}{{ target.name }} - Users - {{ brand.name }}{% endblock %} +{% block heading %}{{ target.name }}{% endblock %} + +{% block admin_content %} +

+ {{ icon("chevron-left", "icon--sm") }} All users + · {{ target.email }} +

+ +{% if saved %} +
{{ icon("check", "icon--sm") }} {{ saved }}
+{% endif %} + +
+
+

Account

+
+
+ + +
+
+ + +

+ An administrator bypasses every permission and every quota below. +

+
+
+
+ +

+ Turning this off signs them out everywhere at once, rather than waiting + for a cookie to expire. +

+
+
+ +
+ +
+
+ +{# --- What they can actually do -------------------------------------------- #} +
+

What this account can do

+

+ Read-only, and deliberately: every switch here is set somewhere else — in the + baseline or in a named group — and a control on + this page would be a third place to change one thing. What it adds is the + source, which is the question the grids could not answer without + opening every group by eye. +

+ + {% for section_name, defs in permission_groups.items() %} +
+ {{ section_name }} + {% for definition in defs %} + {% set state = explained[definition.key] %} +
+ {{ icon("check" if state.on else "x", "icon--sm") }} + + {{ definition.label }} + {% if state.on %} + + from {{ state.source | join(", ") }} + + {% else %} + not granted + {% endif %} + +
+ {% endfor %} +
+ {% endfor %} +
+ +{# --- Membership ----------------------------------------------------------- #} +
+

Groups

+

+ Edited from the group's own page. One control per value, so a save here + cannot undo a save there. +

+ {% if target.groups %} +
+ {% for group in target.groups %} + {{ group.name }} + {% endfor %} +
+ {% else %} +

In no group. They get the baseline and nothing more.

+ {% endif %} +
+ +{# --- Quotas and usage ----------------------------------------------------- #} +
+

This month

+

+ Counted from the first of the month, UTC. Recorded for every reply including + one that was stopped or failed — an endpoint charges for tokens it generated + whether or not anybody wanted them. +

+
+
+
Tokens
+
+ {{ "{:,}".format(usage.tokens) }} + {% if limits.monthly_tokens %} of {{ "{:,}".format(limits.monthly_tokens) }}{% endif %} + + ({{ "{:,}".format(usage.prompt_tokens) }} prompt, + {{ "{:,}".format(usage.completion_tokens) }} written) + +
+
+
+
Replies
+
{{ usage.replies }}
+
+
+
Images
+
+ {{ usage.images }} this month, {{ usage.images_today }} today + {% if limits.images_per_day %} (limit {{ limits.images_per_day }} a day){% endif %} +
+
+
+ +

Limits in force

+

+ Resolved across their groups by maximum — the union rule + applied to numbers, so a second group can only ever grant more. Zero means no + limit and wins outright, because a group saying “unlimited” must not count + for less than one saying “a million”. +

+
+ {% for key, label, description in limit_defs %} +
+
{{ label }}
+
+ {% if limits[key] %}{{ "{:,}".format(limits[key]) }}{% else %}no limit{% endif %} + {{ description }} +
+
+ {% endfor %} +
+
+ +{# --- Models --------------------------------------------------------------- #} +
+

Models they can use

+

+ Model access is separate from permissions: a permission says what somebody + may do, this says what they may do it with. +

+ {% if models %} +
+ {% for model in models %} + {{ model.label }} + {% endfor %} +
+ {% else %} +

None. They cannot start a chat at all.

+ {% endif %} +
+ +{# --- Dangerous ------------------------------------------------------------ #} +
+

Password and removal

+
+ + +
+

+ Signs them out everywhere. An administrator resetting a password usually + means the account is compromised or the person has gone. +

+ +
+ +
+

+ Their chats, folders and library go too, and every share naming them or + naming anything of theirs. +

+
+{% endblock %} diff --git a/src/lembas/web/templates/admin/users.html b/src/lembas/web/templates/admin/users.html index 6ad39d6..24918d7 100644 --- a/src/lembas/web/templates/admin/users.html +++ b/src/lembas/web/templates/admin/users.html @@ -7,157 +7,103 @@ {% block admin_content %}

- Everyone with an account on this instance. Administrators bypass every - permission; ordinary users get the baseline permissions plus whatever their - groups add. + Every account on this instance. Open one to see what it can actually do and + where each of those permissions came from. Group membership is edited from the + group's own page — one control per value, so a save + on one screen cannot undo a save on another.

{% if saved %}
{{ icon("check", "icon--sm") }} {{ saved }}
{% endif %} -
- - - {% if q %}Clear{% endif %} -
+
+
+ + + {% if q %}Clear{% endif %} +
+
-
- Add a user -
-
- - +
+ {% for person in users %} + +
+ + {{ person.name }} + {% if person.role == "admin" %}admin{% endif %} + {% if person.role == "pending" %}pending{% endif %} + {% if not person.active %}disabled{% endif %} + + {{ person.email }}
-
- - +
+ {% if person.groups %} + + {{ person.groups | map(attribute="name") | join(", ") }} + + {% endif %} + {% set spent = usage[person.id] %} + {% if spent.tokens %} + {{ "{:,}".format(spent.tokens) }} + {% endif %}
-
- - -

- At least 8 characters. Tell them to change it — you will know it otherwise. -

-
-
- - + + {% else %} +
+

{{ "Nobody matches that." if q else "No accounts yet." }}

+
+ {% endfor %} +
+ +{% if pager.pages > 1 %} +
+ {% if pager.page > 1 %} + + {{ icon("chevron-left", "icon--sm") }} Previous + + {% endif %} + Page {{ pager.page }} of {{ pager.pages }} · {{ pager.total }} accounts + {% if pager.page < pager.pages %} + + Next {{ icon("chevron-right", "icon--sm") }} + + {% endif %} +
+{% endif %} + +
+

Add an account

+

+ Without going through registration — useful when sign-up is closed. +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
- -
- -
- -

- Accounts {{ users|length }} -

- -{% for account in users %} -
-
-
-
- - {{ account.name }} - {{ account.email }} - {% if account.is_admin %}admin{% endif %} - {% if not account.active %}deactivated{% endif %} - {% if account.id == user.id %}you{% endif %} -
- - {% if account.last_login_at %} - last seen {{ account.last_login_at.strftime("%Y-%m-%d %H:%M") }} - {% else %} - never signed in - {% endif %} - -
- -
- - -
- -
- - -

- admin can do everything, including this page. - user is an ordinary account. - pending cannot sign in until promoted. -

-
- -
- -

- Deactivating signs them out everywhere immediately, rather than waiting - for their session to expire. -

-
- - {% if groups %} -
- Groups -
- {% for group in groups %} - - {% endfor %} -
-
- {% endif %} - - {% if account.is_admin and admin_count <= 1 %} -
- {{ icon("warning", "alert__icon") }} - - The only administrator. Promote someone else before demoting or - deactivating this account — an instance with no admin can only be - recovered with lembas create-admin. - -
- {% endif %} - -
-
- - + +
-{% endfor %} {% endblock %} diff --git a/src/lembas/web/templates/library/_share.html b/src/lembas/web/templates/library/_share.html index 11fc8ca..c5297f8 100644 --- a/src/lembas/web/templates/library/_share.html +++ b/src/lembas/web/templates/library/_share.html @@ -1,63 +1,30 @@ {% from "_macros.html" import icon %} {# - The share panel on a detail page. + The share panel's placeholder on a detail page. - Sharing grants *reading*. Two people editing one note with no history and no - merge is worse than the inconvenience of copying it, so there is no "can edit" - here and the copy is deliberate rather than missing. + It fetches `_share_panel.html` on load rather than being rendered inline, and + that is the whole change: the panel used to be checkboxes inside the + resource's *save form*, so a share only happened if you also saved the + resource, and the list of candidates was every group and every account on the + instance, unpaginated, on every detail page. - Only the owner sees this at all: someone a thing was shared with cannot share - it onward, which keeps "who can see this" answerable by asking one person. + Sharing still grants *reading*. Two people editing one note with no history and + no merge is worse than the inconvenience of copying it, so there is no "can + edit" and its absence is deliberate rather than missing. + + Only the owner sees it at all — someone a thing was shared with cannot share it + onward — and the route enforces that as well, because a template is not a + permission check. #} {% if is_owner and can_share %} -
-

- Shared with - {% if shared_users or shared_groups %} - {{ shared_users|length + shared_groups|length }} - {% else %} - nobody - {% endif %} -

-

- They will be able to read this, and their models will find it. They cannot - change it or share it on. -

- - {% if groups %} -
- -
- {% for group in groups %} - - {% endfor %} -
-
- {% endif %} - - {% if people %} -
- -
- {% for person in people %} - - {% endfor %} -
-
- {% endif %} - - {% if not groups and not people %} -

There is nobody else on this instance yet.

- {% endif %} -
+
+
+

Shared with

+
+
{% elif not is_owner %}
{{ icon("users", "alert__icon") }} diff --git a/src/lembas/web/templates/library/_share_panel.html b/src/lembas/web/templates/library/_share_panel.html new file mode 100644 index 0000000..0a5f105 --- /dev/null +++ b/src/lembas/web/templates/library/_share_panel.html @@ -0,0 +1,95 @@ +{% from "_macros.html" import icon %} +{# + Who can see one thing, and the search that changes it. + + Swapped into itself after every change, so what is on screen is always what is + stored -- the old panel was a set of checkboxes that only took effect if the + resource happened to be saved afterwards, which is a control that silently + does nothing. + + Every fetch in here names its own `hx-target`. This fragment is included on + pages whose forms carry an inherited target, and an element that fetches + without one aims at whatever an ancestor said -- the bug the jobs chip had, and + the reason `tests/test_chat.py` walks the composer for it. + + The switches are `