Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
da677b7
feat(api_keys): implement API key management with CRUD operations and…
ImMohammad20000 May 25, 2026
754cbdc
Merge branch 'dev' into apikey
ImMohammad20000 May 25, 2026
75e2a8e
chore: better code
ImMohammad20000 May 31, 2026
d4a3f26
fix
ImMohammad20000 May 31, 2026
c23cf34
fix
ImMohammad20000 May 31, 2026
6a1ebe3
Update c9b48df42f10_add_api_keys_table.py
ImMohammad20000 May 31, 2026
d9a240e
Potential fix for pull request finding 'CodeQL / Use of a broken or w…
ImMohammad20000 May 31, 2026
cf9e23e
format code
ImMohammad20000 May 31, 2026
a5413b6
refactor: apikey search
ImMohammad20000 May 31, 2026
89e82b0
feat: Add limits for creating apikeys and control disabled admin apikeys
ImMohammad20000 May 31, 2026
14ebea7
refactor: Add is_usable property and remove limit on apikey creation
ImMohammad20000 May 31, 2026
efc3b8a
fix: change apikey role when admin role is changed
ImMohammad20000 May 31, 2026
0e77317
fix: Hash the generated key, not model.raw_key
ImMohammad20000 May 31, 2026
e584000
fix: API key role sync should be atomic with admin update
ImMohammad20000 May 31, 2026
58d59dc
fix: 204 delete response body
ImMohammad20000 May 31, 2026
99cae34
fix: Keep the API-key fallback when bearer auth is invalid
ImMohammad20000 May 31, 2026
8f82754
fix: Salted hashes cannot be used for exact DB lookup
ImMohammad20000 May 31, 2026
2120cd4
format code
ImMohammad20000 May 31, 2026
d2435b9
feat: Add catch for apikeys and add notifications for apikeys and add
ImMohammad20000 May 31, 2026
75f2070
format code
ImMohammad20000 May 31, 2026
efd3376
fix import error
ImMohammad20000 May 31, 2026
0b5072c
feat: Add ablity to revoke api keys
ImMohammad20000 Jun 7, 2026
c86f703
Merge branch 'dev' into apikey
ImMohammad20000 Jun 8, 2026
2516c9b
fix igration id
ImMohammad20000 Jun 8, 2026
9dd0649
fix migration error
ImMohammad20000 Jun 8, 2026
4297559
format code and fix ruff error
ImMohammad20000 Jun 8, 2026
dad99d8
refactor: replace hmac with sha256
ImMohammad20000 Jun 13, 2026
b7f5841
refactor: replace APIKeysPermissions with CRUDPermissions in RolePerm…
ImMohammad20000 Jun 15, 2026
72a184c
fix: update permission requirement from 'modify' to 'update' in modif…
ImMohammad20000 Jun 15, 2026
989383d
feat(dashboard): implement API key management features
ImMohammad20000 Jun 15, 2026
df8a89a
fix: update permission requirement from 'modify' to 'update' in API k…
ImMohammad20000 Jun 15, 2026
e0d8c7a
Refactor code structure for improved readability and maintainability
ImMohammad20000 Jun 15, 2026
61a804e
feat(api-keys): enhance API key management with advanced search and f…
ImMohammad20000 Jun 15, 2026
cb5d3d5
Merge branch 'dev' into apikey
ImMohammad20000 Jun 15, 2026
688fb61
fix migrations
ImMohammad20000 Jun 15, 2026
a591bc5
fix
ImMohammad20000 Jun 15, 2026
c23e4ce
Merge branch 'dev' into apikey
ImMohammad20000 Jun 19, 2026
3a1f855
fix migrations
ImMohammad20000 Jun 19, 2026
bb0081c
fieat: Add api_key_trimmed to save first and last 3 char of apikey
w-zz-w-zz-w Jun 19, 2026
b435ab4
feat: add prefix for apikeys
w-zz-w-zz-w Jun 19, 2026
c53ce90
fix
w-zz-w-zz-w Jun 19, 2026
80cca8d
refactor(api_key): replace role_id FK with roles JSON column + strict…
w-zz-w-zz-w Jun 19, 2026
4c5db60
refactor: update frontend
w-zz-w-zz-w Jun 19, 2026
95fba43
feat(api_key): add permission inheritance for API keys
x0sina Jun 19, 2026
4e823a8
feat(api_key): add admin assignment and improve UI/UX
x0sina Jun 20, 2026
7be142b
feat(api_key): add admin assignment capability for API keys
x0sina Jun 20, 2026
6a40899
feat(api_keys): change create permission to boolean-only
x0sina Jun 20, 2026
0d8a39e
fix(migrations): remove default values and unused column from api_keys
x0sina Jun 20, 2026
0ba8552
feat(api_keys): add bulk delete functionality
x0sina Jun 20, 2026
1ff38cd
test(api_key): remove boolean create permission acceptance test
x0sina Jun 20, 2026
a1ff376
feat(dashboard): improve RTL and locale-aware formatting for API keys
x0sina Jun 20, 2026
8e74fe1
test(api_key): add owner admin fixture and improve test isolation
x0sina Jun 20, 2026
062dd61
feat(api_key): upgrade to bcrypt hashing with secret key derivation
x0sina Jun 20, 2026
fda2c74
feat(api_key): simplify crypto by removing secret key derivation
x0sina Jun 20, 2026
c4b65d7
feat(list-generator): add mobile-specific column width and visibility…
x0sina Jun 20, 2026
795918c
feat(api-key-modal): simplify default permissions initialization
x0sina Jun 20, 2026
e299299
Revert "feat(api_key): simplify crypto by removing secret key derivat…
ImMohammad20000 Jun 21, 2026
27fb836
Revert "feat(api_key): upgrade to bcrypt hashing with secret key deri…
ImMohammad20000 Jun 21, 2026
bfc374e
fix: delete admin apikeys befor deleting admin
ImMohammad20000 Jun 21, 2026
0a14dd3
update index.ts file
ImMohammad20000 Jun 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/db/crud/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
get_complete_period_start_for_filter,
to_utc_for_filter,
)
from app.db.models import Admin, AdminNotificationReminder, AdminRole, AdminUsageLogs, NodeUserUsage, ReminderType, User
from app.db.models import APIKey, Admin, AdminNotificationReminder, AdminRole, AdminUsageLogs, NodeUserUsage, ReminderType, User
from app.models.admin import (
AdminCreate,
AdminDetails,
Expand Down Expand Up @@ -235,6 +235,7 @@ async def remove_admin(db: AsyncSession, dbadmin: Admin) -> None:
db (AsyncSession): Database session.
dbadmin (Admin): The admin object to be removed.
"""
await db.execute(delete(APIKey).where(APIKey.admin_id == dbadmin.id))
await db.delete(dbadmin)
await db.commit()

Expand Down Expand Up @@ -771,6 +772,7 @@ async def remove_admins(db: AsyncSession, admin_ids: list[int]) -> None:
return

await db.execute(update(User).where(User.admin_id.in_(admin_ids)).values(admin_id=None))
await db.execute(delete(APIKey).where(APIKey.admin_id.in_(admin_ids)))
await db.execute(delete(AdminUsageLogs).where(AdminUsageLogs.admin_id.in_(admin_ids)))
await db.execute(delete(Admin).where(Admin.id.in_(admin_ids)))
await db.commit()
125 changes: 125 additions & 0 deletions app/db/crud/api_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import uuid
from datetime import datetime as dt, timezone as tz

from sqlalchemy import func, select
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


async def create_api_key(
db: AsyncSession,
admin_id: int,
model: APIKeyCreate,
) -> tuple[str, APIKey]:
raw_uuid = str(uuid.uuid4())
raw_key = f"pg_key_{raw_uuid}"
db_key = APIKey(
admin_id=admin_id,
permissions={} if model.inherit_permissions else model.permissions.model_dump(exclude_none=True),
inherit_permissions=model.inherit_permissions,
name=model.name,
note=model.note,
key_hash=hash_api_key(raw_key),
api_key_trimmed=f"pg_key_{raw_uuid[:3]}***{raw_uuid[-3:]}",
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:
lookup_id = api_key_lookup_id(raw_api_key)

stmt = (
select(APIKey)
.where(
APIKey.key_hash.startswith(f"v1${lookup_id}$"),
APIKey.status != APIKeyStatus.disabled,
)
.options(selectinload(APIKey.admin).selectinload(Admin.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):
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))
return (await db.execute(stmt)).scalar_one_or_none()


async def get_api_keys_by_ids(db: AsyncSession, key_ids: list[int]) -> list[APIKey]:
if not key_ids:
return []

stmt = select(APIKey).where(APIKey.id.in_(key_ids)).options(selectinload(APIKey.admin))
return list((await db.execute(stmt)).scalars().all())


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)
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_uuid = str(uuid.uuid4())
raw_key = f"pg_key_{raw_uuid}"
db_key.key_hash = hash_api_key(raw_key)
db_key.api_key_trimmed = f"pg_key_{raw_uuid[:3]}***{raw_uuid[-3:]}"
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
24 changes: 24 additions & 0 deletions app/db/migrations/versions/9aa99aaee80f_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""empty message

Revision ID: 9aa99aaee80f
Revises: c9b48df42f10, d4f8c1b2a9e3
Create Date: 2026-06-19 10:09:22.055892

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '9aa99aaee80f'
down_revision = ('c9b48df42f10', 'd4f8c1b2a9e3')
branch_labels = None
depends_on = None


def upgrade() -> None:
pass


def downgrade() -> None:
pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""api key inherit permissions

Revision ID: b6c9d0e1f2a3
Revises: 9aa99aaee80f
Create Date: 2026-06-19 20:30:00.000000

"""

import json

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "b6c9d0e1f2a3"
down_revision = "9aa99aaee80f"
branch_labels = None
depends_on = None


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:
with op.batch_alter_table("api_keys", schema=None) as batch_op:
batch_op.add_column(sa.Column("inherit_permissions", sa.Boolean(), nullable=False, server_default="1"))

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)
api_key_permissions = permissions.get("api_keys")
if not isinstance(api_key_permissions, dict):
continue

