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:
+138
-12
@@ -10,11 +10,21 @@ from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import ROLE_ADMIN, ROLE_PENDING, ROLE_USER, Group, Model, User
|
||||
from lembas.db.models import (
|
||||
PRINCIPAL_GROUP,
|
||||
PRINCIPAL_USER,
|
||||
ROLE_ADMIN,
|
||||
ROLE_PENDING,
|
||||
ROLE_USER,
|
||||
Group,
|
||||
Model,
|
||||
User,
|
||||
)
|
||||
from lembas.security import permissions
|
||||
from lembas.security.passwords import hash_password, validate_password
|
||||
from lembas.security.sessions import revoke_all_for_user
|
||||
from lembas.services import settings_store
|
||||
from lembas.services import settings_store, sharing
|
||||
from lembas.services import usage as usage_service
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -54,22 +64,71 @@ def _would_orphan_the_instance(db: DBSession, user: User) -> bool:
|
||||
|
||||
|
||||
# --- Users -------------------------------------------------------------------
|
||||
# List plus detail, which is the shape this codebase already mandates for admin
|
||||
# lists and the one `/admin/models` follows. The single page it replaces
|
||||
# rendered a full form per account *and* a membership grid, and edited that
|
||||
# membership from the opposite side to `/admin/groups` -- so a full-form POST
|
||||
# from either overwrote what the other had just shown.
|
||||
#
|
||||
# Membership is now edited from **one** side, the group's. A user's page links
|
||||
# to their groups and does not offer to change them, because two controls
|
||||
# writing one value is how each becomes the answer to "why did my change not
|
||||
# stick?".
|
||||
PAGE_SIZE = 25
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def users_page(request: Request, db: Db, user: AdminUser, q: str = "", saved: str = ""):
|
||||
async def users_page(
|
||||
request: Request, db: Db, user: AdminUser, q: str = "", saved: str = "", page: int = 1
|
||||
):
|
||||
query = select(User).order_by(User.created_at)
|
||||
if q.strip():
|
||||
pattern = f"%{q.strip()}%"
|
||||
query = query.where(or_(User.name.ilike(pattern), User.email.ilike(pattern)))
|
||||
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||
pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page = min(max(1, page), pages)
|
||||
rows = list(db.scalars(query.offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE)))
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/users.html",
|
||||
{
|
||||
"users": list(db.scalars(query)),
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"users": rows,
|
||||
"usage": {row.id: usage_service.summary(db, row) for row in rows},
|
||||
"roles": ROLES,
|
||||
"q": q,
|
||||
"saved": saved,
|
||||
"pager": {"page": page, "pages": pages, "total": total},
|
||||
"admin_count": _admin_count(db),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
async def user_detail(request: Request, db: Db, user: AdminUser, user_id: str, saved: str = ""):
|
||||
"""One account, and the answer to "what can this person actually do?".
|
||||
|
||||
That answer is `permissions.explain`, which is `resolve`'s working shown
|
||||
rather than thrown away. Read-only on purpose: every one of those switches
|
||||
is set somewhere else -- the baseline, or a named group -- and a control here
|
||||
would be a third place to change one thing.
|
||||
"""
|
||||
target = _user(db, user_id)
|
||||
return render(
|
||||
request,
|
||||
"admin/user_detail.html",
|
||||
{
|
||||
"target": target,
|
||||
"roles": ROLES,
|
||||
"explained": permissions.explain(db, target),
|
||||
"permission_groups": permissions.permission_groups(),
|
||||
"limits": permissions.limits_for(db, target),
|
||||
"limit_defs": permissions.LIMIT_DEFS,
|
||||
"usage": usage_service.summary(db, target),
|
||||
"models": permissions.models_visible_to(db, target),
|
||||
"saved": saved,
|
||||
"admin_count": _admin_count(db),
|
||||
},
|
||||
)
|
||||
@@ -114,8 +173,13 @@ async def update_user(
|
||||
name: str = Form(...),
|
||||
role: str = Form(ROLE_USER),
|
||||
active: bool = Form(False),
|
||||
group_ids: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
"""Name, role and whether the account is active. **Not membership.**
|
||||
|
||||
That moved to the group's page. It used to be here as well, and a full-form
|
||||
POST from either side overwrote whatever the other had -- two controls, one
|
||||
value, and no answer to which one wins.
|
||||
"""
|
||||
target = _user(db, user_id)
|
||||
|
||||
losing_admin = target.role == ROLE_ADMIN and (role != ROLE_ADMIN or not active)
|
||||
@@ -128,7 +192,6 @@ async def update_user(
|
||||
target.name = name.strip()[:120] or target.name
|
||||
target.role = role if role in ROLES else target.role
|
||||
target.active = active
|
||||
target.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))
|
||||
|
||||
# A deactivated or demoted user must lose their live sessions immediately,
|
||||
# otherwise the change only takes effect when their cookie happens to expire.
|
||||
@@ -137,7 +200,9 @@ async def update_user(
|
||||
|
||||
db.commit()
|
||||
log.info("%s updated account %s (role=%s active=%s)", user.email, target.email, role, active)
|
||||
return RedirectResponse(f"/admin/users?saved=Saved+{target.email}.", status_code=303)
|
||||
return RedirectResponse(
|
||||
f"/admin/users/{target.id}?saved=Saved+{target.email}.", status_code=303
|
||||
)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/password")
|
||||
@@ -146,7 +211,7 @@ async def reset_password(
|
||||
) -> Response:
|
||||
target = _user(db, user_id)
|
||||
if (problem := validate_password(password)) is not None:
|
||||
return RedirectResponse(f"/admin/users?saved={problem}", status_code=303)
|
||||
return RedirectResponse(f"/admin/users/{user_id}?saved={problem}", status_code=303)
|
||||
|
||||
target.password_hash = hash_password(password)
|
||||
db.commit()
|
||||
@@ -155,7 +220,7 @@ async def reset_password(
|
||||
revoke_all_for_user(db, target)
|
||||
log.info("%s reset the password for %s", user.email, target.email)
|
||||
return RedirectResponse(
|
||||
f"/admin/users?saved=Password+reset+for+{target.email}.+Sessions+revoked.",
|
||||
f"/admin/users/{target.id}?saved=Password+reset.+Sessions+revoked.",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
@@ -175,6 +240,15 @@ async def delete_user(db: Db, user: AdminUser, user_id: str) -> Response:
|
||||
|
||||
email = target.email
|
||||
# Chats and folders cascade; that is the point of deleting an account.
|
||||
#
|
||||
# Shares do not, and never did. `Share.principal_id` and
|
||||
# `Share.resource_id` both point at one of several tables depending on a
|
||||
# sibling column, which SQLite cannot express as a foreign key -- so a
|
||||
# deleted account left behind every grant *to* it and every grant *of* its
|
||||
# own work. Both halves, and both before the delete, while the rows are
|
||||
# still there to be found.
|
||||
sharing.forget_owner(db, target.id)
|
||||
sharing.forget_principal(db, PRINCIPAL_USER, target.id)
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
log.info("%s deleted account %s", user.email, email)
|
||||
@@ -182,17 +256,42 @@ async def delete_user(db: Db, user: AdminUser, user_id: str) -> Response:
|
||||
|
||||
|
||||
# --- Groups ------------------------------------------------------------------
|
||||
# The same list-plus-detail shape. The old page rendered every group's full
|
||||
# permission grid, every member and every model on one screen, which is fine for
|
||||
# two groups and unreadable at ten.
|
||||
@router.get("/groups")
|
||||
async def groups_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
|
||||
groups = list(db.scalars(select(Group).order_by(Group.name)))
|
||||
return render(
|
||||
request,
|
||||
"admin/groups.html",
|
||||
{
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"groups": groups,
|
||||
"granted": {
|
||||
group.id: sum(1 for on in (group.permissions_json or {}).values() if on)
|
||||
for group in groups
|
||||
},
|
||||
"permission_groups": permissions.permission_groups(),
|
||||
"baseline": permissions.baseline_permissions(db),
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}")
|
||||
async def group_detail(request: Request, db: Db, user: AdminUser, group_id: str, saved: str = ""):
|
||||
group = _group(db, group_id)
|
||||
return render(
|
||||
request,
|
||||
"admin/group_detail.html",
|
||||
{
|
||||
"group": group,
|
||||
"users": list(db.scalars(select(User).order_by(User.name))),
|
||||
"models": list(db.scalars(select(Model).order_by(Model.position, Model.model_id))),
|
||||
"permission_groups": permissions.permission_groups(),
|
||||
"baseline": permissions.baseline_permissions(db),
|
||||
"limit_defs": permissions.LIMIT_DEFS,
|
||||
"limits": group.limits_json or {},
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
@@ -216,6 +315,7 @@ async def create_group(db: Db, user: AdminUser, name: str = Form(...)) -> Respon
|
||||
|
||||
@router.post("/groups/{group_id}")
|
||||
async def update_group(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
group_id: str,
|
||||
@@ -226,6 +326,7 @@ async def update_group(
|
||||
model_ids: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
group = _group(db, group_id)
|
||||
form = await request.form()
|
||||
|
||||
group.name = name.strip()[:120] or group.name
|
||||
group.description = description.strip()[:1000]
|
||||
@@ -235,9 +336,26 @@ async def update_group(
|
||||
group.users = list(db.scalars(select(User).where(User.id.in_(user_ids or []))))
|
||||
group.models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or []))))
|
||||
|
||||
# Quotas. Only what was submitted and could be read as a number is stored, so
|
||||
# a blank box means "this group has no opinion" and contributes nothing to
|
||||
# the resolution -- which is what `limits_for` needs in order to tell it
|
||||
# apart from a deliberate zero, and zero here means *no limit*.
|
||||
wanted: dict[str, int] = {}
|
||||
for key in permissions.LIMIT_KEYS:
|
||||
raw = str(form.get(f"limit_{key}") or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
wanted[key] = max(0, int(raw))
|
||||
except ValueError:
|
||||
continue
|
||||
group.limits_json = wanted
|
||||
|
||||
db.commit()
|
||||
log.info("%s updated group %s", user.email, group.name)
|
||||
return RedirectResponse(f"/admin/groups?saved=Saved+{group.name}.", status_code=303)
|
||||
return RedirectResponse(
|
||||
f"/admin/groups/{group.id}?saved=Saved+{group.name}.", status_code=303
|
||||
)
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/delete")
|
||||
@@ -245,8 +363,16 @@ async def delete_group(db: Db, user: AdminUser, group_id: str) -> Response:
|
||||
group = _group(db, group_id)
|
||||
name = group.name
|
||||
# Members and model links go with it; the users themselves are untouched.
|
||||
#
|
||||
# Every share naming this group goes too. Nothing cascades -- see
|
||||
# `sharing.forget_principal` -- so a deleted group left its grants behind,
|
||||
# and a group id is a random hex string that nothing reissues today and
|
||||
# nothing promises not to reissue tomorrow.
|
||||
dropped = sharing.forget_principal(db, PRINCIPAL_GROUP, group.id)
|
||||
db.delete(group)
|
||||
db.commit()
|
||||
if dropped:
|
||||
log.info("dropped %d share(s) naming group %s", dropped, name)
|
||||
log.info("%s deleted group %s", user.email, name)
|
||||
return RedirectResponse(f"/admin/groups?saved=Deleted+{name}.", status_code=303)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user