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:
@@ -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.
|
||||
Reference in New Issue
Block a user