Scaffold project, data model and artwork
Establish the LLeMbas foundation: FastAPI/Jinja/SQLite layout, the ORM schema, and the original SVG identity. Notable decisions, all recorded in comments at the point they matter: - No Alembic. SQLite only, schema created at startup, so models carry a few columns nothing reads yet (Message.parent_id for branching, content_parts_json for multimodal turns). Adding them later to a live database without migrations is the painful path. - Sessions are server-side rows keyed by a SHA-256 of the cookie value, not JWTs, so logout and bans revoke access immediately. - Upstream API keys are Fernet-encrypted with a key derived from LEMBAS_SECRET_KEY. decrypt() fails soft to "" so rotating the secret degrades to re-entering keys rather than crashing the admin UI. - Artwork is generated by scripts/build_artwork.py rather than hand-drawn per file: the mallorn leaf appears in the icon, favicon, lockup and banner, and one source is the only way those stay in sync. The wordmark is Source Serif 4 (OFL) converted to outlines, because a README banner cannot load a webfont and <text> would render in whatever serif the viewer happens to have. - Icons live in a template partial, not assets/, because same-document <use href="#id"> is universally supported and the cross-document form is not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
"""Users, groups and login sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String, Table, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
from lembas.db.types import JSONDict
|
||||
|
||||
# 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="")
|
||||
permissions_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
users: Mapped[list[User]] = relationship(secondary=user_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)
|
||||
Reference in New Issue
Block a user