mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 01:52:17 -05:00
auth: database tables and migration
Adds the five auth_ tables and one DDL-only Alembic revision. Nothing reads or writes them yet; the data layer and endpoints follow in later commits. All five tables are created now, not just the two phase 1 needs. The reason is migrations/env.py's `target_metadata = Base.metadata`: if the database carried auth_api_key while models.py did not, the next autogenerated revision anyone produced would emit op.drop_table for it. Models and schema have to move together, so the phase 2/5 tables land as models now and gain their business logic later. Design notes, all following existing conventions in this schema: - Tables are prefixed auth_ because `user` and `session` are reserved words in PostgreSQL and MySQL, and quoting them at every call site is a footgun. - No relationship() on any auth table. Auth lookups are single-row queries on an indexed column, so there is nothing to eager-load; a `sessions` collection on AuthUser would fetch every session row on every login, and lazy-loading one under asyncio raises MissingGreenlet. - Enum-valued columns (level, actor_kind) are String, matching multi_color_direction. There is not one sa.Enum in this schema, and adding one would create a native PostgreSQL type and a MySQL ENUM(...) with three different ALTER dialects. - Uniqueness is enforced with unique indexes rather than named UNIQUE constraints. That is the portable form across all four dialects, and it keeps the multi-tenancy seam cheap: auth_user.username can later become a composite (tenant_id, username) index without dropping a constraint whose generated name differs per dialect. - The revision is pure DDL with no DML, which is what makes it safe as a single revision on CockroachDB -- the aafcd7fb0e84 -> 304a32906234 split was needed only because the second half wrote data. The revision body is alembic's own autogenerate output against a database at 415a8f855e14, so it matches the conventions of the existing revisions rather than being hand-approximated. Verified on SQLite: upgrade from scratch creates all five tables with the four unique indexes; downgrade -1 drops them all; upgrade replays cleanly. Critically, running autogenerate again after upgrade emits an empty migration, proving the models and the schema agree and that no future autogenerate will try to drop the phase 2/5 tables.
This commit is contained in:
1 parent
e0074e344b
commit
a46cad036f
2 files changed
+276
No files matched your search
@@ -0,0 +1,158 @@
|
||||
"""Auth tables.
|
||||
|
||||
Revision ID: 6e496ae15523
|
||||
Revises: 415a8f855e14
|
||||
Create Date: 2026-07-27 20:13:56.370544
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "6e496ae15523"
|
||||
down_revision = "415a8f855e14"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Perform the upgrade."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"auth_user",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("username", sa.String(length=64), nullable=False, comment="Stored lowercased."),
|
||||
sa.Column("display_name", sa.String(length=128), nullable=True),
|
||||
sa.Column(
|
||||
"email", sa.String(length=256), nullable=True, comment="Used for OIDC matching only, never delivery."
|
||||
),
|
||||
sa.Column("password_hash", sa.Text(), nullable=True, comment="Null for OIDC- or certificate-only accounts."),
|
||||
sa.Column(
|
||||
"level", sa.String(length=16), nullable=False, comment="read, edit or manage. See spoolman.auth.levels."
|
||||
),
|
||||
sa.Column("is_admin", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_owner", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("must_change_password", sa.Boolean(), nullable=False),
|
||||
sa.Column("totp_secret", sa.Text(), nullable=True, comment="Fernet-encrypted. Phase 4."),
|
||||
sa.Column("totp_enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("oidc_issuer", sa.String(length=256), nullable=True, comment="Phase 3."),
|
||||
sa.Column("oidc_subject", sa.String(length=256), nullable=True, comment="Phase 3."),
|
||||
sa.Column("failed_logins", sa.Integer(), nullable=False),
|
||||
sa.Column("locked_until", sa.DateTime(), nullable=True),
|
||||
sa.Column("registered", sa.DateTime(), nullable=False),
|
||||
sa.Column("last_login", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_auth_user_id"), "auth_user", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_auth_user_username"), "auth_user", ["username"], unique=True)
|
||||
op.create_table(
|
||||
"auth_api_key",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=64), nullable=False),
|
||||
sa.Column("level", sa.String(length=16), nullable=False),
|
||||
sa.Column("prefix", sa.String(length=16), nullable=False, comment="Indexes the row at lookup."),
|
||||
sa.Column("key_hash", sa.Text(), nullable=False),
|
||||
sa.Column("created", sa.DateTime(), nullable=False),
|
||||
sa.Column("expires", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_used", sa.DateTime(), nullable=True),
|
||||
sa.Column("revoked", sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["auth_user.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_auth_api_key_id"), "auth_api_key", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_auth_api_key_prefix"), "auth_api_key", ["prefix"], unique=True)
|
||||
op.create_index(op.f("ix_auth_api_key_user_id"), "auth_api_key", ["user_id"], unique=False)
|
||||
op.create_table(
|
||||
"auth_audit_log",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("date", sa.DateTime(), nullable=False),
|
||||
sa.Column("event", sa.String(length=64), nullable=False),
|
||||
sa.Column("actor_user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("actor_kind", sa.String(length=16), nullable=False),
|
||||
sa.Column("target", sa.String(length=128), nullable=True),
|
||||
sa.Column("ip", sa.String(length=64), nullable=True),
|
||||
sa.Column("user_agent", sa.String(length=256), nullable=True),
|
||||
sa.Column("detail", sa.Text(), nullable=True, comment="JSON object."),
|
||||
sa.ForeignKeyConstraint(
|
||||
["actor_user_id"],
|
||||
["auth_user.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_auth_audit_log_date"), "auth_audit_log", ["date"], unique=False)
|
||||
op.create_index(op.f("ix_auth_audit_log_id"), "auth_audit_log", ["id"], unique=False)
|
||||
op.create_table(
|
||||
"auth_certificate",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=64), nullable=False),
|
||||
sa.Column(
|
||||
"fingerprint_sha256",
|
||||
sa.String(length=95),
|
||||
nullable=False,
|
||||
comment="Colon-separated uppercase hex, 95 chars.",
|
||||
),
|
||||
sa.Column("subject_dn", sa.String(length=512), nullable=False),
|
||||
sa.Column("created", sa.DateTime(), nullable=False),
|
||||
sa.Column("last_used", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["auth_user.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_auth_certificate_fingerprint_sha256"), "auth_certificate", ["fingerprint_sha256"], unique=True
|
||||
)
|
||||
op.create_index(op.f("ix_auth_certificate_id"), "auth_certificate", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_auth_certificate_user_id"), "auth_certificate", ["user_id"], unique=False)
|
||||
op.create_table(
|
||||
"auth_session",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False, comment="SHA-256 hex of the token."),
|
||||
sa.Column("created", sa.DateTime(), nullable=False),
|
||||
sa.Column("expires", sa.DateTime(), nullable=False),
|
||||
sa.Column("last_seen", sa.DateTime(), nullable=False),
|
||||
sa.Column("remember", sa.Boolean(), nullable=False),
|
||||
sa.Column("user_agent", sa.String(length=256), nullable=True),
|
||||
sa.Column("ip", sa.String(length=64), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["auth_user.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_auth_session_id"), "auth_session", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_auth_session_token_hash"), "auth_session", ["token_hash"], unique=True)
|
||||
op.create_index(op.f("ix_auth_session_user_id"), "auth_session", ["user_id"], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Perform the downgrade."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f("ix_auth_session_user_id"), table_name="auth_session")
|
||||
op.drop_index(op.f("ix_auth_session_token_hash"), table_name="auth_session")
|
||||
op.drop_index(op.f("ix_auth_session_id"), table_name="auth_session")
|
||||
op.drop_table("auth_session")
|
||||
op.drop_index(op.f("ix_auth_certificate_user_id"), table_name="auth_certificate")
|
||||
op.drop_index(op.f("ix_auth_certificate_id"), table_name="auth_certificate")
|
||||
op.drop_index(op.f("ix_auth_certificate_fingerprint_sha256"), table_name="auth_certificate")
|
||||
op.drop_table("auth_certificate")
|
||||
op.drop_index(op.f("ix_auth_audit_log_id"), table_name="auth_audit_log")
|
||||
op.drop_index(op.f("ix_auth_audit_log_date"), table_name="auth_audit_log")
|
||||
op.drop_table("auth_audit_log")
|
||||
op.drop_index(op.f("ix_auth_api_key_user_id"), table_name="auth_api_key")
|
||||
op.drop_index(op.f("ix_auth_api_key_prefix"), table_name="auth_api_key")
|
||||
op.drop_index(op.f("ix_auth_api_key_id"), table_name="auth_api_key")
|
||||
op.drop_table("auth_api_key")
|
||||
op.drop_index(op.f("ix_auth_user_username"), table_name="auth_user")
|
||||
op.drop_index(op.f("ix_auth_user_id"), table_name="auth_user")
|
||||
op.drop_table("auth_user")
|
||||
# ### end Alembic commands ###
|
||||
@@ -128,3 +128,121 @@ class SpoolField(Base):
|
||||
spool: Mapped[Spool] = relationship(back_populates="extra")
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
|
||||
value: Mapped[str] = mapped_column(Text())
|
||||
|
||||
|
||||
# The authentication tables below are all prefixed `auth_`, because `user` and `session`
|
||||
# are reserved words in PostgreSQL and MySQL and quoting them at every call site is a
|
||||
# footgun not worth taking.
|
||||
#
|
||||
# None of them declare a relationship(). Auth lookups are single-row queries by an
|
||||
# indexed column, so there is nothing to eager-load; a `sessions` collection on AuthUser
|
||||
# would fetch every session row on every login for no benefit, and lazy-loading one
|
||||
# under asyncio raises MissingGreenlet. Related rows are queried and deleted explicitly.
|
||||
#
|
||||
# Enum-valued columns are stored as String, matching `multi_color_direction` above --
|
||||
# there is not one sa.Enum in this schema, and adding one would create a native
|
||||
# PostgreSQL type and a MySQL ENUM(...) with three different ALTER dialects.
|
||||
#
|
||||
# Multi-tenancy seam (see AUTHENTICATION_PLAN.md): every table uses an integer surrogate
|
||||
# primary key so a tenant_id foreign key is a pure add-column later. Uniqueness is
|
||||
# enforced by unique *indexes* rather than named UNIQUE constraints, so
|
||||
# `auth_user.username` can become a composite (tenant_id, username) index without having
|
||||
# to drop a constraint whose generated name differs per dialect. `auth_api_key.prefix`
|
||||
# stays globally unique even then: it is a lookup key, not a name.
|
||||
|
||||
|
||||
class AuthUser(Base):
|
||||
"""A user account. Used from phase 1."""
|
||||
|
||||
__tablename__ = "auth_user"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
username: Mapped[str] = mapped_column(String(64), index=True, unique=True, comment="Stored lowercased.")
|
||||
display_name: Mapped[str | None] = mapped_column(String(128))
|
||||
email: Mapped[str | None] = mapped_column(String(256), comment="Used for OIDC matching only, never delivery.")
|
||||
password_hash: Mapped[str | None] = mapped_column(Text(), comment="Null for OIDC- or certificate-only accounts.")
|
||||
level: Mapped[str] = mapped_column(String(16), comment="read, edit or manage. See spoolman.auth.levels.")
|
||||
is_admin: Mapped[bool] = mapped_column()
|
||||
is_owner: Mapped[bool] = mapped_column()
|
||||
is_active: Mapped[bool] = mapped_column()
|
||||
must_change_password: Mapped[bool] = mapped_column()
|
||||
totp_secret: Mapped[str | None] = mapped_column(Text(), comment="Fernet-encrypted. Phase 4.")
|
||||
totp_enabled: Mapped[bool] = mapped_column()
|
||||
oidc_issuer: Mapped[str | None] = mapped_column(String(256), comment="Phase 3.")
|
||||
oidc_subject: Mapped[str | None] = mapped_column(String(256), comment="Phase 3.")
|
||||
failed_logins: Mapped[int] = mapped_column()
|
||||
locked_until: Mapped[datetime | None] = mapped_column()
|
||||
registered: Mapped[datetime] = mapped_column()
|
||||
last_login: Mapped[datetime | None] = mapped_column()
|
||||
|
||||
|
||||
class AuthSession(Base):
|
||||
"""A server-side login session. Used from phase 1.
|
||||
|
||||
Sessions are opaque random tokens stored as their SHA-256 digest rather than JWTs,
|
||||
so that they can be listed and revoked.
|
||||
"""
|
||||
|
||||
__tablename__ = "auth_session"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("auth_user.id"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), index=True, unique=True, comment="SHA-256 hex of the token.")
|
||||
created: Mapped[datetime] = mapped_column()
|
||||
expires: Mapped[datetime] = mapped_column()
|
||||
last_seen: Mapped[datetime] = mapped_column()
|
||||
remember: Mapped[bool] = mapped_column()
|
||||
user_agent: Mapped[str | None] = mapped_column(String(256))
|
||||
ip: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class AuthApiKey(Base):
|
||||
"""A machine credential. Table created in phase 1, used from phase 2."""
|
||||
|
||||
__tablename__ = "auth_api_key"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("auth_user.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(64))
|
||||
level: Mapped[str] = mapped_column(String(16))
|
||||
prefix: Mapped[str] = mapped_column(String(16), index=True, unique=True, comment="Indexes the row at lookup.")
|
||||
key_hash: Mapped[str] = mapped_column(Text())
|
||||
created: Mapped[datetime] = mapped_column()
|
||||
expires: Mapped[datetime | None] = mapped_column()
|
||||
last_used: Mapped[datetime | None] = mapped_column()
|
||||
revoked: Mapped[bool] = mapped_column()
|
||||
|
||||
|
||||
class AuthAuditLog(Base):
|
||||
"""An audit trail entry. Table created in phase 1, used from phase 2."""
|
||||
|
||||
__tablename__ = "auth_audit_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
date: Mapped[datetime] = mapped_column(index=True)
|
||||
event: Mapped[str] = mapped_column(String(64))
|
||||
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("auth_user.id"))
|
||||
actor_kind: Mapped[str] = mapped_column(String(16))
|
||||
target: Mapped[str | None] = mapped_column(String(128))
|
||||
ip: Mapped[str | None] = mapped_column(String(64))
|
||||
user_agent: Mapped[str | None] = mapped_column(String(256))
|
||||
detail: Mapped[str | None] = mapped_column(Text(), comment="JSON object.")
|
||||
|
||||
|
||||
class AuthCertificate(Base):
|
||||
"""An mTLS client certificate mapped to a user. Table created in phase 1, used from phase 5."""
|
||||
|
||||
__tablename__ = "auth_certificate"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("auth_user.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(64))
|
||||
fingerprint_sha256: Mapped[str] = mapped_column(
|
||||
String(95),
|
||||
index=True,
|
||||
unique=True,
|
||||
comment="Colon-separated uppercase hex, 95 chars.",
|
||||
)
|
||||
subject_dn: Mapped[str] = mapped_column(String(512))
|
||||
created: Mapped[datetime] = mapped_column()
|
||||
last_used: Mapped[datetime | None] = mapped_column()
|
||||
Reference in new issue
Block a user