changed = False
for action in ("read", "read_simple", "update", "delete"):
if api_key_permissions.get(action) is True:
api_key_permissions[action] = {"scope": 2}
changed = True

if changed:
permissions["api_keys"] = api_key_permissions
conn.execute(admin_roles.update().where(admin_roles.c.id == role_id).values(permissions=permissions))


def downgrade() -> None:
with op.batch_alter_table("api_keys", schema=None) as batch_op:
batch_op.drop_column("inherit_permissions")
111 changes: 111 additions & 0 deletions app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""add api keys table

Revision ID: c9b48df42f10
Revises: f9c69a49f544
Create Date: 2026-05-25 00:00:00.000000

"""

from alembic import op
import sqlalchemy as sa
import app.db.compiles_types
import json


# revision identifiers, used by Alembic.
revision = "c9b48df42f10"
down_revision = "f9c69a49f544"
branch_labels = None
depends_on = None


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("api_key_trimmed", sa.String(length=16), nullable=False),
sa.Column("permissions", sa.JSON(), 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.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)

# Update admin_roles permissions to include api_keys entry
OWNER_ADMIN_API_KEY_PERMS = {
"create": True,
"read": {"scope": 2},
"read_simple": {"scope": 2},
"update": {"scope": 2},
"delete": {"scope": 2},
}
OPERATOR_API_KEY_PERMS = {
"read": {"scope": 1},
"read_simple": {"scope": 1},
"update": {"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 {}

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:
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")

conn = op.get_bind()
if conn.dialect.name == "postgresql":
op.execute("DROP TYPE IF EXISTS apikeystatus")
Loading