1b8c9f948c
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>
251 lines
8.7 KiB
Python
251 lines
8.7 KiB
Python
"""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
|