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>
7.2 KiB
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:
- Maximum across groups — a second group can only ever grant more.
- Absent contributes nothing — a group with no opinion about tokens must not silently make somebody unlimited.
- 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)andforget_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.ownedexists besideget. 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.
unreadis 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.pyserves 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.