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 `