Files
LLeMbas/docs/notes/permissions-and-sharing.md
T
Jaroslav Beneš 9d7fb72bdb 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>
2026-08-06 16:48:14 +02:00

144 lines
7.2 KiB
Markdown

# 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.