diff --git a/migrations/versions/2026_07_27_2013-6e496ae15523_auth_tables.py b/migrations/versions/2026_07_27_2013-6e496ae15523_auth_tables.py new file mode 100644 index 00000000..d277ea22 --- /dev/null +++ b/migrations/versions/2026_07_27_2013-6e496ae15523_auth_tables.py @@ -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 ### diff --git a/spoolman/database/models.py b/spoolman/database/models.py index e7d75980..24440289 100644 --- a/spoolman/database/models.py +++ b/spoolman/database/models.py @@ -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()