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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-06 16:48:14 +02:00
parent 20bb569b00
commit 1b8c9f948c
31 changed files with 2226 additions and 377 deletions
+176 -3
View File
@@ -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.