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

sharing.forget_principal has existed since shares did, documented as the thing
that stops a recycled id inheriting somebody's grant, and was called by nobody.
Deleting a group left every grant naming it; deleting an account left both the
grants to it and the grants of its own work -- that second half is the one
nothing else could catch, since their rows cascade and the shares of those rows
have nothing to cascade from. Both now run before the delete, while the rows are
still findable, and a deleted resource forgets its own.

library.share defaulted to False, which meant sharing shipped documented as done
and unreachable: the panel only renders for somebody holding it, so out of the
box nobody could share anything and nothing said why. It is on.

The panel itself was checkboxes inside the resource's *save form*, listing every
group and every account on the instance, unpaginated, on every detail page -- and
a tick only took effect if you also saved the resource. It is its own routes now:
search, one grant per POST, the panel re-rendered from what is stored. Anything
already shared stays listed whatever the search says, or removing a grant would
mean searching for the name it was given to.

Reports join the shareable set and memories still do not: a finished piece of
work is the thing somebody most wants to hand over, and a record about a person
is not content to pass round. reports.visible became sharing.visible_to, which is
the one line its own docstring predicted. Two things fell out: `owned` beside
`get`, because sharing grants reading and deleting is the owner's alone; and
reading somebody else's report no longer clears their unread dot.

Permissions gained the answer to "what can this person actually do?" --
explain() is resolve()'s working shown rather than thrown away, naming admin, the
baseline, or the groups that granted each one. That is the simulation the union
rule exists to make unnecessary, and until now the only way to get it was to open
every group and read the grids by eye. Users and groups are list-plus-detail, and
membership is edited from one side: it was on both, and a full-form POST from
either overwrote what the other had shown.

Read and write are split for notes, memory and skills -- checked on the tool's
declared risk, after the gate so it can only narrow, and defaulting on.

