From 735890943266de065fa3b50849530ba450afb402 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 23 Mar 2026 20:52:25 -0400 Subject: [PATCH] feat: add ApiKey model and migration 0027 Adds api_keys table with user_id FK, key_hash, key_prefix, scope (read/write), last_used_at, revoked_at. SHA-256 hashed, soft-delete. Co-Authored-By: Claude Sonnet 4.6 --- alembic/versions/0027_add_api_keys.py | 34 +++++++++++++++++++++ src/fabledassistant/models/__init__.py | 1 + src/fabledassistant/models/api_key.py | 42 ++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 alembic/versions/0027_add_api_keys.py create mode 100644 src/fabledassistant/models/api_key.py diff --git a/alembic/versions/0027_add_api_keys.py b/alembic/versions/0027_add_api_keys.py new file mode 100644 index 0000000..9ff7c32 --- /dev/null +++ b/alembic/versions/0027_add_api_keys.py @@ -0,0 +1,34 @@ +"""add api_keys table + +Revision ID: 0027 +Revises: 0026 +Create Date: 2026-03-23 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0027" +down_revision = "0026" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "api_keys", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("key_hash", sa.Text(), nullable=False, unique=True), + sa.Column("key_prefix", sa.Text(), nullable=False), + sa.Column("scope", sa.Text(), nullable=False), + sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"]) + op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"]) + + +def downgrade() -> None: + op.drop_table("api_keys") diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py index ba883bc..4e406c1 100644 --- a/src/fabledassistant/models/__init__.py +++ b/src/fabledassistant/models/__init__.py @@ -40,3 +40,4 @@ from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402, from fabledassistant.models.notification import Notification # noqa: E402, F401 from fabledassistant.models.rss_feed import RssFeed, RssItem # noqa: E402, F401 from fabledassistant.models.weather_cache import WeatherCache # noqa: E402, F401 +from fabledassistant.models.api_key import ApiKey # noqa: E402, F401 diff --git a/src/fabledassistant/models/api_key.py b/src/fabledassistant/models/api_key.py new file mode 100644 index 0000000..e7e45c8 --- /dev/null +++ b/src/fabledassistant/models/api_key.py @@ -0,0 +1,42 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text +from sqlalchemy.orm import Mapped, mapped_column + +from fabledassistant.models import Base +from fabledassistant.models.base import CreatedAtMixin + + +class ApiKey(Base, CreatedAtMixin): + __tablename__ = "api_keys" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(Text, nullable=False) + key_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True) + key_prefix: Mapped[str] = mapped_column(Text, nullable=False) + scope: Mapped[str] = mapped_column(Text, nullable=False) # "read" or "write" + last_used_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + revoked_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + __table_args__ = ( + Index("ix_api_keys_user_id", "user_id"), + Index("ix_api_keys_key_hash", "key_hash"), + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "key_prefix": self.key_prefix, + "scope": self.scope, + "last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, + "created_at": self.created_at.isoformat(), + "revoked_at": self.revoked_at.isoformat() if self.revoked_at else None, + }