mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-29 19:21:40 +00:00
Add user account schema: users, sessions, one-shot tokens, ownership columns
users/auth_sessions/auth_tokens tables plus a nullable owner_user_id on projects, claude_skills, claude_connectors, claude_plugins, and claude_models (null = shared/legacy, so upgrades keep behaving as before). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR
This commit is contained in:
@@ -72,6 +72,54 @@ def _in(column: str, values: tuple[str, ...]) -> str:
|
||||
return f"{column} IN ({joined})"
|
||||
|
||||
|
||||
# ---- User accounts (email + password). The first account created (the setup flow)
|
||||
# is the admin; every later account is created by an admin. ``password_hash`` is null
|
||||
# until an invited user sets a password through their invite link. Ownership columns
|
||||
# elsewhere (``owner_user_id``) reference ``users.id`` *without* an FK — same rationale
|
||||
# as ``agents.model_id``: deleting a user must never orphan or cascade away resources,
|
||||
# so ``delete_user`` explicitly reassigns owned rows to shared (NULL) instead.
|
||||
users = Table(
|
||||
"users",
|
||||
metadata,
|
||||
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
|
||||
Column("email", String, nullable=False, unique=True), # stored lowercased
|
||||
Column("password_hash", String), # scrypt (handler.authn); null = invite not accepted
|
||||
Column("is_admin", Boolean, nullable=False, server_default="0"),
|
||||
Column("disabled", Boolean, nullable=False, server_default="0"),
|
||||
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
# Browser sessions. The API hands out a random bearer token at login and stores only its
|
||||
# SHA-256 here, so a database dump never contains a usable session credential.
|
||||
auth_sessions = Table(
|
||||
"auth_sessions",
|
||||
metadata,
|
||||
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
|
||||
Column("user_id", BigInteger, ForeignKey("users.id"), nullable=False),
|
||||
Column("token_hash", String, nullable=False, unique=True),
|
||||
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
|
||||
Column("expires_at", PortableTimestamp, nullable=False),
|
||||
Column("last_used_at", PortableTimestamp),
|
||||
)
|
||||
|
||||
# One-shot links: password resets and invites (an invite is just a longer-lived reset on
|
||||
# an account that has no password yet). Hash-stored like sessions; ``used_at`` makes them
|
||||
# single-use.
|
||||
AUTH_TOKEN_PURPOSES = ("reset", "invite")
|
||||
|
||||
auth_tokens = Table(
|
||||
"auth_tokens",
|
||||
metadata,
|
||||
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
|
||||
Column("user_id", BigInteger, ForeignKey("users.id"), nullable=False),
|
||||
Column("token_hash", String, nullable=False, unique=True),
|
||||
Column("purpose", String, nullable=False),
|
||||
Column("expires_at", PortableTimestamp, nullable=False),
|
||||
Column("used_at", PortableTimestamp),
|
||||
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
|
||||
CheckConstraint(_in("purpose", AUTH_TOKEN_PURPOSES), name="ck_auth_tokens_purpose"),
|
||||
)
|
||||
|
||||
projects = Table(
|
||||
"projects",
|
||||
metadata,
|
||||
@@ -80,6 +128,9 @@ projects = Table(
|
||||
Column("git_remote", String),
|
||||
# Pointer to a secret (env:VAR / file:/path / cmd:...), never the token — README 3.7.
|
||||
Column("credential_ref", String),
|
||||
# Owning user account; null = shared/legacy (visible to everyone, admin-managed).
|
||||
# No FK by design — see the ``users`` table comment.
|
||||
Column("owner_user_id", BigInteger),
|
||||
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
@@ -354,6 +405,8 @@ claude_skills = Table(
|
||||
Column("description", String),
|
||||
Column("content", String, nullable=False), # markdown body below the front-matter
|
||||
Column("enabled", Boolean, nullable=False, server_default="1"),
|
||||
# Owning user; null = shared (synced for every user's agents). No FK — see ``users``.
|
||||
Column("owner_user_id", BigInteger),
|
||||
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
|
||||
Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()),
|
||||
)
|
||||
@@ -390,6 +443,8 @@ claude_connectors = Table(
|
||||
Column("url", String), # http/sse: the endpoint
|
||||
Column("headers", PortableJSON), # http/sse: header map (may carry auth)
|
||||
Column("enabled", Boolean, nullable=False, server_default="1"),
|
||||
# Owning user; null = shared (applied to every user's launches). No FK — see ``users``.
|
||||
Column("owner_user_id", BigInteger),
|
||||
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
|
||||
CheckConstraint(_in("transport", MCP_TRANSPORTS), name="ck_claude_connectors_transport"),
|
||||
)
|
||||
@@ -404,6 +459,8 @@ claude_plugins = Table(
|
||||
Column("marketplace", String, nullable=False), # marketplace key, e.g. "acme-tools"
|
||||
Column("marketplace_repo", String, nullable=False), # "owner/repo" or a git URL
|
||||
Column("enabled", Boolean, nullable=False, server_default="1"),
|
||||
# Owning user; null = shared. No FK — see ``users``.
|
||||
Column("owner_user_id", BigInteger),
|
||||
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
|
||||
UniqueConstraint("name", "marketplace", name="uq_claude_plugins_name_marketplace"),
|
||||
)
|
||||
@@ -430,6 +487,8 @@ claude_models = Table(
|
||||
Column("harness", String, nullable=False, server_default="claude"),
|
||||
Column("env", PortableJSON), # extra env overrides (timeouts, max tokens, …), merged last
|
||||
Column("enabled", Boolean, nullable=False, server_default="1"),
|
||||
# Owning user; null = shared (offered in every user's spawn dropdown). No FK — see ``users``.
|
||||
Column("owner_user_id", BigInteger),
|
||||
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""user accounts: email login, sessions, reset/invite links, per-user ownership
|
||||
|
||||
Revision ID: 0016_user_accounts
|
||||
Revises: 0015_model_harness
|
||||
Create Date: 2026-08-12
|
||||
|
||||
Replaces "know the API key" with email + password accounts: the first account created
|
||||
becomes the admin, later accounts are created by an admin (invite links), and password
|
||||
resets ride the same one-shot-token table. Resources gain a nullable ``owner_user_id``
|
||||
(projects, skills, connectors, plugins, model backends) — null means shared/legacy, so
|
||||
an upgraded deployment behaves exactly as before until users start owning things. The
|
||||
legacy env tokens keep working for scripts/CI; no data backfill is needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from handler.db.types import PortableBigInt, PortableTimestamp
|
||||
|
||||
revision: str = "0016_user_accounts"
|
||||
down_revision: str | None = "0015_model_harness"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# Tables that gain per-user ownership. Nullable, no FK (mirrors agents.model_id: a
|
||||
# deleted user must never orphan resources — delete_user reassigns rows to shared).
|
||||
_OWNED_TABLES = (
|
||||
"projects",
|
||||
"claude_skills",
|
||||
"claude_connectors",
|
||||
"claude_plugins",
|
||||
"claude_models",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
|
||||
sa.Column("email", sa.String(), nullable=False, unique=True),
|
||||
sa.Column("password_hash", sa.String()),
|
||||
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default="0"),
|
||||
sa.Column("disabled", sa.Boolean(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_table(
|
||||
"auth_sessions",
|
||||
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("token_hash", sa.String(), nullable=False, unique=True),
|
||||
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("expires_at", PortableTimestamp, nullable=False),
|
||||
sa.Column("last_used_at", PortableTimestamp),
|
||||
)
|
||||
op.create_table(
|
||||
"auth_tokens",
|
||||
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("token_hash", sa.String(), nullable=False, unique=True),
|
||||
sa.Column("purpose", sa.String(), nullable=False),
|
||||
sa.Column("expires_at", PortableTimestamp, nullable=False),
|
||||
sa.Column("used_at", PortableTimestamp),
|
||||
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
|
||||
sa.CheckConstraint("purpose IN ('reset', 'invite')", name="ck_auth_tokens_purpose"),
|
||||
)
|
||||
for table in _OWNED_TABLES:
|
||||
op.add_column(table, sa.Column("owner_user_id", sa.BigInteger()))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in _OWNED_TABLES:
|
||||
op.drop_column(table, "owner_user_id")
|
||||
op.drop_table("auth_tokens")
|
||||
op.drop_table("auth_sessions")
|
||||
op.drop_table("users")
|
||||
Reference in New Issue
Block a user