Quotas are the union rule applied to numbers, with the corner that makes it
interesting: zero means "no limit" and wins outright, or a group saying unlimited
would count for less than one saying a million. Absent means "no opinion".
_narrower folds a group's ceiling with the instance's and is deliberately not
min, for the same reason. Five axes, enforced where each is knowable -- before a
reply is built, before a second one starts, on an agent reply's clock, before a
minute of GPU, and beside the helper cap -- and usage is recorded even for a
reply that was stopped or errored, because an endpoint charges either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-06 16:48:14 +02:00
parent 757ab305ee
commit 9d7fb72bdb
34 changed files with 2405 additions and 390 deletions
+10 -2
View File
@@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin 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 # PLAN.md tracks what is and is not built
ruff check . # lint (line length 100) ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons; python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
@@ -82,7 +82,9 @@ src/lembas/
folders.py folder CRUD folders.py folder CRUD
admin.py connections + instance settings admin.py connections + instance settings
admin_models.py model ordering, defaults, images, access 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_audio.py speech-to-text and text-to-speech endpoints
admin_search.py web search provider and credentials admin_search.py web search provider and credentials
admin_images.py the ComfyUI, and the workflow templates on it 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 wake.py starting a reply from outside a request -- one lock
discipline, shared by finished jobs and by schedules discipline, shared by finished jobs and by schedules
sharing.py one visibility rule for every library store 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}} prompts.py every injected prompt fragment, and {{variables}}
metrics.py tokens, context percentage and tokens/second metrics.py tokens, context percentage and tokens/second
tokens.py the chars/4 estimate, for endpoints that report none 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). schedule something wrote a note and said it had).
- `docs/notes/image-generation.md` -- the ComfyUI workflow with holes in it, what - `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. 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 - `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, 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 why vectors from two models never meet, and the session event that notices a
+26 -11
View File
@@ -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 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, and skills, speech in and out, image generation over ComfyUI, users and groups,
model administration, installable as an app, reports, messages, and scheduled 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, What remains before the first stable release is written out below, in phases,
under [The road to 1.0.0](#the-road-to-100). under [The road to 1.0.0](#the-road-to-100).
@@ -521,15 +521,27 @@ seen working.
it finishes it finishes
### Phase 6 — permissions, quotas and sharing (`0.9.6`) ### Phase 6 — permissions, quotas and sharing (`0.9.6`)
- [ ] **"What can this user actually do?"** answered on screen, from the - [x] **"What can this user actually do?"** answered on screen, and *where each
resolution that already computes it permission came from* — `explain()` is the resolution's working shown
- [ ] Membership edited from one side; a searchable, paginated user list rather than thrown away, which is the simulation the union rule exists to
- [ ] Reading and writing split within a gate where the difference matters make unnecessary
- [ ] **Quotas on a group**, resolved by maximum — the union rule applied to - [x] List plus detail for users and groups; membership edited from **one** side,
numbers — and enforced where the existing budgets are since a full-form POST from either used to overwrite the other's view
- [ ] Deleting a group or a user forgets its grants, which it never did - [x] Reading and writing split for the three gates where the difference is a
- [ ] Sharing as its own action with a search box, a shared-with-me filter, and real decision — checked on the tool's risk, after the gate, defaulting on
reports shareable. Sharing stays read-only - [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`) ### Phase 7 — packaging and updating (`0.9.7`)
- [ ] **Docker**, with the data on a volume and a TLS proxy expected in front - [ ] **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. - **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. 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. cannot be answered without simulating every group.
- **System prompts replace, never stack.** Two layers that disagree give the - **System prompts replace, never stack.** Two layers that disagree give the
model contradictory instructions and nobody can tell which is losing. model contradictory instructions and nobody can tell which is losing.
+143
View File
@@ -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.<gate>.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.
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "0.9.5" __version__ = "0.9.6"
+138 -12
View File
@@ -10,11 +10,21 @@ from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import AdminUser, Db 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 import permissions
from lembas.security.passwords import hash_password, validate_password from lembas.security.passwords import hash_password, validate_password
from lembas.security.sessions import revoke_all_for_user 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 from lembas.web.templating import render
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -54,22 +64,71 @@ def _would_orphan_the_instance(db: DBSession, user: User) -> bool:
# --- Users ------------------------------------------------------------------- # --- 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") @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) query = select(User).order_by(User.created_at)
if q.strip(): if q.strip():
pattern = f"%{q.strip()}%" pattern = f"%{q.strip()}%"
query = query.where(or_(User.name.ilike(pattern), User.email.ilike(pattern))) 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( return render(
request, request,
"admin/users.html", "admin/users.html",
{ {
"users": list(db.scalars(query)), "users": rows,
"groups": list(db.scalars(select(Group).order_by(Group.name))), "usage": {row.id: usage_service.summary(db, row) for row in rows},
"roles": ROLES, "roles": ROLES,
"q": q, "q": q,
"saved": saved, "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), "admin_count": _admin_count(db),
}, },
) )
@@ -114,8 +173,13 @@ async def update_user(
name: str = Form(...), name: str = Form(...),
role: str = Form(ROLE_USER), role: str = Form(ROLE_USER),
active: bool = Form(False), active: bool = Form(False),
group_ids: list[str] = Form(default=[]),
) -> Response: ) -> 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) target = _user(db, user_id)
losing_admin = target.role == ROLE_ADMIN and (role != ROLE_ADMIN or not active) 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.name = name.strip()[:120] or target.name
target.role = role if role in ROLES else target.role target.role = role if role in ROLES else target.role
target.active = active 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, # A deactivated or demoted user must lose their live sessions immediately,
# otherwise the change only takes effect when their cookie happens to expire. # otherwise the change only takes effect when their cookie happens to expire.
@@ -137,7 +200,9 @@ async def update_user(
db.commit() db.commit()
log.info("%s updated account %s (role=%s active=%s)", user.email, target.email, role, active) 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") @router.post("/users/{user_id}/password")
@@ -146,7 +211,7 @@ async def reset_password(
) -> Response: ) -> Response:
target = _user(db, user_id) target = _user(db, user_id)
if (problem := validate_password(password)) is not None: 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) target.password_hash = hash_password(password)
db.commit() db.commit()
@@ -155,7 +220,7 @@ async def reset_password(
revoke_all_for_user(db, target) revoke_all_for_user(db, target)
log.info("%s reset the password for %s", user.email, target.email) log.info("%s reset the password for %s", user.email, target.email)
return RedirectResponse( 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, status_code=303,
) )
@@ -175,6 +240,15 @@ async def delete_user(db: Db, user: AdminUser, user_id: str) -> Response:
email = target.email email = target.email
# Chats and folders cascade; that is the point of deleting an account. # 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.delete(target)
db.commit() db.commit()
log.info("%s deleted account %s", user.email, email) 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 ------------------------------------------------------------------ # --- 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") @router.get("/groups")
async def groups_page(request: Request, db: Db, user: AdminUser, saved: str = ""): async def groups_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
groups = list(db.scalars(select(Group).order_by(Group.name)))
return render( return render(
request, request,
"admin/groups.html", "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))), "users": list(db.scalars(select(User).order_by(User.name))),
"models": list(db.scalars(select(Model).order_by(Model.position, Model.model_id))), "models": list(db.scalars(select(Model).order_by(Model.position, Model.model_id))),
"permission_groups": permissions.permission_groups(), "permission_groups": permissions.permission_groups(),
"baseline": permissions.baseline_permissions(db), "baseline": permissions.baseline_permissions(db),
"limit_defs": permissions.LIMIT_DEFS,
"limits": group.limits_json or {},
"saved": saved, "saved": saved,
}, },
) )
@@ -216,6 +315,7 @@ async def create_group(db: Db, user: AdminUser, name: str = Form(...)) -> Respon
@router.post("/groups/{group_id}") @router.post("/groups/{group_id}")
async def update_group( async def update_group(
request: Request,
db: Db, db: Db,
user: AdminUser, user: AdminUser,
group_id: str, group_id: str,
@@ -226,6 +326,7 @@ async def update_group(
model_ids: list[str] = Form(default=[]), model_ids: list[str] = Form(default=[]),
) -> Response: ) -> Response:
group = _group(db, group_id) group = _group(db, group_id)
form = await request.form()
group.name = name.strip()[:120] or group.name group.name = name.strip()[:120] or group.name
group.description = description.strip()[:1000] 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.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 [])))) 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() db.commit()
log.info("%s updated group %s", user.email, group.name) 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") @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) group = _group(db, group_id)
name = group.name name = group.name
# Members and model links go with it; the users themselves are untouched. # 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.delete(group)
db.commit() db.commit()
if dropped:
log.info("dropped %d share(s) naming group %s", dropped, name)
log.info("%s deleted group %s", user.email, name) log.info("%s deleted group %s", user.email, name)
return RedirectResponse(f"/admin/groups?saved=Deleted+{name}.", status_code=303) return RedirectResponse(f"/admin/groups?saved=Deleted+{name}.", status_code=303)
+42
View File
@@ -975,6 +975,36 @@ def _note_rewind(chat: Chat) -> None:
chat.rewound_at = datetime.now(UTC) 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( def _send(
request: Request, request: Request,
db: Db, db: Db,
@@ -997,6 +1027,18 @@ def _send(
prefixes of it, with Stop pointing at whichever bubble came first in the prefixes of it, with Stop pointing at whichever bubble came first in the
document. 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): if queued := _reply_in_flight(db, chat):
waiting = db.scalar( waiting = db.scalar(
select(func.count()) select(func.count())
+73 -37
View File
@@ -23,10 +23,7 @@ from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.api.pages import sidebar_context from lembas.api.pages import sidebar_context
from lembas.db.models import ( from lembas.db.models import (
AUTHOR_USER, AUTHOR_USER,
PRINCIPAL_GROUP,
PRINCIPAL_USER,
Document, Document,
Group,
KnowledgeBase, KnowledgeBase,
Note, Note,
Skill, Skill,
@@ -61,32 +58,21 @@ def _page(db: DBSession, query, page: int):
return rows, {"page": page, "pages": pages, "total": total} return rows, {"page": page, "pages": pages, "total": total}
def _shared_context(db: DBSession, user: User, resource) -> dict: def _shared_context(db: DBSession, user: User, resource, kind: str) -> dict:
"""Everything the share panel on a detail page needs.""" """What the share placeholder needs, which is now three facts.
grants = sharing.grants_for(db, resource)
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 { return {
"can_share": permissions.has(db, user, "library.share"), "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, "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 ------------------------------------------------------------------- # --- Shell -------------------------------------------------------------------
@router.get("/library") @router.get("/library")
async def library_home(user: RequiredUser): 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. # before /library/knowledge/{base_id}, or "document" is parsed as a base id.
# FastAPI matches in registration order and this has bitten before. # FastAPI matches in registration order and this has bitten before.
@router.get("/library/knowledge") @router.get("/library/knowledge")
async def knowledge_list(request: Request, db: Db, user: RequiredUser, error: str = ""): async def knowledge_list(
"""The bases, not the documents. A library is a set of places first.""" request: Request, db: Db, user: RequiredUser, error: str = "", shared: bool = False
bases = list(db.scalars(documents_service.visible_bases(db, user).order_by(KnowledgeBase.name))) ):
"""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 = { counts = {
base.id: db.scalar( base.id: db.scalar(
select(func.count()).select_from(Document).where(Document.base_id == base.id) 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", "section": "knowledge",
"bases": bases, "bases": bases,
"counts": counts, "counts": counts,
"shared": shared,
"error": error, "error": error,
**sidebar_context(db, user), **sidebar_context(db, user),
}, },
@@ -200,7 +199,7 @@ async def base_detail(
"documents": rows, "documents": rows,
"q": q, "q": q,
"pager": pager, "pager": pager,
**_shared_context(db, user, base), **_shared_context(db, user, base, "base"),
**sidebar_context(db, user), **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.name = name
base.description = str(form.get("description", "")).strip()[:2000] base.description = str(form.get("description", "")).strip()[:2000]
db.commit() db.commit()
_apply_shares(db, user, base, form)
return RedirectResponse( return RedirectResponse(
f"/library/knowledge/{base.id}", status_code=status.HTTP_303_SEE_OTHER 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 ------------------------------------------------------------------- # --- Notes -------------------------------------------------------------------
@router.get("/library/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(): if q.strip():
rows = notes_service.search( rows = notes_service.search(
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q) db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q)
) )
pager = {"page": 1, "pages": 1, "total": len(rows)} pager = {"page": 1, "pages": 1, "total": len(rows)}
else: else:
rows, pager = _page( query = (
db, notes_service.visible(db, user).order_by(Note.updated_at.desc()), page 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( return render(
request, request,
"library/notes.html", "library/notes.html",
@@ -362,6 +378,7 @@ async def notes_list(request: Request, db: Db, user: RequiredUser, q: str = "",
"section": "notes", "section": "notes",
"notes": rows, "notes": rows,
"q": q, "q": q,
"shared": shared,
"pager": pager, "pager": pager,
**sidebar_context(db, user), **sidebar_context(db, user),
}, },
@@ -389,7 +406,7 @@ async def note_detail(request: Request, db: Db, user: RequiredUser, note_id: str
"section": "notes", "section": "notes",
"note": note, "note": note,
"body_html": render_markdown(note.body), "body_html": render_markdown(note.body),
**_shared_context(db, user, note), **_shared_context(db, user, note, "note"),
**sidebar_context(db, user), **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() form = await request.form()
notes_service.update(db, note, title=str(form.get("title", "")), body=str(form.get("body", ""))) 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) 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 ------------------------------------------------------------------ # --- Skills ------------------------------------------------------------------
@router.get("/library/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(): if q.strip():
rows = skills_service.search( rows = skills_service.search(
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q) db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q)
) )
pager = {"page": 1, "pages": 1, "total": len(rows)} pager = {"page": 1, "pages": 1, "total": len(rows)}
else: 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( return render(
request, request,
"library/skills.html", "library/skills.html",
@@ -443,6 +479,7 @@ async def skills_list(request: Request, db: Db, user: RequiredUser, q: str = "",
"section": "skills", "section": "skills",
"skills": rows, "skills": rows,
"q": q, "q": q,
"shared": shared,
"pager": pager, "pager": pager,
**sidebar_context(db, user), **sidebar_context(db, user),
}, },
@@ -470,7 +507,7 @@ async def skill_detail(request: Request, db: Db, user: RequiredUser, skill_id: s
"section": "skills", "section": "skills",
"skill": skill, "skill": skill,
"revisions": skill.revisions, "revisions": skill.revisions,
**_shared_context(db, user, skill), **_shared_context(db, user, skill, "skill"),
**sidebar_context(db, user), **sidebar_context(db, user),
}, },
) )
@@ -511,7 +548,6 @@ async def update_skill(request: Request, db: Db, user: RequiredUser, skill_id: s
author=AUTHOR_USER, author=AUTHOR_USER,
note="edited by hand", note="edited by hand",
) )
_apply_shares(db, user, skill, form)
return RedirectResponse(f"/library/skills/{skill.id}", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(f"/library/skills/{skill.id}", status_code=status.HTTP_303_SEE_OTHER)
+38 -4
View File
@@ -18,12 +18,15 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response from fastapi.responses import RedirectResponse, Response
from sqlalchemy import select
from lembas.api.deps import Db, RequiredUser, require_permission from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.api.library import PAGE_SIZE, _page from lembas.api.library import PAGE_SIZE, _page
from lembas.api.pages import sidebar_context from lembas.api.pages import sidebar_context
from lembas.db.models import Report from lembas.db.models import Report
from lembas.security import permissions
from lembas.services import reports as reports_service from lembas.services import reports as reports_service
from lembas.services import sharing
from lembas.services.library import retrieval from lembas.services.library import retrieval
from lembas.services.markdown import render_markdown from lembas.services.markdown import render_markdown
from lembas.web.templating import render from lembas.web.templating import render
@@ -34,7 +37,20 @@ router = APIRouter(dependencies=[Depends(require_permission("reports.use"))], ta
@router.get("/reports") @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(): if q.strip():
rows = reports_service.search( rows = reports_service.search(
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q) 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)} pager = {"page": 1, "pages": 1, "total": len(rows)}
else: else:
rows, pager = _page( 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( return render(
request, request,
@@ -51,6 +73,7 @@ async def reports_list(request: Request, db: Db, user: RequiredUser, q: str = ""
"section": "reports", "section": "reports",
"reports": rows, "reports": rows,
"q": q, "q": q,
"shared": shared,
"pager": pager, "pager": pager,
**sidebar_context(db, user), **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 # 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 # 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. # 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( return render(
request, request,
"reports/detail.html", "reports/detail.html",
@@ -74,6 +102,10 @@ async def report_detail(request: Request, db: Db, user: RequiredUser, report_id:
"report": report, "report": report,
# Model output, through the one path allowed to emit HTML. # Model output, through the one path allowed to emit HTML.
"body_html": render_markdown(report.body), "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), **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") @router.post("/api/reports/{report_id}/delete")
async def delete_report(db: Db, user: RequiredUser, report_id: str) -> Response: 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: if report is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That report is not available.") raise HTTPException(status.HTTP_404_NOT_FOUND, "That report is not available.")
reports_service.delete(db, report) reports_service.delete(db, report)
+176
View File
@@ -0,0 +1,176 @@
"""Giving somebody else access to one thing.
Its own routes and its own fragment, rather than a block of checkboxes riding
along with the resource's save form. Three reasons, in the order they bite:
- **It rendered every group and every person on the instance, unpaginated, on
every detail page.** That is fine for a household and unusable for anything
else, and the page it is on has nothing to do with how many accounts exist.
- **A share was only stored if the resource was saved.** Ticking a box and
navigating away did nothing, silently, which is the shape of failure this
codebase keeps cataloguing.
- Sharing a *report* has no save form to ride along with at all.
So: search, and each grant is its own POST. The fragment re-renders itself after
every change, which is what keeps "who can see this" a thing you read rather
than a thing you reconstruct from checkboxes.
**Only the owner may reach any of it.** Somebody a thing was shared with cannot
share it on -- that is what keeps "who can see this?" answerable by asking one
person -- and the check is `sharing.can_write`, which is ownership and nothing
else.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
from sqlalchemy import or_, select
from lembas.api.deps import Db, RequiredUser
from lembas.db.models import (
PRINCIPAL_GROUP,
PRINCIPAL_USER,
Group,
KnowledgeBase,
Note,
Report,
Skill,
User,
)
from lembas.security import permissions
from lembas.services import sharing
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/library/share", tags=["sharing"])
# What a URL may name, and what it resolves to. A fixed table rather than a
# lookup by string on `sharing.RESOURCE_TYPES`, because that one maps class to
# string and this needs the other direction -- and because a route segment is
# request input, so the set of things it may name belongs written down.
KINDS: dict[str, type] = {
"base": KnowledgeBase,
"note": Note,
"skill": Skill,
"report": Report,
}
# Candidates offered at once. Enough that a small instance never has to type
# anything, few enough that a large one is not a page of names.
MAX_CANDIDATES = 12
def _resource(db: Db, kind: str, resource_id: str, user: User):
model = KINDS.get(kind)
if model is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Not a shareable kind.")
resource = db.get(model, resource_id)
# Ownership, not readability. Being able to see a thing is not being able to
# give it away, and the 404 rather than a 403 is deliberate: somebody who
# cannot share it has no business learning whether it exists.
if resource is None or not sharing.can_write(resource, user):
raise HTTPException(status.HTTP_404_NOT_FOUND, "That is not yours to share.")
return resource
def _panel(request: Request, db: Db, user: User, kind: str, resource, q: str = "") -> Response:
grants = sharing.grants_for(db, resource)
shared_users = [g.principal_id for g in grants if g.principal_type == PRINCIPAL_USER]
shared_groups = [g.principal_id for g in grants if g.principal_type == PRINCIPAL_GROUP]
needle = q.strip()
pattern = f"%{needle}%"
group_query = select(Group).order_by(Group.name)
people_query = select(User).where(User.id != user.id).order_by(User.name)
if needle:
group_query = group_query.where(Group.name.ilike(pattern))
people_query = people_query.where(
or_(User.name.ilike(pattern), User.email.ilike(pattern))
)
# Anything already shared is shown whatever the search says, or the only way
# to remove a grant would be to search for the name it was given to.
groups = list(db.scalars(group_query.limit(MAX_CANDIDATES)))
people = list(db.scalars(people_query.limit(MAX_CANDIDATES)))
for existing in db.scalars(select(Group).where(Group.id.in_(shared_groups or [""]))):
if existing.id not in {g.id for g in groups}:
groups.insert(0, existing)
for existing in db.scalars(select(User).where(User.id.in_(shared_users or [""]))):
if existing.id not in {p.id for p in people}:
people.insert(0, existing)
return render(
request,
"library/_share_panel.html",
{
"kind": kind,
"resource": resource,
"q": needle,
"groups": groups,
"people": people,
"shared_users": shared_users,
"shared_groups": shared_groups,
"share_count": len(grants),
# Whether the lists were cut, so the panel can say "search for
# somebody" rather than implying these are all the names there are.
"truncated": len(people) >= MAX_CANDIDATES or len(groups) >= MAX_CANDIDATES,
},
)
@router.get("/{kind}/{resource_id}")
async def share_panel(
request: Request, db: Db, user: RequiredUser, kind: str, resource_id: str, q: str = ""
) -> Response:
resource = _resource(db, kind, resource_id, user)
if not permissions.has(db, user, "library.share"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not share things.")
return _panel(request, db, user, kind, resource, q)
@router.post("/{kind}/{resource_id}")
async def set_share(
request: Request,
db: Db,
user: RequiredUser,
kind: str,
resource_id: str,
principal_type: str = Form(""),
principal_id: str = Form(""),
on: bool = Form(False),
q: str = Form(""),
) -> Response:
"""Add or remove one grant, and answer with the panel.
One grant per request rather than a submitted set, because the set is what
made the old panel need every name on the instance in front of you before
you could change one of them.
"""
resource = _resource(db, kind, resource_id, user)
if not permissions.has(db, user, "library.share"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not share things.")
if principal_type not in (PRINCIPAL_USER, PRINCIPAL_GROUP):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unknown principal.")
grants = sharing.grants_for(db, resource)
users = [g.principal_id for g in grants if g.principal_type == PRINCIPAL_USER]
groups = [g.principal_id for g in grants if g.principal_type == PRINCIPAL_GROUP]
target = users if principal_type == PRINCIPAL_USER else groups
# Validated against what exists, so a crafted id cannot write a grant naming
# nothing -- which would be invisible in the panel and unremovable from it.
exists = db.get(User if principal_type == PRINCIPAL_USER else Group, principal_id)
if on and exists is not None and principal_id not in target:
target.append(principal_id)
elif not on and principal_id in target:
target.remove(principal_id)
sharing.set_grants(db, resource, user_ids=users, group_ids=groups)
log.info(
"%s %s %s %s with %s", user.email, "shared" if on else "unshared", kind,
resource_id, principal_id,
)
return _panel(request, db, user, kind, resource, q)
+4
View File
@@ -48,6 +48,7 @@ from lembas.db.models.library import (
PRINCIPAL_USER, PRINCIPAL_USER,
RESOURCE_BASE, RESOURCE_BASE,
RESOURCE_NOTE, RESOURCE_NOTE,
RESOURCE_REPORT,
RESOURCE_SKILL, RESOURCE_SKILL,
SOURCE_LINK, SOURCE_LINK,
SOURCE_UPLOAD, SOURCE_UPLOAD,
@@ -101,6 +102,7 @@ from lembas.db.models.user import (
Group, Group,
PushSubscription, PushSubscription,
Session, Session,
Usage,
User, User,
user_groups, user_groups,
) )
@@ -108,6 +110,7 @@ from lembas.db.models.user import (
__all__ = [ __all__ = [
"AUTHOR_MODEL", "AUTHOR_MODEL",
"PushSubscription", "PushSubscription",
"Usage",
"AUTH_KEY", "AUTH_KEY",
"AUTH_METHODS", "AUTH_METHODS",
"AUTH_PASSWORD", "AUTH_PASSWORD",
@@ -126,6 +129,7 @@ __all__ = [
"PRINCIPAL_USER", "PRINCIPAL_USER",
"RESOURCE_BASE", "RESOURCE_BASE",
"RESOURCE_NOTE", "RESOURCE_NOTE",
"RESOURCE_REPORT",
"RESOURCE_SKILL", "RESOURCE_SKILL",
"RESPONSE_JSON", "RESPONSE_JSON",
"RESPONSE_MODES", "RESPONSE_MODES",
+7
View File
@@ -53,6 +53,13 @@ SOURCE_LINK = "link"
RESOURCE_BASE = "base" RESOURCE_BASE = "base"
RESOURCE_NOTE = "note" RESOURCE_NOTE = "note"
RESOURCE_SKILL = "skill" 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_USER = "user"
PRINCIPAL_GROUP = "group" PRINCIPAL_GROUP = "group"
+62 -1
View File
@@ -5,7 +5,18 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Any 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
@@ -69,6 +80,16 @@ class Group(UUIDPrimaryKey, Timestamps, Base):
# lembas.security.permissions. # lembas.security.permissions.
permissions_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) 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") users: Mapped[list[User]] = relationship(secondary=user_groups, back_populates="groups")
models: Mapped[list[Model]] = relationship( models: Mapped[list[Model]] = relationship(
"Model", secondary="model_groups", back_populates="groups" "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) 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"<Usage {self.user_id} {self.period}>"
+2
View File
@@ -41,6 +41,7 @@ from lembas.api import (
push, push,
reports, reports,
schedules, schedules,
sharing,
terminal, terminal,
) )
from lembas.api.deps import RedirectToLogin, is_htmx, login_redirect 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(reports.router)
app.include_router(schedules.router) app.include_router(schedules.router)
app.include_router(agents.router) app.include_router(agents.router)
app.include_router(sharing.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(admin_users.router) app.include_router(admin_users.router)
app.include_router(admin_models.router) app.include_router(admin_models.router)
+176 -3
View File
@@ -225,9 +225,15 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
PermissionDef( PermissionDef(
"library.share", "library.share",
"Share library items", "Share library items",
"Give other people, or a group, access to their documents, notes and " "Give other people, or a group, access to their knowledge bases, notes, "
"skills. Sharing grants reading only.", "skills and reports. Sharing grants reading only — never changing, and "
False, "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", "Library",
), ),
PermissionDef( PermissionDef(
@@ -260,8 +266,53 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
True, True,
"Library", "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.<gate>.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) PERMISSION_KEYS = tuple(d.key for d in PERMISSION_DEFS)
DEFAULT_PERMISSIONS = {d.key: d.default 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) 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]: def models_visible_to(db: DBSession, user: User | None) -> list[Model]:
"""Models a user may start a chat with, in display order. """Models a user may start a chat with, in display order.
+57
View File
@@ -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.models import KIND_AGENT, ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
from lembas.db.session import session_scope from lembas.db.session import session_scope
from lembas.security import permissions
from lembas.services import canvas as canvas_service from lembas.services import canvas as canvas_service
from lembas.services import chat as chat_service from lembas.services import chat as chat_service
from lembas.services import compaction as compaction_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 prompts as prompts_service
from lembas.services import push as push_service from lembas.services import push as push_service
from lembas.services import tools as tools_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 policy as agent_policy
from lembas.services.agent import session as agent_session from lembas.services.agent import session as agent_session
from lembas.services.agent import tools as agent_tools from lembas.services.agent import tools as agent_tools
@@ -437,6 +439,21 @@ async def shutdown() -> None:
await task 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: async def _run(generation: Generation) -> None:
"""Produce one reply, then persist it. Never raises into the task. """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) endpoint, model_id = chat_service.resolve_endpoint(db, chat)
owner = db.get(User, chat.user_id) 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. # Read while the session is open: everything below outlives it.
# Resolved once, so that what the loop is allowed to *run* is the # Resolved once, so that what the loop is allowed to *run* is the
# same set the endpoint was *offered* -- not whatever happens to # 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 # vision model, a plain string to anything else, or the endpoint
# rejects the whole request. # rejects the whole request.
vision = chat_service.model_supports(db, chat, "vision") 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) chat_rounds = settings_store.chat_rounds(db)
# A helper's chat is bounded by its own number, not the instance's. # 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 # 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")) nudge_enabled = bool(settings_store.agents(db).get("nudge_unfinished"))
limits = tool_context.agent.limits if tool_context.agent else None 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 # 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 # 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 # 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.stopped = generation.stopped
message.complete = True 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: if title and not chat.title_generated:
chat.title = title chat.title = title
chat.title_generated = True chat.title_generated = True
+24
View File
@@ -403,6 +403,23 @@ async def _review(
# --- The runner ---------------------------------------------------------------- # --- 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: async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
"""Generate one image, review it if there is anybody to ask, and keep one.""" """Generate one image, review it if there is anybody to ask, and keep one."""
from lembas.db.session import session_scope 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."}, {**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 {} values = context.image_config or {}
config = config_of(context) config = config_of(context)
if not config.configured: if not config.configured:
+26 -7
View File
@@ -20,6 +20,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
from lembas.db.models import CHUNK_REPORT, SOURCE_MANUAL, SOURCES, Report, User from lembas.db.models import CHUNK_REPORT, SOURCE_MANUAL, SOURCES, Report, User
from lembas.services import sharing
from lembas.services.library import retrieval from lembas.services.library import retrieval
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -33,21 +34,35 @@ SNIPPET_CHARS = 400
def visible(user: User | None): 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 no session because it builds a query rather than running one, and
takes `None` to mean nobody so an unauthenticated caller gets an empty takes `None` to mean nobody so an unauthenticated caller gets an empty
result instead of an exception -- the same shape `sharing.visible_to` has, result instead of an exception.
so a later move to shared reports is a change of one line here.
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(sharing.visible_to(Report, user))
return select(Report).where(Report.id.is_(None))
return select(Report).where(Report.owner_id == user.id)
def get(db: DBSession, report_id: str, user: User | None) -> Report | None: def get(db: DBSession, report_id: str, user: User | None) -> Report | None:
report = db.get(Report, report_id) 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 None
return report return report
@@ -202,6 +217,10 @@ def mark_read(db: DBSession, report: Report) -> Report:
def delete(db: DBSession, report: Report) -> None: 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.delete(report)
db.commit() db.commit()
+51 -3
View File
@@ -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. 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 Documents are deliberately absent from that list. They are shared through the
@@ -25,7 +25,7 @@ from __future__ import annotations
import logging import logging
from typing import Any 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 sqlalchemy.orm import Session as DBSession
from lembas.db.models import ( from lembas.db.models import (
@@ -33,9 +33,11 @@ from lembas.db.models import (
PRINCIPAL_USER, PRINCIPAL_USER,
RESOURCE_BASE, RESOURCE_BASE,
RESOURCE_NOTE, RESOURCE_NOTE,
RESOURCE_REPORT,
RESOURCE_SKILL, RESOURCE_SKILL,
KnowledgeBase, KnowledgeBase,
Note, Note,
Report,
Share, Share,
Skill, Skill,
User, User,
@@ -49,6 +51,12 @@ RESOURCE_TYPES: dict[Any, str] = {
KnowledgeBase: RESOURCE_BASE, KnowledgeBase: RESOURCE_BASE,
Note: RESOURCE_NOTE, Note: RESOURCE_NOTE,
Skill: RESOURCE_SKILL, 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)) 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]: def owned_by(model: Any, user: User | None) -> ColumnElement[bool]:
"""Rows this user may *change*. """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 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__ = [ __all__ = [
"can_read", "can_read",
"can_write", "can_write",
"forget_owner",
"forget_principal", "forget_principal",
"forget_resource", "forget_resource",
"grants_for", "grants_for",
"only_shared",
"owned_by", "owned_by",
"resource_type", "resource_type",
"set_grants", "set_grants",
+7
View File
@@ -76,6 +76,7 @@ from typing import TYPE_CHECKING, Any
from lembas.db.models import KIND_AGENT, Chat, User from lembas.db.models import KIND_AGENT, Chat, User
from lembas.db.session import session_scope from lembas.db.session import session_scope
from lembas.security import permissions
from lembas.services import settings_store from lembas.services import settings_store
from lembas.services.agent import policy as agent_policy 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 # rather than told it has run out of helpers, and the counter should
# only move for a call that is about to spend one. # only move for a call that is about to spend one.
values = settings_store.subagents(db) 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) refusal = _budget(generation_service.running_for(parent_id), values)
if refusal: if refusal:
return _error(refusal, task=task) return _error(refusal, task=task)
+16
View File
@@ -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. # instrument -- `git log` is a read whatever its risk class says.
writes_off = scoped_writes_off(chat) 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( return ToolSet(
tuple( tuple(
tool 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 gate_of(tool.family) not in off
and not (writes_off and tool.risk == RISK_WRITE) and not (writes_off and tool.risk == RISK_WRITE)
and may_write(tool)
# Nothing to read and nothing to improve. Offering `skill_get` with # 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 # no skills is what makes a model spend a round looking one up and
# being told it does not exist -- and `context.skills` already # being told it does not exist -- and `context.skills` already
+161
View File
@@ -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",
]
@@ -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 %}
<p class="admin-lede">
<a href="/admin/groups">{{ icon("chevron-left", "icon--sm") }} All groups</a>
· A group only ever <em>adds</em>. Anything already in the baseline is shown
below as such, so a tick here that changes nothing looks like one.
</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %}
<form method="post" action="/admin/groups/{{ group.id }}" class="form-grid">
<section class="card">
<h2 class="card__title">Name</h2>
<div class="field">
<label class="field__label" for="name">Name</label>
<input class="input" id="name" name="name" value="{{ group.name }}" required>
</div>
<div class="field">
<label class="field__label" for="description">What it is for</label>
<input class="input" id="description" name="description"
value="{{ group.description }}" maxlength="1000">
</div>
</section>
<section class="card">
<h2 class="card__title">Permissions this group adds</h2>
{% for section_name, defs in permission_groups.items() %}
<div class="field">
<span class="field__label">{{ section_name }}</span>
{% for definition in defs %}
<label class="checkbox perm-row">
<input type="checkbox" name="permission" value="{{ definition.key }}"
{{ 'checked' if (group.permissions_json or {}).get(definition.key) }}>
<span>
<strong>{{ definition.label }}</strong>
{% if baseline[definition.key] %}
<span class="badge">already in the baseline</span>
{% endif %}
<span class="perm-row__desc">{{ definition.description }}</span>
</span>
</label>
{% endfor %}
</div>
{% endfor %}
</section>
{#
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.
#}
<section class="card">
<h2 class="card__title">Quotas</h2>
<p class="card__lede">
Resolved across a person's groups by <strong>maximum</strong> — the union
rule applied to numbers, so a second group can only grant more.
<strong>Leave a box empty</strong> for “no opinion”, and use
<strong>0</strong> for “no limit”, which beats any number another group
sets. Administrators are unlimited whatever is here.
</p>
<div class="field-row">
{% for key, label, description in limit_defs %}
<div class="field">
<label class="field__label" for="limit-{{ key }}">{{ label }}</label>
<input class="input" id="limit-{{ key }}" name="limit_{{ key }}"
type="number" min="0" step="1"
value="{{ limits.get(key, '') }}" placeholder="no opinion">
<p class="field__hint">{{ description }}</p>
</div>
{% endfor %}
</div>
</section>
{#
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.
#}
<section class="card">
<h2 class="card__title">Members</h2>
<p class="card__lede">
The one place membership is edited. A user's own page links here rather
than offering a second control for the same value.
</p>
<div class="checkbox-row">
{% for person in users %}
<label class="checkbox">
<input type="checkbox" name="user_ids" value="{{ person.id }}"
{{ 'checked' if person in group.users }}>
<span>{{ person.name }} <span class="faint text-xs">{{ person.email }}</span></span>
</label>
{% endfor %}
</div>
</section>
<section class="card">
<h2 class="card__title">Models this group unlocks</h2>
<p class="card__lede">
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.
</p>
<div class="checkbox-row">
{% for model in models %}
<label class="checkbox">
<input type="checkbox" name="model_ids" value="{{ model.id }}"
{{ 'checked' if model in group.models }}>
<span>{{ model_avatar(model, "model-avatar model-avatar--sm") }} {{ model.label }}</span>
</label>
{% endfor %}
</div>
</section>
<div class="btn-row">
<button class="btn btn--primary" type="submit">Save group</button>
</div>
</form>
<section class="card">
<h2 class="card__title">Remove</h2>
<form method="post" action="/admin/groups/{{ group.id }}/delete"
data-confirm="Delete {{ group.name }}? Its members keep their accounts.">
<button class="btn btn--danger btn--sm" type="submit">
{{ icon("trash", "icon--sm") }} Delete this group
</button>
</form>
<p class="field__hint">
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.
</p>
</section>
{% endblock %}
+38 -115
View File
@@ -1,5 +1,5 @@
{% extends "admin/_layout.html" %} {% extends "admin/_layout.html" %}
{% from "_macros.html" import icon, model_avatar %} {% from "_macros.html" import icon %}
{% set section = "groups" %} {% set section = "groups" %}
{% block title %}Groups &amp; permissions - {{ brand.name }}{% endblock %} {% block title %}Groups &amp; permissions - {{ brand.name }}{% endblock %}
@@ -9,8 +9,9 @@
<p class="admin-lede"> <p class="admin-lede">
Permissions are a <strong>union</strong>: everyone starts with the baseline Permissions are a <strong>union</strong>: everyone starts with the baseline
below, and each group they belong to can add more. A group never takes 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. something away, so being in a second group can only widen what someone can do
Administrators bypass all of it. which is what keeps “why can this person not do X?” answerable without
simulating every group they are in. Administrators bypass all of it.
</p> </p>
{% if saved %} {% if saved %}
@@ -19,7 +20,7 @@
<section class="card"> <section class="card">
<h2 class="card__title">Baseline permissions</h2> <h2 class="card__title">Baseline permissions</h2>
<p class="text-sm muted" style="margin-bottom: var(--sp-4)"> <p class="card__lede">
What every signed-in user can do before any group is considered. Turn 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. something off here and grant it through a group to make it opt-in.
</p> </p>
@@ -48,7 +49,39 @@
Groups <span class="badge">{{ groups|length }}</span> Groups <span class="badge">{{ groups|length }}</span>
</h2> </h2>
<section class="card"> {#
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.
#}
<div class="model-rows">
{% for group in groups %}
<a class="model-row" href="/admin/groups/{{ group.id }}">
<div class="model-row__main">
<span class="model-row__name">{{ group.name }}</span>
{% if group.description %}
<span class="model-row__id">{{ group.description }}</span>
{% endif %}
</div>
<div class="model-row__meta">
<span class="badge">{{ group.users | length }} member{{ '' if group.users|length == 1 else 's' }}</span>
{% if granted[group.id] %}
<span class="badge badge--leaf">+{{ granted[group.id] }} permission{{ '' if granted[group.id] == 1 else 's' }}</span>
{% endif %}
{% if group.limits_json %}<span class="badge">quotas</span>{% endif %}
</div>
</a>
{% else %}
<div class="empty" style="padding: var(--sp-8) 0">
<p class="empty__text">
No groups yet. Everybody gets the baseline above and nothing more.
</p>
</div>
{% endfor %}
</div>
<section class="card" style="margin-top: var(--sp-6)">
<form method="post" action="/admin/groups" class="btn-row"> <form method="post" action="/admin/groups" class="btn-row">
<input class="input" name="name" placeholder="New group name" required <input class="input" name="name" placeholder="New group name" required
aria-label="New group name"> aria-label="New group name">
@@ -57,114 +90,4 @@
</button> </button>
</form> </form>
</section> </section>
{% if not groups %}
<div class="empty" style="padding: var(--sp-8) 0">
{{ icon("users", "empty__mark") }}
<p class="empty__text">
No groups yet. Create one to grant extra permissions, or to restrict a model
to a subset of users.
</p>
</div>
{% endif %}
{% for group in groups %}
<section class="card">
<form method="post" action="/admin/groups/{{ group.id }}">
<div class="card__header">
<strong>{{ group.name }}</strong>
<span class="text-xs faint">
{{ group.users|length }} member{{ '' if group.users|length == 1 else 's' }},
{{ group.models|length }} model{{ '' if group.models|length == 1 else 's' }}
</span>
</div>
<div class="field">
<label class="field__label" for="gn-{{ group.id }}">Name</label>
<input class="input" id="gn-{{ group.id }}" name="name" value="{{ group.name }}" required>
</div>
<div class="field">
<label class="field__label" for="gd-{{ group.id }}">Description</label>
<input class="input" id="gd-{{ group.id }}" name="description"
value="{{ group.description }}" placeholder="What is this group for?">
</div>
<div class="field">
<span class="field__label">Grants</span>
<p class="field__hint" style="margin-bottom: var(--sp-2)">
Anything already in the baseline stays on regardless — these only add.
</p>
{% for section_name, defs in permission_groups.items() %}
{% for definition in defs %}
<label class="checkbox perm-row">
<input type="checkbox" name="permission" value="{{ definition.key }}"
{{ 'checked' if (group.permissions_json or {}).get(definition.key) }}>
<span>
<strong>{{ definition.label }}</strong>
<span class="perm-row__desc">
{{ definition.description }}
{% if baseline[definition.key] %}<em>(already in the baseline)</em>{% endif %}
</span>
</span>
</label>
{% endfor %}
{% endfor %}
</div>
<div class="field">
<span class="field__label">Members</span>
{% if users %}
<div class="checkbox-row">
{% for account in users %}
<label class="checkbox">
<input type="checkbox" name="user_ids" value="{{ account.id }}"
{{ 'checked' if account in group.users }}>
<span>{{ account.name }}</span>
</label>
{% endfor %}
</div>
{% else %}
<p class="field__hint">No users yet.</p>
{% endif %}
</div>
<div class="field">
<span class="field__label">Model access</span>
<p class="field__hint" style="margin-bottom: var(--sp-2)">
Models marked “available to everyone” are reachable regardless. These
grant access to the restricted ones.
</p>
{% if models %}
<div class="checkbox-row">
{% for model in models %}
<label class="checkbox {{ 'is-muted' if model.public }}">
<input type="checkbox" name="model_ids" value="{{ model.id }}"
{{ 'checked' if model in group.models }}>
<span>{{ model.label }}{% if model.public %} <em>(public)</em>{% endif %}</span>
</label>
{% endfor %}
</div>
{% else %}
<p class="field__hint">No models yet.</p>
{% endif %}
</div>
<div class="btn-row">
<button class="btn btn--primary" type="submit">Save {{ group.name }}</button>
</div>
</form>
<div class="card__footer">
<span class="text-xs faint">Deleting a group leaves its members alone.</span>
<form method="post" action="/admin/groups/{{ group.id }}/delete"
data-confirm="Delete the group “{{ group.name }}”? Its members keep their accounts."
data-confirm-title="Delete group">
<button class="btn btn--sm btn--danger" type="submit">
{{ icon("trash", "icon--sm") }} Delete
</button>
</form>
</div>
</section>
{% endfor %}
{% endblock %} {% endblock %}
@@ -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 %}
<p class="admin-lede">
<a href="/admin/users">{{ icon("chevron-left", "icon--sm") }} All users</a>
· {{ target.email }}
</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %}
<form method="post" action="/admin/users/{{ target.id }}" class="form-grid">
<section class="card">
<h2 class="card__title">Account</h2>
<div class="field-row">
<div class="field">
<label class="field__label" for="name">Name</label>
<input class="input" id="name" name="name" value="{{ target.name }}" required>
</div>
<div class="field">
<label class="field__label" for="role">Role</label>
<select class="input" id="role" name="role">
{% for role in roles %}
<option value="{{ role }}" {{ 'selected' if target.role == role }}>{{ role }}</option>
{% endfor %}
</select>
<p class="field__hint">
An administrator bypasses every permission and every quota below.
</p>
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="active" value="true" {{ 'checked' if target.active }}>
<span>Active</span>
</label>
<p class="field__hint">
Turning this off signs them out everywhere at once, rather than waiting
for a cookie to expire.
</p>
</div>
</section>
<div class="btn-row">
<button class="btn btn--primary" type="submit">Save</button>
</div>
</form>
{# --- What they can actually do -------------------------------------------- #}
<section class="card">
<h2 class="card__title">What this account can do</h2>
<p class="card__lede">
Read-only, and deliberately: every switch here is set somewhere else — in the
<a href="/admin/groups">baseline</a> 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
<em>source</em>, which is the question the grids could not answer without
opening every group by eye.
</p>
{% for section_name, defs in permission_groups.items() %}
<div class="field">
<span class="field__label">{{ section_name }}</span>
{% for definition in defs %}
{% set state = explained[definition.key] %}
<div class="perm-row" style="display: flex; gap: var(--sp-3); align-items: baseline">
{{ icon("check" if state.on else "x", "icon--sm") }}
<span>
<strong>{{ definition.label }}</strong>
{% if state.on %}
<span class="perm-row__desc">
from {{ state.source | join(", ") }}
</span>
{% else %}
<span class="perm-row__desc faint">not granted</span>
{% endif %}
</span>
</div>
{% endfor %}
</div>
{% endfor %}
</section>
{# --- Membership ----------------------------------------------------------- #}
<section class="card">
<h2 class="card__title">Groups</h2>
<p class="card__lede">
Edited from the group's own page. One control per value, so a save here
cannot undo a save there.
</p>
{% if target.groups %}
<div class="btn-row">
{% for group in target.groups %}
<a class="btn btn--sm" href="/admin/groups/{{ group.id }}">{{ group.name }}</a>
{% endfor %}
</div>
{% else %}
<p class="muted text-sm">In no group. They get the baseline and nothing more.</p>
{% endif %}
</section>
{# --- Quotas and usage ----------------------------------------------------- #}
<section class="card">
<h2 class="card__title">This month</h2>
<p class="card__lede">
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.
</p>
<dl class="mode-list">
<div class="mode-list__row">
<dt><strong>Tokens</strong></dt>
<dd>
{{ "{:,}".format(usage.tokens) }}
{% if limits.monthly_tokens %} of {{ "{:,}".format(limits.monthly_tokens) }}{% endif %}
<span class="faint text-xs">
({{ "{:,}".format(usage.prompt_tokens) }} prompt,
{{ "{:,}".format(usage.completion_tokens) }} written)
</span>
</dd>
</div>
<div class="mode-list__row">
<dt><strong>Replies</strong></dt>
<dd>{{ usage.replies }}</dd>
</div>
<div class="mode-list__row">
<dt><strong>Images</strong></dt>
<dd>
{{ usage.images }} this month, {{ usage.images_today }} today
{% if limits.images_per_day %} (limit {{ limits.images_per_day }} a day){% endif %}
</dd>
</div>
</dl>
<h3 class="section-title">Limits in force</h3>
<p class="field__hint">
Resolved across their groups by <strong>maximum</strong> — 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”.
</p>
<dl class="mode-list">
{% for key, label, description in limit_defs %}
<div class="mode-list__row">
<dt><strong>{{ label }}</strong></dt>
<dd>
{% if limits[key] %}{{ "{:,}".format(limits[key]) }}{% else %}no limit{% endif %}
<span class="perm-row__desc">{{ description }}</span>
</dd>
</div>
{% endfor %}
</dl>
</section>
{# --- Models --------------------------------------------------------------- #}
<section class="card">
<h2 class="card__title">Models they can use</h2>
<p class="card__lede">
Model access is separate from permissions: a permission says what somebody
may do, this says what they may do it with.
</p>
{% if models %}
<div class="btn-row">
{% for model in models %}
<a class="btn btn--sm" href="/admin/models/{{ model.id }}/edit">{{ model.label }}</a>
{% endfor %}
</div>
{% else %}
<p class="muted text-sm">None. They cannot start a chat at all.</p>
{% endif %}
</section>
{# --- Dangerous ------------------------------------------------------------ #}
<section class="card">
<h2 class="card__title">Password and removal</h2>
<form method="post" action="/admin/users/{{ target.id }}/password" class="btn-row">
<input class="input" name="password" type="password" required
placeholder="New password" aria-label="New password" style="flex: 1">
<button class="btn" type="submit">Reset password</button>
</form>
<p class="field__hint">
Signs them out everywhere. An administrator resetting a password usually
means the account is compromised or the person has gone.
</p>
<form method="post" action="/admin/users/{{ target.id }}/delete"
data-confirm="Delete {{ target.email }}? Their chats, folders and library go with them."
style="margin-top: var(--sp-4)">
<button class="btn btn--danger btn--sm" type="submit">
{{ icon("trash", "icon--sm") }} Delete this account
</button>
</form>
<p class="field__hint">
Their chats, folders and library go too, and every share naming them or
naming anything of theirs.
</p>
</section>
{% endblock %}
+86 -140
View File
@@ -7,157 +7,103 @@
{% block admin_content %} {% block admin_content %}
<p class="admin-lede"> <p class="admin-lede">
Everyone with an account on this instance. Administrators bypass every Every account on this instance. Open one to see what it can actually do and
permission; ordinary users get the baseline permissions plus whatever their where each of those permissions came from. Group membership is edited from the
groups add. <a href="/admin/groups">group's</a> own page — one control per value, so a save
on one screen cannot undo a save on another.
</p> </p>
{% if saved %} {% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div> <div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %} {% endif %}
<form method="get" action="/admin/users" class="btn-row" style="margin-bottom: var(--sp-5)"> <div class="filter-bar">
<input class="input" type="search" name="q" value="{{ q }}" <form class="filter-form" method="get" action="/admin/users">
placeholder="Search by name or email" aria-label="Search users"> <input class="input" type="search" name="q" value="{{ q }}"
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Search</button> placeholder="Search by name or email…" aria-label="Search users">
{% if q %}<a class="btn btn--ghost" href="/admin/users">Clear</a>{% endif %} <button class="btn" type="submit">{{ icon("search", "icon--sm") }} Filter</button>
</form> {% if q %}<a class="btn btn--ghost" href="/admin/users">Clear</a>{% endif %}
</form>
</div>
<details class="card"> <div class="model-rows">
<summary class="card__title" style="cursor: pointer">Add a user</summary> {% for person in users %}
<form method="post" action="/admin/users" style="margin-top: var(--sp-4)"> <a class="model-row" href="/admin/users/{{ person.id }}">
<div class="field"> <div class="model-row__main">
<label class="field__label" for="nu-name">Name</label> <span class="model-row__name">
<input class="input" id="nu-name" name="name" required> {{ person.name }}
{% if person.role == "admin" %}<span class="badge badge--leaf">admin</span>{% endif %}
{% if person.role == "pending" %}<span class="badge">pending</span>{% endif %}
{% if not person.active %}<span class="badge">disabled</span>{% endif %}
</span>
<span class="model-row__id">{{ person.email }}</span>
</div> </div>
<div class="field"> <div class="model-row__meta">
<label class="field__label" for="nu-email">Email</label> {% if person.groups %}
<input class="input" id="nu-email" name="email" type="email" required> <span class="text-xs faint">
{{ person.groups | map(attribute="name") | join(", ") }}
</span>
{% endif %}
{% set spent = usage[person.id] %}
{% if spent.tokens %}
<span class="badge" title="Tokens this month">{{ "{:,}".format(spent.tokens) }}</span>
{% endif %}
</div> </div>
<div class="field"> </a>
<label class="field__label" for="nu-password">Password</label> {% else %}
<input class="input" id="nu-password" name="password" type="password" <div class="empty" style="padding: var(--sp-8) 0">
required minlength="8" autocomplete="new-password"> <p class="empty__text">{{ "Nobody matches that." if q else "No accounts yet." }}</p>
<p class="field__hint"> </div>
At least 8 characters. Tell them to change it — you will know it otherwise. {% endfor %}
</p> </div>
</div>
<div class="field"> {% if pager.pages > 1 %}
<label class="field__label" for="nu-role">Role</label> <div class="btn-row" style="margin-top: var(--sp-5)">
<select class="select" id="nu-role" name="role"> {% if pager.page > 1 %}
{% for role in roles %} <a class="btn btn--sm" href="/admin/users?page={{ pager.page - 1 }}{% if q %}&q={{ q|urlencode }}{% endif %}">
<option value="{{ role }}" {{ 'selected' if role == 'user' }}>{{ role }}</option> {{ icon("chevron-left", "icon--sm") }} Previous
{% endfor %} </a>
</select> {% endif %}
<span class="text-sm faint">Page {{ pager.page }} of {{ pager.pages }} · {{ pager.total }} accounts</span>
{% if pager.page < pager.pages %}
<a class="btn btn--sm" href="/admin/users?page={{ pager.page + 1 }}{% if q %}&q={{ q|urlencode }}{% endif %}">
Next {{ icon("chevron-right", "icon--sm") }}
</a>
{% endif %}
</div>
{% endif %}
<section class="card" style="margin-top: var(--sp-8)">
<h2 class="card__title">Add an account</h2>
<p class="card__lede">
Without going through registration — useful when sign-up is closed.
</p>
<form method="post" action="/admin/users" class="form-grid">
<div class="field-row">
<div class="field">
<label class="field__label" for="new-name">Name</label>
<input class="input" id="new-name" name="name" required>
</div>
<div class="field">
<label class="field__label" for="new-email">Email</label>
<input class="input" id="new-email" name="email" type="email" required>
</div>
<div class="field">
<label class="field__label" for="new-password">Password</label>
<input class="input" id="new-password" name="password" type="password" required>
</div>
<div class="field">
<label class="field__label" for="new-role">Role</label>
<select class="input" id="new-role" name="role">
{% for role in roles %}<option value="{{ role }}">{{ role }}</option>{% endfor %}
</select>
</div>
</div> </div>
<div class="btn-row"> <div class="btn-row">
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} Create</button> <button class="btn btn--primary" type="submit">
</div> {{ icon("plus", "icon--sm") }} Create account
</form>
</details>
<h2 class="admin-section-title">
Accounts <span class="badge">{{ users|length }}</span>
</h2>
{% for account in users %}
<section class="card">
<form method="post" action="/admin/users/{{ account.id }}">
<div class="card__header">
<div class="row" style="gap: var(--sp-2); min-width: 0">
<span class="status-dot {{ 'is-ok' if account.active else 'is-off' }}"></span>
<strong class="truncate">{{ account.name }}</strong>
<code class="text-xs faint">{{ account.email }}</code>
{% if account.is_admin %}<span class="badge badge--leaf">admin</span>{% endif %}
{% if not account.active %}<span class="badge badge--danger">deactivated</span>{% endif %}
{% if account.id == user.id %}<span class="badge">you</span>{% endif %}
</div>
<span class="text-xs faint">
{% if account.last_login_at %}
last seen {{ account.last_login_at.strftime("%Y-%m-%d %H:%M") }}
{% else %}
never signed in
{% endif %}
</span>
</div>
<div class="field">
<label class="field__label" for="un-{{ account.id }}">Name</label>
<input class="input" id="un-{{ account.id }}" name="name" value="{{ account.name }}" required>
</div>
<div class="field">
<label class="field__label" for="ur-{{ account.id }}">Role</label>
<select class="select" id="ur-{{ account.id }}" name="role">
{% for role in roles %}
<option value="{{ role }}" {{ 'selected' if role == account.role }}>{{ role }}</option>
{% endfor %}
</select>
<p class="field__hint">
<strong>admin</strong> can do everything, including this page.
<strong>user</strong> is an ordinary account.
<strong>pending</strong> cannot sign in until promoted.
</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="active" value="true" {{ 'checked' if account.active }}>
<span>Active — may sign in</span>
</label>
<p class="field__hint">
Deactivating signs them out everywhere immediately, rather than waiting
for their session to expire.
</p>
</div>
{% if groups %}
<div class="field">
<span class="field__label">Groups</span>
<div class="checkbox-row">
{% for group in groups %}
<label class="checkbox">
<input type="checkbox" name="group_ids" value="{{ group.id }}"
{{ 'checked' if group in account.groups }}>
<span>{{ group.name }}</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
{% if account.is_admin and admin_count <= 1 %}
<div class="alert alert--warning">
{{ icon("warning", "alert__icon") }}
<span>
The only administrator. Promote someone else before demoting or
deactivating this account — an instance with no admin can only be
recovered with <code>lembas create-admin</code>.
</span>
</div>
{% endif %}
<div class="btn-row"><button class="btn btn--primary" type="submit">Save</button></div>
</form>
<div class="card__footer">
<form method="post" action="/admin/users/{{ account.id }}/password" class="btn-row">
<input class="input" type="password" name="password" minlength="8"
placeholder="Set a new password" autocomplete="new-password" required
aria-label="New password for {{ account.email }}">
<button class="btn btn--sm" type="submit">{{ icon("key", "icon--sm") }} Reset</button>
</form>
{% if account.id != user.id %}
<form method="post" action="/admin/users/{{ account.id }}/delete"
data-confirm="Delete {{ account.email }} and all their chats? This cannot be undone."
data-confirm-title="Delete account">
<button class="btn btn--sm btn--danger" type="submit">
{{ icon("trash", "icon--sm") }} Delete
</button> </button>
</form> </div>
{% endif %} </form>
</div>
</section> </section>
{% endfor %}
{% endblock %} {% endblock %}
+21 -54
View File
@@ -1,63 +1,30 @@
{% from "_macros.html" import icon %} {% 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 It fetches `_share_panel.html` on load rather than being rendered inline, and
merge is worse than the inconvenience of copying it, so there is no "can edit" that is the whole change: the panel used to be checkboxes inside the
here and the copy is deliberate rather than missing. 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 Sharing still grants *reading*. Two people editing one note with no history and
it onward, which keeps "who can see this" answerable by asking one person. 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 %} {% if is_owner and can_share %}
<section class="card"> <div hx-get="/api/library/share/{{ share_kind }}/{{ share_id }}"
<h2 class="card__title"> hx-trigger="load"
Shared with hx-target="this"
{% if shared_users or shared_groups %} hx-swap="outerHTML">
<span class="badge badge--leaf">{{ shared_users|length + shared_groups|length }}</span> <section class="card">
{% else %} <h2 class="card__title">Shared with <span class="badge"></span></h2>
<span class="badge">nobody</span> </section>
{% endif %} </div>
</h2>
<p class="card__lede">
They will be able to read this, and their models will find it. They cannot
change it or share it on.
</p>
{% if groups %}
<div class="field">
<label class="field__label">Groups</label>
<div class="checkbox-row">
{% for group in groups %}
<label class="checkbox">
<input type="checkbox" name="share_group" value="{{ group.id }}"
{{ 'checked' if group.id in shared_groups }}>
<span>{{ group.name }}</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
{% if people %}
<div class="field">
<label class="field__label">People</label>
<div class="checkbox-row">
{% for person in people %}
<label class="checkbox">
<input type="checkbox" name="share_user" value="{{ person.id }}"
{{ 'checked' if person.id in shared_users }}>
<span>{{ person.name }}</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
{% if not groups and not people %}
<p class="muted text-sm">There is nobody else on this instance yet.</p>
{% endif %}
</section>
{% elif not is_owner %} {% elif not is_owner %}
<div class="alert"> <div class="alert">
{{ icon("users", "alert__icon") }} {{ icon("users", "alert__icon") }}
@@ -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 `<label>`s carrying no `role="menuitem"`, for the reason the
scope menu's are: `ui.js` closes a picker when a menuitem is clicked, which is
right for an action and wrong for a list you set several of.
#}
<section class="card" id="share-panel">
<h2 class="card__title">
Shared with
{% if share_count %}
<span class="badge badge--leaf">{{ share_count }}</span>
{% else %}
<span class="badge">nobody</span>
{% endif %}
</h2>
<p class="card__lede">
They will be able to read this, and their models will find it. They cannot
change it, delete it, or share it on — so “who can see this?” stays a
question you can answer.
</p>
<div class="field">
<label class="field__label" for="share-search">Find somebody</label>
<input class="input" id="share-search" type="search" name="q" value="{{ q }}"
placeholder="Name, email or group…"
hx-get="/api/library/share/{{ kind }}/{{ resource.id }}"
hx-trigger="input changed delay:250ms, search"
hx-target="#share-panel"
hx-swap="outerHTML">
{% if truncated %}
<p class="field__hint">
Showing the first few. Type to narrow it — anything already shared stays
listed whatever you search for.
</p>
{% endif %}
</div>
{% if groups %}
<div class="field">
<label class="field__label">Groups</label>
<div class="checkbox-row">
{% for group in groups %}
{% set on = group.id in shared_groups %}
<label class="checkbox">
<input type="checkbox" {{ 'checked' if on }}
hx-post="/api/library/share/{{ kind }}/{{ resource.id }}"
hx-vals='{"principal_type": "group", "principal_id": "{{ group.id }}",
"on": "{{ 'false' if on else 'true' }}", "q": "{{ q }}"}'
hx-target="#share-panel"
hx-swap="outerHTML">
<span>{{ group.name }}</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
{% if people %}
<div class="field">
<label class="field__label">People</label>
<div class="checkbox-row">
{% for person in people %}
{% set on = person.id in shared_users %}
<label class="checkbox">
<input type="checkbox" {{ 'checked' if on }}
hx-post="/api/library/share/{{ kind }}/{{ resource.id }}"
hx-vals='{"principal_type": "user", "principal_id": "{{ person.id }}",
"on": "{{ 'false' if on else 'true' }}", "q": "{{ q }}"}'
hx-target="#share-panel"
hx-swap="outerHTML">
<span>{{ person.name }} <span class="faint text-xs">{{ person.email }}</span></span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
{% if not groups and not people %}
<p class="muted text-sm">
{% if q %}Nobody matches “{{ q }}”.{% else %}There is nobody else here yet.{% endif %}
</p>
{% endif %}
</section>
@@ -13,6 +13,13 @@
everything in it comes with it. everything in it comes with it.
</p> </p>
{# Links rather than a form, so a filtered view is a URL you can keep. #}
<div class="filter-tabs" style="margin-bottom: var(--sp-4)">
<a class="filter-tab {{ 'is-active' if not shared }}" href="/library/knowledge">All bases</a>
<a class="filter-tab {{ 'is-active' if shared }}"
href="/library/knowledge?shared=1">Shared with me</a>
</div>
{% if error %} {% if error %}
<div class="alert alert--error">{{ icon("warning", "alert__icon") }} <span>{{ error }}</span></div> <div class="alert alert--error">{{ icon("warning", "alert__icon") }} <span>{{ error }}</span></div>
{% endif %} {% endif %}
@@ -15,6 +15,15 @@
yours, not its. yours, not its.
</p> </p>
{#
Two views, as links, so a filtered list is a real URL you can keep -- the same
shape the admin lists use. A badge on a row answers "is this mine?"; the question somebody has is
"what have people given me?", which a mixed list of two hundred cannot answer.
#}
<div class="filter-tabs" style="margin-bottom: var(--sp-4)">
<a class="filter-tab {{ 'is-active' if not shared }}" href="/library/notes">All notes</a>
<a class="filter-tab {{ 'is-active' if shared }}" href="/library/notes?shared=1">Shared with me</a>
</div>
<form method="get" action="/library/notes" class="btn-row" style="margin-bottom: var(--sp-5)"> <form method="get" action="/library/notes" class="btn-row" style="margin-bottom: var(--sp-5)">
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1" <input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
placeholder="Search notes…"> placeholder="Search notes…">
@@ -16,6 +16,14 @@
change can be read and undone. change can be read and undone.
</p> </p>
{#
Two views, as links, so a filtered list is a real URL you can keep -- the same
shape the admin lists use. Same as the notes list.
#}
<div class="filter-tabs" style="margin-bottom: var(--sp-4)">
<a class="filter-tab {{ 'is-active' if not shared }}" href="/library/skills">All skills</a>
<a class="filter-tab {{ 'is-active' if shared }}" href="/library/skills?shared=1">Shared with me</a>
</div>
<form method="get" action="/library/skills" class="btn-row" style="margin-bottom: var(--sp-5)"> <form method="get" action="/library/skills" class="btn-row" style="margin-bottom: var(--sp-5)">
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1" <input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
placeholder="Search skills…"> placeholder="Search skills…">
@@ -36,6 +36,15 @@
{{ body_html | safe }} {{ body_html | safe }}
</article> </article>
{# The same panel every library store uses. A finished piece of work is the
thing somebody most wants to hand over. #}
<div style="margin-top: var(--sp-6)">
{% include "library/_share.html" %}
</div>
{# Only the owner may delete. Sharing grants reading, so somebody a report was
shared with sees the panel above saying so and no button here. #}
{% if report.owner_id == user.id %}
<div class="btn-row" style="margin-top: var(--sp-6)"> <div class="btn-row" style="margin-top: var(--sp-6)">
<form method="post" action="/api/reports/{{ report.id }}/delete" <form method="post" action="/api/reports/{{ report.id }}/delete"
data-confirm="Delete this report? It cannot be brought back."> data-confirm="Delete this report? It cannot be brought back.">
@@ -44,4 +53,5 @@
</button> </button>
</form> </form>
</div> </div>
{% endif %}
{% endblock %} {% endblock %}
@@ -12,6 +12,14 @@
schedule leaves its result here. Nothing on this page can be replied to. schedule leaves its result here. Nothing on this page can be replied to.
</p> </p>
{# Reports became shareable and this filter arrived with them: a feed that
quietly grew somebody else's work with no way to see only theirs would be
worse than one that never grew. #}
<div class="filter-tabs" style="margin-bottom: var(--sp-4)">
<a class="filter-tab {{ 'is-active' if not shared }}" href="/reports">All reports</a>
<a class="filter-tab {{ 'is-active' if shared }}" href="/reports?shared=1">Shared with me</a>
</div>
<form method="get" action="/reports" class="btn-row" style="margin-bottom: var(--sp-5)"> <form method="get" action="/reports" class="btn-row" style="margin-bottom: var(--sp-5)">
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1" <input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
placeholder="Search reports…"> placeholder="Search reports…">
+250
View File
@@ -0,0 +1,250 @@
"""What an account may spend, and the arithmetic that decides.
The rule to hold is the one the permissions already hold, applied to numbers:
**a second group can only ever grant more.** Its awkward corner is zero, which
means "no limit" so the maximum has to be taken with zero winning outright, or
a group saying "unlimited" would count for less than one saying "a million".
"""
from __future__ import annotations
import pytest
from sqlalchemy import select
from lembas.db.models import ROLE_USER, Group, Usage, User
from lembas.security import permissions
from lembas.services import usage as usage_service
@pytest.fixture
def reader(db, registered) -> User:
person = User(
email="sam@shire.test", name="Sam", password_hash="x", role=ROLE_USER # noqa: S106
)
db.add(person)
db.commit()
return person
def _group(db, reader, **limits) -> Group:
group = Group(name=f"g{len(limits)}-{id(limits)}", limits_json=limits)
group.users.append(reader)
db.add(group)
db.commit()
return group
# --- Resolution -----------------------------------------------------------------
def test_nobody_is_limited_until_somebody_says_so(db, reader):
"""A quota that arrived with an upgrade and started refusing replies would
be the worst possible way to introduce one."""
assert permissions.limits_for(db, reader) == permissions.NO_LIMITS
def test_a_second_group_can_only_grant_more(db, reader):
_group(db, reader, monthly_tokens=1000)
_group(db, reader, monthly_tokens=5000)
db.refresh(reader)
assert permissions.limit(db, reader, "monthly_tokens") == 5000
def test_zero_means_no_limit_and_wins_outright(db, reader):
"""The union rule's awkward corner. A plain maximum would make "unlimited"
count for less than "a million", which is the rule inverted for exactly the
value somebody sets when they mean *stop limiting this person*."""
_group(db, reader, monthly_tokens=1000)
_group(db, reader, monthly_tokens=0)
db.refresh(reader)
assert permissions.limit(db, reader, "monthly_tokens") == 0
def test_a_group_with_no_opinion_contributes_nothing(db, reader):
"""Absent is not zero. If it were, adding a group that says nothing about
tokens would silently make somebody unlimited."""
_group(db, reader, monthly_tokens=1000)
_group(db, reader, images_per_day=5)
db.refresh(reader)
assert permissions.limit(db, reader, "monthly_tokens") == 1000
assert permissions.limit(db, reader, "images_per_day") == 5
def test_nonsense_in_the_column_is_ignored(db, reader):
"""A row can be written by hand or by an older version, and a limit nobody
can parse must not become a limit of zero which is *unlimited* here, the
opposite of failing safe."""
_group(db, reader, monthly_tokens="lots")
_group(db, reader, monthly_tokens=1000)
db.refresh(reader)
assert permissions.limit(db, reader, "monthly_tokens") == 1000
def test_an_administrator_is_unlimited(db, registered):
admin = db.scalars(select(User).order_by(User.created_at)).first()
group = Group(name="tight", limits_json={"monthly_tokens": 1})
group.users.append(admin)
db.add(group)
db.commit()
assert permissions.limits_for(db, admin) == permissions.NO_LIMITS
# --- Recording ------------------------------------------------------------------
def test_usage_accumulates_into_one_row_per_month(db, reader):
usage_service.record(db, reader.id, prompt_tokens=100, completion_tokens=50)
usage_service.record(db, reader.id, prompt_tokens=10, completion_tokens=5)
db.commit()
rows = list(db.scalars(select(Usage).where(Usage.user_id == reader.id)))
assert len(rows) == 1
assert rows[0].prompt_tokens == 110
assert rows[0].completion_tokens == 55
assert rows[0].replies == 2
assert usage_service.month_tokens(db, reader.id) == 165
def test_recording_never_raises(db):
"""Bookkeeping that broke a reply would be worse than no bookkeeping."""
usage_service.record(db, "", prompt_tokens=10)
usage_service.record(db, "nobody-at-all", prompt_tokens=10)
def test_the_token_gate_answers_before_anything_is_spent(db, reader):
_group(db, reader, monthly_tokens=100)
db.refresh(reader)
assert usage_service.over_token_budget(db, reader) == ""
usage_service.record(db, reader.id, prompt_tokens=60, completion_tokens=60)
db.commit()
reason = usage_service.over_token_budget(db, reader)
assert "month" in reason
assert "100" in reason
def test_an_unlimited_account_is_never_over(db, reader):
usage_service.record(db, reader.id, prompt_tokens=10**9)
db.commit()
assert usage_service.over_token_budget(db, reader) == ""
# --- Narrowing ------------------------------------------------------------------
@pytest.mark.parametrize(
("instance", "quota", "expected"),
[
(900, 300, 300), # the group is tighter
(300, 900, 300), # the instance is tighter
(0, 300, 300), # the instance has no limit
(300, 0, 300), # the group has no opinion
(0, 0, 0), # neither
],
)
def test_two_ceilings_fold_to_the_tighter_one(instance, quota, expected):
"""Not `min`: a zero on either side would win and turn "no opinion" into
"no time at all". Written once because getting it wrong in one place is a
limit that silently stops working."""
from lembas.services.generation import _narrower
assert _narrower(instance, quota) == expected
# --- The screens ----------------------------------------------------------------
def test_a_group_stores_only_the_boxes_that_were_filled(db, client, registered, reader):
group = Group(name="team")
db.add(group)
db.commit()
client.post(
f"/admin/groups/{group.id}",
data={
"name": "team",
"limit_monthly_tokens": "5000",
"limit_images_per_day": "",
"limit_concurrent_replies": "not a number",
},
follow_redirects=False,
)
db.refresh(group)
assert group.limits_json == {"monthly_tokens": 5000}
def test_the_user_page_says_where_each_permission_came_from(db, client, registered, reader):
"""The question the grids could not answer without opening every group by
eye which is exactly the simulation the union rule exists to avoid."""
group = Group(name="writers", permissions_json={"tools.notes.write": True})
group.users.append(reader)
db.add(group)
db.commit()
explained = permissions.explain(db, reader)
assert explained["tools.notes.write"]["on"] is True
assert "writers" in explained["tools.notes.write"]["source"]
# And something the baseline gives says baseline rather than naming a group.
assert explained["chat.create"]["source"] == ["baseline"]
page = client.get(f"/admin/users/{reader.id}").text
assert "writers" in page
assert "What this account can do" in page
def test_an_administrator_is_explained_as_bypassing(db, registered):
admin = db.scalars(select(User).order_by(User.created_at)).first()
explained = permissions.explain(db, admin)
assert all(entry["source"] == ["admin"] for entry in explained.values())
def test_membership_is_edited_from_the_group_and_not_the_user(db, client, registered, reader):
"""Two controls writing one value is how each becomes the answer to "why did
my change not stick?". The user page links to the group instead."""
group = Group(name="team")
db.add(group)
db.commit()
# No control for it on the user's page.
assert 'name="group_ids"' not in client.get(f"/admin/users/{reader.id}").text
# And a POST that tries anyway changes nothing.
client.post(
f"/admin/users/{reader.id}",
data={"name": "Sam", "role": "user", "active": "true", "group_ids": [group.id]},
follow_redirects=False,
)
db.refresh(reader)
assert reader.groups == []
client.post(
f"/admin/groups/{group.id}",
data={"name": "team", "user_ids": [reader.id]},
follow_redirects=False,
)
db.refresh(reader)
assert [g.name for g in reader.groups] == ["team"]
# Once they are in it, their page links to where it is edited.
assert f"/admin/groups/{group.id}" in client.get(f"/admin/users/{reader.id}").text
def test_the_user_list_is_paginated_and_searchable(db, client, registered):
for n in range(30):
db.add(
User(
email=f"p{n}@shire.test",
name=f"Person {n}",
password_hash="x", # noqa: S106
)
)
db.commit()
first = client.get("/admin/users").text
assert "Page 1 of" in first
found = client.get("/admin/users?q=Person+7").text
assert "Person 7" in found
assert "Person 12" not in found
+289
View File
@@ -0,0 +1,289 @@
"""Sharing: what it grants, what it does not, and what it leaves behind.
Half of this file is about grants that outlive what they name. `Share` carries
no foreign key in either direction `principal_id` points at a user *or* a
group and `resource_id` at one of four tables, neither of which SQLite can
express so nothing cascades, and every delete has to say so explicitly.
`sharing.forget_principal` existed for exactly this and was called by nobody.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import (
ROLE_USER,
Group,
Note,
Report,
Share,
User,
)
from lembas.security import permissions
from lembas.services import reports as reports_service
from lembas.services import sharing
from lembas.services.library import notes as notes_service
@pytest.fixture
def owner(db, registered) -> User:
return db.scalars(select(User).order_by(User.created_at)).first()
@pytest.fixture
def reader(db) -> User:
person = User(
email="sam@shire.test", name="Sam", password_hash="x", role=ROLE_USER # noqa: S106
)
db.add(person)
db.commit()
return person
# --- Dangling grants ------------------------------------------------------------
def test_deleting_a_group_drops_the_shares_naming_it(db, client, registered, owner, reader):
"""It never did. A group id is a random hex string that nothing reissues
today and nothing promises not to reissue tomorrow."""
group = Group(name="team")
group.users.append(reader)
db.add(group)
db.commit()
note = notes_service.create(db, owner=owner, title="Secret", body="mellon")
sharing.set_grants(db, note, user_ids=[], group_ids=[group.id])
assert db.scalars(select(Share)).all()
client.post(f"/admin/groups/{group.id}/delete", follow_redirects=False)
db.expire_all()
assert db.scalars(select(Share)).all() == []
def test_deleting_an_account_drops_both_halves(db, client, registered, owner, reader):
"""Grants **to** them, and grants **of** their own work. The second is the
one nothing else could catch: their rows cascade, and the shares of those
rows have nothing to cascade from."""
theirs = notes_service.create(db, owner=reader, title="Theirs", body="x")
sharing.set_grants(db, theirs, user_ids=[owner.id], group_ids=[])
mine = notes_service.create(db, owner=owner, title="Mine", body="y")
sharing.set_grants(db, mine, user_ids=[reader.id], group_ids=[])
assert len(db.scalars(select(Share)).all()) == 2
client.post(f"/admin/users/{reader.id}/delete", follow_redirects=False)
db.expire_all()
assert db.scalars(select(Share)).all() == []
def test_deleting_a_report_drops_its_grants(db, owner, reader):
report = reports_service.create(db, owner=owner, title="Findings", body="x")
sharing.set_grants(db, report, user_ids=[reader.id], group_ids=[])
reports_service.delete(db, report)
db.expire_all()
assert db.scalars(select(Share)).all() == []
# --- Reports ---------------------------------------------------------------------
def test_a_shared_report_is_visible_and_not_deletable(db, owner, reader):
"""Sharing grants reading. Being able to see a report is not being able to
delete it out from under the person who filed it."""
report = reports_service.create(db, owner=owner, title="Findings", body="x")
sharing.set_grants(db, report, user_ids=[reader.id], group_ids=[])
assert reports_service.get(db, report.id, reader) is not None
assert reports_service.owned(db, report.id, reader) is None
assert reports_service.owned(db, report.id, owner) is not None
def test_an_unshared_report_stays_invisible(db, owner, reader):
report = reports_service.create(db, owner=owner, title="Findings", body="x")
assert reports_service.get(db, report.id, reader) is None
assert list(db.scalars(reports_service.visible(reader))) == []
def test_a_shared_report_appears_in_the_feed_and_the_filter(db, owner, reader):
report = reports_service.create(db, owner=owner, title="Findings", body="x")
theirs = reports_service.create(db, owner=reader, title="Mine", body="y")
sharing.set_grants(db, report, user_ids=[reader.id], group_ids=[])
everything = {r.id for r in db.scalars(reports_service.visible(reader))}
only_shared = {
r.id for r in db.scalars(select(Report).where(sharing.only_shared(Report, reader)))
}
assert everything == {report.id, theirs.id}
assert only_shared == {report.id}
def test_reading_somebody_elses_report_does_not_clear_their_dot(db, client, registered, owner):
"""`unread` is the owner's notification. Somebody it was shared with opening
it would silence a dot meant for a person who has not seen it."""
other = User(
email="sam@shire.test", name="Sam", password_hash="x", role=ROLE_USER # noqa: S106
)
db.add(other)
db.commit()
report = reports_service.create(db, owner=other, title="Theirs", body="x")
report.unread = True
db.commit()
sharing.set_grants(db, report, user_ids=[owner.id], group_ids=[])
assert client.get(f"/reports/{report.id}").status_code == 200
db.refresh(report)
assert report.unread is True
# --- Shared with me --------------------------------------------------------------
def test_only_shared_excludes_your_own(db, owner, reader):
mine = notes_service.create(db, owner=reader, title="Mine", body="x")
theirs = notes_service.create(db, owner=owner, title="Theirs", body="y")
sharing.set_grants(db, theirs, user_ids=[reader.id], group_ids=[])
rows = {n.id for n in db.scalars(select(Note).where(sharing.only_shared(Note, reader)))}
assert rows == {theirs.id}
assert mine.id not in rows
def test_the_filter_is_a_url_you_can_keep(db, client, registered):
for path in ("/library/notes", "/library/skills", "/library/knowledge", "/reports"):
page = client.get(f"{path}?shared=1")
assert page.status_code == 200, path
assert "Shared with me" in page.text, path
# --- The panel -------------------------------------------------------------------
def test_a_grant_is_stored_the_moment_it_is_made(db, client, registered, owner, reader):
"""It used to ride along with the resource's save form, so ticking a box and
navigating away did nothing silently."""
note = notes_service.create(db, owner=owner, title="Note", body="x")
response = client.post(
f"/api/library/share/note/{note.id}",
data={"principal_type": "user", "principal_id": reader.id, "on": "true"},
)
assert response.status_code == 200
assert sharing.can_read(db, note, reader) is True
# And the answer is the panel, showing what is now true.
assert "Shared with" in response.text
def test_a_grant_can_be_taken_back(db, client, registered, owner, reader):
note = notes_service.create(db, owner=owner, title="Note", body="x")
sharing.set_grants(db, note, user_ids=[reader.id], group_ids=[])
client.post(
f"/api/library/share/note/{note.id}",
data={"principal_type": "user", "principal_id": reader.id, "on": "false"},
)
db.expire_all()
assert sharing.can_read(db, note, reader) is False
def test_the_panel_searches_rather_than_listing_everybody(db, client, registered, owner):
"""It rendered every group and every account on the instance, unpaginated,
on every detail page."""
for n in range(30):
db.add(
User(
email=f"p{n}@shire.test",
name=f"Person {n}",
password_hash="x", # noqa: S106
)
)
db.commit()
note = notes_service.create(db, owner=owner, title="Note", body="x")
everything = client.get(f"/api/library/share/note/{note.id}").text
assert everything.count('name="principal_id"') == 0 # values ride in hx-vals
assert "Person 29" not in everything
found = client.get(f"/api/library/share/note/{note.id}?q=Person+29").text
assert "Person 29" in found
def test_an_existing_grant_stays_listed_whatever_the_search_says(
db, client, registered, owner, reader
):
"""Otherwise the only way to remove a grant would be to search for the name
it was given to."""
note = notes_service.create(db, owner=owner, title="Note", body="x")
sharing.set_grants(db, note, user_ids=[reader.id], group_ids=[])
page = client.get(f"/api/library/share/note/{note.id}?q=nobody-matches-this").text
assert "sam@shire.test" in page
def test_somebody_it_was_shared_with_cannot_share_it_on(db, client, registered, owner, reader):
"""What keeps "who can see this?" answerable by asking one person."""
note = notes_service.create(db, owner=owner, title="Note", body="x")
sharing.set_grants(db, note, user_ids=[reader.id], group_ids=[])
other = TestClient(client.app)
other.post(
"/auth/register",
data={"name": "Merry", "email": "merry@shire.test", "password": "a-fine-second-breakfast"},
follow_redirects=False,
)
# Signed in as somebody else entirely: the panel is not theirs to open.
assert other.get(f"/api/library/share/note/{note.id}").status_code == 404
def test_a_grant_naming_nothing_is_refused(db, client, registered, owner):
"""A crafted id would write a grant that is invisible in the panel and
unremovable from it."""
note = notes_service.create(db, owner=owner, title="Note", body="x")
client.post(
f"/api/library/share/note/{note.id}",
data={"principal_type": "user", "principal_id": "not-a-real-id", "on": "true"},
)
assert db.scalars(select(Share)).all() == []
def test_sharing_is_on_by_default(db, registered, owner):
"""It was off, which meant sharing shipped documented as done and
unreachable: the panel only renders for somebody who holds this."""
assert permissions.has(db, owner, "library.share") is True
assert permissions.DEFAULT_PERMISSIONS["library.share"] is True
# --- Read and write, split -------------------------------------------------------
def test_a_reader_can_search_notes_and_not_write_them(db, reader):
from lembas.db.models import Chat, Connection, Model
from lembas.services import settings_store
from lembas.services import tools as tools_service
from lembas.services.crypto import encrypt
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt(""))
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="m", capabilities_json={"tools": True}))
db.commit()
chat = Chat(user_id=reader.id, model_id="m", connection_id=connection.id)
db.add(chat)
db.commit()
settings_store.update(db, {"default_permissions": {"tools.notes.write": False}})
offered = {tool.name for tool in tools_service.resolve_tools(db, chat, reader).defs}
assert "notes_search" in offered
assert "notes_get" in offered
assert "notes_create" not in offered
assert "notes_edit" not in offered
assert "notes_delete" not in offered
def test_the_write_half_defaults_on(db, reader):
"""An instance that never looks behaves exactly as it did."""
for key in ("tools.notes.write", "tools.memory.write", "tools.skills.write"):
assert permissions.DEFAULT_PERMISSIONS[key] is True