From a414a18fde4b741011d8dd03430f7b18f9792484 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:10:31 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR --- src/handler/db/tables.py | 59 ++++++++++++++ .../migrations/versions/0016_user_accounts.py | 79 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src/handler/migrations/versions/0016_user_accounts.py diff --git a/src/handler/db/tables.py b/src/handler/db/tables.py index 64c3882..e83a464 100644 --- a/src/handler/db/tables.py +++ b/src/handler/db/tables.py @@ -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()), ) diff --git a/src/handler/migrations/versions/0016_user_accounts.py b/src/handler/migrations/versions/0016_user_accounts.py new file mode 100644 index 0000000..4630b41 --- /dev/null +++ b/src/handler/migrations/versions/0016_user_accounts.py @@ -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")