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:
@@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused
|
||||
lembas secret-key # generate LEMBAS_SECRET_KEY
|
||||
lembas create-admin # create or promote an admin
|
||||
|
||||
pytest # 2024 tests, ~2min
|
||||
pytest # 2062 tests, ~2min
|
||||
# PLAN.md tracks what is and is not built
|
||||
ruff check . # lint (line length 100)
|
||||
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
|
||||
@@ -82,7 +82,9 @@ src/lembas/
|
||||
folders.py folder CRUD
|
||||
admin.py connections + instance settings
|
||||
admin_models.py model ordering, defaults, images, access
|
||||
admin_users.py users, groups, permissions
|
||||
admin_users.py users, groups, permissions, quotas -- list plus detail,
|
||||
with "what can this person actually do?" answered
|
||||
sharing.py the share panel: search, and one grant per request
|
||||
admin_audio.py speech-to-text and text-to-speech endpoints
|
||||
admin_search.py web search provider and credentials
|
||||
admin_images.py the ComfyUI, and the workflow templates on it
|
||||
@@ -139,6 +141,8 @@ src/lembas/
|
||||
wake.py starting a reply from outside a request -- one lock
|
||||
discipline, shared by finished jobs and by schedules
|
||||
sharing.py one visibility rule for every library store
|
||||
usage.py what an account spent this month, and whether it may spend
|
||||
more -- written even when the reply failed
|
||||
prompts.py every injected prompt fragment, and {{variables}}
|
||||
metrics.py tokens, context percentage and tokens/second
|
||||
tokens.py the chars/4 estimate, for endpoints that report none
|
||||
@@ -199,6 +203,10 @@ touching the code it names -- these are the same notes, not a summary.
|
||||
schedule something wrote a note and said it had).
|
||||
- `docs/notes/image-generation.md` -- the ComfyUI workflow with holes in it, what
|
||||
substitution walks, the review-and-retry loop, and how a failure reports itself.
|
||||
- `docs/notes/permissions-and-sharing.md` -- the union rule shown rather than
|
||||
thrown away, where read and write are split and why not everywhere, quotas as
|
||||
the union rule applied to numbers (and why zero wins), where each is enforced,
|
||||
and the three deletes that have to forget a share because nothing cascades.
|
||||
- `docs/notes/search-and-extraction.md` -- extraction limits as a snapshot, why
|
||||
reciprocal rank fusion and not a weight, how a record scores as its best chunk,
|
||||
why vectors from two models never meet, and the session event that notices a
|
||||
|
||||
@@ -9,7 +9,7 @@ reasoning, tool calling with web search, custom HTTP tools and MCP servers,
|
||||
agent chats that work on a machine over SSH, a knowledge library, notes, memory
|
||||
and skills, speech in and out, image generation over ComfyUI, users and groups,
|
||||
model administration, installable as an app, reports, messages, and scheduled
|
||||
work that runs on its own. 2024 tests, `ruff` clean.
|
||||
work that runs on its own. 2062 tests, `ruff` clean.
|
||||
|
||||
What remains before the first stable release is written out below, in phases,
|
||||
under [The road to 1.0.0](#the-road-to-100).
|
||||
@@ -521,15 +521,27 @@ seen working.
|
||||
it finishes
|
||||
|
||||
### Phase 6 — permissions, quotas and sharing (`0.9.6`)
|
||||
- [ ] **"What can this user actually do?"** answered on screen, from the
|
||||
resolution that already computes it
|
||||
- [ ] Membership edited from one side; a searchable, paginated user list
|
||||
- [ ] Reading and writing split within a gate where the difference matters
|
||||
- [ ] **Quotas on a group**, resolved by maximum — the union rule applied to
|
||||
numbers — and enforced where the existing budgets are
|
||||
- [ ] Deleting a group or a user forgets its grants, which it never did
|
||||
- [ ] Sharing as its own action with a search box, a shared-with-me filter, and
|
||||
reports shareable. Sharing stays read-only
|
||||
- [x] **"What can this user actually do?"** answered on screen, and *where each
|
||||
permission came from* — `explain()` is the resolution's working shown
|
||||
rather than thrown away, which is the simulation the union rule exists to
|
||||
make unnecessary
|
||||
- [x] List plus detail for users and groups; membership edited from **one** side,
|
||||
since a full-form POST from either used to overwrite the other's view
|
||||
- [x] Reading and writing split for the three gates where the difference is a
|
||||
real decision — checked on the tool's risk, after the gate, defaulting on
|
||||
- [x] **Quotas on a group**, resolved by maximum with **zero meaning no limit
|
||||
and winning outright**, and enforced at the five places each is knowable:
|
||||
before a reply is built, before a second one starts, on an agent reply's
|
||||
clock, before a minute of GPU, and beside the helper cap
|
||||
- [x] Usage recorded even for a reply that was stopped or failed, because an
|
||||
endpoint charges either way and a quota a Stop button walks past is not one
|
||||
- [x] **Deleting a group or a user forgets its grants, which it never did** —
|
||||
both halves for an account, since their rows cascade and the shares of
|
||||
those rows have nothing to cascade from
|
||||
- [x] Sharing as its own action with a search box — one grant per request, stored
|
||||
the moment it is made rather than when the resource happens to be saved
|
||||
- [x] A "Shared with me" filter in all four listings, reports shareable, and
|
||||
`library.share` on by default. Sharing stays read-only
|
||||
|
||||
### Phase 7 — packaging and updating (`0.9.7`)
|
||||
- [ ] **Docker**, with the data on a volume and a TLS proxy expected in front
|
||||
@@ -625,7 +637,10 @@ Recorded because each looks like an oversight until you know the reason.
|
||||
|
||||
- **No JavaScript build step.** Browser libraries are hash-pinned and committed.
|
||||
A self-hosted tool should work offline and not report page views to a CDN.
|
||||
- **Permissions union, never deny.** With denies, "why can this user not do X"
|
||||
- **Permissions union, never deny**, and quotas resolved by maximum for the same
|
||||
reason -- with the corner that zero means *no limit* and therefore wins, or
|
||||
"unlimited" would count for less than a large number. With denies, "why can
|
||||
this user not do X"
|
||||
cannot be answered without simulating every group.
|
||||
- **System prompts replace, never stack.** Two layers that disagree give the
|
||||
model contradictory instructions and nobody can tell which is losing.
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "0.9.5"
|
||||
__version__ = "0.9.6"
|
||||
|
||||
+138
-12
@@ -10,11 +10,21 @@ from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import ROLE_ADMIN, ROLE_PENDING, ROLE_USER, Group, Model, User
|
||||
from lembas.db.models import (
|
||||
PRINCIPAL_GROUP,
|
||||
PRINCIPAL_USER,
|
||||
ROLE_ADMIN,
|
||||
ROLE_PENDING,
|
||||
ROLE_USER,
|
||||
Group,
|
||||
Model,
|
||||
User,
|
||||
)
|
||||
from lembas.security import permissions
|
||||
from lembas.security.passwords import hash_password, validate_password
|
||||
from lembas.security.sessions import revoke_all_for_user
|
||||
from lembas.services import settings_store
|
||||
from lembas.services import settings_store, sharing
|
||||
from lembas.services import usage as usage_service
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -54,22 +64,71 @@ def _would_orphan_the_instance(db: DBSession, user: User) -> bool:
|
||||
|
||||
|
||||
# --- Users -------------------------------------------------------------------
|
||||
# List plus detail, which is the shape this codebase already mandates for admin
|
||||
# lists and the one `/admin/models` follows. The single page it replaces
|
||||
# rendered a full form per account *and* a membership grid, and edited that
|
||||
# membership from the opposite side to `/admin/groups` -- so a full-form POST
|
||||
# from either overwrote what the other had just shown.
|
||||
#
|
||||
# Membership is now edited from **one** side, the group's. A user's page links
|
||||
# to their groups and does not offer to change them, because two controls
|
||||
# writing one value is how each becomes the answer to "why did my change not
|
||||
# stick?".
|
||||
PAGE_SIZE = 25
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def users_page(request: Request, db: Db, user: AdminUser, q: str = "", saved: str = ""):
|
||||
async def users_page(
|
||||
request: Request, db: Db, user: AdminUser, q: str = "", saved: str = "", page: int = 1
|
||||
):
|
||||
query = select(User).order_by(User.created_at)
|
||||
if q.strip():
|
||||
pattern = f"%{q.strip()}%"
|
||||
query = query.where(or_(User.name.ilike(pattern), User.email.ilike(pattern)))
|
||||
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||
pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page = min(max(1, page), pages)
|
||||
rows = list(db.scalars(query.offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE)))
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/users.html",
|
||||
{
|
||||
"users": list(db.scalars(query)),
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"users": rows,
|
||||
"usage": {row.id: usage_service.summary(db, row) for row in rows},
|
||||
"roles": ROLES,
|
||||
"q": q,
|
||||
"saved": saved,
|
||||
"pager": {"page": page, "pages": pages, "total": total},
|
||||
"admin_count": _admin_count(db),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
async def user_detail(request: Request, db: Db, user: AdminUser, user_id: str, saved: str = ""):
|
||||
"""One account, and the answer to "what can this person actually do?".
|
||||
|
||||
That answer is `permissions.explain`, which is `resolve`'s working shown
|
||||
rather than thrown away. Read-only on purpose: every one of those switches
|
||||
is set somewhere else -- the baseline, or a named group -- and a control here
|
||||
would be a third place to change one thing.
|
||||
"""
|
||||
target = _user(db, user_id)
|
||||
return render(
|
||||
request,
|
||||
"admin/user_detail.html",
|
||||
{
|
||||
"target": target,
|
||||
"roles": ROLES,
|
||||
"explained": permissions.explain(db, target),
|
||||
"permission_groups": permissions.permission_groups(),
|
||||
"limits": permissions.limits_for(db, target),
|
||||
"limit_defs": permissions.LIMIT_DEFS,
|
||||
"usage": usage_service.summary(db, target),
|
||||
"models": permissions.models_visible_to(db, target),
|
||||
"saved": saved,
|
||||
"admin_count": _admin_count(db),
|
||||
},
|
||||
)
|
||||
@@ -114,8 +173,13 @@ async def update_user(
|
||||
name: str = Form(...),
|
||||
role: str = Form(ROLE_USER),
|
||||
active: bool = Form(False),
|
||||
group_ids: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
"""Name, role and whether the account is active. **Not membership.**
|
||||
|
||||
That moved to the group's page. It used to be here as well, and a full-form
|
||||
POST from either side overwrote whatever the other had -- two controls, one
|
||||
value, and no answer to which one wins.
|
||||
"""
|
||||
target = _user(db, user_id)
|
||||
|
||||
losing_admin = target.role == ROLE_ADMIN and (role != ROLE_ADMIN or not active)
|
||||
@@ -128,7 +192,6 @@ async def update_user(
|
||||
target.name = name.strip()[:120] or target.name
|
||||
target.role = role if role in ROLES else target.role
|
||||
target.active = active
|
||||
target.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))
|
||||
|
||||
# A deactivated or demoted user must lose their live sessions immediately,
|
||||
# otherwise the change only takes effect when their cookie happens to expire.
|
||||
@@ -137,7 +200,9 @@ async def update_user(
|
||||
|
||||
db.commit()
|
||||
log.info("%s updated account %s (role=%s active=%s)", user.email, target.email, role, active)
|
||||
return RedirectResponse(f"/admin/users?saved=Saved+{target.email}.", status_code=303)
|
||||
return RedirectResponse(
|
||||
f"/admin/users/{target.id}?saved=Saved+{target.email}.", status_code=303
|
||||
)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/password")
|
||||
@@ -146,7 +211,7 @@ async def reset_password(
|
||||
) -> Response:
|
||||
target = _user(db, user_id)
|
||||
if (problem := validate_password(password)) is not None:
|
||||
return RedirectResponse(f"/admin/users?saved={problem}", status_code=303)
|
||||
return RedirectResponse(f"/admin/users/{user_id}?saved={problem}", status_code=303)
|
||||
|
||||
target.password_hash = hash_password(password)
|
||||
db.commit()
|
||||
@@ -155,7 +220,7 @@ async def reset_password(
|
||||
revoke_all_for_user(db, target)
|
||||
log.info("%s reset the password for %s", user.email, target.email)
|
||||
return RedirectResponse(
|
||||
f"/admin/users?saved=Password+reset+for+{target.email}.+Sessions+revoked.",
|
||||
f"/admin/users/{target.id}?saved=Password+reset.+Sessions+revoked.",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
@@ -175,6 +240,15 @@ async def delete_user(db: Db, user: AdminUser, user_id: str) -> Response:
|
||||
|
||||
email = target.email
|
||||
# Chats and folders cascade; that is the point of deleting an account.
|
||||
#
|
||||
# Shares do not, and never did. `Share.principal_id` and
|
||||
# `Share.resource_id` both point at one of several tables depending on a
|
||||
# sibling column, which SQLite cannot express as a foreign key -- so a
|
||||
# deleted account left behind every grant *to* it and every grant *of* its
|
||||
# own work. Both halves, and both before the delete, while the rows are
|
||||
# still there to be found.
|
||||
sharing.forget_owner(db, target.id)
|
||||
sharing.forget_principal(db, PRINCIPAL_USER, target.id)
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
log.info("%s deleted account %s", user.email, email)
|
||||
@@ -182,17 +256,42 @@ async def delete_user(db: Db, user: AdminUser, user_id: str) -> Response:
|
||||
|
||||
|
||||
# --- Groups ------------------------------------------------------------------
|
||||
# The same list-plus-detail shape. The old page rendered every group's full
|
||||
# permission grid, every member and every model on one screen, which is fine for
|
||||
# two groups and unreadable at ten.
|
||||
@router.get("/groups")
|
||||
async def groups_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
|
||||
groups = list(db.scalars(select(Group).order_by(Group.name)))
|
||||
return render(
|
||||
request,
|
||||
"admin/groups.html",
|
||||
{
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"groups": groups,
|
||||
"granted": {
|
||||
group.id: sum(1 for on in (group.permissions_json or {}).values() if on)
|
||||
for group in groups
|
||||
},
|
||||
"permission_groups": permissions.permission_groups(),
|
||||
"baseline": permissions.baseline_permissions(db),
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}")
|
||||
async def group_detail(request: Request, db: Db, user: AdminUser, group_id: str, saved: str = ""):
|
||||
group = _group(db, group_id)
|
||||
return render(
|
||||
request,
|
||||
"admin/group_detail.html",
|
||||
{
|
||||
"group": group,
|
||||
"users": list(db.scalars(select(User).order_by(User.name))),
|
||||
"models": list(db.scalars(select(Model).order_by(Model.position, Model.model_id))),
|
||||
"permission_groups": permissions.permission_groups(),
|
||||
"baseline": permissions.baseline_permissions(db),
|
||||
"limit_defs": permissions.LIMIT_DEFS,
|
||||
"limits": group.limits_json or {},
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
@@ -216,6 +315,7 @@ async def create_group(db: Db, user: AdminUser, name: str = Form(...)) -> Respon
|
||||
|
||||
@router.post("/groups/{group_id}")
|
||||
async def update_group(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
group_id: str,
|
||||
@@ -226,6 +326,7 @@ async def update_group(
|
||||
model_ids: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
group = _group(db, group_id)
|
||||
form = await request.form()
|
||||
|
||||
group.name = name.strip()[:120] or group.name
|
||||
group.description = description.strip()[:1000]
|
||||
@@ -235,9 +336,26 @@ async def update_group(
|
||||
group.users = list(db.scalars(select(User).where(User.id.in_(user_ids or []))))
|
||||
group.models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or []))))
|
||||
|
||||
# Quotas. Only what was submitted and could be read as a number is stored, so
|
||||
# a blank box means "this group has no opinion" and contributes nothing to
|
||||
# the resolution -- which is what `limits_for` needs in order to tell it
|
||||
# apart from a deliberate zero, and zero here means *no limit*.
|
||||
wanted: dict[str, int] = {}
|
||||
for key in permissions.LIMIT_KEYS:
|
||||
raw = str(form.get(f"limit_{key}") or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
wanted[key] = max(0, int(raw))
|
||||
except ValueError:
|
||||
continue
|
||||
group.limits_json = wanted
|
||||
|
||||
db.commit()
|
||||
log.info("%s updated group %s", user.email, group.name)
|
||||
return RedirectResponse(f"/admin/groups?saved=Saved+{group.name}.", status_code=303)
|
||||
return RedirectResponse(
|
||||
f"/admin/groups/{group.id}?saved=Saved+{group.name}.", status_code=303
|
||||
)
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/delete")
|
||||
@@ -245,8 +363,16 @@ async def delete_group(db: Db, user: AdminUser, group_id: str) -> Response:
|
||||
group = _group(db, group_id)
|
||||
name = group.name
|
||||
# Members and model links go with it; the users themselves are untouched.
|
||||
#
|
||||
# Every share naming this group goes too. Nothing cascades -- see
|
||||
# `sharing.forget_principal` -- so a deleted group left its grants behind,
|
||||
# and a group id is a random hex string that nothing reissues today and
|
||||
# nothing promises not to reissue tomorrow.
|
||||
dropped = sharing.forget_principal(db, PRINCIPAL_GROUP, group.id)
|
||||
db.delete(group)
|
||||
db.commit()
|
||||
if dropped:
|
||||
log.info("dropped %d share(s) naming group %s", dropped, name)
|
||||
log.info("%s deleted group %s", user.email, name)
|
||||
return RedirectResponse(f"/admin/groups?saved=Deleted+{name}.", status_code=303)
|
||||
|
||||
|
||||
@@ -975,6 +975,36 @@ def _note_rewind(chat: Chat) -> None:
|
||||
chat.rewound_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def _too_many_replies(db: DBSession, chat: Chat, user: User) -> str:
|
||||
"""Why this account may not start another reply right now, or "".
|
||||
|
||||
In-process, and that is exact rather than approximate only because this
|
||||
application runs one worker -- see the first known limit in PLAN.md. With
|
||||
several, this becomes a guess, and a quota that is a guess should be a
|
||||
number in the database instead. Stated here rather than discovered.
|
||||
"""
|
||||
from lembas.security import permissions
|
||||
|
||||
ceiling = permissions.limit(db, user, "concurrent_replies")
|
||||
if ceiling <= 0:
|
||||
return ""
|
||||
mine = {
|
||||
row[0]
|
||||
for row in db.execute(select(Chat.id).where(Chat.user_id == user.id)).all()
|
||||
}
|
||||
running = sum(
|
||||
1
|
||||
for chat_id in mine
|
||||
if chat_id != chat.id and generation_service.running_for(chat_id) is not None
|
||||
)
|
||||
if running < ceiling:
|
||||
return ""
|
||||
return (
|
||||
f"You already have {running} repl{'y' if running == 1 else 'ies'} being "
|
||||
f"written, which is this account's limit. Wait for one to finish."
|
||||
)
|
||||
|
||||
|
||||
def _send(
|
||||
request: Request,
|
||||
db: Db,
|
||||
@@ -997,6 +1027,18 @@ def _send(
|
||||
prefixes of it, with Stop pointing at whichever bubble came first in the
|
||||
document.
|
||||
"""
|
||||
# How many of *this account's* chats are already writing. Checked here and
|
||||
# not inside `generation`, because this is where there is somebody to tell:
|
||||
# a schedule firing or a finished job waking a chat has nobody at the
|
||||
# keyboard, and refusing those would be a quota silently eating work an
|
||||
# administrator set up on purpose.
|
||||
#
|
||||
# This chat's own reply does not count against it -- a second message here
|
||||
# is queued rather than sent, a few lines down, and that path is what the
|
||||
# queue is for.
|
||||
if busy := _too_many_replies(db, chat, user):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, busy)
|
||||
|
||||
if queued := _reply_in_flight(db, chat):
|
||||
waiting = db.scalar(
|
||||
select(func.count())
|
||||
|
||||
+73
-37
@@ -23,10 +23,7 @@ from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.api.pages import sidebar_context
|
||||
from lembas.db.models import (
|
||||
AUTHOR_USER,
|
||||
PRINCIPAL_GROUP,
|
||||
PRINCIPAL_USER,
|
||||
Document,
|
||||
Group,
|
||||
KnowledgeBase,
|
||||
Note,
|
||||
Skill,
|
||||
@@ -61,32 +58,21 @@ def _page(db: DBSession, query, page: int):
|
||||
return rows, {"page": page, "pages": pages, "total": total}
|
||||
|
||||
|
||||
def _shared_context(db: DBSession, user: User, resource) -> dict:
|
||||
"""Everything the share panel on a detail page needs."""
|
||||
grants = sharing.grants_for(db, resource)
|
||||
def _shared_context(db: DBSession, user: User, resource, kind: str) -> dict:
|
||||
"""What the share placeholder needs, which is now three facts.
|
||||
|
||||
The panel itself is fetched from `api/sharing.py`, so the names, the search
|
||||
and the grants are no longer built here -- and neither is a query for every
|
||||
account on the instance on every detail page.
|
||||
"""
|
||||
return {
|
||||
"can_share": permissions.has(db, user, "library.share"),
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"people": list(
|
||||
db.scalars(select(User).where(User.id != user.id).order_by(User.name))
|
||||
),
|
||||
"shared_users": [g.principal_id for g in grants if g.principal_type == PRINCIPAL_USER],
|
||||
"shared_groups": [g.principal_id for g in grants if g.principal_type == PRINCIPAL_GROUP],
|
||||
"is_owner": resource.owner_id == user.id,
|
||||
"share_kind": kind,
|
||||
"share_id": resource.id,
|
||||
}
|
||||
|
||||
|
||||
def _apply_shares(db: DBSession, user: User, resource, form) -> None:
|
||||
if not permissions.has(db, user, "library.share") or resource.owner_id != user.id:
|
||||
return
|
||||
sharing.set_grants(
|
||||
db,
|
||||
resource,
|
||||
user_ids=form.getlist("share_user"),
|
||||
group_ids=form.getlist("share_group"),
|
||||
)
|
||||
|
||||
|
||||
# --- Shell -------------------------------------------------------------------
|
||||
@router.get("/library")
|
||||
async def library_home(user: RequiredUser):
|
||||
@@ -98,9 +84,21 @@ async def library_home(user: RequiredUser):
|
||||
# before /library/knowledge/{base_id}, or "document" is parsed as a base id.
|
||||
# FastAPI matches in registration order and this has bitten before.
|
||||
@router.get("/library/knowledge")
|
||||
async def knowledge_list(request: Request, db: Db, user: RequiredUser, error: str = ""):
|
||||
"""The bases, not the documents. A library is a set of places first."""
|
||||
bases = list(db.scalars(documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)))
|
||||
async def knowledge_list(
|
||||
request: Request, db: Db, user: RequiredUser, error: str = "", shared: bool = False
|
||||
):
|
||||
"""The bases, not the documents. A library is a set of places first.
|
||||
|
||||
`shared=1` narrows to bases other people have given this reader — the same
|
||||
filter the notes and skills lists carry, and the one that makes "what have
|
||||
people shared with me?" a question with an answer.
|
||||
"""
|
||||
query = (
|
||||
select(KnowledgeBase).where(sharing.only_shared(KnowledgeBase, user))
|
||||
if shared
|
||||
else documents_service.visible_bases(db, user)
|
||||
)
|
||||
bases = list(db.scalars(query.order_by(KnowledgeBase.name)))
|
||||
counts = {
|
||||
base.id: db.scalar(
|
||||
select(func.count()).select_from(Document).where(Document.base_id == base.id)
|
||||
@@ -115,6 +113,7 @@ async def knowledge_list(request: Request, db: Db, user: RequiredUser, error: st
|
||||
"section": "knowledge",
|
||||
"bases": bases,
|
||||
"counts": counts,
|
||||
"shared": shared,
|
||||
"error": error,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
@@ -200,7 +199,7 @@ async def base_detail(
|
||||
"documents": rows,
|
||||
"q": q,
|
||||
"pager": pager,
|
||||
**_shared_context(db, user, base),
|
||||
**_shared_context(db, user, base, "base"),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -220,7 +219,6 @@ async def update_base(request: Request, db: Db, user: RequiredUser, base_id: str
|
||||
base.name = name
|
||||
base.description = str(form.get("description", "")).strip()[:2000]
|
||||
db.commit()
|
||||
_apply_shares(db, user, base, form)
|
||||
return RedirectResponse(
|
||||
f"/library/knowledge/{base.id}", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
@@ -345,16 +343,34 @@ async def document_content(db: Db, user: RequiredUser, document_id: str) -> Resp
|
||||
|
||||
# --- Notes -------------------------------------------------------------------
|
||||
@router.get("/library/notes")
|
||||
async def notes_list(request: Request, db: Db, user: RequiredUser, q: str = "", page: int = 1):
|
||||
async def notes_list(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
q: str = "",
|
||||
page: int = 1,
|
||||
shared: bool = False,
|
||||
):
|
||||
"""`shared=1` narrows to what other people have given this reader.
|
||||
|
||||
A separate view rather than a badge in the mixed list. A badge answers "is
|
||||
this mine?" for a row already on screen; the question somebody has is "what
|
||||
have people given me?", which a mixed list of two hundred cannot answer.
|
||||
Searching inside it is deliberately left out -- the search path returns
|
||||
ranked ids and re-filtering them by owner would silently shorten the page.
|
||||
"""
|
||||
if q.strip():
|
||||
rows = notes_service.search(
|
||||
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q)
|
||||
)
|
||||
pager = {"page": 1, "pages": 1, "total": len(rows)}
|
||||
else:
|
||||
rows, pager = _page(
|
||||
db, notes_service.visible(db, user).order_by(Note.updated_at.desc()), page
|
||||
query = (
|
||||
select(Note).where(sharing.only_shared(Note, user))
|
||||
if shared
|
||||
else notes_service.visible(db, user)
|
||||
)
|
||||
rows, pager = _page(db, query.order_by(Note.updated_at.desc()), page)
|
||||
return render(
|
||||
request,
|
||||
"library/notes.html",
|
||||
@@ -362,6 +378,7 @@ async def notes_list(request: Request, db: Db, user: RequiredUser, q: str = "",
|
||||
"section": "notes",
|
||||
"notes": rows,
|
||||
"q": q,
|
||||
"shared": shared,
|
||||
"pager": pager,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
@@ -389,7 +406,7 @@ async def note_detail(request: Request, db: Db, user: RequiredUser, note_id: str
|
||||
"section": "notes",
|
||||
"note": note,
|
||||
"body_html": render_markdown(note.body),
|
||||
**_shared_context(db, user, note),
|
||||
**_shared_context(db, user, note, "note"),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -413,7 +430,6 @@ async def update_note(request: Request, db: Db, user: RequiredUser, note_id: str
|
||||
|
||||
form = await request.form()
|
||||
notes_service.update(db, note, title=str(form.get("title", "")), body=str(form.get("body", "")))
|
||||
_apply_shares(db, user, note, form)
|
||||
return RedirectResponse(f"/library/notes/{note.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@@ -428,14 +444,34 @@ async def delete_note(db: Db, user: RequiredUser, note_id: str) -> Response:
|
||||
|
||||
# --- Skills ------------------------------------------------------------------
|
||||
@router.get("/library/skills")
|
||||
async def skills_list(request: Request, db: Db, user: RequiredUser, q: str = "", page: int = 1):
|
||||
async def skills_list(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
q: str = "",
|
||||
page: int = 1,
|
||||
shared: bool = False,
|
||||
):
|
||||
"""`shared=1` narrows to what other people have given this reader.
|
||||
|
||||
A separate view rather than a badge in the mixed list. A badge answers "is
|
||||
this mine?" for a row already on screen; the question somebody has is "what
|
||||
have people given me?", which a mixed list of two hundred cannot answer.
|
||||
Searching inside it is deliberately left out -- the search path returns
|
||||
ranked ids and re-filtering them by owner would silently shorten the page.
|
||||
"""
|
||||
if q.strip():
|
||||
rows = skills_service.search(
|
||||
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q)
|
||||
)
|
||||
pager = {"page": 1, "pages": 1, "total": len(rows)}
|
||||
else:
|
||||
rows, pager = _page(db, skills_service.visible(db, user).order_by(Skill.name), page)
|
||||
query = (
|
||||
select(Skill).where(sharing.only_shared(Skill, user))
|
||||
if shared
|
||||
else skills_service.visible(db, user)
|
||||
)
|
||||
rows, pager = _page(db, query.order_by(Skill.name), page)
|
||||
return render(
|
||||
request,
|
||||
"library/skills.html",
|
||||
@@ -443,6 +479,7 @@ async def skills_list(request: Request, db: Db, user: RequiredUser, q: str = "",
|
||||
"section": "skills",
|
||||
"skills": rows,
|
||||
"q": q,
|
||||
"shared": shared,
|
||||
"pager": pager,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
@@ -470,7 +507,7 @@ async def skill_detail(request: Request, db: Db, user: RequiredUser, skill_id: s
|
||||
"section": "skills",
|
||||
"skill": skill,
|
||||
"revisions": skill.revisions,
|
||||
**_shared_context(db, user, skill),
|
||||
**_shared_context(db, user, skill, "skill"),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -511,7 +548,6 @@ async def update_skill(request: Request, db: Db, user: RequiredUser, skill_id: s
|
||||
author=AUTHOR_USER,
|
||||
note="edited by hand",
|
||||
)
|
||||
_apply_shares(db, user, skill, form)
|
||||
return RedirectResponse(f"/library/skills/{skill.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
|
||||
@@ -18,12 +18,15 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.api.library import PAGE_SIZE, _page
|
||||
from lembas.api.pages import sidebar_context
|
||||
from lembas.db.models import Report
|
||||
from lembas.security import permissions
|
||||
from lembas.services import reports as reports_service
|
||||
from lembas.services import sharing
|
||||
from lembas.services.library import retrieval
|
||||
from lembas.services.markdown import render_markdown
|
||||
from lembas.web.templating import render
|
||||
@@ -34,7 +37,20 @@ router = APIRouter(dependencies=[Depends(require_permission("reports.use"))], ta
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
async def reports_list(request: Request, db: Db, user: RequiredUser, q: str = "", page: int = 1):
|
||||
async def reports_list(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
q: str = "",
|
||||
page: int = 1,
|
||||
shared: bool = False,
|
||||
):
|
||||
"""`shared=1` narrows to reports other people have shared with this reader.
|
||||
|
||||
Reports became shareable at the same time as this filter appeared, and the
|
||||
two arrived together on purpose: a feed that quietly grew somebody else's
|
||||
work with no way to see only theirs is worse than one that never grew.
|
||||
"""
|
||||
if q.strip():
|
||||
rows = reports_service.search(
|
||||
db, user, q, limit=PAGE_SIZE, vector=await retrieval.embed_query(db, q)
|
||||
@@ -42,7 +58,13 @@ async def reports_list(request: Request, db: Db, user: RequiredUser, q: str = ""
|
||||
pager = {"page": 1, "pages": 1, "total": len(rows)}
|
||||
else:
|
||||
rows, pager = _page(
|
||||
db, reports_service.visible(user).order_by(Report.created_at.desc()), page
|
||||
db,
|
||||
(
|
||||
select(Report).where(sharing.only_shared(Report, user))
|
||||
if shared
|
||||
else reports_service.visible(user)
|
||||
).order_by(Report.created_at.desc()),
|
||||
page,
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
@@ -51,6 +73,7 @@ async def reports_list(request: Request, db: Db, user: RequiredUser, q: str = ""
|
||||
"section": "reports",
|
||||
"reports": rows,
|
||||
"q": q,
|
||||
"shared": shared,
|
||||
"pager": pager,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
@@ -65,6 +88,11 @@ async def report_detail(request: Request, db: Db, user: RequiredUser, report_id:
|
||||
# Opening one is what reading it means. Done before rendering so the dot on
|
||||
# the way in and the dot on the way back to the list agree -- the poller
|
||||
# would otherwise re-announce a report the reader is looking at.
|
||||
#
|
||||
# Only the owner's own reading counts. `unread` is the owner's dot, and
|
||||
# somebody a report was shared with opening it would otherwise clear a
|
||||
# notification meant for a person who has not seen it.
|
||||
if report.owner_id == user.id:
|
||||
reports_service.mark_read(db, report)
|
||||
return render(
|
||||
request,
|
||||
@@ -74,6 +102,10 @@ async def report_detail(request: Request, db: Db, user: RequiredUser, report_id:
|
||||
"report": report,
|
||||
# Model output, through the one path allowed to emit HTML.
|
||||
"body_html": render_markdown(report.body),
|
||||
"can_share": permissions.has(db, user, "library.share"),
|
||||
"is_owner": report.owner_id == user.id,
|
||||
"share_kind": "report",
|
||||
"share_id": report.id,
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -81,7 +113,9 @@ async def report_detail(request: Request, db: Db, user: RequiredUser, report_id:
|
||||
|
||||
@router.post("/api/reports/{report_id}/delete")
|
||||
async def delete_report(db: Db, user: RequiredUser, report_id: str) -> Response:
|
||||
report = reports_service.get(db, report_id, user)
|
||||
# `owned`, not `get`: sharing grants reading, so being able to see a report
|
||||
# is not being able to delete it out from under the person who filed it.
|
||||
report = reports_service.owned(db, report_id, user)
|
||||
if report is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That report is not available.")
|
||||
reports_service.delete(db, report)
|
||||
|
||||
@@ -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)
|
||||
@@ -48,6 +48,7 @@ from lembas.db.models.library import (
|
||||
PRINCIPAL_USER,
|
||||
RESOURCE_BASE,
|
||||
RESOURCE_NOTE,
|
||||
RESOURCE_REPORT,
|
||||
RESOURCE_SKILL,
|
||||
SOURCE_LINK,
|
||||
SOURCE_UPLOAD,
|
||||
@@ -101,6 +102,7 @@ from lembas.db.models.user import (
|
||||
Group,
|
||||
PushSubscription,
|
||||
Session,
|
||||
Usage,
|
||||
User,
|
||||
user_groups,
|
||||
)
|
||||
@@ -108,6 +110,7 @@ from lembas.db.models.user import (
|
||||
__all__ = [
|
||||
"AUTHOR_MODEL",
|
||||
"PushSubscription",
|
||||
"Usage",
|
||||
"AUTH_KEY",
|
||||
"AUTH_METHODS",
|
||||
"AUTH_PASSWORD",
|
||||
@@ -126,6 +129,7 @@ __all__ = [
|
||||
"PRINCIPAL_USER",
|
||||
"RESOURCE_BASE",
|
||||
"RESOURCE_NOTE",
|
||||
"RESOURCE_REPORT",
|
||||
"RESOURCE_SKILL",
|
||||
"RESPONSE_JSON",
|
||||
"RESPONSE_MODES",
|
||||
|
||||
@@ -53,6 +53,13 @@ SOURCE_LINK = "link"
|
||||
RESOURCE_BASE = "base"
|
||||
RESOURCE_NOTE = "note"
|
||||
RESOURCE_SKILL = "skill"
|
||||
# A report is shareable and a memory is not, and the line between them is the
|
||||
# one already drawn elsewhere: a finished piece of work is exactly the thing
|
||||
# somebody wants to hand over, and a record *about a person* is not content to
|
||||
# pass round. The constant lives here beside the other three even though Report
|
||||
# is not a library model, because `Share.resource_type` is one column and its
|
||||
# vocabulary belongs in one place.
|
||||
RESOURCE_REPORT = "report"
|
||||
|
||||
PRINCIPAL_USER = "user"
|
||||
PRINCIPAL_GROUP = "group"
|
||||
|
||||
@@ -5,7 +5,18 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String, Table, Text
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Table,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
@@ -69,6 +80,16 @@ class Group(UUIDPrimaryKey, Timestamps, Base):
|
||||
# lembas.security.permissions.
|
||||
permissions_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
# What members of this group may spend. Resolved across a user's groups by
|
||||
# **maximum**, which is the union rule applied to numbers: being in a second
|
||||
# group can only ever grant more. Zero means "no limit" and therefore wins
|
||||
# outright, because a group that says "unlimited" saying less than one that
|
||||
# says "a million" would be the union rule inverted for one value.
|
||||
#
|
||||
# Absent keys mean the group has no opinion and contribute nothing. See
|
||||
# security/permissions.py:limits_for.
|
||||
limits_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
users: Mapped[list[User]] = relationship(secondary=user_groups, back_populates="groups")
|
||||
models: Mapped[list[Model]] = relationship(
|
||||
"Model", secondary="model_groups", back_populates="groups"
|
||||
@@ -149,3 +170,43 @@ class PushSubscription(UUIDPrimaryKey, Timestamps, Base):
|
||||
|
||||
|
||||
Index("ix_push_subscriptions_user_id", PushSubscription.user_id)
|
||||
|
||||
|
||||
class Usage(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""What one account spent in one period.
|
||||
|
||||
A row per user per period rather than a row per reply. A per-reply ledger is
|
||||
what somebody eventually wants for a bill; this exists to answer one
|
||||
question on the request path -- "has this account used its month?" -- and
|
||||
that question wants one indexed lookup, not a sum over ten thousand rows.
|
||||
|
||||
`period` is a plain "YYYY-MM" string in **UTC**. Not the reader's timezone:
|
||||
a quota that resets at a different instant for each member of a group is a
|
||||
quota nobody can reason about, and the month boundary is not something
|
||||
anybody experiences to the hour.
|
||||
|
||||
Written by `generation._persist`, which is the single writer for everything
|
||||
a reply produced, so a reply that is stopped or errors still records what it
|
||||
spent -- an endpoint charges for tokens it generated whether or not the
|
||||
reply was wanted.
|
||||
"""
|
||||
|
||||
__tablename__ = "usage"
|
||||
__table_args__ = (UniqueConstraint("user_id", "period", name="uq_usage_user_period"),)
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
period: Mapped[str] = mapped_column(String(7), nullable=False)
|
||||
|
||||
prompt_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
completion_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
replies: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
# Counted separately because it is its own quota: one picture is a minute of
|
||||
# somebody's GPU and no tokens at all, so a token budget says nothing about
|
||||
# it. `images_today` on the resolved limits is the daily half; this is the
|
||||
# month's running total, for the admin screen.
|
||||
images: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Usage {self.user_id} {self.period}>"
|
||||
|
||||
@@ -41,6 +41,7 @@ from lembas.api import (
|
||||
push,
|
||||
reports,
|
||||
schedules,
|
||||
sharing,
|
||||
terminal,
|
||||
)
|
||||
from lembas.api.deps import RedirectToLogin, is_htmx, login_redirect
|
||||
@@ -194,6 +195,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(reports.router)
|
||||
app.include_router(schedules.router)
|
||||
app.include_router(agents.router)
|
||||
app.include_router(sharing.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(admin_users.router)
|
||||
app.include_router(admin_models.router)
|
||||
|
||||
@@ -225,9 +225,15 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
||||
PermissionDef(
|
||||
"library.share",
|
||||
"Share library items",
|
||||
"Give other people, or a group, access to their documents, notes and "
|
||||
"skills. Sharing grants reading only.",
|
||||
False,
|
||||
"Give other people, or a group, access to their knowledge bases, notes, "
|
||||
"skills and reports. Sharing grants reading only — never changing, and "
|
||||
"never sharing on.",
|
||||
# On. It was off, which meant sharing shipped documented as done and
|
||||
# unreachable: the panel is only rendered for somebody who holds this,
|
||||
# so out of the box nobody could share anything and nothing said why.
|
||||
# An instance that wants it off can say so; one that never looked should
|
||||
# get the feature it was told it had.
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
PermissionDef(
|
||||
@@ -260,8 +266,53 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
# --- Reading and writing, split where the difference matters ------------
|
||||
# Three gates cover both, and for these three the two halves are genuinely
|
||||
# different decisions: a model that may *read* somebody's notes and not add
|
||||
# to them is a reasonable thing to want, and until now `tools.notes` was one
|
||||
# switch over five tools.
|
||||
#
|
||||
# Not split for every gate. `tools.web_search` has no write half; `report`
|
||||
# is a write with no read worth withholding; `agent` has modes, which are a
|
||||
# finer instrument than a permission and are per chat. A permission that
|
||||
# answers "the same as that one" is a permission nobody should be asked
|
||||
# about -- the reasoning `schedule.use` already carries.
|
||||
#
|
||||
# **All three default on**, so an instance that never looks behaves exactly
|
||||
# as it did: `_family_allowed` reads them only to *narrow* what the gate
|
||||
# already allowed.
|
||||
PermissionDef(
|
||||
"tools.notes.write",
|
||||
"Write notes",
|
||||
"Let a model create, change and delete notes. Without it, it can still "
|
||||
"search and read the ones that are there.",
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.memory.write",
|
||||
"Record memories",
|
||||
"Let a model add and forget short facts about this person. Without it, "
|
||||
"the memories it already has are still shown to it every turn.",
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.skills.write",
|
||||
"Write skills",
|
||||
"Let a model write new skills and change existing ones. Without it, it "
|
||||
"follows the skills that are there and cannot add to them — which is "
|
||||
"the setting for an instance whose skills are curated by hand.",
|
||||
True,
|
||||
"Library",
|
||||
),
|
||||
)
|
||||
|
||||
# Gates whose read and write halves are separate permissions. Keyed on the gate,
|
||||
# with the permission derived as `tools.<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)
|
||||
DEFAULT_PERMISSIONS = {d.key: d.default for d in PERMISSION_DEFS}
|
||||
|
||||
@@ -304,6 +355,128 @@ def has(db: DBSession, user: User | None, key: str) -> bool:
|
||||
return resolve(db, user).get(key, False)
|
||||
|
||||
|
||||
def explain(db: DBSession, user: User | None) -> dict[str, dict]:
|
||||
"""Every permission, whether this user has it, and **where it came from**.
|
||||
|
||||
The question the admin screens could not answer. `resolve` has always
|
||||
computed the union and thrown the working away, so "why can this person do
|
||||
X?" meant opening every group they belong to and reading the grids by eye --
|
||||
which is exactly the simulation the union rule exists to avoid needing.
|
||||
|
||||
`source` is "admin" (bypassing everything), "baseline", or the names of the
|
||||
groups that granted it. A permission that is off has no source, because
|
||||
nothing granted it -- there is no such thing as a deny here to point at.
|
||||
"""
|
||||
keys = PERMISSION_KEYS
|
||||
if user is None:
|
||||
return {key: {"on": False, "source": []} for key in keys}
|
||||
if user.is_admin:
|
||||
return {key: {"on": True, "source": ["admin"]} for key in keys}
|
||||
|
||||
baseline = baseline_permissions(db)
|
||||
out: dict[str, dict] = {}
|
||||
for key in keys:
|
||||
sources = ["baseline"] if baseline.get(key) else []
|
||||
sources += [
|
||||
group.name for group in user.groups if (group.permissions_json or {}).get(key)
|
||||
]
|
||||
out[key] = {"on": bool(sources), "source": sources}
|
||||
return out
|
||||
|
||||
|
||||
# --- Quotas -------------------------------------------------------------------
|
||||
# What a group may raise, and what each number means. Every one of them is
|
||||
# **zero for no limit**, which is the convention `max_completion_tokens` and
|
||||
# `index_chars` already use here, and it is what makes "unlimited" sayable at all.
|
||||
#
|
||||
# Five axes rather than one, because they fail differently and a single "budget"
|
||||
# would have to pick an exchange rate between a token and a minute of somebody's
|
||||
# GPU. There isn't one.
|
||||
LIMIT_DEFS: tuple[tuple[str, str, str], ...] = (
|
||||
(
|
||||
"monthly_tokens",
|
||||
"Tokens a month",
|
||||
"Prompt and completion together, across every chat, reset on the first "
|
||||
"of the month. Reached, a reply says so before it spends anything "
|
||||
"rather than stopping half way through.",
|
||||
),
|
||||
(
|
||||
"concurrent_replies",
|
||||
"Replies at once",
|
||||
"How many of their chats may be writing at the same time. This is the "
|
||||
"one that stops one person queueing every other person's work behind "
|
||||
"them on a single endpoint.",
|
||||
),
|
||||
(
|
||||
"agent_seconds",
|
||||
"Longest agent reply",
|
||||
"Seconds of wall clock for one reply in an agent chat, if lower than "
|
||||
"the instance's own. Waiting for somebody to approve something does "
|
||||
"not count.",
|
||||
),
|
||||
(
|
||||
"images_per_day",
|
||||
"Images a day",
|
||||
"Each one is a minute of somebody's GPU and no tokens at all, so a "
|
||||
"token budget says nothing about it.",
|
||||
),
|
||||
(
|
||||
"helpers_per_reply",
|
||||
"Helpers per reply",
|
||||
"How many subagents one reply may send, if lower than the instance's "
|
||||
"own.",
|
||||
),
|
||||
)
|
||||
|
||||
LIMIT_KEYS = tuple(key for key, _, _ in LIMIT_DEFS)
|
||||
|
||||
# Nobody is limited until somebody says so. A quota that arrived with an upgrade
|
||||
# and started refusing replies would be the worst possible way to introduce one.
|
||||
NO_LIMITS: dict[str, int] = dict.fromkeys(LIMIT_KEYS, 0)
|
||||
|
||||
|
||||
def limits_for(db: DBSession, user: User | None) -> dict[str, int]:
|
||||
"""What this user may spend, resolved across their groups.
|
||||
|
||||
**By maximum**, which is the union rule applied to numbers: being in a second
|
||||
group can only ever grant more, never less. That is the same promise the
|
||||
permissions make, and having one of the two work the other way round is how
|
||||
"why can this person not do X" stops being answerable.
|
||||
|
||||
**Zero wins outright**, because zero means "no limit". Taking the plain
|
||||
maximum would make a group saying "unlimited" count for less than one saying
|
||||
"a million", which is the union rule inverted for exactly one value -- and it
|
||||
is the value somebody sets when they mean *stop limiting this person*.
|
||||
|
||||
An administrator is unlimited, for the reason `resolve` gives them every
|
||||
permission: they can raise their own quota in two clicks, and pretending
|
||||
otherwise is theatre.
|
||||
"""
|
||||
if user is None or user.is_admin:
|
||||
return dict(NO_LIMITS)
|
||||
|
||||
resolved = dict(NO_LIMITS)
|
||||
for key in LIMIT_KEYS:
|
||||
values = []
|
||||
for group in user.groups:
|
||||
raw = (group.limits_json or {}).get(key)
|
||||
if raw is None:
|
||||
continue # no opinion, contributes nothing
|
||||
try:
|
||||
values.append(max(0, int(raw)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not values or 0 in values:
|
||||
resolved[key] = 0
|
||||
else:
|
||||
resolved[key] = max(values)
|
||||
return resolved
|
||||
|
||||
|
||||
def limit(db: DBSession, user: User | None, key: str) -> int:
|
||||
return limits_for(db, user).get(key, 0)
|
||||
|
||||
|
||||
def models_visible_to(db: DBSession, user: User | None) -> list[Model]:
|
||||
"""Models a user may start a chat with, in display order.
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import KIND_AGENT, ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.security import permissions
|
||||
from lembas.services import canvas as canvas_service
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import compaction as compaction_service
|
||||
@@ -36,6 +37,7 @@ from lembas.services import metrics as metrics_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import push as push_service
|
||||
from lembas.services import tools as tools_service
|
||||
from lembas.services import usage as usage_service
|
||||
from lembas.services.agent import policy as agent_policy
|
||||
from lembas.services.agent import session as agent_session
|
||||
from lembas.services.agent import tools as agent_tools
|
||||
@@ -437,6 +439,21 @@ async def shutdown() -> None:
|
||||
await task
|
||||
|
||||
|
||||
def _narrower(instance: float, quota: int) -> float:
|
||||
"""The tighter of two ceilings, where **zero means no limit**.
|
||||
|
||||
Not `min`: a zero on either side would win and turn "no opinion" into "no
|
||||
time at all". Written once and used wherever a group's number meets the
|
||||
instance's, because getting it wrong in one of those places is a limit that
|
||||
silently stops working.
|
||||
"""
|
||||
if instance <= 0:
|
||||
return float(quota)
|
||||
if quota <= 0:
|
||||
return float(instance)
|
||||
return float(min(instance, quota))
|
||||
|
||||
|
||||
async def _run(generation: Generation) -> None:
|
||||
"""Produce one reply, then persist it. Never raises into the task.
|
||||
|
||||
@@ -483,6 +500,16 @@ async def _run(generation: Generation) -> None:
|
||||
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
|
||||
owner = db.get(User, chat.user_id)
|
||||
|
||||
# Before the request is built, not while it streams. Every other
|
||||
# budget here can only be noticed part way through and so ends with
|
||||
# `_wrap_up` asking for a final answer; this one is knowable in
|
||||
# advance, and a reply that trails off because a month ran out mid
|
||||
# sentence would be the failure `_wrap_up` exists to prevent.
|
||||
over = usage_service.over_token_budget(db, owner)
|
||||
if over:
|
||||
generation.error = over
|
||||
return
|
||||
|
||||
# Read while the session is open: everything below outlives it.
|
||||
# Resolved once, so that what the loop is allowed to *run* is the
|
||||
# same set the endpoint was *offered* -- not whatever happens to
|
||||
@@ -520,6 +547,10 @@ async def _run(generation: Generation) -> None:
|
||||
# vision model, a plain string to anything else, or the endpoint
|
||||
# rejects the whole request.
|
||||
vision = chat_service.model_supports(db, chat, "vision")
|
||||
# Resolved while the session is open, like everything else here.
|
||||
# Empty for an admin and for a user in no group, which is every
|
||||
# instance that has not set one -- see permissions.limits_for.
|
||||
quota = permissions.limits_for(db, owner)
|
||||
chat_rounds = settings_store.chat_rounds(db)
|
||||
# A helper's chat is bounded by its own number, not the instance's.
|
||||
# Only reached in an *ordinary* helper chat -- an agent one is sized
|
||||
@@ -532,6 +563,15 @@ async def _run(generation: Generation) -> None:
|
||||
nudge_enabled = bool(settings_store.agents(db).get("nudge_unfinished"))
|
||||
|
||||
limits = tool_context.agent.limits if tool_context.agent else None
|
||||
# A group's ceiling narrows the instance's, never widens it. `min` of
|
||||
# two numbers where zero means "no limit" cannot be written as `min`:
|
||||
# the zero would win and turn a group with no opinion into an unlimited
|
||||
# one, so the two are folded by `_narrower`.
|
||||
if limits is not None and quota.get("agent_seconds"):
|
||||
limits = replace(
|
||||
limits,
|
||||
wall_seconds=_narrower(limits.wall_seconds, quota["agent_seconds"]),
|
||||
)
|
||||
# A ceiling, not a schedule -- the loop below ends the moment a round
|
||||
# produces no tool calls, which is the model saying it is done. Zero
|
||||
# means an ordinary chat has no ceiling either; `steps` is already a
|
||||
@@ -2083,6 +2123,23 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None:
|
||||
message.stopped = generation.stopped
|
||||
message.complete = True
|
||||
|
||||
# What this reply cost, against the account's month. Here because
|
||||
# this is the single writer and it runs for a reply that finished, a
|
||||
# reply that was stopped and a reply that errored alike -- an
|
||||
# endpoint charges for tokens it generated whether or not anybody
|
||||
# wanted them, and a quota that only counted happy paths is one a
|
||||
# Stop button can walk past. `metrics.from_generation` is the one
|
||||
# place the three figures are worked out, so this is the same
|
||||
# arithmetic the bubble shows.
|
||||
spent = metrics_service.from_generation(generation)
|
||||
usage_service.record(
|
||||
db,
|
||||
chat.user_id,
|
||||
prompt_tokens=spent.prompt_tokens,
|
||||
completion_tokens=spent.completion_tokens,
|
||||
images=len(generation.attachment_ids),
|
||||
)
|
||||
|
||||
if title and not chat.title_generated:
|
||||
chat.title = title
|
||||
chat.title_generated = True
|
||||
|
||||
@@ -403,6 +403,23 @@ async def _review(
|
||||
|
||||
|
||||
# --- The runner ----------------------------------------------------------------
|
||||
def _over_quota(context: ToolContext) -> str:
|
||||
"""Why this account may not draw another picture today, or "".
|
||||
|
||||
Its own session, opened and closed before anything else: this runs before a
|
||||
request that takes a minute, and holding a session across one is the trade
|
||||
every long call in this codebase already refuses.
|
||||
"""
|
||||
from lembas.db.models import User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import usage as usage_service
|
||||
|
||||
if not context.owner_id:
|
||||
return ""
|
||||
with session_scope() as db:
|
||||
return usage_service.over_image_budget(db, db.get(User, context.owner_id))
|
||||
|
||||
|
||||
async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
"""Generate one image, review it if there is anybody to ask, and keep one."""
|
||||
from lembas.db.session import session_scope
|
||||
@@ -427,6 +444,13 @@ async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
{**event, "status": "error", "error": "No chat."},
|
||||
)
|
||||
|
||||
# Before a minute of somebody's GPU is spent. Its own quota because it is
|
||||
# its own cost: a picture is no tokens at all, so a token budget says
|
||||
# nothing about how many of them one account may make.
|
||||
over = _over_quota(context)
|
||||
if over:
|
||||
return ToolOutcome(over, {**event, "status": "error", "error": over})
|
||||
|
||||
values = context.image_config or {}
|
||||
config = config_of(context)
|
||||
if not config.configured:
|
||||
|
||||
@@ -20,6 +20,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import CHUNK_REPORT, SOURCE_MANUAL, SOURCES, Report, User
|
||||
from lembas.services import sharing
|
||||
from lembas.services.library import retrieval
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -33,21 +34,35 @@ SNIPPET_CHARS = 400
|
||||
|
||||
|
||||
def visible(user: User | None):
|
||||
"""Every report this person owns.
|
||||
"""Every report this person owns or has been shared.
|
||||
|
||||
Takes no session because it builds a query rather than running one, and
|
||||
takes `None` to mean nobody so an unauthenticated caller gets an empty
|
||||
result instead of an exception -- the same shape `sharing.visible_to` has,
|
||||
so a later move to shared reports is a change of one line here.
|
||||
result instead of an exception.
|
||||
|
||||
It said "a later move to shared reports is a change of one line here", and
|
||||
it was: `sharing.visible_to` is that line. Every listing, search and detail
|
||||
page went through this already, which is what made the move safe.
|
||||
"""
|
||||
if user is None:
|
||||
return select(Report).where(Report.id.is_(None))
|
||||
return select(Report).where(Report.owner_id == user.id)
|
||||
return select(Report).where(sharing.visible_to(Report, user))
|
||||
|
||||
|
||||
def get(db: DBSession, report_id: str, user: User | None) -> Report | None:
|
||||
report = db.get(Report, report_id)
|
||||
if report is None or user is None or report.owner_id != user.id:
|
||||
if report is None or not sharing.can_read(db, report, user):
|
||||
return None
|
||||
return report
|
||||
|
||||
|
||||
def owned(db: DBSession, report_id: str, user: User | None) -> Report | None:
|
||||
"""The same, but only when they own it.
|
||||
|
||||
Sharing grants **reading**, so deleting and marking-as-read are the owner's
|
||||
alone. Two functions rather than a flag, because a route that wants one and
|
||||
calls the other is a bug you can see in the name.
|
||||
"""
|
||||
report = db.get(Report, report_id)
|
||||
if report is None or not sharing.can_write(report, user):
|
||||
return None
|
||||
return report
|
||||
|
||||
@@ -202,6 +217,10 @@ def mark_read(db: DBSession, report: Report) -> Report:
|
||||
|
||||
|
||||
def delete(db: DBSession, report: Report) -> None:
|
||||
# Shares carry no foreign key to their resource, so nothing cascades and
|
||||
# this has to be said. A grant left behind names a report that has gone --
|
||||
# harmless now and a grant to whoever next holds that id later.
|
||||
sharing.forget_resource(db, report)
|
||||
db.delete(report)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Who may see a document, a note or a skill.
|
||||
"""Who may see a knowledge base, a note, a skill or a report.
|
||||
|
||||
One rule, in one place, for all three: you can see a resource if you own it, if
|
||||
One rule, in one place, for all four: you can see a resource if you own it, if
|
||||
it was shared with you by name, or if it was shared with a group you are in.
|
||||
|
||||
Documents are deliberately absent from that list. They are shared through the
|
||||
@@ -25,7 +25,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import ColumnElement, delete, or_, select
|
||||
from sqlalchemy import ColumnElement, and_, delete, or_, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import (
|
||||
@@ -33,9 +33,11 @@ from lembas.db.models import (
|
||||
PRINCIPAL_USER,
|
||||
RESOURCE_BASE,
|
||||
RESOURCE_NOTE,
|
||||
RESOURCE_REPORT,
|
||||
RESOURCE_SKILL,
|
||||
KnowledgeBase,
|
||||
Note,
|
||||
Report,
|
||||
Share,
|
||||
Skill,
|
||||
User,
|
||||
@@ -49,6 +51,12 @@ RESOURCE_TYPES: dict[Any, str] = {
|
||||
KnowledgeBase: RESOURCE_BASE,
|
||||
Note: RESOURCE_NOTE,
|
||||
Skill: RESOURCE_SKILL,
|
||||
# A report joins the list and a memory still does not. A finished piece of
|
||||
# work is the thing somebody most wants to hand over -- "here is what the
|
||||
# Monday run found" -- and a report is read once and never answered, so
|
||||
# sharing it has none of the two-editors problem that keeps writing off the
|
||||
# table everywhere else here.
|
||||
Report: RESOURCE_REPORT,
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +96,19 @@ def visible_to(model: Any, user: User | None) -> ColumnElement[bool]:
|
||||
return or_(model.owner_id == user.id, model.id.in_(shared))
|
||||
|
||||
|
||||
def only_shared(model: Any, user: User | None) -> ColumnElement[bool]:
|
||||
"""Rows this user may see and does **not** own.
|
||||
|
||||
The "Shared with me" filter. Worth having as its own listing rather than a
|
||||
badge in the mixed one: a badge answers "is this mine?" for a row already on
|
||||
screen, and the question somebody actually has is "what have people given
|
||||
me?", which a mixed list of two hundred cannot answer at all.
|
||||
"""
|
||||
if user is None:
|
||||
return model.id.is_(None)
|
||||
return and_(visible_to(model, user), model.owner_id != user.id)
|
||||
|
||||
|
||||
def owned_by(model: Any, user: User | None) -> ColumnElement[bool]:
|
||||
"""Rows this user may *change*.
|
||||
|
||||
@@ -197,12 +218,39 @@ def forget_principal(db: DBSession, principal_type: str, principal_id: str) -> i
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
def forget_owner(db: DBSession, owner_id: str) -> int:
|
||||
"""Drop every share of everything a departing account owned.
|
||||
|
||||
Their rows cascade when the account goes; the shares of those rows do not,
|
||||
because `Share.resource_id` has no foreign key to point at. Left behind,
|
||||
they are grants naming resources that no longer exist -- harmless today,
|
||||
and a grant to whoever next receives one of those ids if a future store
|
||||
ever reuses them.
|
||||
|
||||
Called *before* the delete, while the rows are still there to be found.
|
||||
`forget_principal` is the other half and covers shares pointing *at* them.
|
||||
"""
|
||||
removed = 0
|
||||
for model in RESOURCE_TYPES:
|
||||
owned = select(model.id).where(model.owner_id == owner_id)
|
||||
result = db.execute(
|
||||
delete(Share).where(
|
||||
Share.resource_type == RESOURCE_TYPES[model],
|
||||
Share.resource_id.in_(owned),
|
||||
)
|
||||
)
|
||||
removed += result.rowcount or 0
|
||||
return removed
|
||||
|
||||
|
||||
__all__ = [
|
||||
"can_read",
|
||||
"can_write",
|
||||
"forget_owner",
|
||||
"forget_principal",
|
||||
"forget_resource",
|
||||
"grants_for",
|
||||
"only_shared",
|
||||
"owned_by",
|
||||
"resource_type",
|
||||
"set_grants",
|
||||
|
||||
@@ -76,6 +76,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from lembas.db.models import KIND_AGENT, Chat, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.security import permissions
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import policy as agent_policy
|
||||
|
||||
@@ -443,6 +444,12 @@ async def _run_subagent(context: ToolContext, args: dict[str, Any]) -> ToolOutco
|
||||
# rather than told it has run out of helpers, and the counter should
|
||||
# only move for a call that is about to spend one.
|
||||
values = settings_store.subagents(db)
|
||||
# A group's ceiling narrows the instance's, never widens it. Zero on
|
||||
# either side means "no opinion", so the two cannot be folded with
|
||||
# `min` -- see generation._narrower for the same arithmetic.
|
||||
allowance = permissions.limit(db, owner, "helpers_per_reply")
|
||||
if allowance:
|
||||
values = {**values, "max_per_reply": min(int(values["max_per_reply"]), allowance)}
|
||||
refusal = _budget(generation_service.running_for(parent_id), values)
|
||||
if refusal:
|
||||
return _error(refusal, task=task)
|
||||
|
||||
@@ -1604,6 +1604,21 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
|
||||
# instrument -- `git log` is a read whatever its risk class says.
|
||||
writes_off = scoped_writes_off(chat)
|
||||
|
||||
# Reading and writing, split for the three gates where the two are genuinely
|
||||
# different decisions. A second check keyed on the tool's **risk**, applied
|
||||
# after the gate rather than instead of it -- so it can only ever narrow
|
||||
# what `_family_allowed` already allowed, and an instance that has never
|
||||
# looked at it behaves exactly as it did, all three defaulting on.
|
||||
#
|
||||
# Here rather than in `_family_allowed` because that one is given a family
|
||||
# and this needs the tool: the whole point is that two tools in one family
|
||||
# get different answers.
|
||||
def may_write(tool: ToolDef) -> bool:
|
||||
gate = gate_of(tool.family)
|
||||
if tool.risk != RISK_WRITE or gate not in permissions.SPLIT_GATES:
|
||||
return True
|
||||
return bool(allowed.get(f"tools.{gate}.write", True))
|
||||
|
||||
return ToolSet(
|
||||
tuple(
|
||||
tool
|
||||
@@ -1619,6 +1634,7 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
|
||||
)
|
||||
and gate_of(tool.family) not in off
|
||||
and not (writes_off and tool.risk == RISK_WRITE)
|
||||
and may_write(tool)
|
||||
# Nothing to read and nothing to improve. Offering `skill_get` with
|
||||
# no skills is what makes a model spend a round looking one up and
|
||||
# being told it does not exist -- and `context.skills` already
|
||||
|
||||
@@ -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 %}
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon, model_avatar %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "groups" %}
|
||||
|
||||
{% block title %}Groups & permissions - {{ brand.name }}{% endblock %}
|
||||
@@ -9,8 +9,9 @@
|
||||
<p class="admin-lede">
|
||||
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
|
||||
something away, so being in a second group can only widen what someone can do.
|
||||
Administrators bypass all of it.
|
||||
something away, so being in a second group can only widen what someone can do —
|
||||
which is what keeps “why can this person not do X?” answerable without
|
||||
simulating every group they are in. Administrators bypass all of it.
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
@@ -19,7 +20,7 @@
|
||||
|
||||
<section class="card">
|
||||
<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
|
||||
something off here and grant it through a group to make it opt-in.
|
||||
</p>
|
||||
@@ -48,7 +49,39 @@
|
||||
Groups <span class="badge">{{ groups|length }}</span>
|
||||
</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">
|
||||
<input class="input" name="name" placeholder="New group name" required
|
||||
aria-label="New group name">
|
||||
@@ -57,114 +90,4 @@
|
||||
</button>
|
||||
</form>
|
||||
</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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -7,157 +7,103 @@
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
Everyone with an account on this instance. Administrators bypass every
|
||||
permission; ordinary users get the baseline permissions plus whatever their
|
||||
groups add.
|
||||
Every account on this instance. Open one to see what it can actually do and
|
||||
where each of those permissions came from. Group membership is edited from the
|
||||
<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>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="get" action="/admin/users" class="btn-row" style="margin-bottom: var(--sp-5)">
|
||||
<div class="filter-bar">
|
||||
<form class="filter-form" method="get" action="/admin/users">
|
||||
<input class="input" type="search" name="q" value="{{ q }}"
|
||||
placeholder="Search by name or email" aria-label="Search users">
|
||||
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Search</button>
|
||||
placeholder="Search by name or email…" aria-label="Search users">
|
||||
<button class="btn" type="submit">{{ icon("search", "icon--sm") }} Filter</button>
|
||||
{% if q %}<a class="btn btn--ghost" href="/admin/users">Clear</a>{% endif %}
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<details class="card">
|
||||
<summary class="card__title" style="cursor: pointer">Add a user</summary>
|
||||
<form method="post" action="/admin/users" style="margin-top: var(--sp-4)">
|
||||
<div class="field">
|
||||
<label class="field__label" for="nu-name">Name</label>
|
||||
<input class="input" id="nu-name" name="name" required>
|
||||
<div class="model-rows">
|
||||
{% for person in users %}
|
||||
<a class="model-row" href="/admin/users/{{ person.id }}">
|
||||
<div class="model-row__main">
|
||||
<span class="model-row__name">
|
||||
{{ 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 class="field">
|
||||
<label class="field__label" for="nu-email">Email</label>
|
||||
<input class="input" id="nu-email" name="email" type="email" required>
|
||||
<div class="model-row__meta">
|
||||
{% if person.groups %}
|
||||
<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 class="field">
|
||||
<label class="field__label" for="nu-password">Password</label>
|
||||
<input class="input" id="nu-password" name="password" type="password"
|
||||
required minlength="8" autocomplete="new-password">
|
||||
<p class="field__hint">
|
||||
At least 8 characters. Tell them to change it — you will know it otherwise.
|
||||
</p>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="empty" style="padding: var(--sp-8) 0">
|
||||
<p class="empty__text">{{ "Nobody matches that." if q else "No accounts yet." }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="nu-role">Role</label>
|
||||
<select class="select" id="nu-role" name="role">
|
||||
{% for role in roles %}
|
||||
<option value="{{ role }}" {{ 'selected' if role == 'user' }}>{{ role }}</option>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if pager.pages > 1 %}
|
||||
<div class="btn-row" style="margin-top: var(--sp-5)">
|
||||
{% if pager.page > 1 %}
|
||||
<a class="btn btn--sm" href="/admin/users?page={{ pager.page - 1 }}{% if q %}&q={{ q|urlencode }}{% endif %}">
|
||||
{{ icon("chevron-left", "icon--sm") }} Previous
|
||||
</a>
|
||||
{% 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 class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} Create</button>
|
||||
</div>
|
||||
</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 class="btn btn--primary" type="submit">
|
||||
{{ icon("plus", "icon--sm") }} Create account
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,63 +1,30 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
The share panel on a detail page.
|
||||
The share panel's placeholder on a detail page.
|
||||
|
||||
Sharing grants *reading*. Two people editing one note with no history and no
|
||||
merge is worse than the inconvenience of copying it, so there is no "can edit"
|
||||
here and the copy is deliberate rather than missing.
|
||||
It fetches `_share_panel.html` on load rather than being rendered inline, and
|
||||
that is the whole change: the panel used to be checkboxes inside the
|
||||
resource's *save form*, so a share only happened if you also saved the
|
||||
resource, and the list of candidates was every group and every account on the
|
||||
instance, unpaginated, on every detail page.
|
||||
|
||||
Only the owner sees this at all: someone a thing was shared with cannot share
|
||||
it onward, which keeps "who can see this" answerable by asking one person.
|
||||
Sharing still grants *reading*. Two people editing one note with no history and
|
||||
no merge is worse than the inconvenience of copying it, so there is no "can
|
||||
edit" and its absence is deliberate rather than missing.
|
||||
|
||||
Only the owner sees it at all — someone a thing was shared with cannot share it
|
||||
onward — and the route enforces that as well, because a template is not a
|
||||
permission check.
|
||||
#}
|
||||
{% if is_owner and can_share %}
|
||||
<section class="card">
|
||||
<h2 class="card__title">
|
||||
Shared with
|
||||
{% if shared_users or shared_groups %}
|
||||
<span class="badge badge--leaf">{{ shared_users|length + shared_groups|length }}</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 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>
|
||||
<div hx-get="/api/library/share/{{ share_kind }}/{{ share_id }}"
|
||||
hx-trigger="load"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Shared with <span class="badge">…</span></h2>
|
||||
</section>
|
||||
</div>
|
||||
{% elif not is_owner %}
|
||||
<div class="alert">
|
||||
{{ 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.
|
||||
</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 %}
|
||||
<div class="alert alert--error">{{ icon("warning", "alert__icon") }} <span>{{ error }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
yours, not its.
|
||||
</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)">
|
||||
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
|
||||
placeholder="Search notes…">
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
change can be read and undone.
|
||||
</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)">
|
||||
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
|
||||
placeholder="Search skills…">
|
||||
|
||||
@@ -36,6 +36,15 @@
|
||||
{{ body_html | safe }}
|
||||
</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)">
|
||||
<form method="post" action="/api/reports/{{ report.id }}/delete"
|
||||
data-confirm="Delete this report? It cannot be brought back.">
|
||||
@@ -44,4 +53,5 @@
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
schedule leaves its result here. Nothing on this page can be replied to.
|
||||
</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)">
|
||||
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
|
||||
placeholder="Search reports…">
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user