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>
213 lines
8.9 KiB
Python
213 lines
8.9 KiB
Python
"""Users, groups and login sessions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
Column,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
String,
|
|
Table,
|
|
Text,
|
|
UniqueConstraint,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
|
from lembas.db.types import JSONDict
|
|
|
|
if TYPE_CHECKING:
|
|
# Annotation only; SQLAlchemy resolves the real class from its registry.
|
|
from lembas.db.models.connection import Model
|
|
from lembas.db.models.tool import CustomTool, McpServer
|
|
|
|
# Roles are a simple ordered ladder rather than a permission matrix. Groups
|
|
# (below) carry finer-grained permissions once the users/groups UI lands.
|
|
ROLE_ADMIN = "admin"
|
|
ROLE_USER = "user"
|
|
ROLE_PENDING = "pending" # registered but awaiting admin approval
|
|
|
|
user_groups = Table(
|
|
"user_groups",
|
|
Base.metadata,
|
|
Column("user_id", String(32), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
|
|
)
|
|
|
|
|
|
class User(UUIDPrimaryKey, Timestamps, Base):
|
|
__tablename__ = "users"
|
|
|
|
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
|
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
|
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
|
role: Mapped[str] = mapped_column(String(16), default=ROLE_USER, nullable=False)
|
|
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
# Per-user preferences: theme, default model, composer behaviour, etc.
|
|
settings_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
|
|
|
groups: Mapped[list[Group]] = relationship(secondary=user_groups, back_populates="users")
|
|
sessions: Mapped[list[Session]] = relationship(
|
|
back_populates="user", cascade="all, delete-orphan"
|
|
)
|
|
|
|
@property
|
|
def is_admin(self) -> bool:
|
|
return self.role == ROLE_ADMIN
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<User {self.email} role={self.role}>"
|
|
|
|
|
|
class Group(UUIDPrimaryKey, Timestamps, Base):
|
|
"""A named set of users. Permissions are enforced once the RBAC pass lands."""
|
|
|
|
__tablename__ = "groups"
|
|
|
|
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
|
description: Mapped[str] = mapped_column(Text, default="")
|
|
|
|
# Only the granted keys need be present. Absent means "no opinion", not
|
|
# "deny" -- permissions union across a user's groups. See
|
|
# lembas.security.permissions.
|
|
permissions_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
|
|
|
# What members of this group may spend. Resolved across a user's groups by
|
|
# **maximum**, which is the union rule applied to numbers: being in a second
|
|
# group can only ever grant more. Zero means "no limit" and therefore wins
|
|
# outright, because a group that says "unlimited" saying less than one that
|
|
# says "a million" would be the union rule inverted for one value.
|
|
#
|
|
# Absent keys mean the group has no opinion and contribute nothing. See
|
|
# security/permissions.py:limits_for.
|
|
limits_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
|
|
|
users: Mapped[list[User]] = relationship(secondary=user_groups, back_populates="groups")
|
|
models: Mapped[list[Model]] = relationship(
|
|
"Model", secondary="model_groups", back_populates="groups"
|
|
)
|
|
custom_tools: Mapped[list[CustomTool]] = relationship(
|
|
"CustomTool", secondary="custom_tool_groups", back_populates="groups"
|
|
)
|
|
mcp_servers: Mapped[list[McpServer]] = relationship(
|
|
"McpServer", secondary="mcp_server_groups", back_populates="groups"
|
|
)
|
|
|
|
|
|
class Session(UUIDPrimaryKey, Timestamps, Base):
|
|
"""Server-side login session.
|
|
|
|
Sessions live in the database rather than in a signed JWT so that logging
|
|
out, banning a user, or rotating a device actually revokes access
|
|
immediately instead of waiting for a token to expire.
|
|
"""
|
|
|
|
__tablename__ = "sessions"
|
|
|
|
user_id: Mapped[str] = mapped_column(
|
|
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
# SHA-256 of the cookie value. The raw token is shown to the browser once
|
|
# and never stored, so a database leak does not hand over live sessions.
|
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
user_agent: Mapped[str] = mapped_column(Text, default="")
|
|
ip_address: Mapped[str] = mapped_column(String(45), default="")
|
|
|
|
user: Mapped[User] = relationship(back_populates="sessions")
|
|
|
|
|
|
Index("ix_sessions_user_id", Session.user_id)
|
|
|
|
|
|
class PushSubscription(UUIDPrimaryKey, Timestamps, Base):
|
|
"""One browser, on one device, that has agreed to be told.
|
|
|
|
Per device rather than per account, and that is not a detail: the permission
|
|
and the subscription both belong to a browser, so somebody signed in on a
|
|
laptop and a phone has two of these and revoking one must not silence the
|
|
other. It is also why there is no "notifications on" column on `User` -- the
|
|
presence of a row here *is* the state, and it cannot drift from what the
|
|
browser thinks.
|
|
|
|
`endpoint` is chosen by the browser vendor and is the address their push
|
|
service will accept a message at. Unique, because a browser that
|
|
re-subscribes hands back the same one and two rows would mean two
|
|
notifications for one arrival.
|
|
|
|
`p256dh` and `auth_secret` are the browser's half of the encryption. Stored
|
|
as the browser gave them, base64url: they are public key material and a
|
|
per-subscription salt, not credentials -- what they protect is the payload,
|
|
and a database holding them can already read everything the payload could
|
|
say. See services/push.py.
|
|
"""
|
|
|
|
__tablename__ = "push_subscriptions"
|
|
|
|
user_id: Mapped[str] = mapped_column(
|
|
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
endpoint: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
|
p256dh: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
auth_secret: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
# Which device this is, for a list somebody can revoke from. Whatever the
|
|
# browser says about itself, trimmed; never parsed.
|
|
label: Mapped[str] = mapped_column(String(200), default="")
|
|
# The last refusal from the push service, kept so a subscription that has
|
|
# stopped working says why rather than being silently useless. A 404 or 410
|
|
# deletes the row instead -- that is the end of its life, not a fault.
|
|
last_error: Mapped[str] = mapped_column(Text, default="")
|
|
|
|
user: Mapped[User] = relationship()
|
|
|
|
|
|
Index("ix_push_subscriptions_user_id", PushSubscription.user_id)
|
|
|
|
|
|
class Usage(UUIDPrimaryKey, Timestamps, Base):
|
|
"""What one account spent in one period.
|
|
|
|
A row per user per period rather than a row per reply. A per-reply ledger is
|
|
what somebody eventually wants for a bill; this exists to answer one
|
|
question on the request path -- "has this account used its month?" -- and
|
|
that question wants one indexed lookup, not a sum over ten thousand rows.
|
|
|
|
`period` is a plain "YYYY-MM" string in **UTC**. Not the reader's timezone:
|
|
a quota that resets at a different instant for each member of a group is a
|
|
quota nobody can reason about, and the month boundary is not something
|
|
anybody experiences to the hour.
|
|
|
|
Written by `generation._persist`, which is the single writer for everything
|
|
a reply produced, so a reply that is stopped or errors still records what it
|
|
spent -- an endpoint charges for tokens it generated whether or not the
|
|
reply was wanted.
|
|
"""
|
|
|
|
__tablename__ = "usage"
|
|
__table_args__ = (UniqueConstraint("user_id", "period", name="uq_usage_user_period"),)
|
|
|
|
user_id: Mapped[str] = mapped_column(
|
|
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
period: Mapped[str] = mapped_column(String(7), nullable=False)
|
|
|
|
prompt_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
completion_tokens: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
replies: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
# Counted separately because it is its own quota: one picture is a minute of
|
|
# somebody's GPU and no tokens at all, so a token budget says nothing about
|
|
# it. `images_today` on the resolved limits is the daily half; this is the
|
|
# month's running total, for the admin screen.
|
|
images: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<Usage {self.user_id} {self.period}>"
|