From da677b7b8d68c4cca1fbeb08028167d48156d1a8 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Mon, 25 May 2026 21:35:33 +0330 Subject: [PATCH 01/56] feat(api_keys): implement API key management with CRUD operations and permissions --- app/db/crud/api_key.py | 80 ++++++++++++ .../c9b48df42f10_add_api_keys_table.py | 115 ++++++++++++++++++ app/db/models.py | 24 ++++ app/models/admin_role.py | 8 ++ app/models/api_key.py | 46 +++++++ app/operation/api_key.py | 89 ++++++++++++++ app/routers/__init__.py | 6 +- app/routers/api_key.py | 63 ++++++++++ app/routers/authentication.py | 112 +++++++++++++++-- 9 files changed, 530 insertions(+), 13 deletions(-) create mode 100644 app/db/crud/api_key.py create mode 100644 app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py create mode 100644 app/models/api_key.py create mode 100644 app/operation/api_key.py create mode 100644 app/routers/api_key.py diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py new file mode 100644 index 000000000..ca33e24bf --- /dev/null +++ b/app/db/crud/api_key.py @@ -0,0 +1,80 @@ +import hashlib +import uuid +from datetime import datetime as dt + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Admin, APIKey + + +def hash_api_key(raw_api_key: str) -> str: + return hashlib.sha256(raw_api_key.encode("utf-8")).hexdigest() + + +async def create_api_key( + db: AsyncSession, + *, + admin_id: int, + role_id: int, + name: str, + note: str | None, + expire_date: dt | None, +) -> tuple[str, APIKey]: + raw_key = str(uuid.uuid4()) + db_key = APIKey( + admin_id=admin_id, + role_id=role_id, + name=name, + note=note, + key_hash=hash_api_key(raw_key), + expire_date=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_hash(db: AsyncSession, key_hash: str) -> APIKey | None: + stmt = ( + select(APIKey) + .where(APIKey.key_hash == key_hash) + .options(selectinload(APIKey.admin).selectinload(Admin.role), selectinload(APIKey.role)) + ) + return (await db.execute(stmt)).scalar_one_or_none() + + +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_key_by_id_for_admin(db: AsyncSession, *, key_id: int, admin_id: int | None = None) -> APIKey | None: + stmt = select(APIKey).where(APIKey.id == key_id).options(selectinload(APIKey.admin), selectinload(APIKey.role)) + if admin_id is not None: + stmt = stmt.where(APIKey.admin_id == admin_id) + return (await db.execute(stmt)).scalar_one_or_none() + + +async def get_api_key_by_name(db: AsyncSession, *, admin_id: int, name: str) -> APIKey | None: + stmt = select(APIKey).where(APIKey.admin_id == admin_id, APIKey.name == name) + return (await db.execute(stmt)).scalar_one_or_none() + + +async def get_api_keys(db: AsyncSession, *, admin_id: int | None, offset: int, limit: int) -> 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) + + 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() diff --git a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py new file mode 100644 index 000000000..c8a635f2d --- /dev/null +++ b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py @@ -0,0 +1,115 @@ +"""add api keys table + +Revision ID: c9b48df42f10 +Revises: 2c6e9d34a1f0 +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 = "2c6e9d34a1f0" +branch_labels = None +depends_on = None + + +OWNER_ADMIN_API_KEY_PERMS = { + "create": True, + "read": True, + "read_simple": True, + "delete": True, +} + +OPERATOR_API_KEY_PERMS = { + "read": {"scope": 1}, + "read_simple": {"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.ForeignKeyConstraint(["admin_id"], ["admins.id"], name=op.f("fk_api_keys_admin_id_admins")), + 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="uq_api_keys_admin_id_name"), + ) + 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)) + + 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") diff --git a/app/db/models.py b/app/db/models.py index fd57a3a81..7b4016d18 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -84,6 +84,9 @@ class Admin(Base, IdMixin, CreatedAtUTCMixin): notification_reminders: Mapped[List["AdminNotificationReminder"]] = relationship( back_populates="admin", init=False, default_factory=list, cascade="all, delete-orphan" ) + api_keys: Mapped[List["APIKey"]] = relationship( + back_populates="admin", init=False, default_factory=list, cascade="all, delete-orphan" + ) password_reset_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) telegram_id: Mapped[Optional[int]] = mapped_column(BigInteger, default=None) @@ -838,6 +841,7 @@ class AdminRole(Base, IdMixin, CreatedAtUTCMixin): disabled_when_limited: Mapped[bool] = mapped_column(default=False, server_default="0") disable_users_when_limited: Mapped[bool] = mapped_column(default=True, server_default="1") admins: Mapped[List["Admin"]] = relationship(back_populates="role", init=False, viewonly=True, lazy="noload") + api_keys: Mapped[List["APIKey"]] = relationship(back_populates="role", init=False, lazy="noload") @hybrid_property def is_builtin(self) -> bool: @@ -849,6 +853,26 @@ def is_builtin(cls): return cls.id <= 3 +class APIKey(Base, IdMixin, CreatedAtUTCMixin): + __tablename__ = "api_keys" + __table_args__ = ( + UniqueConstraint("key_hash"), + UniqueConstraint("admin_id", "name"), + Index("ix_api_keys_admin_id", "admin_id"), + Index("ix_api_keys_created_at", "created_at"), + Index("ix_api_keys_expire_date", "expire_date"), + ) + + admin_id: Mapped[int] = fk_id_column("admins.id", ondelete="CASCADE") + admin: Mapped["Admin"] = relationship(back_populates="api_keys", init=False) + name: Mapped[str] = mapped_column(String(128), nullable=False) + note: Mapped[Optional[str]] = mapped_column(String(512), default=None) + key_hash: Mapped[str] = mapped_column(String(128), nullable=False) + role_id: Mapped[int] = fk_id_column("admin_roles.id") + role: Mapped["AdminRole"] = relationship(back_populates="api_keys", init=False, lazy="selectin") + expire_date: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) + + class TempKey(Base): __tablename__ = "temp_keys" diff --git a/app/models/admin_role.py b/app/models/admin_role.py index 0ef486d4a..79191ff85 100644 --- a/app/models/admin_role.py +++ b/app/models/admin_role.py @@ -81,6 +81,13 @@ class HwidsPermissions(_ResourcePermissions): delete: RoleActionValue | None = None +class APIKeysPermissions(_ResourcePermissions): + create: RoleActionValue | None = None + read: RoleActionValue | None = None + read_simple: RoleActionValue | None = None + delete: RoleActionValue | None = None + + class RoleLimits(BaseModel): max_users: int | None = None data_limit_min: int | None = None @@ -126,6 +133,7 @@ class RolePermissions(BaseModel): system: SystemPermissions | None = None hwids: HwidsPermissions | None = None admin_roles: CRUDPermissions | None = None + api_keys: APIKeysPermissions | None = None model_config = ConfigDict(from_attributes=True) diff --git a/app/models/api_key.py b/app/models/api_key.py new file mode 100644 index 000000000..42b237a3b --- /dev/null +++ b/app/models/api_key.py @@ -0,0 +1,46 @@ +from datetime import datetime as dt, timezone as tz +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.utils.helpers import fix_datetime_timezone + + +class APIKeyBase(BaseModel): + name: str = Field(min_length=1, max_length=128) + note: str | None = Field(default=None, max_length=512) + role_id: int = Field(ge=1) + expire_date: dt | None = None + + model_config = ConfigDict(from_attributes=True) + + +class APIKeyCreate(APIKeyBase): + @field_validator("expire_date", mode="before") + @classmethod + def validate_expire_date(cls, value): + if value is None: + return None + parsed = fix_datetime_timezone(value) + if parsed <= dt.now(tz.utc): + raise ValueError("expire_date must be in the future") + return parsed + + +class APIKeyResponse(APIKeyBase): + id: int + admin_id: int + created_at: dt + + +class APIKeyCreateResponse(APIKeyResponse): + api_key: str + + +class APIKeysResponse(BaseModel): + api_keys: list[APIKeyResponse] + total: int + + +Offset = Annotated[int, Field(default=0, ge=0)] +Limit = Annotated[int, Field(default=50, ge=1, le=200)] diff --git a/app/operation/api_key.py b/app/operation/api_key.py new file mode 100644 index 000000000..36b988123 --- /dev/null +++ b/app/operation/api_key.py @@ -0,0 +1,89 @@ +from datetime import datetime as dt, timezone as tz + +from sqlalchemy.exc import IntegrityError + +from app.db import AsyncSession +from app.db.crud.admin_role import get_role +from app.db.crud.api_key import ( + create_api_key, + delete_api_key, + get_api_key_by_id, + get_api_key_by_id_for_admin, + get_api_key_by_name, + get_api_keys, +) +from app.models.admin import AdminDetails +from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysResponse +from app.operation import BaseOperation + + +class APIKeyOperation(BaseOperation): + async def create_api_key( + self, db: AsyncSession, *, admin: AdminDetails, data: APIKeyCreate + ) -> APIKeyCreateResponse: + if admin.id is None: + await self.raise_error(message="API key creation is not available for env admins", code=403) + + role = await get_role(db, data.role_id) + if role is None: + await self.raise_error(message="Role not found", code=404) + + if not admin.is_owner and admin.role and role.id != admin.role.id: + await self.raise_error(message="Only owner can create API keys with a different role", code=403) + + duplicate = await get_api_key_by_name(db, admin_id=admin.id, name=data.name) + if duplicate is not None: + await self.raise_error(message="API key name already exists", code=409) + + if data.expire_date is not None and data.expire_date <= dt.now(tz.utc): + await self.raise_error(message="expire_date must be in the future", code=422) + + try: + raw_key, db_key = await create_api_key( + db, + admin_id=admin.id, + role_id=data.role_id, + name=data.name, + note=data.note, + expire_date=data.expire_date, + ) + await db.commit() + except IntegrityError: + await self.raise_error(message="API key already exists", code=409, db=db) + + return APIKeyCreateResponse( + id=db_key.id, + admin_id=db_key.admin_id, + name=db_key.name, + note=db_key.note, + role_id=db_key.role_id, + created_at=db_key.created_at, + expire_date=db_key.expire_date, + api_key=raw_key, + ) + + async def list_api_keys(self, db: AsyncSession, *, admin: AdminDetails, offset: int, limit: int) -> APIKeysResponse: + scope_admin_id = None if admin.is_owner else admin.id + rows, total = await get_api_keys(db, admin_id=scope_admin_id, offset=offset, limit=limit) + return APIKeysResponse(api_keys=[APIKeyResponse.model_validate(row) for row in rows], total=total) + + async def get_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> APIKeyResponse: + db_key = await get_api_key_by_id_for_admin( + db, + key_id=key_id, + admin_id=None if admin.is_owner else admin.id, + ) + if db_key is None: + await self.raise_error(message="API key not found", code=404) + return APIKeyResponse.model_validate(db_key) + + async def delete_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> None: + db_key = await get_api_key_by_id(db, key_id) + if db_key is None: + await self.raise_error(message="API key not found", code=404) + + if not admin.is_owner and db_key.admin_id != admin.id: + await self.raise_error(message="Permission denied", code=403) + + await delete_api_key(db, db_key) + await db.commit() diff --git a/app/routers/__init__.py b/app/routers/__init__.py index 18dcc6d3a..c7def101f 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -3,11 +3,13 @@ from . import ( admin, admin_role, - core, + api_key, client_template, + core, group, home, host, + hwid, node, settings, setup, @@ -15,7 +17,6 @@ system, user, user_template, - hwid, ) api_router = APIRouter() @@ -23,6 +24,7 @@ routers = [ home.router, admin.router, + api_key.router, admin_role.router, setup.router, system.router, diff --git a/app/routers/api_key.py b/app/routers/api_key.py new file mode 100644 index 000000000..b7969076d --- /dev/null +++ b/app/routers/api_key.py @@ -0,0 +1,63 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Query, status + +from app.db import AsyncSession, get_db +from app.models.admin import AdminDetails +from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysResponse +from app.operation import OperatorType +from app.operation.api_key import APIKeyOperation +from app.utils import responses + +from .authentication import require_permission + +router = APIRouter( + tags=["API Keys"], + prefix="/api/api_key", + responses={401: responses._401, 403: responses._403}, +) + +api_key_operator = APIKeyOperation(operator_type=OperatorType.API) + + +@router.post( + "", + response_model=APIKeyCreateResponse, + status_code=status.HTTP_201_CREATED, + responses={409: responses._409}, +) +async def create_api_key( + data: APIKeyCreate, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("api_keys", "create")), +): + return await api_key_operator.create_api_key(db, admin=admin, data=data) + + +@router.get("s", response_model=APIKeysResponse) +async def list_api_keys( + offset: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("api_keys", "read")), +): + return await api_key_operator.list_api_keys(db, admin=admin, offset=offset, limit=limit) + + +@router.get("/{key_id}", response_model=APIKeyResponse, responses={404: responses._404}) +async def get_api_key( + key_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("api_keys", "read")), +): + return await api_key_operator.get_api_key(db, admin=admin, key_id=key_id) + + +@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT, responses={404: responses._404}) +async def remove_api_key( + key_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("api_keys", "delete")), +): + await api_key_operator.delete_api_key(db, admin=admin, key_id=key_id) + return {} diff --git a/app/routers/authentication.py b/app/routers/authentication.py index b7bdb2150..e9d044728 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -1,7 +1,8 @@ -from datetime import timezone as tz +from datetime import datetime as dt, timezone as tz +from uuid import UUID from aiogram.utils.web_app import WebAppInitData, safe_parse_webapp_init_data -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordBearer from sqlalchemy import func, select @@ -12,6 +13,7 @@ get_admin_by_id as get_admin_by_id_crud, get_admin_by_telegram_id, ) +from app.db.crud.api_key import get_api_key_by_hash, hash_api_key from app.db.models import Admin, AdminUsageLogs, User from app.models.admin import AdminDetails, AdminRoleData, AdminStatus, AdminValidationResult, verify_password from app.models.admin_role import RoleAccess, RoleFeatures, RoleLimits, RolePermissions @@ -21,7 +23,7 @@ from app.utils.jwt import get_admin_payload from config import auth_settings, runtime_settings -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/token") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/token", auto_error=False) # Owner-level role data given to env admins — full permissions, bypasses all checks _ENV_ADMIN_ROLE = AdminRoleData( @@ -73,6 +75,74 @@ def _is_token_valid_for_admin(db_admin: Admin, payload: dict) -> bool: return db_admin.password_reset_at.astimezone(tz.utc) <= payload.get("created_at") +def _extract_api_key(request: Request) -> str | None: + auth = request.headers.get("Authorization") + x_api_key = request.headers.get("X-Api-Key") + + if x_api_key: + return x_api_key.strip() + + if not auth: + return None + + scheme, _, credentials = auth.partition(" ") + if not scheme or not credentials: + return None + + scheme = scheme.lower().strip() + credentials = credentials.strip() + if scheme == "apikey": + return credentials + return None + + +async def _build_admin_metrics(db: AsyncSession, admin_id: int) -> tuple[int, int]: + total_users = (await db.execute(select(func.count(User.id)).where(User.admin_id == admin_id))).scalar() or 0 + reseted_usage = ( + await db.execute( + select(func.coalesce(func.sum(AdminUsageLogs.used_traffic_at_reset), 0)).where( + AdminUsageLogs.admin_id == admin_id + ) + ) + ).scalar() or 0 + return int(total_users), int(reseted_usage) + + +async def get_admin_from_api_key(db: AsyncSession, raw_key: str, *, with_metrics: bool = False) -> AdminDetails | None: + if not raw_key: + return None + + try: + parsed_key = UUID(raw_key) + except ValueError: + return None + if parsed_key.version != 4: + return None + + db_key = await get_api_key_by_hash(db, hash_api_key(str(parsed_key))) + if db_key is None: + return None + + db_admin = db_key.admin + if db_admin is None: + return None + + if db_key.expire_date is not None: + expire_at = db_key.expire_date if db_key.expire_date.tzinfo else db_key.expire_date.replace(tzinfo=tz.utc) + if expire_at.astimezone(tz.utc) <= dt.now(tz.utc): + return None + + if with_metrics: + total_users, reseted_usage = await _build_admin_metrics(db, db_admin.id) + admin = _build_admin_details(db_admin, total_users=total_users, reseted_usage=reseted_usage) + else: + admin = _build_admin_details(db_admin) + + if db_key.role is not None: + admin.role = AdminRoleData.model_validate(db_key.role) + return admin + + async def get_admin(db: AsyncSession, token: str) -> AdminDetails | None: payload = await get_admin_payload(token) if not payload: @@ -134,36 +204,56 @@ async def get_admin_with_metrics(db: AsyncSession, token: str) -> AdminDetails | return None -async def get_current(db: AsyncSession = Depends(get_db), token: str = Depends(oauth2_scheme)): - admin: AdminDetails | None = await get_admin(db, token) +async def get_current(request: Request, db: AsyncSession = Depends(get_db), token: str | None = Depends(oauth2_scheme)): + admin: AdminDetails | None = None + + if token: + admin = await get_admin(db, token) + else: + api_key = _extract_api_key(request) + if api_key: + admin = await get_admin_from_api_key(db, api_key) + if not admin: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, + headers={"WWW-Authenticate": "Bearer, ApiKey"}, ) if admin.status == AdminStatus.disabled: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="your account has been disabled", - headers={"WWW-Authenticate": "Bearer"}, + headers={"WWW-Authenticate": "Bearer, ApiKey"}, ) return admin -async def get_current_with_metrics(db: AsyncSession = Depends(get_db), token: str = Depends(oauth2_scheme)): - admin: AdminDetails | None = await get_admin_with_metrics(db, token) +async def get_current_with_metrics( + request: Request, + db: AsyncSession = Depends(get_db), + token: str | None = Depends(oauth2_scheme), +): + admin: AdminDetails | None = None + + if token: + admin = await get_admin_with_metrics(db, token) + else: + api_key = _extract_api_key(request) + if api_key: + admin = await get_admin_from_api_key(db, api_key, with_metrics=True) + if not admin: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, + headers={"WWW-Authenticate": "Bearer, ApiKey"}, ) if admin.status == AdminStatus.disabled: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="your account has been disabled", - headers={"WWW-Authenticate": "Bearer"}, + headers={"WWW-Authenticate": "Bearer, ApiKey"}, ) return admin From 75e2a8e047c271ce0823bc373c01da67138987cc Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 08:06:25 +0330 Subject: [PATCH 02/56] chore: better code --- app/db/crud/api_key.py | 16 +++++++--------- app/operation/api_key.py | 13 +++++-------- app/routers/api_key.py | 4 ++-- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index ca33e24bf..6db7e7734 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import selectinload from app.db.models import Admin, APIKey +from app.models.api_key import APIKeyCreate def hash_api_key(raw_api_key: str) -> str: @@ -17,19 +18,16 @@ async def create_api_key( db: AsyncSession, *, admin_id: int, - role_id: int, - name: str, - note: str | None, - expire_date: dt | None, + mmodel:APIKeyCreate, ) -> tuple[str, APIKey]: raw_key = str(uuid.uuid4()) db_key = APIKey( admin_id=admin_id, - role_id=role_id, - name=name, - note=note, - key_hash=hash_api_key(raw_key), - expire_date=expire_date, + role_id=model.role_id, + name=model.name, + note=model.note, + key_hash=hash_api_key(model.raw_key), + expire_date=model.expire_date, ) db.add(db_key) await db.flush() diff --git a/app/operation/api_key.py b/app/operation/api_key.py index 36b988123..99e46a851 100644 --- a/app/operation/api_key.py +++ b/app/operation/api_key.py @@ -19,33 +19,30 @@ class APIKeyOperation(BaseOperation): async def create_api_key( - self, db: AsyncSession, *, admin: AdminDetails, data: APIKeyCreate + self, db: AsyncSession, *, admin: AdminDetails, model: APIKeyCreate ) -> APIKeyCreateResponse: if admin.id is None: await self.raise_error(message="API key creation is not available for env admins", code=403) - role = await get_role(db, data.role_id) + role = await get_role(db, model.role_id) if role is None: await self.raise_error(message="Role not found", code=404) if not admin.is_owner and admin.role and role.id != admin.role.id: await self.raise_error(message="Only owner can create API keys with a different role", code=403) - duplicate = await get_api_key_by_name(db, admin_id=admin.id, name=data.name) + duplicate = await get_api_key_by_name(db, admin_id=admin.id, name=model.name) if duplicate is not None: await self.raise_error(message="API key name already exists", code=409) - if data.expire_date is not None and data.expire_date <= dt.now(tz.utc): + if model.expire_date is not None and model.expire_date <= dt.now(tz.utc): await self.raise_error(message="expire_date must be in the future", code=422) try: raw_key, db_key = await create_api_key( db, admin_id=admin.id, - role_id=data.role_id, - name=data.name, - note=data.note, - expire_date=data.expire_date, + model=model, ) await db.commit() except IntegrityError: diff --git a/app/routers/api_key.py b/app/routers/api_key.py index b7969076d..b74962b62 100644 --- a/app/routers/api_key.py +++ b/app/routers/api_key.py @@ -27,11 +27,11 @@ responses={409: responses._409}, ) async def create_api_key( - data: APIKeyCreate, + model: APIKeyCreate, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(require_permission("api_keys", "create")), ): - return await api_key_operator.create_api_key(db, admin=admin, data=data) + return await api_key_operator.create_api_key(db, admin=admin, model=model) @router.get("s", response_model=APIKeysResponse) From d4a3f26a347eb04e24dcdc3fec439ff6260c3f1f Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 08:18:24 +0330 Subject: [PATCH 03/56] fix --- app/db/crud/api_key.py | 10 ++-------- app/utils/crypto.py | 4 ++++ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index 6db7e7734..803f2acc0 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -1,24 +1,18 @@ -import hashlib import uuid -from datetime import datetime as dt - from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.db.models import Admin, APIKey from app.models.api_key import APIKeyCreate - - -def hash_api_key(raw_api_key: str) -> str: - return hashlib.sha256(raw_api_key.encode("utf-8")).hexdigest() +from app.utils.crypto import hash_api_key async def create_api_key( db: AsyncSession, *, admin_id: int, - mmodel:APIKeyCreate, + model:APIKeyCreate, ) -> tuple[str, APIKey]: raw_key = str(uuid.uuid4()) db_key = APIKey( diff --git a/app/utils/crypto.py b/app/utils/crypto.py index 3380cb94b..58bec66b6 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -1,4 +1,5 @@ import base64 +import hashlib import binascii from cryptography import x509 @@ -97,3 +98,6 @@ def generate_wireguard_keypair() -> tuple[str, str]: base64.b64encode(private_key_bytes).decode("ascii"), base64.b64encode(public_key_bytes).decode("ascii"), ) + +def hash_api_key(raw_api_key: str) -> str: + return hashlib.sha256(raw_api_key.encode("utf-8")).hexdigest() From c23cf347b4ccbeb2652c67cd7b2ff263ae0eff59 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 08:28:26 +0330 Subject: [PATCH 04/56] fix --- app/db/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/db/models.py b/app/db/models.py index 7cf9f2030..0259f0890 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -867,10 +867,10 @@ class APIKey(Base, IdMixin, CreatedAtUTCMixin): admin_id: Mapped[int] = fk_id_column("admins.id", ondelete="CASCADE") admin: Mapped["Admin"] = relationship(back_populates="api_keys", init=False) name: Mapped[str] = mapped_column(String(128), nullable=False) - note: Mapped[Optional[str]] = mapped_column(String(512), default=None) key_hash: Mapped[str] = mapped_column(String(128), nullable=False) role_id: Mapped[int] = fk_id_column("admin_roles.id") role: Mapped["AdminRole"] = relationship(back_populates="api_keys", init=False, lazy="selectin") + note: Mapped[Optional[str]] = mapped_column(String(512), default=None) expire_date: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) From 6a1ebe300c5f90bf8336c443e4a86723dd4a45bb Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 08:33:02 +0330 Subject: [PATCH 05/56] Update c9b48df42f10_add_api_keys_table.py --- app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py index c8a635f2d..808517060 100644 --- a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py +++ b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py @@ -58,11 +58,11 @@ def upgrade() -> None: 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.ForeignKeyConstraint(["admin_id"], ["admins.id"], name=op.f("fk_api_keys_admin_id_admins")), + 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="uq_api_keys_admin_id_name"), + 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) From d9a240e1dbbd23c513a137b70e4a2c1dc40e288b Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 08:41:34 +0330 Subject: [PATCH 06/56] Potential fix for pull request finding 'CodeQL / Use of a broken or weak cryptographic hashing algorithm on sensitive data' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- app/utils/crypto.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/utils/crypto.py b/app/utils/crypto.py index 58bec66b6..c038d7594 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -1,6 +1,7 @@ import base64 import hashlib import binascii +import os from cryptography import x509 from cryptography.hazmat.backends import default_backend @@ -100,4 +101,14 @@ def generate_wireguard_keypair() -> tuple[str, str]: ) def hash_api_key(raw_api_key: str) -> str: - return hashlib.sha256(raw_api_key.encode("utf-8")).hexdigest() + iterations = 310000 + salt = os.urandom(16) + derived_key = hashlib.pbkdf2_hmac( + "sha256", + raw_api_key.encode("utf-8"), + salt, + iterations, + ) + salt_b64 = base64.b64encode(salt).decode("ascii") + dk_b64 = base64.b64encode(derived_key).decode("ascii") + return f"pbkdf2_sha256${iterations}${salt_b64}${dk_b64}" From cf9e23e8de593a96d62b5fe8d0606f0d23d7cb57 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 08:44:34 +0330 Subject: [PATCH 07/56] format code --- app/db/crud/api_key.py | 3 +-- app/operation/user.py | 17 ++++++++++++----- app/utils/crypto.py | 1 + tests/api/test_user.py | 24 ++++++++++++++---------- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index 803f2acc0..28abb14fe 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -10,9 +10,8 @@ async def create_api_key( db: AsyncSession, - *, admin_id: int, - model:APIKeyCreate, + model: APIKeyCreate, ) -> tuple[str, APIKey]: raw_key = str(uuid.uuid4()) db_key = APIKey( diff --git a/app/operation/user.py b/app/operation/user.py index 38544de65..f0708d4f8 100644 --- a/app/operation/user.py +++ b/app/operation/user.py @@ -545,8 +545,10 @@ async def create_user( await self.raise_error( message=f"HWID limit cannot be less than {effective_hwid_conf.min_limit}", code=400, db=db ) - if effective_hwid_conf.max_limit is not None and effective_hwid_conf.max_limit > 0 and ( - new_user.hwid_limit > effective_hwid_conf.max_limit or new_user.hwid_limit == 0 + if ( + effective_hwid_conf.max_limit is not None + and effective_hwid_conf.max_limit > 0 + and (new_user.hwid_limit > effective_hwid_conf.max_limit or new_user.hwid_limit == 0) ): await self.raise_error( message=f"HWID limit cannot exceed {effective_hwid_conf.max_limit}", code=400, db=db @@ -643,12 +645,17 @@ async def _prepare_modified_user( admin.role.hwid if admin.role is not None else None, ) if effective_hwid_conf is not None: - if effective_hwid_conf.min_limit is not None and modified_user.hwid_limit < effective_hwid_conf.min_limit: + if ( + effective_hwid_conf.min_limit is not None + and modified_user.hwid_limit < effective_hwid_conf.min_limit + ): await self.raise_error( message=f"HWID limit cannot be less than {effective_hwid_conf.min_limit}", code=400, db=db ) - if effective_hwid_conf.max_limit is not None and effective_hwid_conf.max_limit > 0 and ( - modified_user.hwid_limit > effective_hwid_conf.max_limit or modified_user.hwid_limit == 0 + if ( + effective_hwid_conf.max_limit is not None + and effective_hwid_conf.max_limit > 0 + and (modified_user.hwid_limit > effective_hwid_conf.max_limit or modified_user.hwid_limit == 0) ): await self.raise_error( message=f"HWID limit cannot exceed {effective_hwid_conf.max_limit}", code=400, db=db diff --git a/app/utils/crypto.py b/app/utils/crypto.py index c038d7594..4e3893f5f 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -100,6 +100,7 @@ def generate_wireguard_keypair() -> tuple[str, str]: base64.b64encode(public_key_bytes).decode("ascii"), ) + def hash_api_key(raw_api_key: str) -> str: iterations = 310000 salt = os.urandom(16) diff --git a/tests/api/test_user.py b/tests/api/test_user.py index 1e1c3ae99..101ad884e 100644 --- a/tests/api/test_user.py +++ b/tests/api/test_user.py @@ -1267,11 +1267,13 @@ def test_xray_subscription_uses_host_specific_template_override(access_token): access_token, name=unique_name("xray_host_override_template"), template_type="xray_subscription", - content=json.dumps({ - "log": {"loglevel": "warning"}, - "inbounds": [{"tag": "placeholder", "protocol": "vmess", "settings": {"clients": []}}], - "outbounds": [{"tag": "template-marker", "protocol": "freedom", "settings": {}}], - }), + content=json.dumps( + { + "log": {"loglevel": "warning"}, + "inbounds": [{"tag": "placeholder", "protocol": "vmess", "settings": {"clients": []}}], + "outbounds": [{"tag": "template-marker", "protocol": "freedom", "settings": {}}], + } + ), ) host_response = client.post( @@ -1337,11 +1339,13 @@ def test_xray_subscription_template_override_isolated_per_host(access_token): access_token, name=unique_name("xray_host_isolated_template"), template_type="xray_subscription", - content=json.dumps({ - "log": {"loglevel": "warning"}, - "inbounds": [{"tag": "placeholder", "protocol": "vmess", "settings": {"clients": []}}], - "outbounds": [{"tag": "template-marker", "protocol": "freedom", "settings": {}}], - }), + content=json.dumps( + { + "log": {"loglevel": "warning"}, + "inbounds": [{"tag": "placeholder", "protocol": "vmess", "settings": {"clients": []}}], + "outbounds": [{"tag": "template-marker", "protocol": "freedom", "settings": {}}], + } + ), ) first_host_response = client.post( From a5413b65c3473f8df1380f9d080eb6a37036a30d Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 09:07:41 +0330 Subject: [PATCH 08/56] refactor: apikey search --- app/db/crud/api_key.py | 26 ++++++++++++------------ app/models/api_key.py | 7 +++++++ app/operation/api_key.py | 30 +++++++++++++++------------- app/routers/api_key.py | 12 +++++------ app/routers/dependencies/__init__.py | 3 +++ app/routers/dependencies/api_key.py | 15 ++++++++++++++ 6 files changed, 59 insertions(+), 34 deletions(-) create mode 100644 app/routers/dependencies/api_key.py diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index 28abb14fe..6b00eeb37 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -42,22 +42,22 @@ async def get_api_key_by_id(db: AsyncSession, key_id: int) -> APIKey | None: return (await db.execute(stmt)).scalar_one_or_none() -async def get_api_key_by_id_for_admin(db: AsyncSession, *, key_id: int, admin_id: int | None = None) -> APIKey | None: - stmt = select(APIKey).where(APIKey.id == key_id).options(selectinload(APIKey.admin), selectinload(APIKey.role)) - if admin_id is not None: - stmt = stmt.where(APIKey.admin_id == admin_id) - return (await db.execute(stmt)).scalar_one_or_none() - - -async def get_api_key_by_name(db: AsyncSession, *, admin_id: int, name: str) -> APIKey | None: - stmt = select(APIKey).where(APIKey.admin_id == admin_id, APIKey.name == name) - return (await db.execute(stmt)).scalar_one_or_none() - - -async def get_api_keys(db: AsyncSession, *, admin_id: int | None, offset: int, limit: int) -> tuple[list[APIKey], int]: +async def get_api_keys( + db: AsyncSession, + *, + admin_id: int | None, + offset: int, + limit: int, + key_id: int | None = None, + name: str | 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) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0 diff --git a/app/models/api_key.py b/app/models/api_key.py index 42b237a3b..a641fa523 100644 --- a/app/models/api_key.py +++ b/app/models/api_key.py @@ -44,3 +44,10 @@ class APIKeysResponse(BaseModel): Offset = Annotated[int, Field(default=0, ge=0)] Limit = Annotated[int, Field(default=50, ge=1, le=200)] + + +class APIKeysQuery(BaseModel): + offset: Offset = 0 + limit: Limit = 50 + key_id: int | None = Field(default=None, ge=1) + name: str | None = Field(default=None, min_length=1, max_length=128) diff --git a/app/operation/api_key.py b/app/operation/api_key.py index 99e46a851..13bdf993e 100644 --- a/app/operation/api_key.py +++ b/app/operation/api_key.py @@ -8,12 +8,10 @@ create_api_key, delete_api_key, get_api_key_by_id, - get_api_key_by_id_for_admin, - get_api_key_by_name, get_api_keys, ) from app.models.admin import AdminDetails -from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysResponse +from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysQuery, APIKeysResponse from app.operation import BaseOperation @@ -31,8 +29,8 @@ async def create_api_key( if not admin.is_owner and admin.role and role.id != admin.role.id: await self.raise_error(message="Only owner can create API keys with a different role", code=403) - duplicate = await get_api_key_by_name(db, admin_id=admin.id, name=model.name) - if duplicate is not None: + duplicates, _ = await get_api_keys(db, admin_id=admin.id, offset=0, limit=1, name=model.name) + if duplicates: await self.raise_error(message="API key name already exists", code=409) if model.expire_date is not None and model.expire_date <= dt.now(tz.utc): @@ -59,20 +57,24 @@ async def create_api_key( api_key=raw_key, ) - async def list_api_keys(self, db: AsyncSession, *, admin: AdminDetails, offset: int, limit: int) -> APIKeysResponse: + async def list_api_keys(self, db: AsyncSession, *, admin: AdminDetails, query: APIKeysQuery) -> APIKeysResponse: scope_admin_id = None if admin.is_owner else admin.id - rows, total = await get_api_keys(db, admin_id=scope_admin_id, offset=offset, limit=limit) + rows, total = await get_api_keys( + db, + admin_id=scope_admin_id, + offset=query.offset, + limit=query.limit, + key_id=query.key_id, + name=query.name, + ) return APIKeysResponse(api_keys=[APIKeyResponse.model_validate(row) for row in rows], total=total) async def get_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> APIKeyResponse: - db_key = await get_api_key_by_id_for_admin( - db, - key_id=key_id, - admin_id=None if admin.is_owner else admin.id, - ) - if db_key is None: + scope_admin_id = None if admin.is_owner else admin.id + rows, _ = await get_api_keys(db, admin_id=scope_admin_id, offset=0, limit=1, key_id=key_id) + if not rows: await self.raise_error(message="API key not found", code=404) - return APIKeyResponse.model_validate(db_key) + return APIKeyResponse.model_validate(rows[0]) async def delete_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> None: db_key = await get_api_key_by_id(db, key_id) diff --git a/app/routers/api_key.py b/app/routers/api_key.py index b74962b62..015c0fc78 100644 --- a/app/routers/api_key.py +++ b/app/routers/api_key.py @@ -1,12 +1,11 @@ -from typing import Annotated - -from fastapi import APIRouter, Depends, Query, status +from fastapi import APIRouter, Depends, status from app.db import AsyncSession, get_db from app.models.admin import AdminDetails -from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysResponse +from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysQuery, APIKeysResponse from app.operation import OperatorType from app.operation.api_key import APIKeyOperation +from app.routers.dependencies import get_api_key_list_query from app.utils import responses from .authentication import require_permission @@ -36,12 +35,11 @@ async def create_api_key( @router.get("s", response_model=APIKeysResponse) async def list_api_keys( - offset: Annotated[int, Query(ge=0)] = 0, - limit: Annotated[int, Query(ge=1, le=200)] = 50, + query: APIKeysQuery = Depends(get_api_key_list_query), db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(require_permission("api_keys", "read")), ): - return await api_key_operator.list_api_keys(db, admin=admin, offset=offset, limit=limit) + return await api_key_operator.list_api_keys(db, admin=admin, query=query) @router.get("/{key_id}", response_model=APIKeyResponse, responses={404: responses._404}) diff --git a/app/routers/dependencies/__init__.py b/app/routers/dependencies/__init__.py index f091ff4e6..809769f43 100644 --- a/app/routers/dependencies/__init__.py +++ b/app/routers/dependencies/__init__.py @@ -1,5 +1,6 @@ from .admin import get_admin_list_query, get_admin_simple_list_query, get_admin_usage_query from .admin_role import get_admin_role_list_query +from .api_key import get_api_key_list_query from .client_template import get_client_template_list_query, get_client_template_simple_list_query from .core import get_core_list_query, get_core_simple_list_query from .group import get_group_list_query, get_group_simple_list_query @@ -28,6 +29,8 @@ "get_admin_usage_query", # admin_role "get_admin_role_list_query", + # api_key + "get_api_key_list_query", # client_template "get_client_template_list_query", "get_client_template_simple_list_query", diff --git a/app/routers/dependencies/api_key.py b/app/routers/dependencies/api_key.py new file mode 100644 index 000000000..b6fa31bcd --- /dev/null +++ b/app/routers/dependencies/api_key.py @@ -0,0 +1,15 @@ +from fastapi import Query + +from app.models.api_key import APIKeysQuery + +from ._common import make_query_dependency + +get_api_key_list_query = make_query_dependency( + APIKeysQuery, + field_overrides={ + "offset": Query(None), + "limit": Query(None), + "key_id": Query(None), + "name": Query(None), + }, +) From 89e82b01cc4e87ae398ed038952d3c7688b16dfd Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 11:03:26 +0330 Subject: [PATCH 09/56] feat: Add limits for creating apikeys and control disabled admin apikeys --- app/db/crud/api_key.py | 32 +++++++++++++++++-- .../c9b48df42f10_add_api_keys_table.py | 10 ++++++ app/db/models.py | 30 +++++++++++++++++ app/models/api_key.py | 11 +++++++ app/operation/api_key.py | 19 +++++++++++ app/operation/user.py | 1 - app/routers/authentication.py | 11 ++++++- app/routers/dependencies/api_key.py | 1 + 8 files changed, 110 insertions(+), 5 deletions(-) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index 6b00eeb37..c3287682b 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -1,9 +1,11 @@ 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, APIKey +from app.db.models import Admin, AdminStatus, APIKey, APIKeyStatus from app.models.api_key import APIKeyCreate from app.utils.crypto import hash_api_key @@ -31,10 +33,19 @@ async def create_api_key( async def get_api_key_by_hash(db: AsyncSession, key_hash: str) -> APIKey | None: stmt = ( select(APIKey) - .where(APIKey.key_hash == key_hash) + .where( + APIKey.key_hash == key_hash, + APIKey.status != APIKeyStatus.disabled, + ) .options(selectinload(APIKey.admin).selectinload(Admin.role), selectinload(APIKey.role)) ) - return (await db.execute(stmt)).scalar_one_or_none() + db_key = (await db.execute(stmt)).scalar_one_or_none() + if db_key is None: + 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: @@ -50,6 +61,7 @@ async def get_api_keys( 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: @@ -58,6 +70,20 @@ async def get_api_keys( stmt = stmt.where(APIKey.id == key_id) if name is not None: stmt = stmt.where(APIKey.name == name) + if status is not None: + now = dt.now(tz.utc) + if status == APIKeyStatus.expired: + # expired = expire_date is set and in the past + stmt = stmt.where(APIKey.expire_date.isnot(None), APIKey.expire_date <= now) + elif status == APIKeyStatus.active: + # active = stored status is active AND not past expire_date + stmt = stmt.where( + APIKey.status == APIKeyStatus.active, + (APIKey.expire_date.is_(None)) | (APIKey.expire_date > now), + ) + 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 diff --git a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py index 808517060..7da382ac5 100644 --- a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py +++ b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py @@ -58,6 +58,12 @@ def upgrade() -> None: 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( + "status", + sa.Enum("active", "disabled", "expired", 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")), @@ -113,3 +119,7 @@ def downgrade() -> None: 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") diff --git a/app/db/models.py b/app/db/models.py index 0259f0890..8a5fbe8a5 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -854,6 +854,12 @@ def is_builtin(cls): return cls.id <= 3 +class APIKeyStatus(str, Enum): + active = "active" + disabled = "disabled" + expired = "expired" + + class APIKey(Base, IdMixin, CreatedAtUTCMixin): __tablename__ = "api_keys" __table_args__ = ( @@ -872,6 +878,30 @@ class APIKey(Base, IdMixin, CreatedAtUTCMixin): role: Mapped["AdminRole"] = relationship(back_populates="api_keys", init=False, lazy="selectin") note: Mapped[Optional[str]] = mapped_column(String(512), default=None) expire_date: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) + status: Mapped[APIKeyStatus] = mapped_column( + SQLEnum(APIKeyStatus, name="apikeystatus", create_constraint=True), + default=APIKeyStatus.active, + server_default="active", + ) + + @hybrid_property + def computed_status(self) -> "APIKeyStatus": + """Returns expired when past expire_date, otherwise the stored status.""" + if self.expire_date is not None: + expire_at = self.expire_date if self.expire_date.tzinfo else self.expire_date.replace(tzinfo=tz.utc) + if expire_at <= dt.now(tz.utc): + return APIKeyStatus.expired + return self.status + + @computed_status.expression + def computed_status(cls): + return case( + ( + and_(cls.expire_date.isnot(None), cls.expire_date <= func.current_timestamp()), + APIKeyStatus.expired.value, + ), + else_=cls.status, + ) class TempKey(Base): diff --git a/app/models/api_key.py b/app/models/api_key.py index a641fa523..3b51fd747 100644 --- a/app/models/api_key.py +++ b/app/models/api_key.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator +from app.db.models import APIKeyStatus from app.utils.helpers import fix_datetime_timezone @@ -31,6 +32,15 @@ class APIKeyResponse(APIKeyBase): id: int admin_id: int created_at: dt + status: APIKeyStatus = APIKeyStatus.active + + @classmethod + def model_validate(cls, obj, *args, **kwargs): + # Use computed_status (which accounts for expiry) when building the response + instance = super().model_validate(obj, *args, **kwargs) + if hasattr(obj, "computed_status"): + instance.status = obj.computed_status + return instance class APIKeyCreateResponse(APIKeyResponse): @@ -51,3 +61,4 @@ class APIKeysQuery(BaseModel): limit: Limit = 50 key_id: int | None = Field(default=None, ge=1) name: str | None = Field(default=None, min_length=1, max_length=128) + status: APIKeyStatus | None = None diff --git a/app/operation/api_key.py b/app/operation/api_key.py index 13bdf993e..436d5d807 100644 --- a/app/operation/api_key.py +++ b/app/operation/api_key.py @@ -13,6 +13,8 @@ from app.models.admin import AdminDetails from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysQuery, APIKeysResponse from app.operation import BaseOperation +from app.operation.permissions import get_effective_limits +from app.utils.system import readable_duration class APIKeyOperation(BaseOperation): @@ -36,6 +38,22 @@ async def create_api_key( if model.expire_date is not None and model.expire_date <= dt.now(tz.utc): await self.raise_error(message="expire_date must be in the future", code=422) + if not admin.is_owner: + limits = get_effective_limits(admin) + seconds = (model.expire_date - dt.now(tz.utc)).total_seconds() if model.expire_date is not None else None + if limits.expire_max is not None and (seconds is None or seconds > limits.expire_max): + await self.raise_error( + message=f"expire_date cannot exceed {readable_duration(limits.expire_max)} from now", + code=400, + db=db, + ) + if limits.expire_min is not None and (seconds is None or seconds < limits.expire_min): + await self.raise_error( + message=f"expire_date must be at least {readable_duration(limits.expire_min)} from now", + code=400, + db=db, + ) + try: raw_key, db_key = await create_api_key( db, @@ -66,6 +84,7 @@ async def list_api_keys(self, db: AsyncSession, *, admin: AdminDetails, query: A limit=query.limit, key_id=query.key_id, name=query.name, + status=query.status, ) return APIKeysResponse(api_keys=[APIKeyResponse.model_validate(row) for row in rows], total=total) diff --git a/app/operation/user.py b/app/operation/user.py index f0708d4f8..01290a606 100644 --- a/app/operation/user.py +++ b/app/operation/user.py @@ -55,7 +55,6 @@ from app.db.models import User, UserStatus, UserTemplate from app.models.admin import AdminDetails from app.models.proxy import ProxyTable -from app.models.settings import HWIDSettings from app.models.stats import ( Period, UserCountMetric, diff --git a/app/routers/authentication.py b/app/routers/authentication.py index e9d044728..f1f58ae07 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -14,7 +14,7 @@ get_admin_by_telegram_id, ) from app.db.crud.api_key import get_api_key_by_hash, hash_api_key -from app.db.models import Admin, AdminUsageLogs, User +from app.db.models import Admin, AdminUsageLogs, User, APIKeyStatus from app.models.admin import AdminDetails, AdminRoleData, AdminStatus, AdminValidationResult, verify_password from app.models.admin_role import RoleAccess, RoleFeatures, RoleLimits, RolePermissions from app.models.settings import Telegram @@ -127,6 +127,15 @@ async def get_admin_from_api_key(db: AsyncSession, raw_key: str, *, with_metrics if db_admin is None: return None + # Reject if the key itself is manually disabled + if db_key.status == APIKeyStatus.disabled: + return None + + # Reject if the owning admin is disabled — keys are not stored as disabled, + # we just block them at auth time + if db_admin.status == AdminStatus.disabled: + return None + if db_key.expire_date is not None: expire_at = db_key.expire_date if db_key.expire_date.tzinfo else db_key.expire_date.replace(tzinfo=tz.utc) if expire_at.astimezone(tz.utc) <= dt.now(tz.utc): diff --git a/app/routers/dependencies/api_key.py b/app/routers/dependencies/api_key.py index b6fa31bcd..a3f9e8b6f 100644 --- a/app/routers/dependencies/api_key.py +++ b/app/routers/dependencies/api_key.py @@ -11,5 +11,6 @@ "limit": Query(None), "key_id": Query(None), "name": Query(None), + "status": Query(None), }, ) From 14ebea71edf72753fe50080f2f762b5be7c49a92 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 11:56:28 +0330 Subject: [PATCH 10/56] refactor: Add is_usable property and remove limit on apikey creation --- app/db/crud/api_key.py | 12 +----- .../c9b48df42f10_add_api_keys_table.py | 2 +- app/db/models.py | 37 ++++++++++--------- app/models/api_key.py | 9 +---- app/operation/api_key.py | 16 -------- app/routers/authentication.py | 26 ++++--------- 6 files changed, 30 insertions(+), 72 deletions(-) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index c3287682b..d8196d21f 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -1,5 +1,4 @@ import uuid -from datetime import datetime as dt, timezone as tz from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -71,16 +70,9 @@ async def get_api_keys( if name is not None: stmt = stmt.where(APIKey.name == name) if status is not None: - now = dt.now(tz.utc) - if status == APIKeyStatus.expired: - # expired = expire_date is set and in the past - stmt = stmt.where(APIKey.expire_date.isnot(None), APIKey.expire_date <= now) - elif status == APIKeyStatus.active: + if status == APIKeyStatus.active: # active = stored status is active AND not past expire_date - stmt = stmt.where( - APIKey.status == APIKeyStatus.active, - (APIKey.expire_date.is_(None)) | (APIKey.expire_date > now), - ) + 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) diff --git a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py index 7da382ac5..e134cb25a 100644 --- a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py +++ b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py @@ -60,7 +60,7 @@ def upgrade() -> None: sa.Column("expire_date", sa.DateTime(timezone=True), nullable=True), sa.Column( "status", - sa.Enum("active", "disabled", "expired", name="apikeystatus"), + sa.Enum("active", "disabled" , name="apikeystatus"), nullable=False, server_default="active", ), diff --git a/app/db/models.py b/app/db/models.py index 8a5fbe8a5..1c7a85757 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -857,7 +857,6 @@ def is_builtin(cls): class APIKeyStatus(str, Enum): active = "active" disabled = "disabled" - expired = "expired" class APIKey(Base, IdMixin, CreatedAtUTCMixin): @@ -885,23 +884,25 @@ class APIKey(Base, IdMixin, CreatedAtUTCMixin): ) @hybrid_property - def computed_status(self) -> "APIKeyStatus": - """Returns expired when past expire_date, otherwise the stored status.""" - if self.expire_date is not None: - expire_at = self.expire_date if self.expire_date.tzinfo else self.expire_date.replace(tzinfo=tz.utc) - if expire_at <= dt.now(tz.utc): - return APIKeyStatus.expired - return self.status - - @computed_status.expression - def computed_status(cls): - return case( - ( - and_(cls.expire_date.isnot(None), cls.expire_date <= func.current_timestamp()), - APIKeyStatus.expired.value, - ), - else_=cls.status, - ) + def is_expired(self) -> bool: + """True when expire_date is set and is in the past.""" + if self.expire_date is None: + return False + expire_at = self.expire_date if self.expire_date.tzinfo else self.expire_date.replace(tzinfo=tz.utc) + return expire_at <= dt.now(tz.utc) + + @is_expired.expression + def is_expired(cls): + return and_(cls.expire_date.isnot(None), cls.expire_date <= func.current_timestamp()) + + @property + def is_usable(self) -> bool: + """False if the key is disabled, its admin is missing/disabled, or it has expired.""" + if self.status == APIKeyStatus.disabled: + return False + if self.admin is None or self.admin.status == AdminStatus.disabled: + return False + return not self.is_expired class TempKey(Base): diff --git a/app/models/api_key.py b/app/models/api_key.py index 3b51fd747..8788aad13 100644 --- a/app/models/api_key.py +++ b/app/models/api_key.py @@ -33,14 +33,7 @@ class APIKeyResponse(APIKeyBase): admin_id: int created_at: dt status: APIKeyStatus = APIKeyStatus.active - - @classmethod - def model_validate(cls, obj, *args, **kwargs): - # Use computed_status (which accounts for expiry) when building the response - instance = super().model_validate(obj, *args, **kwargs) - if hasattr(obj, "computed_status"): - instance.status = obj.computed_status - return instance + is_expired: bool = False class APIKeyCreateResponse(APIKeyResponse): diff --git a/app/operation/api_key.py b/app/operation/api_key.py index 436d5d807..750e2af37 100644 --- a/app/operation/api_key.py +++ b/app/operation/api_key.py @@ -38,22 +38,6 @@ async def create_api_key( if model.expire_date is not None and model.expire_date <= dt.now(tz.utc): await self.raise_error(message="expire_date must be in the future", code=422) - if not admin.is_owner: - limits = get_effective_limits(admin) - seconds = (model.expire_date - dt.now(tz.utc)).total_seconds() if model.expire_date is not None else None - if limits.expire_max is not None and (seconds is None or seconds > limits.expire_max): - await self.raise_error( - message=f"expire_date cannot exceed {readable_duration(limits.expire_max)} from now", - code=400, - db=db, - ) - if limits.expire_min is not None and (seconds is None or seconds < limits.expire_min): - await self.raise_error( - message=f"expire_date must be at least {readable_duration(limits.expire_min)} from now", - code=400, - db=db, - ) - try: raw_key, db_key = await create_api_key( db, diff --git a/app/routers/authentication.py b/app/routers/authentication.py index f1f58ae07..532752987 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -1,4 +1,3 @@ -from datetime import datetime as dt, timezone as tz from uuid import UUID from aiogram.utils.web_app import WebAppInitData, safe_parse_webapp_init_data @@ -110,36 +109,25 @@ async def _build_admin_metrics(db: AsyncSession, admin_id: int) -> tuple[int, in async def get_admin_from_api_key(db: AsyncSession, raw_key: str, *, with_metrics: bool = False) -> AdminDetails | None: if not raw_key: - return None + return try: parsed_key = UUID(raw_key) except ValueError: - return None + return if parsed_key.version != 4: - return None + return db_key = await get_api_key_by_hash(db, hash_api_key(str(parsed_key))) if db_key is None: - return None + return db_admin = db_key.admin if db_admin is None: - return None + return - # Reject if the key itself is manually disabled - if db_key.status == APIKeyStatus.disabled: - return None - - # Reject if the owning admin is disabled — keys are not stored as disabled, - # we just block them at auth time - if db_admin.status == AdminStatus.disabled: - return None - - if db_key.expire_date is not None: - expire_at = db_key.expire_date if db_key.expire_date.tzinfo else db_key.expire_date.replace(tzinfo=tz.utc) - if expire_at.astimezone(tz.utc) <= dt.now(tz.utc): - return None + if not db_key.is_usable: + return if with_metrics: total_users, reseted_usage = await _build_admin_metrics(db, db_admin.id) From efc3b8a8a53fc17bd393abd948dae90bc5186ede Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 12:51:56 +0330 Subject: [PATCH 11/56] fix: change apikey role when admin role is changed --- app/db/crud/api_key.py | 8 +++++++- app/db/models.py | 5 +++++ app/operation/admin.py | 6 ++++++ app/routers/authentication.py | 8 ++++---- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index d8196d21f..a9ed44380 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -1,6 +1,6 @@ import uuid -from sqlalchemy import func, select +from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -87,3 +87,9 @@ async def get_api_keys( async def delete_api_key(db: AsyncSession, db_key: APIKey) -> None: await db.delete(db_key) await db.flush() + + +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 diff --git a/app/db/models.py b/app/db/models.py index 1c7a85757..6605d3830 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -153,6 +153,11 @@ def users_sync_blocked(self) -> bool: def total_users(self) -> int: return len(self.users) + @property + def has_api_keys(self) -> bool: + """True when the admin owns at least one API key.""" + return len(self.api_keys) > 0 + class AdminUsageLogs(Base, IdMixin): __tablename__ = "admin_usage_logs" diff --git a/app/operation/admin.py b/app/operation/admin.py index 79bcbbd3e..1aa9e57e3 100644 --- a/app/operation/admin.py +++ b/app/operation/admin.py @@ -18,6 +18,7 @@ reset_admin_usage, update_admin, ) +from app.db.crud.api_key import update_api_keys_role from app.db.crud.bulk import activate_all_disabled_users, disable_all_active_users from app.db.crud.user import get_users, remove_users from app.models.user import UserListQuery @@ -121,6 +122,11 @@ async def _modify_admin( old_status = db_admin.status db_admin = await update_admin(db, db_admin, modified_admin) + # Keep API key roles in sync with the admin's new role + if modified_admin.role_id is not None and db_admin.has_api_keys: + await update_api_keys_role(db, db_admin.id, modified_admin.role_id) + await db.commit() + # Sync users to nodes if admin status changed due to data_limit change if modified_admin.data_limit is not None: if old_status != AdminStatus.limited and db_admin.status == AdminStatus.limited: diff --git a/app/routers/authentication.py b/app/routers/authentication.py index 532752987..b688b6672 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -215,13 +215,13 @@ async def get_current(request: Request, db: AsyncSession = Depends(get_db), toke raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer, ApiKey"}, + headers={"WWW-Authenticate": "Bearer"}, ) if admin.status == AdminStatus.disabled: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="your account has been disabled", - headers={"WWW-Authenticate": "Bearer, ApiKey"}, + headers={"WWW-Authenticate": "Bearer"}, ) return admin @@ -244,13 +244,13 @@ async def get_current_with_metrics( raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer, ApiKey"}, + headers={"WWW-Authenticate": "Bearer"}, ) if admin.status == AdminStatus.disabled: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="your account has been disabled", - headers={"WWW-Authenticate": "Bearer, ApiKey"}, + headers={"WWW-Authenticate": "Bearer"}, ) return admin From 0e77317da904d37b1f7f351679c4d934ff6b5ce5 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 13:08:34 +0330 Subject: [PATCH 12/56] fix: Hash the generated key, not model.raw_key --- app/db/crud/api_key.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index a9ed44380..253a7575b 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -20,7 +20,7 @@ async def create_api_key( role_id=model.role_id, name=model.name, note=model.note, - key_hash=hash_api_key(model.raw_key), + key_hash=hash_api_key(raw_key), expire_date=model.expire_date, ) db.add(db_key) From e584000e69d81db7ee3a75ef6964b491e136dd73 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 13:24:18 +0330 Subject: [PATCH 13/56] fix: API key role sync should be atomic with admin update --- app/db/crud/admin.py | 4 ++++ app/operation/admin.py | 6 ------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/db/crud/admin.py b/app/db/crud/admin.py index 6d95b0f87..2b224ba0d 100644 --- a/app/db/crud/admin.py +++ b/app/db/crud/admin.py @@ -9,6 +9,7 @@ get_complete_period_start_for_filter, to_utc_for_filter, ) +from app.db.crud.api_key import update_api_keys_role from app.db.models import Admin, AdminNotificationReminder, AdminRole, AdminUsageLogs, NodeUserUsage, ReminderType, User from app.models.admin import ( AdminCreate, @@ -147,6 +148,9 @@ async def update_admin(db: AsyncSession, db_admin: Admin, modified_admin: AdminM if modified_admin.notification_enable is not None: db_admin.notification_enable = modified_admin.notification_enable.model_dump() + if modified_admin.role_id is not None: + await update_api_keys_role(db, db_admin.id, modified_admin.role_id) + await db.commit() await db.refresh(db_admin) await load_admin_attrs(db_admin) diff --git a/app/operation/admin.py b/app/operation/admin.py index 1aa9e57e3..79bcbbd3e 100644 --- a/app/operation/admin.py +++ b/app/operation/admin.py @@ -18,7 +18,6 @@ reset_admin_usage, update_admin, ) -from app.db.crud.api_key import update_api_keys_role from app.db.crud.bulk import activate_all_disabled_users, disable_all_active_users from app.db.crud.user import get_users, remove_users from app.models.user import UserListQuery @@ -122,11 +121,6 @@ async def _modify_admin( old_status = db_admin.status db_admin = await update_admin(db, db_admin, modified_admin) - # Keep API key roles in sync with the admin's new role - if modified_admin.role_id is not None and db_admin.has_api_keys: - await update_api_keys_role(db, db_admin.id, modified_admin.role_id) - await db.commit() - # Sync users to nodes if admin status changed due to data_limit change if modified_admin.data_limit is not None: if old_status != AdminStatus.limited and db_admin.status == AdminStatus.limited: From 58d59dc3f4ea6a61e84a7e83d3550c7317d74ca0 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 13:27:22 +0330 Subject: [PATCH 14/56] fix: 204 delete response body --- app/routers/api_key.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/routers/api_key.py b/app/routers/api_key.py index 015c0fc78..d629c95e0 100644 --- a/app/routers/api_key.py +++ b/app/routers/api_key.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Depends, status +from starlette.responses import Response from app.db import AsyncSession, get_db from app.models.admin import AdminDetails @@ -58,4 +59,4 @@ async def remove_api_key( admin: AdminDetails = Depends(require_permission("api_keys", "delete")), ): await api_key_operator.delete_api_key(db, admin=admin, key_id=key_id) - return {} + return Response(status_code=status.HTTP_204_NO_CONTENT) From 99cae347f67eb7e67f92a2a6333a5c8ede0574be Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 13:32:05 +0330 Subject: [PATCH 15/56] fix: Keep the API-key fallback when bearer auth is invalid --- app/routers/authentication.py | 41 ++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/app/routers/authentication.py b/app/routers/authentication.py index b688b6672..32c4554a4 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -1,3 +1,4 @@ +from datetime import timezone as tz from uuid import UUID from aiogram.utils.web_app import WebAppInitData, safe_parse_webapp_init_data @@ -13,7 +14,7 @@ get_admin_by_telegram_id, ) from app.db.crud.api_key import get_api_key_by_hash, hash_api_key -from app.db.models import Admin, AdminUsageLogs, User, APIKeyStatus +from app.db.models import Admin, AdminUsageLogs, User from app.models.admin import AdminDetails, AdminRoleData, AdminStatus, AdminValidationResult, verify_password from app.models.admin_role import RoleAccess, RoleFeatures, RoleLimits, RolePermissions from app.models.settings import Telegram @@ -201,15 +202,36 @@ async def get_admin_with_metrics(db: AsyncSession, token: str) -> AdminDetails | return None -async def get_current(request: Request, db: AsyncSession = Depends(get_db), token: str | None = Depends(oauth2_scheme)): +async def _get_admin_from_request_credentials( + request: Request, + db: AsyncSession, + token: str | None, + *, + with_metrics: bool = False, +) -> AdminDetails | None: admin: AdminDetails | None = None if token: - admin = await get_admin(db, token) - else: + try: + if with_metrics: + admin = await get_admin_with_metrics(db, token) + else: + admin = await get_admin(db, token) + except HTTPException as exc: + if exc.status_code not in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN): + raise + admin = None + + if not admin: api_key = _extract_api_key(request) if api_key: - admin = await get_admin_from_api_key(db, api_key) + admin = await get_admin_from_api_key(db, api_key, with_metrics=with_metrics) + + return admin + + +async def get_current(request: Request, db: AsyncSession = Depends(get_db), token: str | None = Depends(oauth2_scheme)): + admin = await _get_admin_from_request_credentials(request, db, token) if not admin: raise HTTPException( @@ -231,14 +253,7 @@ async def get_current_with_metrics( db: AsyncSession = Depends(get_db), token: str | None = Depends(oauth2_scheme), ): - admin: AdminDetails | None = None - - if token: - admin = await get_admin_with_metrics(db, token) - else: - api_key = _extract_api_key(request) - if api_key: - admin = await get_admin_from_api_key(db, api_key, with_metrics=True) + admin = await _get_admin_from_request_credentials(request, db, token, with_metrics=True) if not admin: raise HTTPException( From 8f827549f6bccfe23600d0b66502c4bfef4cc1ec Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 13:40:58 +0330 Subject: [PATCH 16/56] fix: Salted hashes cannot be used for exact DB lookup --- app/db/crud/api_key.py | 10 ++++---- app/routers/authentication.py | 4 ++-- app/utils/crypto.py | 44 ++++++++++++++++++++++++++++++++--- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index 253a7575b..59f207be0 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -6,7 +6,7 @@ from app.db.models import Admin, AdminStatus, APIKey, APIKeyStatus from app.models.api_key import APIKeyCreate -from app.utils.crypto import hash_api_key +from app.utils.crypto import API_KEY_HASH_VERSION, api_key_lookup_id, hash_api_key, verify_api_key async def create_api_key( @@ -29,17 +29,19 @@ async def create_api_key( return raw_key, db_key -async def get_api_key_by_hash(db: AsyncSession, key_hash: str) -> APIKey | None: +async def get_api_key_by_raw_key(db: AsyncSession, raw_api_key: str) -> APIKey | None: + lookup_prefix = f"{API_KEY_HASH_VERSION}${api_key_lookup_id(raw_api_key)}$" stmt = ( select(APIKey) .where( - APIKey.key_hash == key_hash, + APIKey.key_hash.startswith(lookup_prefix), 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: + 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: diff --git a/app/routers/authentication.py b/app/routers/authentication.py index 32c4554a4..8d03167ee 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -13,7 +13,7 @@ get_admin_by_id as get_admin_by_id_crud, get_admin_by_telegram_id, ) -from app.db.crud.api_key import get_api_key_by_hash, hash_api_key +from app.db.crud.api_key import get_api_key_by_raw_key from app.db.models import Admin, AdminUsageLogs, User from app.models.admin import AdminDetails, AdminRoleData, AdminStatus, AdminValidationResult, verify_password from app.models.admin_role import RoleAccess, RoleFeatures, RoleLimits, RolePermissions @@ -119,7 +119,7 @@ async def get_admin_from_api_key(db: AsyncSession, raw_key: str, *, with_metrics if parsed_key.version != 4: return - db_key = await get_api_key_by_hash(db, hash_api_key(str(parsed_key))) + db_key = await get_api_key_by_raw_key(db, str(parsed_key)) if db_key is None: return diff --git a/app/utils/crypto.py b/app/utils/crypto.py index 4e3893f5f..8ba345e0e 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -1,6 +1,7 @@ import base64 import hashlib import binascii +import hmac import os from cryptography import x509 @@ -101,15 +102,52 @@ def generate_wireguard_keypair() -> tuple[str, str]: ) +API_KEY_HASH_VERSION = "v1" +API_KEY_HASH_ALGORITHM = "pbkdf2_sha256" +API_KEY_HASH_ITERATIONS = 310000 +API_KEY_LOOKUP_BYTES = 16 + + +def api_key_lookup_id(raw_api_key: str) -> str: + digest = hashlib.sha256(raw_api_key.encode("utf-8")).digest()[:API_KEY_LOOKUP_BYTES] + return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + + def hash_api_key(raw_api_key: str) -> str: - iterations = 310000 salt = os.urandom(16) derived_key = hashlib.pbkdf2_hmac( "sha256", raw_api_key.encode("utf-8"), salt, - iterations, + API_KEY_HASH_ITERATIONS, ) salt_b64 = base64.b64encode(salt).decode("ascii") dk_b64 = base64.b64encode(derived_key).decode("ascii") - return f"pbkdf2_sha256${iterations}${salt_b64}${dk_b64}" + lookup_id = api_key_lookup_id(raw_api_key) + return f"{API_KEY_HASH_VERSION}${lookup_id}${API_KEY_HASH_ALGORITHM}${API_KEY_HASH_ITERATIONS}${salt_b64}${dk_b64}" + + +def verify_api_key(raw_api_key: str, stored_hash: str) -> bool: + parts = stored_hash.split("$") + lookup_id: str | None = None + if len(parts) == 6 and parts[0] == API_KEY_HASH_VERSION: + _, lookup_id, algorithm, iterations_raw, salt_b64, dk_b64 = parts + elif len(parts) == 4: + algorithm, iterations_raw, salt_b64, dk_b64 = parts + else: + return False + + if algorithm != API_KEY_HASH_ALGORITHM: + return False + if lookup_id is not None and not hmac.compare_digest(lookup_id, api_key_lookup_id(raw_api_key)): + return False + + try: + iterations = int(iterations_raw) + salt = base64.b64decode(salt_b64, validate=True) + expected_key = base64.b64decode(dk_b64, validate=True) + except (ValueError, binascii.Error): + return False + + derived_key = hashlib.pbkdf2_hmac("sha256", raw_api_key.encode("utf-8"), salt, iterations) + return hmac.compare_digest(derived_key, expected_key) From 2120cd4ab877ac338e87caa5253c8d9f9d77b82e Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 13:42:32 +0330 Subject: [PATCH 17/56] format code --- app/operation/api_key.py | 2 -- app/utils/crypto.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/operation/api_key.py b/app/operation/api_key.py index 750e2af37..cb0d3619b 100644 --- a/app/operation/api_key.py +++ b/app/operation/api_key.py @@ -13,8 +13,6 @@ from app.models.admin import AdminDetails from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysQuery, APIKeysResponse from app.operation import BaseOperation -from app.operation.permissions import get_effective_limits -from app.utils.system import readable_duration class APIKeyOperation(BaseOperation): diff --git a/app/utils/crypto.py b/app/utils/crypto.py index 8ba345e0e..1b1bb36c2 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -146,7 +146,7 @@ def verify_api_key(raw_api_key: str, stored_hash: str) -> bool: iterations = int(iterations_raw) salt = base64.b64decode(salt_b64, validate=True) expected_key = base64.b64decode(dk_b64, validate=True) - except (ValueError, binascii.Error): + except ValueError, binascii.Error: return False derived_key = hashlib.pbkdf2_hmac("sha256", raw_api_key.encode("utf-8"), salt, iterations) From d2435b9a002217d2229af480aae1d585ea615a04 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 16:20:51 +0330 Subject: [PATCH 18/56] feat: Add catch for apikeys and add notifications for apikeys and add mmodify endpoint --- app/db/crud/api_key.py | 29 ++++++-- .../c9b48df42f10_add_api_keys_table.py | 2 + app/models/admin_role.py | 1 + app/models/api_key.py | 18 +++++ app/models/notification_enable.py | 1 + app/models/settings.py | 1 + app/notification/__init__.py | 16 +++++ app/notification/discord/__init__.py | 4 ++ app/notification/discord/api_key.py | 68 +++++++++++++++++++ app/notification/discord/messages.py | 18 +++++ app/notification/telegram/__init__.py | 4 ++ app/notification/telegram/api_key.py | 57 ++++++++++++++++ app/notification/telegram/messages.py | 35 ++++++++++ app/operation/api_key.py | 52 +++++++++++++- app/routers/api_key.py | 19 +++++- app/routers/authentication.py | 17 ++++- app/utils/crypto.py | 41 +++++++---- 17 files changed, 361 insertions(+), 22 deletions(-) create mode 100644 app/notification/discord/api_key.py create mode 100644 app/notification/telegram/api_key.py diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index 59f207be0..19a1423b6 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -1,12 +1,14 @@ import uuid -from sqlalchemy import func, select, update +from aiocache import cached +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_HASH_VERSION, api_key_lookup_id, hash_api_key, verify_api_key +from app.utils.jwt import get_secret_key async def create_api_key( @@ -15,12 +17,13 @@ async def create_api_key( 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), + key_hash=hash_api_key(raw_key, pepper=pepper), expire_date=model.expire_date, ) db.add(db_key) @@ -30,18 +33,26 @@ async def create_api_key( async def get_api_key_by_raw_key(db: AsyncSession, raw_api_key: str) -> APIKey | None: - lookup_prefix = f"{API_KEY_HASH_VERSION}${api_key_lookup_id(raw_api_key)}$" + pepper = await get_secret_key() + lookup_id = api_key_lookup_id(raw_api_key) + stmt = ( select(APIKey) .where( - APIKey.key_hash.startswith(lookup_prefix), + 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): + + 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: @@ -91,6 +102,14 @@ async def delete_api_key(db: AsyncSession, db_key: APIKey) -> None: await db.flush() +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)) diff --git a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py index e134cb25a..58f57a167 100644 --- a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py +++ b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py @@ -24,12 +24,14 @@ "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}, } diff --git a/app/models/admin_role.py b/app/models/admin_role.py index 79191ff85..8b54441b1 100644 --- a/app/models/admin_role.py +++ b/app/models/admin_role.py @@ -85,6 +85,7 @@ class APIKeysPermissions(_ResourcePermissions): create: RoleActionValue | None = None read: RoleActionValue | None = None read_simple: RoleActionValue | None = None + modify: RoleActionValue | None = None delete: RoleActionValue | None = None diff --git a/app/models/api_key.py b/app/models/api_key.py index 8788aad13..1a5bda96f 100644 --- a/app/models/api_key.py +++ b/app/models/api_key.py @@ -28,6 +28,24 @@ def validate_expire_date(cls, value): return parsed +class APIKeyUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=128) + note: str | None = Field(default=None, max_length=512) + role_id: int | None = Field(default=None, ge=1) + expire_date: dt | None = None + status: APIKeyStatus | None = None + + @field_validator("expire_date", mode="before") + @classmethod + def validate_expire_date(cls, value): + if value is None: + return None + parsed = fix_datetime_timezone(value) + if parsed <= dt.now(tz.utc): + raise ValueError("expire_date must be in the future") + return parsed + + class APIKeyResponse(APIKeyBase): id: int admin_id: int diff --git a/app/models/notification_enable.py b/app/models/notification_enable.py index 4902d11a4..f97cd8ca6 100644 --- a/app/models/notification_enable.py +++ b/app/models/notification_enable.py @@ -48,5 +48,6 @@ class NotificationEnable(BaseModel): node: NodeNotificationEnable = Field(default_factory=NodeNotificationEnable) user: UserNotificationEnable = Field(default_factory=UserNotificationEnable) user_template: BaseNotificationEnable = Field(default_factory=BaseNotificationEnable) + api_key: BaseNotificationEnable = Field(default_factory=BaseNotificationEnable) days_left: bool = Field(default=True) percentage_reached: bool = Field(default=True) diff --git a/app/models/settings.py b/app/models/settings.py index a2939f8e1..ccecca17b 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -135,6 +135,7 @@ class NotificationChannels(BaseModel): node: NotificationChannel = Field(default_factory=NotificationChannel) user: NotificationChannel = Field(default_factory=NotificationChannel) user_template: NotificationChannel = Field(default_factory=NotificationChannel) + api_key: NotificationChannel = Field(default_factory=NotificationChannel) class NotificationSettings(BaseModel): diff --git a/app/notification/__init__.py b/app/notification/__init__.py index 43e165498..f3ea01d04 100644 --- a/app/notification/__init__.py +++ b/app/notification/__init__.py @@ -6,6 +6,7 @@ from app.models.group import GroupResponse from app.models.host import BaseHost from app.models.node import NodeNotification, NodeResponse +from app.models.api_key import APIKeyResponse from app.models.user import UserNotificationResponse from app.models.user_template import UserTemplateResponse from app.settings import notification_enable @@ -13,6 +14,21 @@ from . import discord as ds, telegram as tg, webhook as wh +async def create_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + if (await notification_enable()).api_key.create: + await asyncio.gather(ds.create_api_key_ds(api_key, admin_username, by), tg.create_api_key_tg(api_key, admin_username, by)) + + +async def modify_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + if (await notification_enable()).api_key.modify: + await asyncio.gather(ds.modify_api_key_ds(api_key, admin_username, by), tg.modify_api_key_tg(api_key, admin_username, by)) + + +async def remove_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + if (await notification_enable()).api_key.delete: + await asyncio.gather(ds.remove_api_key_ds(api_key, admin_username, by), tg.remove_api_key_tg(api_key, admin_username, by)) + + async def create_admin_role(role: AdminRoleResponse, by: str): if (await notification_enable()).admin_role.create: await asyncio.gather(ds.create_admin_role(role, by), tg.create_admin_role(role, by)) diff --git a/app/notification/discord/__init__.py b/app/notification/discord/__init__.py index 06e69cee4..afaeab635 100644 --- a/app/notification/discord/__init__.py +++ b/app/notification/discord/__init__.py @@ -1,5 +1,6 @@ from .admin import admin_login, admin_reset_usage, admin_usage_limit_reached, create_admin, modify_admin, remove_admin from .admin_role import create_admin_role, modify_admin_role, remove_admin_role +from .api_key import create_api_key as create_api_key_ds, modify_api_key as modify_api_key_ds, remove_api_key as remove_api_key_ds from .core import create_core, modify_core, remove_core from .group import create_group, modify_group, remove_group from .host import create_host, modify_host, modify_hosts, remove_host @@ -19,6 +20,9 @@ "create_admin_role", "modify_admin_role", "remove_admin_role", + "create_api_key_ds", + "modify_api_key_ds", + "remove_api_key_ds", "create_host", "modify_host", "remove_host", diff --git a/app/notification/discord/api_key.py b/app/notification/discord/api_key.py new file mode 100644 index 000000000..8a1a2a65f --- /dev/null +++ b/app/notification/discord/api_key.py @@ -0,0 +1,68 @@ +from app.models.api_key import APIKeyResponse +from app.models.settings import NotificationSettings +from app.notification.client import send_discord_message +from app.notification.helpers import get_discord_webhook +from app.settings import notification_settings + +from . import colors, messages + +ENTITY = "api_key" + + +async def create_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + data = messages.CREATE_API_KEY.copy() + data["description"] = data["description"].format( + name=api_key.name, + role_id=api_key.role_id, + expire_date=api_key.expire_date or "Never", + ) + data["footer"]["text"] = data["footer"]["text"].format( + id=api_key.id, + admin_username=admin_username, + by=by, + ) + data["color"] = colors.GREEN + + settings: NotificationSettings = await notification_settings() + if settings.notify_discord: + webhook_url = get_discord_webhook(settings, ENTITY) + await send_discord_message(data, webhook_url) + + +async def modify_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + data = messages.MODIFY_API_KEY.copy() + data["description"] = data["description"].format( + name=api_key.name, + role_id=api_key.role_id, + expire_date=api_key.expire_date or "Never", + status=api_key.status.value, + ) + data["footer"]["text"] = data["footer"]["text"].format( + id=api_key.id, + admin_username=admin_username, + by=by, + ) + data["color"] = colors.BLUE + + settings: NotificationSettings = await notification_settings() + if settings.notify_discord: + webhook_url = get_discord_webhook(settings, ENTITY) + await send_discord_message(data, webhook_url) + + +async def remove_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + data = messages.REMOVE_API_KEY.copy() + data["description"] = data["description"].format( + name=api_key.name, + ) + data["footer"]["text"] = data["footer"]["text"].format( + id=api_key.id, + admin_username=admin_username, + by=by, + ) + data["color"] = colors.RED + + settings: NotificationSettings = await notification_settings() + if settings.notify_discord: + webhook_url = get_discord_webhook(settings, ENTITY) + await send_discord_message(data, webhook_url) diff --git a/app/notification/discord/messages.py b/app/notification/discord/messages.py index 7a47fc9d2..35ca1c485 100644 --- a/app/notification/discord/messages.py +++ b/app/notification/discord/messages.py @@ -291,3 +291,21 @@ "description": "**Name:** {name}\n", "footer": {"text": "ID: {id}\nBy: {by}"}, } + +CREATE_API_KEY = { + "title": "🆕 Create API Key", + "description": "**Name:** {name}\n**Role ID:** {role_id}\n**Expire Date:** {expire_date}", + "footer": {"text": "ID: {id}\nBelongs To: {admin_username}\nBy: {by}"}, +} + +MODIFY_API_KEY = { + "title": "✏️ Modify API Key", + "description": "**Name:** {name}\n**Role ID:** {role_id}\n**Expire Date:** {expire_date}\n**Status:** {status}", + "footer": {"text": "ID: {id}\nBelongs To: {admin_username}\nBy: {by}"}, +} + +REMOVE_API_KEY = { + "title": "🗑️ Remove API Key", + "description": "**Name:** {name}\n", + "footer": {"text": "ID: {id}\nBelongs To: {admin_username}\nBy: {by}"}, +} diff --git a/app/notification/telegram/__init__.py b/app/notification/telegram/__init__.py index b910e947e..f15ce7e32 100644 --- a/app/notification/telegram/__init__.py +++ b/app/notification/telegram/__init__.py @@ -1,5 +1,6 @@ from .admin import admin_login, admin_reset_usage, admin_usage_limit_reached, create_admin, modify_admin, remove_admin from .admin_role import create_admin_role, modify_admin_role, remove_admin_role +from .api_key import create_api_key as create_api_key_tg, modify_api_key as modify_api_key_tg, remove_api_key as remove_api_key_tg from .core import create_core, modify_core, remove_core from .group import create_group, modify_group, remove_group from .host import create_host, modify_host, modify_hosts, remove_host @@ -19,6 +20,9 @@ "create_admin_role", "modify_admin_role", "remove_admin_role", + "create_api_key_tg", + "modify_api_key_tg", + "remove_api_key_tg", "create_host", "modify_host", "remove_host", diff --git a/app/notification/telegram/api_key.py b/app/notification/telegram/api_key.py new file mode 100644 index 000000000..25a1e0c79 --- /dev/null +++ b/app/notification/telegram/api_key.py @@ -0,0 +1,57 @@ +from app.models.api_key import APIKeyResponse +from app.models.settings import NotificationSettings +from app.notification.client import send_telegram_message +from app.notification.helpers import get_telegram_channel +from app.settings import notification_settings +from app.utils.helpers import escape_tg_html + +from . import messages + +ENTITY = "api_key" + + +async def create_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + name, admin_username, by = escape_tg_html((api_key.name, admin_username, by)) + data = messages.CREATE_API_KEY.format( + id=api_key.id, + name=name, + role_id=api_key.role_id, + expire_date=api_key.expire_date or "Never", + admin_username=admin_username, + by=by, + ) + settings: NotificationSettings = await notification_settings() + if settings.notify_telegram: + chat_id, topic_id = get_telegram_channel(settings, ENTITY) + await send_telegram_message(data, chat_id, topic_id) + + +async def modify_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + name, admin_username, by = escape_tg_html((api_key.name, admin_username, by)) + data = messages.MODIFY_API_KEY.format( + id=api_key.id, + name=name, + role_id=api_key.role_id, + expire_date=api_key.expire_date or "Never", + status=api_key.status.value, + admin_username=admin_username, + by=by, + ) + settings: NotificationSettings = await notification_settings() + if settings.notify_telegram: + chat_id, topic_id = get_telegram_channel(settings, ENTITY) + await send_telegram_message(data, chat_id, topic_id) + + +async def remove_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + name, admin_username, by = escape_tg_html((api_key.name, admin_username, by)) + data = messages.REMOVE_API_KEY.format( + id=api_key.id, + name=name, + admin_username=admin_username, + by=by, + ) + settings: NotificationSettings = await notification_settings() + if settings.notify_telegram: + chat_id, topic_id = get_telegram_channel(settings, ENTITY) + await send_telegram_message(data, chat_id, topic_id) diff --git a/app/notification/telegram/messages.py b/app/notification/telegram/messages.py index d6b64c90f..d756090de 100644 --- a/app/notification/telegram/messages.py +++ b/app/notification/telegram/messages.py @@ -361,3 +361,38 @@ ID: {id} By: #{by} """ + +CREATE_API_KEY = """ +#Create_API_Key +➖➖➖➖➖➖➖➖➖ +Name: {name} +Role ID: {role_id} +Expire Date: {expire_date} +➖➖➖➖➖➖➖➖➖ +ID: {id} +Belongs To: {admin_username} +By: #{by} +""" + +MODIFY_API_KEY = """ +✏️ #Modify_API_Key +➖➖➖➖➖➖➖➖➖ +Name: {name} +Role ID: {role_id} +Expire Date: {expire_date} +Status: {status} +➖➖➖➖➖➖➖➖➖ +ID: {id} +Belongs To: {admin_username} +By: #{by} +""" + +REMOVE_API_KEY = """ +#Remove_API_Key +➖➖➖➖➖➖➖➖➖ +Name: {name} +➖➖➖➖➖➖➖➖➖ +ID: {id} +Belongs To: {admin_username} +By: #{by} +""" diff --git a/app/operation/api_key.py b/app/operation/api_key.py index cb0d3619b..61cf405da 100644 --- a/app/operation/api_key.py +++ b/app/operation/api_key.py @@ -9,9 +9,22 @@ delete_api_key, get_api_key_by_id, get_api_keys, + update_api_key, +) +from app.notification import ( + create_api_key as notify_create, + modify_api_key as notify_modify, + remove_api_key as notify_delete, ) from app.models.admin import AdminDetails -from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysQuery, APIKeysResponse +from app.models.api_key import ( + APIKeyCreate, + APIKeyCreateResponse, + APIKeyResponse, + APIKeyUpdate, + APIKeysQuery, + APIKeysResponse, +) from app.operation import BaseOperation @@ -43,6 +56,7 @@ async def create_api_key( model=model, ) await db.commit() + await notify_create(APIKeyResponse.model_validate(db_key), admin.username, admin.username) except IntegrityError: await self.raise_error(message="API key already exists", code=409, db=db) @@ -70,6 +84,38 @@ async def list_api_keys(self, db: AsyncSession, *, admin: AdminDetails, query: A ) return APIKeysResponse(api_keys=[APIKeyResponse.model_validate(row) for row in rows], total=total) + async def modify_api_key( + self, db: AsyncSession, *, admin: AdminDetails, key_id: int, model: APIKeyUpdate + ) -> APIKeyResponse: + db_key = await get_api_key_by_id(db, key_id) + if db_key is None: + await self.raise_error(message="API key not found", code=404) + + if not admin.is_owner and db_key.admin_id != admin.id: + await self.raise_error(message="Permission denied", code=403) + + if model.name is not None and model.name != db_key.name: + duplicates, _ = await get_api_keys(db, admin_id=db_key.admin_id, offset=0, limit=1, name=model.name) + if duplicates: + await self.raise_error(message="API key name already exists", code=409) + + if model.role_id is not None and model.role_id != db_key.role_id: + role = await get_role(db, model.role_id) + if role is None: + await self.raise_error(message="Role not found", code=404) + if not admin.is_owner and admin.role and role.id != admin.role.id: + await self.raise_error(message="Only owner can assign a different role to API keys", code=403) + + update_data = model.model_dump(exclude_unset=True) + db_key = await update_api_key(db, db_key, update_data) + await db.commit() + + api_key_resp = APIKeyResponse.model_validate(db_key) + admin_username = db_key.admin.username if db_key.admin else "Unknown" + await notify_modify(api_key_resp, admin_username, admin.username) + + return api_key_resp + async def get_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> APIKeyResponse: scope_admin_id = None if admin.is_owner else admin.id rows, _ = await get_api_keys(db, admin_id=scope_admin_id, offset=0, limit=1, key_id=key_id) @@ -85,5 +131,9 @@ async def delete_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: if not admin.is_owner and db_key.admin_id != admin.id: await self.raise_error(message="Permission denied", code=403) + api_key_resp = APIKeyResponse.model_validate(db_key) + admin_username = db_key.admin.username if db_key.admin else "Unknown" + await delete_api_key(db, db_key) await db.commit() + await notify_delete(api_key_resp, admin_username, admin.username) diff --git a/app/routers/api_key.py b/app/routers/api_key.py index d629c95e0..4ae632f7d 100644 --- a/app/routers/api_key.py +++ b/app/routers/api_key.py @@ -3,7 +3,14 @@ from app.db import AsyncSession, get_db from app.models.admin import AdminDetails -from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysQuery, APIKeysResponse +from app.models.api_key import ( + APIKeyCreate, + APIKeyCreateResponse, + APIKeyResponse, + APIKeyUpdate, + APIKeysQuery, + APIKeysResponse, +) from app.operation import OperatorType from app.operation.api_key import APIKeyOperation from app.routers.dependencies import get_api_key_list_query @@ -43,6 +50,16 @@ async def list_api_keys( return await api_key_operator.list_api_keys(db, admin=admin, query=query) +@router.patch("/{key_id}", response_model=APIKeyResponse, responses={404: responses._404, 409: responses._409}) +async def modify_api_key( + key_id: int, + model: APIKeyUpdate, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("api_keys", "modify")), +): + return await api_key_operator.modify_api_key(db, admin=admin, key_id=key_id, model=model) + + @router.get("/{key_id}", response_model=APIKeyResponse, responses={404: responses._404}) async def get_api_key( key_id: int, diff --git a/app/routers/authentication.py b/app/routers/authentication.py index 8d03167ee..4ca68d172 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -6,7 +6,8 @@ from fastapi.security import OAuth2PasswordBearer from sqlalchemy import func, select -from app.db import AsyncSession, get_db +from aiocache import cached +from app.db import AsyncSession, GetDB, get_db from app.db.crud.admin import ( find_admins_by_telegram_id, get_admin as get_admin_by_username, @@ -109,6 +110,18 @@ async def _build_admin_metrics(db: AsyncSession, admin_id: int) -> tuple[int, in async def get_admin_from_api_key(db: AsyncSession, raw_key: str, *, with_metrics: bool = False) -> AdminDetails | None: + return await _get_admin_from_api_key_internal(db, raw_key, with_metrics=with_metrics) + + +@cached(ttl=60) +async def _get_admin_from_api_key_cached(raw_key: str, with_metrics: bool) -> AdminDetails | None: + async with GetDB() as db: + return await _get_admin_from_api_key_internal(db, raw_key, with_metrics=with_metrics) + + +async def _get_admin_from_api_key_internal( + db: AsyncSession, raw_key: str, *, with_metrics: bool = False +) -> AdminDetails | None: if not raw_key: return @@ -225,7 +238,7 @@ async def _get_admin_from_request_credentials( if not admin: api_key = _extract_api_key(request) if api_key: - admin = await get_admin_from_api_key(db, api_key, with_metrics=with_metrics) + admin = await _get_admin_from_api_key_cached(api_key, with_metrics) return admin diff --git a/app/utils/crypto.py b/app/utils/crypto.py index 1b1bb36c2..86b2d9459 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -103,7 +103,8 @@ def generate_wireguard_keypair() -> tuple[str, str]: API_KEY_HASH_VERSION = "v1" -API_KEY_HASH_ALGORITHM = "pbkdf2_sha256" +API_KEY_HMAC_ALGORITHM = "hmac_sha256" +API_KEY_PBKDF2_ALGORITHM = "pbkdf2_sha256" API_KEY_HASH_ITERATIONS = 310000 API_KEY_LOOKUP_BYTES = 16 @@ -113,31 +114,45 @@ def api_key_lookup_id(raw_api_key: str) -> str: return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") -def hash_api_key(raw_api_key: str) -> str: +def hash_api_key(raw_api_key: str, pepper: str = "") -> str: salt = os.urandom(16) - derived_key = hashlib.pbkdf2_hmac( - "sha256", - raw_api_key.encode("utf-8"), - salt, - API_KEY_HASH_ITERATIONS, - ) salt_b64 = base64.b64encode(salt).decode("ascii") - dk_b64 = base64.b64encode(derived_key).decode("ascii") lookup_id = api_key_lookup_id(raw_api_key) - return f"{API_KEY_HASH_VERSION}${lookup_id}${API_KEY_HASH_ALGORITHM}${API_KEY_HASH_ITERATIONS}${salt_b64}${dk_b64}" + + # Use HMAC-SHA256 + key = pepper.encode("utf-8") + salt + h = hmac.new(key, raw_api_key.encode("utf-8"), hashlib.sha256) + hash_hex = h.hexdigest() + return f"{API_KEY_HASH_VERSION}${lookup_id}${API_KEY_HMAC_ALGORITHM}${salt_b64}${hash_hex}" -def verify_api_key(raw_api_key: str, stored_hash: str) -> bool: +def verify_api_key(raw_api_key: str, stored_hash: str, pepper: str = "") -> bool: parts = stored_hash.split("$") + + # Handle HMAC-SHA256 (regardless of v1/v2 prefix as long as algorithm matches) + if len(parts) == 5 and parts[2] == API_KEY_HMAC_ALGORITHM: + _, lookup_id, algorithm, salt_b64, hash_hex = parts + if not hmac.compare_digest(lookup_id, api_key_lookup_id(raw_api_key)): + return False + try: + salt = base64.b64decode(salt_b64, validate=True) + except ValueError, binascii.Error: + return False + + key = pepper.encode("utf-8") + salt + h = hmac.new(key, raw_api_key.encode("utf-8"), hashlib.sha256) + return hmac.compare_digest(h.hexdigest(), hash_hex) + + # Handle PBKDF2-SHA256 (Legacy) lookup_id: str | None = None - if len(parts) == 6 and parts[0] == API_KEY_HASH_VERSION: + if len(parts) == 6: _, lookup_id, algorithm, iterations_raw, salt_b64, dk_b64 = parts elif len(parts) == 4: algorithm, iterations_raw, salt_b64, dk_b64 = parts else: return False - if algorithm != API_KEY_HASH_ALGORITHM: + if algorithm != API_KEY_PBKDF2_ALGORITHM: return False if lookup_id is not None and not hmac.compare_digest(lookup_id, api_key_lookup_id(raw_api_key)): return False From 75f2070c517f3c8c792f2591b2c9410716af7f54 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 16:22:17 +0330 Subject: [PATCH 19/56] format code --- app/notification/__init__.py | 12 +++++++++--- app/notification/discord/__init__.py | 6 +++++- app/notification/telegram/__init__.py | 6 +++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/app/notification/__init__.py b/app/notification/__init__.py index f3ea01d04..26024dcdd 100644 --- a/app/notification/__init__.py +++ b/app/notification/__init__.py @@ -16,17 +16,23 @@ async def create_api_key(api_key: APIKeyResponse, admin_username: str, by: str): if (await notification_enable()).api_key.create: - await asyncio.gather(ds.create_api_key_ds(api_key, admin_username, by), tg.create_api_key_tg(api_key, admin_username, by)) + await asyncio.gather( + ds.create_api_key_ds(api_key, admin_username, by), tg.create_api_key_tg(api_key, admin_username, by) + ) async def modify_api_key(api_key: APIKeyResponse, admin_username: str, by: str): if (await notification_enable()).api_key.modify: - await asyncio.gather(ds.modify_api_key_ds(api_key, admin_username, by), tg.modify_api_key_tg(api_key, admin_username, by)) + await asyncio.gather( + ds.modify_api_key_ds(api_key, admin_username, by), tg.modify_api_key_tg(api_key, admin_username, by) + ) async def remove_api_key(api_key: APIKeyResponse, admin_username: str, by: str): if (await notification_enable()).api_key.delete: - await asyncio.gather(ds.remove_api_key_ds(api_key, admin_username, by), tg.remove_api_key_tg(api_key, admin_username, by)) + await asyncio.gather( + ds.remove_api_key_ds(api_key, admin_username, by), tg.remove_api_key_tg(api_key, admin_username, by) + ) async def create_admin_role(role: AdminRoleResponse, by: str): diff --git a/app/notification/discord/__init__.py b/app/notification/discord/__init__.py index afaeab635..9d34da7e9 100644 --- a/app/notification/discord/__init__.py +++ b/app/notification/discord/__init__.py @@ -1,6 +1,10 @@ from .admin import admin_login, admin_reset_usage, admin_usage_limit_reached, create_admin, modify_admin, remove_admin from .admin_role import create_admin_role, modify_admin_role, remove_admin_role -from .api_key import create_api_key as create_api_key_ds, modify_api_key as modify_api_key_ds, remove_api_key as remove_api_key_ds +from .api_key import ( + create_api_key as create_api_key_ds, + modify_api_key as modify_api_key_ds, + remove_api_key as remove_api_key_ds, +) from .core import create_core, modify_core, remove_core from .group import create_group, modify_group, remove_group from .host import create_host, modify_host, modify_hosts, remove_host diff --git a/app/notification/telegram/__init__.py b/app/notification/telegram/__init__.py index f15ce7e32..3e139361d 100644 --- a/app/notification/telegram/__init__.py +++ b/app/notification/telegram/__init__.py @@ -1,6 +1,10 @@ from .admin import admin_login, admin_reset_usage, admin_usage_limit_reached, create_admin, modify_admin, remove_admin from .admin_role import create_admin_role, modify_admin_role, remove_admin_role -from .api_key import create_api_key as create_api_key_tg, modify_api_key as modify_api_key_tg, remove_api_key as remove_api_key_tg +from .api_key import ( + create_api_key as create_api_key_tg, + modify_api_key as modify_api_key_tg, + remove_api_key as remove_api_key_tg, +) from .core import create_core, modify_core, remove_core from .group import create_group, modify_group, remove_group from .host import create_host, modify_host, modify_hosts, remove_host From efd3376ae97e0efde35e143b233c26d43e8a5f24 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 16:26:41 +0330 Subject: [PATCH 20/56] fix import error --- app/notification/discord/api_key.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/notification/discord/api_key.py b/app/notification/discord/api_key.py index 8a1a2a65f..dd3e2cc54 100644 --- a/app/notification/discord/api_key.py +++ b/app/notification/discord/api_key.py @@ -1,6 +1,6 @@ from app.models.api_key import APIKeyResponse from app.models.settings import NotificationSettings -from app.notification.client import send_discord_message +from app.notification.client import send_discord_webhook from app.notification.helpers import get_discord_webhook from app.settings import notification_settings @@ -26,7 +26,7 @@ async def create_api_key(api_key: APIKeyResponse, admin_username: str, by: str): settings: NotificationSettings = await notification_settings() if settings.notify_discord: webhook_url = get_discord_webhook(settings, ENTITY) - await send_discord_message(data, webhook_url) + await send_discord_webhook(data, webhook_url) async def modify_api_key(api_key: APIKeyResponse, admin_username: str, by: str): @@ -47,7 +47,7 @@ async def modify_api_key(api_key: APIKeyResponse, admin_username: str, by: str): settings: NotificationSettings = await notification_settings() if settings.notify_discord: webhook_url = get_discord_webhook(settings, ENTITY) - await send_discord_message(data, webhook_url) + await send_discord_webhook(data, webhook_url) async def remove_api_key(api_key: APIKeyResponse, admin_username: str, by: str): @@ -65,4 +65,4 @@ async def remove_api_key(api_key: APIKeyResponse, admin_username: str, by: str): settings: NotificationSettings = await notification_settings() if settings.notify_discord: webhook_url = get_discord_webhook(settings, ENTITY) - await send_discord_message(data, webhook_url) + await send_discord_webhook(data, webhook_url) From 0b5072caa5f41da8d72b1ab60cb4da62c58ebc99 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 7 Jun 2026 12:28:47 +0330 Subject: [PATCH 21/56] feat: Add ablity to revoke api keys --- app/db/crud/api_key.py | 15 +++- .../c9b48df42f10_add_api_keys_table.py | 7 +- app/db/models.py | 1 + app/models/api_key.py | 1 + app/operation/api_key.py | 33 +++++++++ app/routers/api_key.py | 9 +++ app/routers/authentication.py | 11 +-- tests/api/test_api_key.py | 71 +++++++++++++++++++ 8 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 tests/api/test_api_key.py diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index 19a1423b6..91424c171 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -1,13 +1,13 @@ import uuid +from datetime import datetime as dt, timezone as tz -from aiocache import cached 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_HASH_VERSION, api_key_lookup_id, hash_api_key, verify_api_key +from app.utils.crypto import api_key_lookup_id, hash_api_key, verify_api_key from app.utils.jwt import get_secret_key @@ -102,6 +102,17 @@ async def delete_api_key(db: AsyncSession, db_key: APIKey) -> None: 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) diff --git a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py index 58f57a167..f0288fd87 100644 --- a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py +++ b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py @@ -60,13 +60,16 @@ def upgrade() -> None: 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"), + 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( + ["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")), diff --git a/app/db/models.py b/app/db/models.py index 6605d3830..feb4e6e4b 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -882,6 +882,7 @@ class APIKey(Base, IdMixin, CreatedAtUTCMixin): role: Mapped["AdminRole"] = relationship(back_populates="api_keys", init=False, lazy="selectin") note: Mapped[Optional[str]] = mapped_column(String(512), default=None) expire_date: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) + revoked_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) status: Mapped[APIKeyStatus] = mapped_column( SQLEnum(APIKeyStatus, name="apikeystatus", create_constraint=True), default=APIKeyStatus.active, diff --git a/app/models/api_key.py b/app/models/api_key.py index 1a5bda96f..60658d1db 100644 --- a/app/models/api_key.py +++ b/app/models/api_key.py @@ -50,6 +50,7 @@ class APIKeyResponse(APIKeyBase): id: int admin_id: int created_at: dt + revoked_at: dt | None = None status: APIKeyStatus = APIKeyStatus.active is_expired: bool = False diff --git a/app/operation/api_key.py b/app/operation/api_key.py index 61cf405da..e6985c934 100644 --- a/app/operation/api_key.py +++ b/app/operation/api_key.py @@ -9,6 +9,7 @@ delete_api_key, get_api_key_by_id, get_api_keys, + revoke_api_key as revoke_api_key_crud, update_api_key, ) from app.notification import ( @@ -68,6 +69,9 @@ async def create_api_key( role_id=db_key.role_id, created_at=db_key.created_at, expire_date=db_key.expire_date, + revoked_at=db_key.revoked_at, + status=db_key.status, + is_expired=db_key.is_expired, api_key=raw_key, ) @@ -116,6 +120,35 @@ async def modify_api_key( return api_key_resp + async def revoke_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> APIKeyCreateResponse: + db_key = await get_api_key_by_id(db, key_id) + if db_key is None: + await self.raise_error(message="API key not found", code=404) + + if not admin.is_owner and db_key.admin_id != admin.id: + await self.raise_error(message="Permission denied", code=403) + + raw_key, db_key = await revoke_api_key_crud(db, db_key) + await db.commit() + + api_key_resp = APIKeyResponse.model_validate(db_key) + admin_username = db_key.admin.username if db_key.admin else "Unknown" + await notify_modify(api_key_resp, admin_username, admin.username) + + return APIKeyCreateResponse( + id=db_key.id, + admin_id=db_key.admin_id, + name=db_key.name, + note=db_key.note, + role_id=db_key.role_id, + created_at=db_key.created_at, + expire_date=db_key.expire_date, + revoked_at=db_key.revoked_at, + status=db_key.status, + is_expired=db_key.is_expired, + api_key=raw_key, + ) + async def get_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> APIKeyResponse: scope_admin_id = None if admin.is_owner else admin.id rows, _ = await get_api_keys(db, admin_id=scope_admin_id, offset=0, limit=1, key_id=key_id) diff --git a/app/routers/api_key.py b/app/routers/api_key.py index 4ae632f7d..83f17fbd1 100644 --- a/app/routers/api_key.py +++ b/app/routers/api_key.py @@ -69,6 +69,15 @@ async def get_api_key( return await api_key_operator.get_api_key(db, admin=admin, key_id=key_id) +@router.post("/{key_id}/revoke", response_model=APIKeyCreateResponse, responses={404: responses._404}) +async def revoke_api_key( + key_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("api_keys", "delete")), +): + return await api_key_operator.revoke_api_key(db, admin=admin, key_id=key_id) + + @router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT, responses={404: responses._404}) async def remove_api_key( key_id: int, diff --git a/app/routers/authentication.py b/app/routers/authentication.py index 4ca68d172..1b9c5e8d8 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -6,8 +6,7 @@ from fastapi.security import OAuth2PasswordBearer from sqlalchemy import func, select -from aiocache import cached -from app.db import AsyncSession, GetDB, get_db +from app.db import AsyncSession, get_db from app.db.crud.admin import ( find_admins_by_telegram_id, get_admin as get_admin_by_username, @@ -113,12 +112,6 @@ async def get_admin_from_api_key(db: AsyncSession, raw_key: str, *, with_metrics return await _get_admin_from_api_key_internal(db, raw_key, with_metrics=with_metrics) -@cached(ttl=60) -async def _get_admin_from_api_key_cached(raw_key: str, with_metrics: bool) -> AdminDetails | None: - async with GetDB() as db: - return await _get_admin_from_api_key_internal(db, raw_key, with_metrics=with_metrics) - - async def _get_admin_from_api_key_internal( db: AsyncSession, raw_key: str, *, with_metrics: bool = False ) -> AdminDetails | None: @@ -238,7 +231,7 @@ async def _get_admin_from_request_credentials( if not admin: api_key = _extract_api_key(request) if api_key: - admin = await _get_admin_from_api_key_cached(api_key, with_metrics) + admin = await get_admin_from_api_key(db, api_key, with_metrics=with_metrics) return admin diff --git a/tests/api/test_api_key.py b/tests/api/test_api_key.py new file mode 100644 index 000000000..add53e386 --- /dev/null +++ b/tests/api/test_api_key.py @@ -0,0 +1,71 @@ +import asyncio + +from fastapi import status +from sqlalchemy import select + +from app.db.models import APIKey +from tests.api import TestSession, client +from tests.api.helpers import auth_headers, create_admin, delete_admin, unique_name + + +def _login(username: str, password: str) -> str: + response = client.post( + "/api/admin/token", + data={"username": username, "password": password, "grant_type": "password"}, + ) + assert response.status_code == status.HTTP_200_OK + return response.json()["access_token"] + + +def _api_key_state(key_id: int) -> tuple[str | None, str]: + async def _get_state(): + async with TestSession() as session: + result = await session.execute(select(APIKey).where(APIKey.id == key_id)) + db_key = result.scalar_one() + revoked_at = db_key.revoked_at.isoformat() if db_key.revoked_at else None + return revoked_at, db_key.status.value + + return asyncio.run(_get_state()) + + +def test_revoke_api_key_rotates_secret_and_blocks_old_key(access_token): + admin = create_admin(access_token, role_id=2) + admin_token = _login(admin["username"], admin["password"]) + + try: + create_response = client.post( + "/api/api_key", + headers=auth_headers(admin_token), + json={"name": unique_name("api_key"), "role_id": 2}, + ) + assert create_response.status_code == status.HTTP_201_CREATED + created = create_response.json() + raw_api_key = created["api_key"] + assert created["revoked_at"] is None + assert created["status"] == "active" + + auth_response = client.get("/api/admin", headers={"X-Api-Key": raw_api_key}) + assert auth_response.status_code == status.HTTP_200_OK + assert auth_response.json()["username"] == admin["username"] + + revoke_response = client.post(f"/api/api_key/{created['id']}/revoke", headers=auth_headers(admin_token)) + assert revoke_response.status_code == status.HTTP_200_OK + revoked = revoke_response.json() + new_api_key = revoked["api_key"] + assert new_api_key != raw_api_key + assert revoked["id"] == created["id"] + assert revoked["revoked_at"] is not None + assert revoked["status"] == "active" + + db_revoked_at, db_status = _api_key_state(created["id"]) + assert db_revoked_at is not None + assert db_status == "active" + + revoked_auth_response = client.get("/api/admin", headers={"X-Api-Key": raw_api_key}) + assert revoked_auth_response.status_code == status.HTTP_401_UNAUTHORIZED + + new_auth_response = client.get("/api/admin", headers={"X-Api-Key": new_api_key}) + assert new_auth_response.status_code == status.HTTP_200_OK + assert new_auth_response.json()["username"] == admin["username"] + finally: + delete_admin(access_token, admin["username"]) From 2516c9b67400ef90e9502a212b9dad7c66eabc1f Mon Sep 17 00:00:00 2001 From: Mohammad Date: Mon, 8 Jun 2026 19:59:40 +0330 Subject: [PATCH 22/56] fix igration id --- app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py index f0288fd87..4a7a7b6aa 100644 --- a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py +++ b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py @@ -1,7 +1,7 @@ """add api keys table Revision ID: c9b48df42f10 -Revises: 2c6e9d34a1f0 +Revises: f9c69a49f544 Create Date: 2026-05-25 00:00:00.000000 """ @@ -15,7 +15,7 @@ # revision identifiers, used by Alembic. revision = "c9b48df42f10" -down_revision = "2c6e9d34a1f0" +down_revision = "f9c69a49f544" branch_labels = None depends_on = None From 9dd06498b2155346dfcecfc13aa1c28516500023 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Mon, 8 Jun 2026 20:34:14 +0330 Subject: [PATCH 23/56] fix migration error --- app/db/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/db/models.py b/app/db/models.py index cbc77d976..38f8d74d7 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -902,7 +902,7 @@ class APIKeyStatus(str, Enum): disabled = "disabled" -class APIKey(Base, IdMixin, CreatedAtUTCMixin): +class APIKey(Base, CreatedAtUTCMixin): __tablename__ = "api_keys" __table_args__ = ( UniqueConstraint("key_hash"), From 4297559f2bda38f46a03ffb78bfc4d4d83b6eeef Mon Sep 17 00:00:00 2001 From: Mohammad Date: Mon, 8 Jun 2026 20:40:33 +0330 Subject: [PATCH 24/56] format code and fix ruff error --- app/operation/user.py | 3 +-- app/routers/authentication.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/operation/user.py b/app/operation/user.py index 097800417..add2777c2 100644 --- a/app/operation/user.py +++ b/app/operation/user.py @@ -358,8 +358,7 @@ async def _persist_bulk_users( public_key, usernames = duplicate_key await self.raise_error( message=( - f"wireguard public_key {public_key} is assigned to multiple new users: " - f"{', '.join(usernames[:2])}" + f"wireguard public_key {public_key} is assigned to multiple new users: {', '.join(usernames[:2])}" ), code=400, db=db, diff --git a/app/routers/authentication.py b/app/routers/authentication.py index 4f7c0ec11..92668c688 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -108,9 +108,9 @@ async def _get_admin_from_api_key_internal( if with_metrics: total_users, reseted_usage = await _build_admin_metrics(db, db_admin.id) - admin = _build_admin_details(db_admin, total_users=total_users, reseted_usage=reseted_usage) + admin = build_admin_details(db_admin, total_users=total_users, reseted_usage=reseted_usage) else: - admin = _build_admin_details(db_admin) + admin = build_admin_details(db_admin) if db_key.role is not None: admin.role = AdminRoleData.model_validate(db_key.role) From dad99d81ab0fe18f8f2a57467016a13b346c0809 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sat, 13 Jun 2026 18:00:37 +0330 Subject: [PATCH 25/56] refactor: replace hmac with sha256 --- app/db/crud/api_key.py | 19 +++-------- app/utils/crypto.py | 61 ++++++++------------------------- tests/api/test_api_key.py | 66 ++++++++++++++++++++++++++++++++++++ tests/test_api_key_crypto.py | 31 +++++++++++++++++ 4 files changed, 117 insertions(+), 60 deletions(-) create mode 100644 tests/test_api_key_crypto.py diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index 91424c171..637af751c 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -1,14 +1,13 @@ import uuid from datetime import datetime as dt, timezone as tz -from sqlalchemy import func, or_, select, update +from sqlalchemy import func, 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( @@ -17,13 +16,12 @@ async def create_api_key( 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), + key_hash=hash_api_key(raw_key), expire_date=model.expire_date, ) db.add(db_key) @@ -33,18 +31,12 @@ async def create_api_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.key_hash.startswith(f"v1${lookup_id}$"), APIKey.status != APIKeyStatus.disabled, ) .options(selectinload(APIKey.admin).selectinload(Admin.role), selectinload(APIKey.role)) @@ -52,7 +44,7 @@ async def get_api_key_by_raw_key(db: AsyncSession, raw_api_key: str) -> APIKey | ) 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): + 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: @@ -104,8 +96,7 @@ async def delete_api_key(db: AsyncSession, db_key: APIKey) -> None: 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.key_hash = hash_api_key(raw_key) db_key.revoked_at = dt.now(tz.utc) db_key.status = APIKeyStatus.active await db.flush() diff --git a/app/utils/crypto.py b/app/utils/crypto.py index 86b2d9459..530d7582f 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -1,8 +1,7 @@ import base64 -import hashlib import binascii +import hashlib import hmac -import os from cryptography import x509 from cryptography.hazmat.backends import default_backend @@ -103,9 +102,7 @@ def generate_wireguard_keypair() -> tuple[str, str]: API_KEY_HASH_VERSION = "v1" -API_KEY_HMAC_ALGORITHM = "hmac_sha256" -API_KEY_PBKDF2_ALGORITHM = "pbkdf2_sha256" -API_KEY_HASH_ITERATIONS = 310000 +API_KEY_SHA256_ALGORITHM = "sha256" API_KEY_LOOKUP_BYTES = 16 @@ -114,55 +111,27 @@ def api_key_lookup_id(raw_api_key: str) -> str: return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") -def hash_api_key(raw_api_key: str, pepper: str = "") -> str: - salt = os.urandom(16) - salt_b64 = base64.b64encode(salt).decode("ascii") +def hash_api_key(raw_api_key: str) -> str: lookup_id = api_key_lookup_id(raw_api_key) - # Use HMAC-SHA256 - key = pepper.encode("utf-8") + salt - h = hmac.new(key, raw_api_key.encode("utf-8"), hashlib.sha256) - hash_hex = h.hexdigest() - return f"{API_KEY_HASH_VERSION}${lookup_id}${API_KEY_HMAC_ALGORITHM}${salt_b64}${hash_hex}" + hash_hex = _sha256_api_key_digest(raw_api_key) + return f"{API_KEY_HASH_VERSION}${lookup_id}${API_KEY_SHA256_ALGORITHM}${hash_hex}" + + +def _sha256_api_key_digest(raw_api_key: str) -> str: + return hashlib.sha256(raw_api_key.encode("utf-8")).hexdigest() -def verify_api_key(raw_api_key: str, stored_hash: str, pepper: str = "") -> bool: +def verify_api_key(raw_api_key: str, stored_hash: str) -> bool: parts = stored_hash.split("$") - # Handle HMAC-SHA256 (regardless of v1/v2 prefix as long as algorithm matches) - if len(parts) == 5 and parts[2] == API_KEY_HMAC_ALGORITHM: - _, lookup_id, algorithm, salt_b64, hash_hex = parts - if not hmac.compare_digest(lookup_id, api_key_lookup_id(raw_api_key)): - return False - try: - salt = base64.b64decode(salt_b64, validate=True) - except ValueError, binascii.Error: - return False - - key = pepper.encode("utf-8") + salt - h = hmac.new(key, raw_api_key.encode("utf-8"), hashlib.sha256) - return hmac.compare_digest(h.hexdigest(), hash_hex) - - # Handle PBKDF2-SHA256 (Legacy) - lookup_id: str | None = None - if len(parts) == 6: - _, lookup_id, algorithm, iterations_raw, salt_b64, dk_b64 = parts - elif len(parts) == 4: - algorithm, iterations_raw, salt_b64, dk_b64 = parts - else: + if len(parts) != 4: return False - if algorithm != API_KEY_PBKDF2_ALGORITHM: + version, lookup_id, algorithm, hash_hex = parts + if version != API_KEY_HASH_VERSION or algorithm != API_KEY_SHA256_ALGORITHM: return False - if lookup_id is not None and not hmac.compare_digest(lookup_id, api_key_lookup_id(raw_api_key)): - return False - - try: - iterations = int(iterations_raw) - salt = base64.b64decode(salt_b64, validate=True) - expected_key = base64.b64decode(dk_b64, validate=True) - except ValueError, binascii.Error: + if not hmac.compare_digest(lookup_id, api_key_lookup_id(raw_api_key)): return False - derived_key = hashlib.pbkdf2_hmac("sha256", raw_api_key.encode("utf-8"), salt, iterations) - return hmac.compare_digest(derived_key, expected_key) + return hmac.compare_digest(_sha256_api_key_digest(raw_api_key), hash_hex) diff --git a/tests/api/test_api_key.py b/tests/api/test_api_key.py index add53e386..7f9ef9650 100644 --- a/tests/api/test_api_key.py +++ b/tests/api/test_api_key.py @@ -28,6 +28,72 @@ async def _get_state(): return asyncio.run(_get_state()) +def test_api_key_authenticates_protected_requests(access_token): + admin = create_admin(access_token, role_id=2) + admin_token = _login(admin["username"], admin["password"]) + + try: + create_response = client.post( + "/api/api_key", + headers=auth_headers(admin_token), + json={"name": unique_name("api_key"), "role_id": 2}, + ) + assert create_response.status_code == status.HTTP_201_CREATED + created = create_response.json() + raw_api_key = created["api_key"] + + current_admin_response = client.get("/api/admin", headers={"X-Api-Key": raw_api_key}) + assert current_admin_response.status_code == status.HTTP_200_OK + assert current_admin_response.json()["username"] == admin["username"] + + list_response = client.get("/api/api_keys", headers={"X-Api-Key": raw_api_key}) + assert list_response.status_code == status.HTTP_200_OK + listed = list_response.json() + assert listed["total"] >= 1 + assert any(api_key["id"] == created["id"] for api_key in listed["api_keys"]) + + detail_response = client.get( + f"/api/api_key/{created['id']}", + headers={"Authorization": f"ApiKey {raw_api_key}"}, + ) + assert detail_response.status_code == status.HTTP_200_OK + detail = detail_response.json() + assert detail["id"] == created["id"] + assert detail["admin_id"] == admin["id"] + assert "api_key" not in detail + + username = unique_name("api_key_user") + create_user_response = client.post( + "/api/user", + headers={"X-Api-Key": raw_api_key}, + json={ + "username": username, + "proxy_settings": {}, + "data_limit": 1024 * 1024, + "data_limit_reset_strategy": "no_reset", + "status": "active", + }, + ) + assert create_user_response.status_code == status.HTTP_201_CREATED + user = create_user_response.json() + assert user["username"] == username + assert user["admin"]["username"] == admin["username"] + + delete_user_response = client.delete( + f"/api/user/{username}", + headers={"Authorization": f"ApiKey {raw_api_key}"}, + ) + assert delete_user_response.status_code == status.HTTP_204_NO_CONTENT + + delete_again_response = client.delete( + f"/api/user/{username}", + headers={"Authorization": f"ApiKey {raw_api_key}"}, + ) + assert delete_again_response.status_code == status.HTTP_404_NOT_FOUND + finally: + delete_admin(access_token, admin["username"]) + + def test_revoke_api_key_rotates_secret_and_blocks_old_key(access_token): admin = create_admin(access_token, role_id=2) admin_token = _login(admin["username"], admin["password"]) diff --git a/tests/test_api_key_crypto.py b/tests/test_api_key_crypto.py new file mode 100644 index 000000000..81a288927 --- /dev/null +++ b/tests/test_api_key_crypto.py @@ -0,0 +1,31 @@ +import hashlib + +from app.utils.crypto import ( + API_KEY_HASH_VERSION, + API_KEY_SHA256_ALGORITHM, + api_key_lookup_id, + hash_api_key, + verify_api_key, +) + + +def test_hash_api_key_uses_sha256_algorithm() -> None: + raw_api_key = "test-api-key" + + stored_hash = hash_api_key(raw_api_key) + version, lookup_id, algorithm, hash_hex = stored_hash.split("$") + expected_hash = hashlib.sha256(raw_api_key.encode("utf-8")).hexdigest() + + assert version == API_KEY_HASH_VERSION + assert lookup_id == api_key_lookup_id(raw_api_key) + assert algorithm == API_KEY_SHA256_ALGORITHM + assert hash_hex == expected_hash + assert verify_api_key(raw_api_key, stored_hash) + assert not verify_api_key("wrong-key", stored_hash) + + +def test_verify_api_key_rejects_non_sha256_algorithm() -> None: + raw_api_key = "test-api-key" + stored_hash = f"v1${api_key_lookup_id(raw_api_key)}$hmac_sha256$invalid" + + assert not verify_api_key(raw_api_key, stored_hash) From b7f5841909ccc56951042f790323eacdb330930d Mon Sep 17 00:00:00 2001 From: Mohammad Date: Mon, 15 Jun 2026 14:25:06 +0330 Subject: [PATCH 26/56] refactor: replace APIKeysPermissions with CRUDPermissions in RolePermissions --- app/models/admin_role.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/app/models/admin_role.py b/app/models/admin_role.py index 7408c4e9a..0e5cfa66a 100644 --- a/app/models/admin_role.py +++ b/app/models/admin_role.py @@ -81,14 +81,6 @@ class HwidsPermissions(_ResourcePermissions): delete: RoleActionValue | None = None -class APIKeysPermissions(_ResourcePermissions): - create: RoleActionValue | None = None - read: RoleActionValue | None = None - read_simple: RoleActionValue | None = None - modify: RoleActionValue | None = None - delete: RoleActionValue | None = None - - class RoleLimits(BaseModel): max_users: int | None = None data_limit_min: int | None = None @@ -134,7 +126,7 @@ class RolePermissions(BaseModel): system: SystemPermissions | None = None hwids: HwidsPermissions | None = None admin_roles: CRUDPermissions | None = None - api_keys: APIKeysPermissions | None = None + api_keys: CRUDPermissions | None = None model_config = ConfigDict(from_attributes=True) From 72a184cfa5aef348118a45f2bd071bc0d87473d5 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Mon, 15 Jun 2026 14:38:44 +0330 Subject: [PATCH 27/56] fix: update permission requirement from 'modify' to 'update' in modify_api_key function --- app/routers/api_key.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routers/api_key.py b/app/routers/api_key.py index 83f17fbd1..ccff8682b 100644 --- a/app/routers/api_key.py +++ b/app/routers/api_key.py @@ -55,7 +55,7 @@ async def modify_api_key( key_id: int, model: APIKeyUpdate, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(require_permission("api_keys", "modify")), + admin: AdminDetails = Depends(require_permission("api_keys", "update")), ): return await api_key_operator.modify_api_key(db, admin=admin, key_id=key_id, model=model) From 989383dcffd1c4852e7b76d218d629c9dd9d15c7 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Mon, 15 Jun 2026 14:50:33 +0330 Subject: [PATCH 28/56] feat(dashboard): implement API key management features - Add API key table component for displaying and managing API keys. - Create API key modal for adding and editing API keys. - Implement API key form with validation using Zod. - Add hooks for API key CRUD operations using React Query. - Update RBAC to allow access to API keys route. - Integrate API key management into the dashboard with appropriate UI elements and alerts. --- dashboard/public/statics/locales/en.json | 32 + dashboard/public/statics/locales/fa.json | 32 + dashboard/public/statics/locales/ru.json | 32 + dashboard/public/statics/locales/zh.json | 32 + dashboard/src/app/router.tsx | 9 + .../common/admin-filter-combobox.tsx | 4 +- dashboard/src/components/layout/sidebar.tsx | 9 + .../api-keys/components/api-keys-table.tsx | 96 + .../features/api-keys/components/columns.tsx | 101 + .../api-keys/dialogs/api-key-modal.tsx | 297 + .../features/api-keys/forms/api-key-form.tsx | 19 + .../features/api-keys/hooks/use-api-keys.ts | 104 + .../components/admin-statistics-card.tsx | 15 +- .../dashboard/components/data-usage-chart.tsx | 8 +- dashboard/src/pages/_dashboard._index.tsx | 6 +- dashboard/src/pages/_dashboard.api-keys.tsx | 172 + dashboard/src/service/api/index.ts | 21821 ++++++++++------ dashboard/src/utils/rbac.ts | 1 + 18 files changed, 14224 insertions(+), 8566 deletions(-) create mode 100644 dashboard/src/features/api-keys/components/api-keys-table.tsx create mode 100644 dashboard/src/features/api-keys/components/columns.tsx create mode 100644 dashboard/src/features/api-keys/dialogs/api-key-modal.tsx create mode 100644 dashboard/src/features/api-keys/forms/api-key-form.tsx create mode 100644 dashboard/src/features/api-keys/hooks/use-api-keys.ts create mode 100644 dashboard/src/pages/_dashboard.api-keys.tsx diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index d315008e7..f86a1f710 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -723,6 +723,37 @@ "no_traffic": "No traffic data available" } }, + "apiKeys": { + "title": "API Keys", + "description": "Manage API keys for programmatic access", + "createKey": "Create API Key", + "editKey": "Modify API Key", + "name": "Name", + "note": "Note", + "role": "Role", + "status": "Status", + "expireDate": "Expire Date", + "createdAt": "Created At", + "revokedAt": "Revoked At", + "revoke": "Revoke", + "delete": "Delete", + "apiKey": "API Key", + "apiKeyCopy": "Copy API Key", + "apiKeyCopySuccess": "API key copied to clipboard", + "apiKeyShowWarning": "This key will only be shown once. Please copy it now.", + "createSuccess": "API key created successfully", + "createFailed": "Failed to create API key", + "updateSuccess": "API key updated successfully", + "updateFailed": "Failed to update API key", + "revokeSuccess": "API key revoked and reissued successfully", + "revokeFailed": "Failed to revoke API key", + "deleteSuccess": "API key deleted successfully", + "deleteFailed": "Failed to delete API key", + "deletePrompt": "Are you sure you want to delete API key \"{{name}}\"?", + "revokePrompt": "Revoking will invalidate the current key and issue a new one. Are you sure?", + "revokeTitle": "Revoke & Reissue API Key", + "deleteTitle": "Delete API Key" + }, "adminRoles": { "title": "Admin Roles", "description": "Manage dashboard roles and RBAC permissions", @@ -839,6 +870,7 @@ "users": "Users", "admins": "Admins", "admin_roles": "Roles", + "api_keys": "API Keys", "nodes": "Nodes", "cores": "Cores", "hosts": "Hosts", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 2945c7caa..edc0db921 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -590,6 +590,37 @@ "no_traffic": "داده‌ای برای ترافیک موجود نیست" } }, + "apiKeys": { + "title": "کلیدهای API", + "description": "مدیریت کلیدهای API برای دسترسی برنامه‌نویسی", + "createKey": "ایجاد کلید API", + "editKey": "ویرایش کلید API", + "name": "نام", + "note": "یادداشت", + "role": "نقش", + "status": "وضعیت", + "expireDate": "تاریخ انقضا", + "createdAt": "تاریخ ایجاد", + "revokedAt": "تاریخ ابطال", + "revoke": "ابطال", + "delete": "حذف", + "apiKey": "کلید API", + "apiKeyCopy": "کپی کلید API", + "apiKeyCopySuccess": "کلید API در حافظه کپی شد", + "apiKeyShowWarning": "این کلید فقط یکبار نمایش داده می‌شود. لطفا همین الان آن را کپی کنید.", + "createSuccess": "کلید API با موفقیت ایجاد شد", + "createFailed": "ایجاد کلید API ناموفق بود", + "updateSuccess": "کلید API با موفقیت بروزرسانی شد", + "updateFailed": "بروزرسانی کلید API ناموفق بود", + "revokeSuccess": "کلید API با موفقیت ابطال و مجدداً صادر شد", + "revokeFailed": "ابطال کلید API ناموفق بود", + "deleteSuccess": "کلید API با موفقیت حذف شد", + "deleteFailed": "حذف کلید API ناموفق بود", + "deletePrompt": "آیا از حذف کلید API «{{name}}» اطمینان دارید؟", + "revokePrompt": "ابطال کلید باعث غیرفعال شدن کلید فعلی و صدور کلید جدید می‌شود. آیا مطمئن هستید؟", + "revokeTitle": "ابطال و صدور مجدد کلید API", + "deleteTitle": "حذف کلید API" + }, "adminRoles": { "title": "نقش‌های مدیر", "description": "مدیریت نقش‌های داشبورد و مجوزهای RBAC", @@ -706,6 +737,7 @@ "users": "کاربران", "admins": "مدیران", "admin_roles": "نقش‌ها", + "api_keys": "کلیدهای API", "nodes": "گره‌ها", "cores": "هسته‌ها", "hosts": "هاست‌ها", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 717f6c89c..85655b50a 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -709,6 +709,37 @@ "no_traffic": "Данные о трафике отсутствуют" } }, + "apiKeys": { + "title": "API ключи", + "description": "Управление API ключами для программного доступа", + "createKey": "Создать API ключ", + "editKey": "Изменить API ключ", + "name": "Название", + "note": "Заметка", + "role": "Роль", + "status": "Статус", + "expireDate": "Дата истечения", + "createdAt": "Дата создания", + "revokedAt": "Дата отзыва", + "revoke": "Отозвать", + "delete": "Удалить", + "apiKey": "API ключ", + "apiKeyCopy": "Копировать API ключ", + "apiKeyCopySuccess": "API ключ скопирован в буфер обмена", + "apiKeyShowWarning": "Этот ключ будет показан только один раз. Пожалуйста, скопируйте его сейчас.", + "createSuccess": "API ключ успешно создан", + "createFailed": "Не удалось создать API ключ", + "updateSuccess": "API ключ успешно обновлен", + "updateFailed": "Не удалось обновить API ключ", + "revokeSuccess": "API ключ успешно отозван и перевыпущен", + "revokeFailed": "Не удалось отозвать API ключ", + "deleteSuccess": "API ключ успешно удален", + "deleteFailed": "Не удалось удалить API ключ", + "deletePrompt": "Вы уверены, что хотите удалить API ключ «{{name}}»?", + "revokePrompt": "Отзыв сделает текущий ключ недействительным и выпустит новый. Вы уверены?", + "revokeTitle": "Отозвать и перевыпустить API ключ", + "deleteTitle": "Удалить API ключ" + }, "adminRoles": { "title": "Роли администраторов", "description": "Управление ролями панели и правами RBAC", @@ -825,6 +856,7 @@ "users": "Пользователи", "admins": "Админы", "admin_roles": "Роли", + "api_keys": "API ключи", "nodes": "Узлы", "cores": "Ядра", "hosts": "Хосты", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index e8596ff53..237f20e29 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -723,6 +723,37 @@ "no_traffic": "没有使用情况数据" } }, + "apiKeys": { + "title": "API 密钥", + "description": "管理用于编程访问的 API 密钥", + "createKey": "创建 API 密钥", + "editKey": "修改 API 密钥", + "name": "名称", + "note": "备注", + "role": "角色", + "status": "状态", + "expireDate": "过期日期", + "createdAt": "创建于", + "revokedAt": "吊销于", + "revoke": "吊销", + "delete": "删除", + "apiKey": "API 密钥", + "apiKeyCopy": "复制 API 密钥", + "apiKeyCopySuccess": "API 密钥已复制到剪贴板", + "apiKeyShowWarning": "此密钥仅显示一次。请立即复制。", + "createSuccess": "API 密钥创建成功", + "createFailed": "创建 API 密钥失败", + "updateSuccess": "API 密钥更新成功", + "updateFailed": "更新 API 密钥失败", + "revokeSuccess": "API 密钥已成功吊销并重新签发", + "revokeFailed": "吊销 API 密钥失败", + "deleteSuccess": "API 密钥删除成功", + "deleteFailed": "删除 API 密钥失败", + "deletePrompt": "您确定要删除 API 密钥「{{name}}」吗?", + "revokePrompt": "吊销将使当前密钥失效并签发新密钥。您确定吗?", + "revokeTitle": "吊销并重新签发 API 密钥", + "deleteTitle": "删除 API 密钥" + }, "adminRoles": { "title": "管理员角色", "description": "管理仪表板角色和 RBAC 权限", @@ -839,6 +870,7 @@ "users": "用户", "admins": "管理员", "admin_roles": "角色", + "api_keys": "API 密钥", "nodes": "节点", "cores": "核心", "hosts": "主机", diff --git a/dashboard/src/app/router.tsx b/dashboard/src/app/router.tsx index b2d489cda..6056e5e62 100644 --- a/dashboard/src/app/router.tsx +++ b/dashboard/src/app/router.tsx @@ -15,6 +15,7 @@ const DashboardLayout = lazyWithChunkRecovery(() => import('../pages/_dashboard' const Dashboard = lazyWithChunkRecovery(() => import('../pages/_dashboard._index')) const AdminsPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.admins')) const AdminRolesPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.admin-roles')) +const ApiKeysPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.api-keys')) const BulkPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.bulk')) const BulkCreatePage = lazyWithChunkRecovery(() => import('../pages/_dashboard.bulk.create')) const BulkDataPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.bulk.data')) @@ -227,6 +228,14 @@ export const router = createHashRouter([ ), }, + { + path: '/api-keys', + element: ( + }> + + + ), + }, { path: '/settings', element: ( diff --git a/dashboard/src/components/common/admin-filter-combobox.tsx b/dashboard/src/components/common/admin-filter-combobox.tsx index 397a02664..986129d3a 100644 --- a/dashboard/src/components/common/admin-filter-combobox.tsx +++ b/dashboard/src/components/common/admin-filter-combobox.tsx @@ -5,7 +5,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import useDirDetection from '@/hooks/use-dir-detection' import { useDebouncedSearch } from '@/hooks/use-debounced-search' import { cn } from '@/lib/utils' -import { type AdminDetails, type AdminSimple, useGetAdminsSimple } from '@/service/api' +import { type AdminSimple, useGetAdminsSimple } from '@/service/api' import { Check, ChevronDown, Loader2, Sigma, UserRound } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -13,7 +13,7 @@ import { useTranslation } from 'react-i18next' interface AdminFilterComboboxProps { value: string onValueChange: (username: string) => void - onAdminSelect?: (admin: AdminDetails | null) => void + onAdminSelect?: (admin: AdminSimple | null) => void className?: string } diff --git a/dashboard/src/components/layout/sidebar.tsx b/dashboard/src/components/layout/sidebar.tsx index bd8ccaa56..115427cd1 100644 --- a/dashboard/src/components/layout/sidebar.tsx +++ b/dashboard/src/components/layout/sidebar.tsx @@ -33,6 +33,7 @@ import { Fingerprint, GithubIcon, Group, + Key, Layers, LayoutDashboardIcon, LayoutTemplate, @@ -67,6 +68,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const canReadHosts = canReadResourcePage(admin, 'hosts') const canReadGroups = canReadResourcePage(admin, 'groups') const canReadAdmins = canReadResourcePage(admin, 'admins') + const canReadApiKeys = canReadResourcePage(admin, 'api_keys') const canReadNodes = canReadResourcePage(admin, 'nodes') const canReadCores = canReadResourcePage(admin, 'cores') const canReadTemplates = canReadResourcePage(admin, 'templates') @@ -211,6 +213,13 @@ export function AppSidebar({ ...props }: React.ComponentProps) { icon: UserKey, }] : []), + ...(canReadApiKeys + ? [{ + title: 'apiKeys.title', + url: '/api-keys', + icon: Key, + }] + : []), ...(nodeNavItems.length > 0 ? [{ title: 'nodes.title', diff --git a/dashboard/src/features/api-keys/components/api-keys-table.tsx b/dashboard/src/features/api-keys/components/api-keys-table.tsx new file mode 100644 index 000000000..9622d93e2 --- /dev/null +++ b/dashboard/src/features/api-keys/components/api-keys-table.tsx @@ -0,0 +1,96 @@ +import { useState, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { + flexRender, + getCoreRowModel, + useReactTable, +} from '@tanstack/react-table' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { setupColumns } from './columns' +import { APIKeyResponse, useListApiKeys } from '../hooks/use-api-keys' +import { Skeleton } from '@/components/ui/skeleton' + +interface ApiKeysTableProps { + onEdit: (apiKey: APIKeyResponse) => void + onDelete: (apiKey: APIKeyResponse) => void + onRevoke: (apiKey: APIKeyResponse) => void +} + +export default function ApiKeysTable({ onEdit, onDelete, onRevoke }: ApiKeysTableProps) { + const { t } = useTranslation() + const { data: apiKeysResponse, isLoading } = useListApiKeys() + const apiKeys = apiKeysResponse?.api_keys || [] + + const columns = useMemo( + () => setupColumns({ t, onEdit, onDelete, onRevoke }), + [t, onEdit, onDelete, onRevoke] + ) + + const table = useReactTable({ + data: apiKeys, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + if (isLoading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) + } + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + {t('noResults')} + + + )} + +
+
+ ) +} diff --git a/dashboard/src/features/api-keys/components/columns.tsx b/dashboard/src/features/api-keys/components/columns.tsx new file mode 100644 index 000000000..0b4e3805b --- /dev/null +++ b/dashboard/src/features/api-keys/components/columns.tsx @@ -0,0 +1,101 @@ +import { ColumnDef } from '@tanstack/react-table' +import { MoreHorizontal, Key, Trash2, Edit2, RotateCcw, Copy, Check, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Badge } from '@/components/ui/badge' +import { APIKeyResponse } from '../hooks/use-api-keys' +import { dateUtils } from '@/utils/dateFormatter' +import { toast } from 'sonner' + +interface ColumnsProps { + t: any + onEdit: (apiKey: APIKeyResponse) => void + onDelete: (apiKey: APIKeyResponse) => void + onRevoke: (apiKey: APIKeyResponse) => void +} + +export const setupColumns = ({ t, onEdit, onDelete, onRevoke }: ColumnsProps): ColumnDef[] => [ + { + accessorKey: 'name', + header: t('apiKeys.name'), + cell: ({ row }) =>
{row.getValue('name')}
, + }, + { + accessorKey: 'role_id', + header: t('apiKeys.role'), + cell: ({ row }) => { + const roleId = row.getValue('role_id') + return {roleId === 1 ? 'Owner' : roleId === 2 ? 'Admin' : 'Role ' + roleId} + }, + }, + { + accessorKey: 'status', + header: t('apiKeys.status'), + cell: ({ row }) => { + const status = row.getValue('status') as string + const isExpired = row.original.is_expired + if (isExpired) return {t('expired')} + return ( + + {t(`admins.${status}`)} + + ) + }, + }, + { + accessorKey: 'expire_date', + header: t('apiKeys.expireDate'), + cell: ({ row }) => { + const date = row.getValue('expire_date') as string | null + return date ? dateUtils.formatDateTime(date) : t('never') + }, + }, + { + accessorKey: 'created_at', + header: t('apiKeys.createdAt'), + cell: ({ row }) => dateUtils.formatDateTime(row.getValue('created_at')), + }, + { + id: 'actions', + cell: ({ row }) => { + const apiKey = row.original + + return ( + + + + + + {t('actions')} + onEdit(apiKey)}> + + {t('edit')} + + onRevoke(apiKey)}> + + {t('apiKeys.revoke')} + + + onDelete(apiKey)} + > + + {t('delete')} + + + + ) + }, + }, +] diff --git a/dashboard/src/features/api-keys/dialogs/api-key-modal.tsx b/dashboard/src/features/api-keys/dialogs/api-key-modal.tsx new file mode 100644 index 000000000..a274fc44c --- /dev/null +++ b/dashboard/src/features/api-keys/dialogs/api-key-modal.tsx @@ -0,0 +1,297 @@ +import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Button } from '@/components/ui/button' +import { LoaderButton } from '@/components/ui/loader-button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + apiKeyFormSchema, + ApiKeyFormValues, + apiKeyFormDefaultValues, +} from '../forms/api-key-form' +import { + useCreateApiKey, + useUpdateApiKey, + APIKeyResponse, +} from '../hooks/use-api-keys' +import { useGetRolesSimple } from '@/service/api' +import { toast } from 'sonner' +import { Key, Copy, Check } from 'lucide-react' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' + +interface ApiKeyModalProps { + isOpen: boolean + onOpenChange: (open: boolean) => void + editingApiKey: APIKeyResponse | null +} + +export default function ApiKeyModal({ + isOpen, + onOpenChange, + editingApiKey, +}: ApiKeyModalProps) { + const { t } = useTranslation() + const [createdKey, setCreatedKey] = useState(null) + const [copied, setCopied] = useState(false) + + const rolesQuery = useGetRolesSimple() + const { admin } = useAdmin() + const createMutation = useCreateApiKey() + const updateMutation = useUpdateApiKey() + + const form = useForm({ + resolver: zodResolver(apiKeyFormSchema), + defaultValues: apiKeyFormDefaultValues, + }) + + const filteredRoles = (rolesQuery.data?.roles || []).filter(role => { + if (!admin) return false + if (admin.role?.is_owner) return true + return role.id === admin.role?.id + }) + + useEffect(() => { + if (editingApiKey) { + form.reset({ + name: editingApiKey.name, + note: editingApiKey.note || '', + role_id: editingApiKey.role_id, + status: editingApiKey.status, + expire_date: editingApiKey.expire_date + ? new Date(editingApiKey.expire_date) + : null, + }) + } else { + form.reset({ + ...apiKeyFormDefaultValues, + role_id: admin?.role?.id || 2 + }) + } + setCreatedKey(null) + }, [editingApiKey, form, isOpen, admin]) + + const onSubmit = async (values: ApiKeyFormValues) => { + try { + if (editingApiKey) { + await updateMutation.mutateAsync({ + keyId: editingApiKey.id, + data: values, + }) + toast.success(t('apiKeys.updateSuccess')) + onOpenChange(false) + } else { + const response = await createMutation.mutateAsync(values) + setCreatedKey(response.api_key) + toast.success(t('apiKeys.createSuccess')) + } + } catch (error: any) { + toast.error( + editingApiKey ? t('apiKeys.updateFailed') : t('apiKeys.createFailed'), + { + description: error?.data?.detail || error?.message, + } + ) + } + } + + const copyToClipboard = () => { + if (createdKey) { + navigator.clipboard.writeText(createdKey) + setCopied(true) + toast.success(t('apiKeys.apiKeyCopySuccess')) + setTimeout(() => setCopied(false), 2000) + } + } + + return ( + + + + + {editingApiKey ? t('apiKeys.editKey') : t('apiKeys.createKey')} + + + + {createdKey ? ( +
+ + + {t('apiKeys.apiKey')} + {t('apiKeys.apiKeyShowWarning')} + +
+ (e.target as HTMLInputElement).select()} + /> + +
+ +
+ ) : ( +
+ + ( + + {t('apiKeys.name')} + + + + + + )} + /> + + ( + + {t('apiKeys.role')} + + + + )} + /> + + ( + + {t('apiKeys.expireDate')} + + + field.onChange( + e.target.value ? new Date(e.target.value) : null + ) + } + /> + + + + )} + /> + + {editingApiKey && ( + ( + + {t('apiKeys.status')} + + + + )} + /> + )} + + ( + + {t('apiKeys.note')} + +