-
Notifications
You must be signed in to change notification settings - Fork 138
feat(api_keys): implement API key management with CRUD operations and permissions #549
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
da677b7
754cbdc
75e2a8e
d4a3f26
c23cf34
6a1ebe3
d9a240e
cf9e23e
a5413b6
89e82b0
14ebea7
efc3b8a
0e77317
e584000
58d59dc
99cae34
8f82754
2120cd4
d2435b9
75f2070
efd3376
0b5072c
c86f703
2516c9b
9dd0649
4297559
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| import uuid | ||
| from datetime import datetime as dt, timezone as tz | ||
|
|
||
| from sqlalchemy import func, or_, select, update | ||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
| from sqlalchemy.orm import selectinload | ||
|
|
||
| from app.db.models import Admin, AdminStatus, APIKey, APIKeyStatus | ||
| from app.models.api_key import APIKeyCreate | ||
| from app.utils.crypto import api_key_lookup_id, hash_api_key, verify_api_key | ||
| from app.utils.jwt import get_secret_key | ||
|
|
||
|
|
||
| async def create_api_key( | ||
| db: AsyncSession, | ||
| admin_id: int, | ||
| model: APIKeyCreate, | ||
| ) -> tuple[str, APIKey]: | ||
| raw_key = str(uuid.uuid4()) | ||
| pepper = await get_secret_key() | ||
| db_key = APIKey( | ||
| admin_id=admin_id, | ||
| role_id=model.role_id, | ||
| name=model.name, | ||
| note=model.note, | ||
| key_hash=hash_api_key(raw_key, pepper=pepper), | ||
| expire_date=model.expire_date, | ||
| ) | ||
| db.add(db_key) | ||
| await db.flush() | ||
| await db.refresh(db_key) | ||
| return raw_key, db_key | ||
|
|
||
|
|
||
| async def get_api_key_by_raw_key(db: AsyncSession, raw_api_key: str) -> APIKey | None: | ||
| pepper = await get_secret_key() | ||
| lookup_id = api_key_lookup_id(raw_api_key) | ||
|
|
||
| stmt = ( | ||
| select(APIKey) | ||
| .where( | ||
| or_( | ||
| APIKey.key_hash.startswith(f"v2${lookup_id}$"), | ||
| APIKey.key_hash.startswith(f"v1${lookup_id}$"), | ||
| # Handle cases where version prefix might be missing in some older implementations | ||
| APIKey.key_hash.startswith(f"{lookup_id}$"), | ||
| ), | ||
| APIKey.status != APIKeyStatus.disabled, | ||
| ) | ||
| .options(selectinload(APIKey.admin).selectinload(Admin.role), selectinload(APIKey.role)) | ||
| .limit(1) | ||
| ) | ||
| db_key = (await db.execute(stmt)).scalar_one_or_none() | ||
|
|
||
| if db_key is None or not verify_api_key(raw_api_key, db_key.key_hash, pepper=pepper): | ||
| return None | ||
| # Reject if the owning admin is disabled | ||
| if db_key.admin is not None and db_key.admin.status == AdminStatus.disabled: | ||
| return None | ||
| return db_key | ||
|
|
||
|
|
||
| async def get_api_key_by_id(db: AsyncSession, key_id: int) -> APIKey | None: | ||
| stmt = select(APIKey).where(APIKey.id == key_id).options(selectinload(APIKey.admin), selectinload(APIKey.role)) | ||
| return (await db.execute(stmt)).scalar_one_or_none() | ||
|
|
||
|
|
||
| async def get_api_keys( | ||
| db: AsyncSession, | ||
| *, | ||
| admin_id: int | None, | ||
| offset: int, | ||
| limit: int, | ||
| key_id: int | None = None, | ||
| name: str | None = None, | ||
| status: APIKeyStatus | None = None, | ||
| ) -> tuple[list[APIKey], int]: | ||
| stmt = select(APIKey).options(selectinload(APIKey.role)) | ||
| if admin_id is not None: | ||
| stmt = stmt.where(APIKey.admin_id == admin_id) | ||
| if key_id is not None: | ||
| stmt = stmt.where(APIKey.id == key_id) | ||
| if name is not None: | ||
| stmt = stmt.where(APIKey.name == name) | ||
| if status is not None: | ||
| if status == APIKeyStatus.active: | ||
| # active = stored status is active AND not past expire_date | ||
| stmt = stmt.where(APIKey.status == APIKeyStatus.active, ~APIKey.is_expired) | ||
| else: | ||
| # disabled = stored status is disabled (expire_date irrelevant) | ||
| stmt = stmt.where(APIKey.status == APIKeyStatus.disabled) | ||
|
|
||
| total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0 | ||
|
|
||
| stmt = stmt.order_by(APIKey.created_at.desc()).offset(offset).limit(limit) | ||
| rows = list((await db.execute(stmt)).scalars().all()) | ||
| return rows, total | ||
|
|
||
|
|
||
| async def delete_api_key(db: AsyncSession, db_key: APIKey) -> None: | ||
| await db.delete(db_key) | ||
| await db.flush() | ||
|
|
||
|
|
||
| async def revoke_api_key(db: AsyncSession, db_key: APIKey) -> tuple[str, APIKey]: | ||
| raw_key = str(uuid.uuid4()) | ||
| pepper = await get_secret_key() | ||
| db_key.key_hash = hash_api_key(raw_key, pepper=pepper) | ||
| db_key.revoked_at = dt.now(tz.utc) | ||
| db_key.status = APIKeyStatus.active | ||
| await db.flush() | ||
| await db.refresh(db_key) | ||
| return raw_key, db_key | ||
|
|
||
|
|
||
| async def update_api_key(db: AsyncSession, db_key: APIKey, update_data: dict) -> APIKey: | ||
| for key, value in update_data.items(): | ||
| setattr(db_key, key, value) | ||
| await db.flush() | ||
| await db.refresh(db_key) | ||
| return db_key | ||
|
|
||
|
|
||
| async def update_api_keys_role(db: AsyncSession, admin_id: int, new_role_id: int) -> int: | ||
| """Update role_id on all API keys belonging to admin_id. Returns affected row count.""" | ||
| result = await db.execute(update(APIKey).where(APIKey.admin_id == admin_id).values(role_id=new_role_id)) | ||
| return result.rowcount | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| """add api keys table | ||
|
|
||
| Revision ID: c9b48df42f10 | ||
| Revises: f9c69a49f544 | ||
| Create Date: 2026-05-25 00:00:00.000000 | ||
|
|
||
| """ | ||
|
|
||
| import json | ||
|
|
||
| from alembic import op | ||
| import sqlalchemy as sa | ||
| import app.db.compiles_types | ||
|
|
||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision = "c9b48df42f10" | ||
| down_revision = "f9c69a49f544" | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| OWNER_ADMIN_API_KEY_PERMS = { | ||
| "create": True, | ||
| "read": True, | ||
| "read_simple": True, | ||
| "modify": True, | ||
| "delete": True, | ||
| } | ||
|
|
||
| OPERATOR_API_KEY_PERMS = { | ||
| "read": {"scope": 1}, | ||
| "read_simple": {"scope": 1}, | ||
| "modify": {"scope": 1}, | ||
| "delete": {"scope": 1}, | ||
| } | ||
|
|
||
|
|
||
| def _normalize_permissions(value): | ||
| if value is None: | ||
| return {} | ||
| if isinstance(value, str): | ||
| try: | ||
| return json.loads(value) | ||
| except json.JSONDecodeError: | ||
| return {} | ||
| if isinstance(value, dict): | ||
| return value | ||
| return {} | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| op.create_table( | ||
| "api_keys", | ||
| sa.Column("id", app.db.compiles_types.SqliteCompatibleBigInteger(), autoincrement=True, nullable=False), | ||
| sa.Column("admin_id", app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=False), | ||
| sa.Column("name", sa.String(length=128), nullable=False), | ||
| sa.Column("note", sa.String(length=512), nullable=True), | ||
| sa.Column("key_hash", sa.String(length=128), nullable=False), | ||
| sa.Column("role_id", app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=False), | ||
| sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), | ||
| sa.Column("expire_date", sa.DateTime(timezone=True), nullable=True), | ||
| sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), | ||
| sa.Column( | ||
| "status", | ||
| sa.Enum("active", "disabled", name="apikeystatus"), | ||
| nullable=False, | ||
| server_default="active", | ||
| ), | ||
| sa.ForeignKeyConstraint( | ||
| ["admin_id"], ["admins.id"], name=op.f("fk_api_keys_admin_id_admins"), ondelete="CASCADE" | ||
| ), | ||
| sa.ForeignKeyConstraint(["role_id"], ["admin_roles.id"], name=op.f("fk_api_keys_role_id_admin_roles")), | ||
| sa.PrimaryKeyConstraint("id", name=op.f("pk_api_keys")), | ||
| sa.UniqueConstraint("key_hash", name=op.f("uq_api_keys_key_hash")), | ||
| sa.UniqueConstraint("admin_id", "name", name=op.f("uq_api_keys_admin_id")), | ||
| ) | ||
| with op.batch_alter_table("api_keys", schema=None) as batch_op: | ||
| batch_op.create_index(batch_op.f("ix_api_keys_admin_id"), ["admin_id"], unique=False) | ||
| batch_op.create_index(batch_op.f("ix_api_keys_created_at"), ["created_at"], unique=False) | ||
| batch_op.create_index(batch_op.f("ix_api_keys_expire_date"), ["expire_date"], unique=False) | ||
|
|
||
| conn = op.get_bind() | ||
| admin_roles = sa.table( | ||
| "admin_roles", | ||
| sa.column("id", sa.Integer), | ||
| sa.column("name", sa.String), | ||
| sa.column("permissions", sa.JSON), | ||
| ) | ||
|
|
||
| rows = conn.execute(sa.select(admin_roles.c.id, admin_roles.c.name, admin_roles.c.permissions)).fetchall() | ||
| for role_id, role_name, role_permissions in rows: | ||
| permissions = _normalize_permissions(role_permissions) | ||
| if "api_keys" in permissions: | ||
| continue | ||
|
|
||
| if role_name in {"owner", "administrator"}: | ||
| permissions["api_keys"] = OWNER_ADMIN_API_KEY_PERMS | ||
| else: | ||
| permissions["api_keys"] = OPERATOR_API_KEY_PERMS | ||
|
|
||
| conn.execute(admin_roles.update().where(admin_roles.c.id == role_id).values(permissions=permissions)) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| conn = op.get_bind() | ||
| admin_roles = sa.table( | ||
| "admin_roles", | ||
| sa.column("id", sa.Integer), | ||
| sa.column("permissions", sa.JSON), | ||
| ) | ||
|
|
||
| rows = conn.execute(sa.select(admin_roles.c.id, admin_roles.c.permissions)).fetchall() | ||
| for role_id, role_permissions in rows: | ||
| permissions = _normalize_permissions(role_permissions) | ||
| if "api_keys" not in permissions: | ||
| continue | ||
| permissions.pop("api_keys", None) | ||
| conn.execute(admin_roles.update().where(admin_roles.c.id == role_id).values(permissions=permissions)) | ||
|
Comment on lines
+113
to
+119
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Preserve pre-existing
|
||
|
|
||
| with op.batch_alter_table("api_keys", schema=None) as batch_op: | ||
| batch_op.drop_index(batch_op.f("ix_api_keys_expire_date")) | ||
| batch_op.drop_index(batch_op.f("ix_api_keys_created_at")) | ||
| batch_op.drop_index(batch_op.f("ix_api_keys_admin_id")) | ||
|
|
||
| op.drop_table("api_keys") | ||
|
|
||
| # Drop the enum type for PostgreSQL | ||
| if conn.dialect.name == "postgresql": | ||
| op.execute("DROP TYPE IF EXISTS apikeystatus") | ||
Uh oh!
There was an error while loading. Please reload this page.