feat: add RBAC + ABAC permission system for admin cabinet

Backend:
- 4 new models: AdminRole, UserRole, AccessPolicy, AdminAuditLog
- Permission engine with RBAC wildcard matching + ABAC policy evaluation
- 26 permission sections (78 unique permissions) covering all admin routes
- require_permission() FastAPI dependency for route-level access control
- JWT tokens carry permissions, roles, role_level for frontend checks
- Admin roles CRUD with level-based hierarchy (viewers → superadmin)
- ABAC policies with time ranges and IP whitelist conditions
- Full audit log with CSV export
- Bootstrap service seeds 5 preset roles and assigns superadmins at startup
- Alembic migration 0011 for all RBAC tables
This commit is contained in:
Fringg
2026-02-25 03:02:40 +03:00
parent a594a0f79f
commit 3fee54f657
40 changed files with 2678 additions and 293 deletions
+19 -1
View File
@@ -11,13 +11,23 @@ from app.config import settings
JWT_ALGORITHM = 'HS256'
def create_access_token(user_id: int, telegram_id: int | None = None) -> str:
def create_access_token(
user_id: int,
telegram_id: int | None = None,
*,
permissions: list[str] | None = None,
roles: list[str] | None = None,
role_level: int = 0,
) -> str:
"""
Create a short-lived access token.
Args:
user_id: Database user ID
telegram_id: Telegram user ID (optional for email-only users)
permissions: RBAC permission strings to embed in token
roles: Role names to embed in token
role_level: Maximum role level (0 = no special level)
Returns:
Encoded JWT access token
@@ -36,6 +46,14 @@ def create_access_token(user_id: int, telegram_id: int | None = None) -> str:
if telegram_id is not None:
payload['telegram_id'] = telegram_id
# RBAC data — only include when provided to keep token compact
if permissions is not None:
payload['permissions'] = permissions
if roles is not None:
payload['roles'] = roles
if role_level > 0:
payload['role_level'] = role_level
secret = settings.get_cabinet_jwt_secret()
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
+85 -4
View File
@@ -224,27 +224,108 @@ async def get_optional_cabinet_user(
async def get_current_admin_user(
request: Request,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> User:
"""
Get current authenticated admin user.
Checks if the user is admin by telegram_id or email.
Checks if the user is admin by legacy config (ADMIN_IDS / ADMIN_EMAILS)
**or** by RBAC role assignment (any role with level > 0).
Args:
request: FastAPI request object
user: Authenticated User object
db: Database session
Returns:
Authenticated admin User object
Raises:
HTTPException: If user is not an admin
HTTPException: If user is not an admin by either mechanism
"""
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
if not is_admin:
# Legacy check: config-based admin list
is_legacy_admin = settings.is_admin(
telegram_id=user.telegram_id,
email=user.email if user.email_verified else None,
)
if is_legacy_admin:
return user
# RBAC check: user has any active role with level > 0
from app.database.crud.rbac import UserRoleCRUD
_permissions, _role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user.id)
if max_level > 0:
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Admin access required',
)
def require_permission(*permissions: str):
"""
FastAPI dependency factory for RBAC permission checks.
Usage::
@router.get("/users", dependencies=[Depends(require_permission("users:read"))])
async def list_users(...): ...
# Or inject the user:
@router.get("/users")
async def list_users(user: User = Depends(require_permission("users:read"))): ...
"""
if not permissions:
raise ValueError('require_permission() requires at least one permission argument')
async def dependency(
request: Request,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> User:
from app.services.permission_service import PermissionService
ip_address = request.client.host if request.client else None
user_agent = request.headers.get('user-agent', '')
for perm in permissions:
allowed, reason = await PermissionService.check_permission(
db, user, perm, ip_address=ip_address,
)
if not allowed:
await PermissionService.log_action(
db,
user_id=user.id,
action=perm,
status='denied',
ip_address=ip_address,
user_agent=user_agent,
request_method=request.method,
request_path=str(request.url.path),
details={'reason': reason},
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Permission denied: {reason}',
)
# Log successful access with all requested permissions
await PermissionService.log_action(
db,
user_id=user.id,
action=','.join(permissions),
status='success',
ip_address=ip_address,
user_agent=user_agent,
request_method=request.method,
request_path=str(request.url.path),
)
await db.commit()
return user
return dependency
+6
View File
@@ -3,6 +3,7 @@
from fastapi import APIRouter
from .admin_apps import router as admin_apps_router
from .admin_audit_log import router as admin_audit_log_router
from .admin_ban_system import router as admin_ban_system_router
from .admin_broadcasts import router as admin_broadcasts_router
from .admin_button_styles import router as admin_button_styles_router
@@ -13,9 +14,11 @@ from .admin_partners import router as admin_partners_router
from .admin_payment_methods import router as admin_payment_methods_router
from .admin_payments import router as admin_payments_router
from .admin_pinned_messages import router as admin_pinned_messages_router
from .admin_policies import router as admin_policies_router
from .admin_promo_offers import router as admin_promo_offers_router
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
from .admin_remnawave import router as admin_remnawave_router
from .admin_roles import router as admin_roles_router
from .admin_servers import router as admin_servers_router
from .admin_settings import router as admin_settings_router
from .admin_stats import router as admin_stats_router
@@ -103,6 +106,9 @@ router.include_router(admin_pinned_messages_router)
router.include_router(admin_button_styles_router)
router.include_router(admin_channels_router)
router.include_router(admin_apps_router)
router.include_router(admin_roles_router)
router.include_router(admin_policies_router)
router.include_router(admin_audit_log_router)
# WebSocket route
router.include_router(websocket_router)
+5 -5
View File
@@ -12,7 +12,7 @@ from app.database.models import User
from app.services.remnawave_service import RemnaWaveService
from app.services.system_settings_service import bot_configuration_service
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -54,7 +54,7 @@ def _get_remnawave_config_uuid() -> str | None:
@router.get('/remnawave/status', response_model=RemnaWaveConfigStatus)
async def get_remnawave_config_status(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('apps:read')),
):
"""Get RemnaWave config integration status."""
config_uuid = _get_remnawave_config_uuid()
@@ -67,7 +67,7 @@ async def get_remnawave_config_status(
@router.put('/remnawave/uuid', response_model=RemnaWaveConfigStatus)
async def set_remnawave_config_uuid(
request: UpdateRemnaWaveUuidRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('apps:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Set RemnaWave subscription config UUID."""
@@ -102,7 +102,7 @@ async def set_remnawave_config_uuid(
@router.get('/remnawave/config')
async def get_remnawave_subscription_config(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('apps:read')),
):
"""Fetch subscription page config from RemnaWave panel."""
config_uuid = _get_remnawave_config_uuid()
@@ -140,7 +140,7 @@ async def get_remnawave_subscription_config(
@router.get('/remnawave/configs')
async def list_remnawave_subscription_configs(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('apps:read')),
):
"""List available subscription page configs from RemnaWave panel."""
try:
+196
View File
@@ -0,0 +1,196 @@
"""Admin audit log routes — view and export admin action history."""
from __future__ import annotations
import csv
import io
from datetime import UTC, datetime
from typing import Any
import structlog
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.rbac import AuditLogCRUD
from app.database.models import User
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/audit-log', tags=['Admin Audit Log'])
# ============ Schemas ============
class AuditLogEntry(BaseModel):
"""Single audit log entry."""
id: int
user_id: int
action: str
resource_type: str | None = None
resource_id: str | None = None
details: dict[str, Any] | None = None
ip_address: str | None = None
user_agent: str | None = None
status: str
request_method: str | None = None
request_path: str | None = None
created_at: datetime | None = None
class AuditLogListResponse(BaseModel):
"""Paginated audit log list."""
items: list[AuditLogEntry]
total: int
limit: int
offset: int
# ============ CSV Export ============
_CSV_COLUMNS = [
'id',
'user_id',
'action',
'resource_type',
'resource_id',
'status',
'ip_address',
'request_method',
'request_path',
'created_at',
'user_agent',
'details',
]
def _logs_to_csv(logs) -> str:
"""Serialize audit log entries to CSV string."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(_CSV_COLUMNS)
for log in logs:
writer.writerow(
[
log.id,
log.user_id,
log.action,
log.resource_type or '',
log.resource_id or '',
log.status,
log.ip_address or '',
log.request_method or '',
log.request_path or '',
log.created_at.isoformat() if log.created_at else '',
(log.user_agent or '')[:200],
str(log.details) if log.details else '',
]
)
return output.getvalue()
# ============ Routes ============
@router.get('', response_model=AuditLogListResponse)
async def list_audit_logs(
admin: User = Depends(require_permission('audit_log:read')),
db: AsyncSession = Depends(get_cabinet_db),
user_id: int | None = Query(default=None),
action: str | None = Query(default=None),
resource_type: str | None = Query(default=None),
status: str | None = Query(default=None),
date_from: datetime | None = Query(default=None),
date_to: datetime | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=500),
offset: int = Query(default=0, ge=0),
):
"""List audit log entries with optional filters and pagination."""
logs, total = await AuditLogCRUD.get_logs(
db,
user_id=user_id,
action=action,
resource_type=resource_type,
status=status,
date_from=date_from,
date_to=date_to,
limit=limit,
offset=offset,
)
items = [
AuditLogEntry(
id=log.id,
user_id=log.user_id,
action=log.action,
resource_type=log.resource_type,
resource_id=log.resource_id,
details=log.details,
ip_address=log.ip_address,
user_agent=log.user_agent,
status=log.status,
request_method=log.request_method,
request_path=log.request_path,
created_at=log.created_at,
)
for log in logs
]
return AuditLogListResponse(
items=items,
total=total,
limit=limit,
offset=offset,
)
@router.get('/export')
async def export_audit_logs(
admin: User = Depends(require_permission('audit_log:export')),
db: AsyncSession = Depends(get_cabinet_db),
user_id: int | None = Query(default=None),
action: str | None = Query(default=None),
resource_type: str | None = Query(default=None),
status: str | None = Query(default=None),
date_from: datetime | None = Query(default=None),
date_to: datetime | None = Query(default=None),
limit: int = Query(default=10000, ge=1, le=50000),
):
"""Export audit logs as CSV file."""
logs, _total = await AuditLogCRUD.get_logs(
db,
user_id=user_id,
action=action,
resource_type=resource_type,
status=status,
date_from=date_from,
date_to=date_to,
limit=limit,
offset=0,
)
csv_content = _logs_to_csv(logs)
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
filename = f'audit_log_{timestamp}.csv'
logger.info(
'Admin exported audit logs',
admin_id=admin.id,
rows=len(logs),
filename=filename,
)
return StreamingResponse(
iter([csv_content]),
media_type='text/csv',
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)
+29 -29
View File
@@ -9,7 +9,7 @@ from app.config import settings
from app.database.models import User
from app.external.ban_system_api import BanSystemAPI, BanSystemAPIError
from ..dependencies import get_current_admin_user
from ..dependencies import require_permission
from ..schemas.ban_system import (
BanAgentHistoryItem,
BanAgentHistoryResponse,
@@ -103,7 +103,7 @@ async def _api_request(api: BanSystemAPI, method: str, *args, **kwargs) -> Any:
@router.get('/status', response_model=BanSystemStatusResponse)
async def get_ban_system_status(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanSystemStatusResponse:
"""Get Ban System integration status."""
return BanSystemStatusResponse(
@@ -117,7 +117,7 @@ async def get_ban_system_status(
@router.get('/stats/raw')
async def get_stats_raw(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> dict:
"""Get raw stats from Ban System API for debugging."""
api = _get_ban_api()
@@ -127,7 +127,7 @@ async def get_stats_raw(
@router.get('/stats', response_model=BanSystemStatsResponse)
async def get_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanSystemStatsResponse:
"""Get overall Ban System statistics."""
from datetime import datetime
@@ -181,7 +181,7 @@ async def get_users(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
status: str | None = Query(None, description='Filter: over_limit, with_limit, unlimited'),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanUsersListResponse:
"""Get list of users from Ban System."""
api = _get_ban_api()
@@ -211,7 +211,7 @@ async def get_users(
@router.get('/users/over-limit', response_model=BanUsersListResponse)
async def get_users_over_limit(
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanUsersListResponse:
"""Get users who exceeded their device limit."""
api = _get_ban_api()
@@ -241,7 +241,7 @@ async def get_users_over_limit(
@router.get('/users/search/{query}')
async def search_users(
query: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanUsersListResponse:
"""Search for users."""
api = _get_ban_api()
@@ -272,7 +272,7 @@ async def search_users(
@router.get('/users/{email}', response_model=BanUserDetailResponse)
async def get_user_detail(
email: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanUserDetailResponse:
"""Get detailed user information."""
api = _get_ban_api()
@@ -325,7 +325,7 @@ async def get_user_detail(
@router.get('/punishments', response_model=BanPunishmentsListResponse)
async def get_punishments(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanPunishmentsListResponse:
"""Get list of active punishments (bans)."""
api = _get_ban_api()
@@ -360,7 +360,7 @@ async def get_punishments(
@router.post('/punishments/{user_id}/unban', response_model=UnbanResponse)
async def unban_user(
user_id: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:unban')),
) -> UnbanResponse:
"""Unban (enable) a user."""
api = _get_ban_api()
@@ -377,7 +377,7 @@ async def unban_user(
@router.post('/ban', response_model=UnbanResponse)
async def ban_user(
request: BanUserRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:ban')),
) -> UnbanResponse:
"""Manually ban a user."""
api = _get_ban_api()
@@ -401,7 +401,7 @@ async def ban_user(
async def get_punishment_history(
query: str,
limit: int = Query(20, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanHistoryResponse:
"""Get punishment history for a user."""
api = _get_ban_api()
@@ -438,7 +438,7 @@ async def get_punishment_history(
@router.get('/nodes', response_model=BanNodesListResponse)
async def get_nodes(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanNodesListResponse:
"""Get list of connected nodes."""
api = _get_ban_api()
@@ -480,7 +480,7 @@ async def get_agents(
search: str | None = Query(None),
health: str | None = Query(None, description='healthy, warning, critical'),
agent_status: str | None = Query(None, alias='status', description='online, offline'),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanAgentsListResponse:
"""Get list of monitoring agents."""
api = _get_ban_api()
@@ -579,7 +579,7 @@ async def get_agents(
@router.get('/agents/summary', response_model=BanAgentsSummary)
async def get_agents_summary(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanAgentsSummary:
"""Get agents summary statistics."""
api = _get_ban_api()
@@ -603,7 +603,7 @@ async def get_agents_summary(
@router.get('/traffic/violations', response_model=BanTrafficViolationsResponse)
async def get_traffic_violations(
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanTrafficViolationsResponse:
"""Get list of traffic limit violations."""
api = _get_ban_api()
@@ -637,7 +637,7 @@ async def get_traffic_violations(
@router.get('/traffic', response_model=BanTrafficResponse)
async def get_traffic(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanTrafficResponse:
"""Get full traffic statistics including top users."""
api = _get_ban_api()
@@ -681,7 +681,7 @@ async def get_traffic(
@router.get('/traffic/top')
async def get_traffic_top(
limit: int = Query(20, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> list[BanTrafficTopItem]:
"""Get top users by traffic."""
api = _get_ban_api()
@@ -744,7 +744,7 @@ def _parse_setting_response(key: str, data: Any, default_type: str = 'str') -> B
@router.get('/settings', response_model=BanSettingsResponse)
async def get_settings(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanSettingsResponse:
"""Get all Ban System settings."""
api = _get_ban_api()
@@ -802,7 +802,7 @@ async def get_settings(
@router.get('/settings/{key}')
async def get_setting(
key: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanSettingDefinition:
"""Get a specific setting."""
api = _get_ban_api()
@@ -815,7 +815,7 @@ async def get_setting(
async def set_setting(
key: str,
value: str = Query(...),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:edit')),
) -> BanSettingDefinition:
"""Set a setting value."""
api = _get_ban_api()
@@ -829,7 +829,7 @@ async def set_setting(
@router.post('/settings/{key}/toggle')
async def toggle_setting(
key: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:edit')),
) -> BanSettingDefinition:
"""Toggle a boolean setting."""
api = _get_ban_api()
@@ -846,7 +846,7 @@ async def toggle_setting(
@router.post('/settings/whitelist/add', response_model=UnbanResponse)
async def whitelist_add(
request: BanWhitelistRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:edit')),
) -> UnbanResponse:
"""Add user to whitelist."""
api = _get_ban_api()
@@ -863,7 +863,7 @@ async def whitelist_add(
@router.post('/settings/whitelist/remove', response_model=UnbanResponse)
async def whitelist_remove(
request: BanWhitelistRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:edit')),
) -> UnbanResponse:
"""Remove user from whitelist."""
api = _get_ban_api()
@@ -883,7 +883,7 @@ async def whitelist_remove(
@router.get('/report', response_model=BanReportResponse)
async def get_report(
hours: int = Query(24, ge=1, le=168),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanReportResponse:
"""Get period report."""
api = _get_ban_api()
@@ -913,7 +913,7 @@ async def get_report(
@router.get('/health', response_model=BanHealthResponse)
async def get_health(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanHealthResponse:
"""Get Ban System health status."""
api = _get_ban_api()
@@ -947,7 +947,7 @@ async def get_health(
@router.get('/health/detailed', response_model=BanHealthDetailedResponse)
async def get_health_detailed(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanHealthDetailedResponse:
"""Get detailed health information."""
api = _get_ban_api()
@@ -967,7 +967,7 @@ async def get_health_detailed(
async def get_agent_history(
node_name: str,
hours: int = Query(24, ge=1, le=168),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanAgentHistoryResponse:
"""Get agent statistics history."""
api = _get_ban_api()
@@ -1003,7 +1003,7 @@ async def get_agent_history(
async def get_user_punishment_history(
email: str,
limit: int = Query(20, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanHistoryResponse:
"""Get punishment history for a specific user."""
api = _get_ban_api()
+12 -12
View File
@@ -18,7 +18,7 @@ from app.services.broadcast_service import (
email_broadcast_service,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.broadcasts import (
BroadcastButton,
BroadcastButtonsResponse,
@@ -247,7 +247,7 @@ def _validate_buttons(buttons: list[str]) -> bool:
@router.get('/filters', response_model=BroadcastFiltersResponse)
async def get_filters(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastFiltersResponse:
"""Get all available filters with user counts."""
@@ -310,7 +310,7 @@ async def get_filters(
@router.get('/tariffs', response_model=BroadcastTariffsResponse)
async def get_tariffs(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastTariffsResponse:
"""Get tariffs for broadcast filtering."""
@@ -333,7 +333,7 @@ async def get_tariffs(
@router.get('/buttons', response_model=BroadcastButtonsResponse)
async def get_buttons(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
) -> BroadcastButtonsResponse:
"""Get available buttons for broadcasts."""
default_buttons = set(DEFAULT_BROADCAST_BUTTONS)
@@ -352,7 +352,7 @@ async def get_buttons(
@router.post('/preview', response_model=BroadcastPreviewResponse)
async def preview_broadcast(
request: BroadcastPreviewRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastPreviewResponse:
"""Preview broadcast recipients count."""
@@ -381,7 +381,7 @@ async def preview_broadcast(
@router.post('', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_broadcast(
request: BroadcastCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Create and start a broadcast."""
@@ -461,7 +461,7 @@ async def create_broadcast(
@router.get('', response_model=BroadcastListResponse)
async def list_broadcasts(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
@@ -487,7 +487,7 @@ async def list_broadcasts(
@router.get('/email-filters', response_model=EmailFiltersResponse)
async def get_email_filters(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> EmailFiltersResponse:
"""Get all available email filters with user counts."""
@@ -523,7 +523,7 @@ async def get_email_filters(
@router.post('/email-preview', response_model=EmailPreviewResponse)
async def preview_email_broadcast(
request: EmailPreviewRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> EmailPreviewResponse:
"""Preview email broadcast recipients count."""
@@ -548,7 +548,7 @@ async def preview_email_broadcast(
@router.post('/send', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_combined_broadcast(
request: CombinedBroadcastCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:send')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Create and start a combined broadcast (telegram/email/both)."""
@@ -679,7 +679,7 @@ async def create_combined_broadcast(
@router.get('/{broadcast_id}', response_model=BroadcastResponse)
async def get_broadcast(
broadcast_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Get broadcast details."""
@@ -695,7 +695,7 @@ async def get_broadcast(
@router.post('/{broadcast_id}/stop', response_model=BroadcastResponse)
async def stop_broadcast(
broadcast_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:send')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Stop a running broadcast (telegram or email)."""
+4 -4
View File
@@ -17,7 +17,7 @@ from app.utils.button_styles_cache import (
load_button_styles_cache,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -112,7 +112,7 @@ def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
@router.get('', response_model=ButtonStylesResponse)
async def get_button_styles(
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('settings:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return current per-section button styles. Admin only."""
@@ -145,7 +145,7 @@ async def get_button_styles(
@router.patch('', response_model=ButtonStylesResponse)
async def update_button_styles(
payload: ButtonStylesUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Partially update per-section button styles. Admin only."""
@@ -243,7 +243,7 @@ async def update_button_styles(
@router.post('/reset', response_model=ButtonStylesResponse)
async def reset_button_styles(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset all button styles to defaults. Admin only."""
+13 -13
View File
@@ -30,7 +30,7 @@ from app.database.models import (
User,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.campaigns import (
AvailablePartnerItem,
CampaignCreateRequest,
@@ -80,7 +80,7 @@ def _get_partner_name(campaign: AdvertisingCampaign) -> str | None:
@router.get('/overview', response_model=CampaignsOverviewResponse)
async def get_overview(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get campaigns overview statistics."""
@@ -108,7 +108,7 @@ async def get_overview(
@router.get('/available-servers', response_model=list[ServerSquadInfo])
async def get_available_servers(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available server squads for campaign subscription bonus."""
@@ -126,7 +126,7 @@ async def get_available_servers(
@router.get('/available-tariffs', response_model=list[TariffListItem])
async def get_available_tariffs(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available tariffs for campaign tariff bonus."""
@@ -155,7 +155,7 @@ async def get_available_tariffs(
@router.get('/available-partners', response_model=list[AvailablePartnerItem])
async def get_available_partners(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of approved partners for campaign partner selector."""
@@ -178,7 +178,7 @@ async def list_campaigns(
include_inactive: bool = True,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all campaigns."""
@@ -211,7 +211,7 @@ async def list_campaigns(
@router.get('/{campaign_id}', response_model=CampaignDetailResponse)
async def get_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed campaign info."""
@@ -257,7 +257,7 @@ async def get_campaign(
@router.get('/{campaign_id}/stats', response_model=CampaignStatisticsResponse)
async def get_campaign_stats(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:stats')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed campaign statistics."""
@@ -303,7 +303,7 @@ async def get_campaign_registrations(
campaign_id: int,
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of users registered through campaign."""
@@ -381,7 +381,7 @@ async def get_campaign_registrations(
@router.post('', response_model=CampaignDetailResponse)
async def create_new_campaign(
request: CampaignCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:create')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new advertising campaign."""
@@ -446,7 +446,7 @@ async def create_new_campaign(
async def update_existing_campaign(
campaign_id: int,
request: CampaignUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing campaign."""
@@ -532,7 +532,7 @@ async def update_existing_campaign(
@router.delete('/{campaign_id}')
async def delete_existing_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a campaign."""
@@ -560,7 +560,7 @@ async def delete_existing_campaign(
@router.post('/{campaign_id}/toggle', response_model=CampaignToggleResponse)
async def toggle_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle campaign active status."""
+6 -6
View File
@@ -14,7 +14,7 @@ from app.database.crud.required_channel import (
from app.database.models import User
from app.services.channel_subscription_service import channel_subscription_service
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.channel import (
ChannelCreateRequest,
ChannelListResponse,
@@ -31,7 +31,7 @@ router = APIRouter(prefix='/admin/channel-subscriptions', tags=['Cabinet Admin C
@router.get('', response_model=ChannelListResponse)
async def list_channels(
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('channels:read')),
) -> ChannelListResponse:
channels = await get_all_channels(db)
return ChannelListResponse(
@@ -44,7 +44,7 @@ async def list_channels(
async def create_channel(
data: ChannelCreateRequest,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('channels:edit')),
) -> ChannelResponse:
ch = await add_channel(
db,
@@ -63,7 +63,7 @@ async def update_channel_endpoint(
channel_db_id: int,
data: ChannelUpdateRequest,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('channels:edit')),
) -> ChannelResponse:
update_data = data.model_dump(exclude_unset=True)
ch = await update_channel(db, channel_db_id, **update_data)
@@ -77,7 +77,7 @@ async def update_channel_endpoint(
async def toggle_channel_endpoint(
channel_db_id: int,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('channels:edit')),
) -> ChannelResponse:
ch = await toggle_channel(db, channel_db_id)
if not ch:
@@ -90,7 +90,7 @@ async def toggle_channel_endpoint(
async def delete_channel_endpoint(
channel_db_id: int,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('channels:edit')),
) -> None:
ok = await delete_channel(db, channel_db_id)
if not ok:
+7 -7
View File
@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..services.email_template_overrides import (
delete_template_override,
get_all_overrides,
@@ -370,7 +370,7 @@ class EmailTemplateSendTestRequest(BaseModel):
@router.get('', summary='List all email template types')
async def list_template_types(
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('email_templates:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""List all available email template types with override status."""
@@ -405,7 +405,7 @@ async def list_template_types(
@router.get('/{notification_type}', summary='Get templates for a notification type')
async def get_templates_for_type(
notification_type: str,
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('email_templates:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Get all language templates for a specific notification type."""
@@ -479,7 +479,7 @@ async def update_template(
notification_type: str,
language: str,
data: EmailTemplateUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('email_templates:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Save a custom email template override."""
@@ -515,7 +515,7 @@ async def update_template(
async def reset_template(
notification_type: str,
language: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('email_templates:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Delete custom template override, reverting to default."""
@@ -543,7 +543,7 @@ async def reset_template(
async def preview_template(
notification_type: str,
data: EmailTemplatePreviewRequest,
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('email_templates:read')),
) -> dict[str, Any]:
"""Preview a rendered email template with sample data."""
valid_types = [t['type'] for t in TEMPLATE_TYPES]
@@ -588,7 +588,7 @@ async def preview_template(
async def send_test_email(
notification_type: str,
data: EmailTemplateSendTestRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('email_templates:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Send a test email to the admin's email address."""
+13 -13
View File
@@ -20,7 +20,7 @@ from app.database.models import (
from app.services.partner_application_service import partner_application_service
from app.services.partner_stats_service import PartnerStatsService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.partners import (
AdminApproveRequest,
AdminPartnerApplicationItem,
@@ -73,7 +73,7 @@ def _build_partner_settings_response() -> PartnerSettingsResponse:
@router.get('/settings', response_model=PartnerSettingsResponse)
async def get_partner_settings(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:settings')),
):
"""Get partner system settings."""
return _build_partner_settings_response()
@@ -82,7 +82,7 @@ async def get_partner_settings(
@router.patch('/settings', response_model=PartnerSettingsResponse)
async def update_partner_settings(
request: PartnerSettingsUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:settings')),
):
"""Update partner system settings."""
from pathlib import Path
@@ -159,7 +159,7 @@ async def list_applications(
application_status: Literal['pending', 'approved', 'rejected', 'none'] | None = Query(None, alias='status'),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List partner applications."""
@@ -205,7 +205,7 @@ async def list_applications(
async def approve_application(
application_id: int,
request: AdminApproveRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:approve')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Approve a partner application."""
@@ -259,7 +259,7 @@ async def approve_application(
async def reject_application(
application_id: int,
request: AdminRejectRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:approve')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reject a partner application."""
@@ -310,7 +310,7 @@ async def reject_application(
@router.get('/stats')
async def get_partner_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get overall partner statistics."""
@@ -340,7 +340,7 @@ async def get_partner_stats(
async def list_partners(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List approved partners."""
@@ -404,7 +404,7 @@ async def list_partners(
@router.get('/{user_id}', response_model=AdminPartnerDetailResponse)
async def get_partner_detail(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed partner info."""
@@ -460,7 +460,7 @@ async def get_partner_detail(
async def update_commission(
user_id: int,
request: AdminUpdateCommissionRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update partner commission percent."""
@@ -495,7 +495,7 @@ async def update_commission(
@router.post('/{user_id}/revoke')
async def revoke_partner(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:revoke')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Revoke partner status."""
@@ -514,7 +514,7 @@ async def revoke_partner(
async def assign_campaign(
user_id: int,
campaign_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Assign a campaign to a partner."""
@@ -558,7 +558,7 @@ async def assign_campaign(
async def unassign_campaign(
user_id: int,
campaign_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unassign a campaign from a partner."""
+6 -6
View File
@@ -17,7 +17,7 @@ from app.services.payment_method_config_service import (
update_sort_order,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -124,7 +124,7 @@ def _enrich_config(config, defaults: dict) -> PaymentMethodConfigResponse:
@router.get('', response_model=list[PaymentMethodConfigResponse])
async def list_payment_methods(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payment_methods:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all payment method configurations."""
@@ -135,7 +135,7 @@ async def list_payment_methods(
@router.get('/promo-groups', response_model=list[PromoGroupSimple])
async def list_promo_groups(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payment_methods:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all promo groups for filter selector."""
@@ -146,7 +146,7 @@ async def list_promo_groups(
@router.get('/{method_id}', response_model=PaymentMethodConfigResponse)
async def get_payment_method(
method_id: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payment_methods:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get a single payment method configuration."""
@@ -163,7 +163,7 @@ async def get_payment_method(
@router.put('/order')
async def update_payment_methods_order(
request: SortOrderRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payment_methods:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Batch update sort order for payment methods."""
@@ -176,7 +176,7 @@ async def update_payment_methods_order(
async def update_payment_method(
method_id: str,
request: PaymentMethodConfigUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payment_methods:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update a payment method configuration."""
+5 -5
View File
@@ -19,7 +19,7 @@ from app.services.payment_verification_service import (
run_manual_check,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -272,7 +272,7 @@ async def get_all_pending_payments(
page: int = Query(1, ge=1, description='Page number'),
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
method_filter: str | None = Query(None, description='Filter by payment method'),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payments:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all pending payments for admin verification."""
@@ -306,7 +306,7 @@ async def get_all_pending_payments(
@router.get('/stats', response_model=PaymentsStatsResponse)
async def get_payments_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payments:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get statistics about pending payments."""
@@ -329,7 +329,7 @@ async def get_payments_stats(
async def get_pending_payment_details(
method: str,
payment_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payments:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get details of a specific pending payment."""
@@ -356,7 +356,7 @@ async def get_pending_payment_details(
async def check_payment_status(
method: str,
payment_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payments:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Manually check and update payment status."""
+12 -12
View File
@@ -22,7 +22,7 @@ from app.services.pinned_message_service import (
)
from app.utils.validators import sanitize_html, validate_html_tags
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.pinned_messages import (
PinnedMessageBroadcastResponse,
PinnedMessageCreateRequest,
@@ -89,7 +89,7 @@ def _get_bot() -> Bot:
@router.get('', response_model=PinnedMessageListResponse)
async def list_pinned_messages(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
@@ -117,7 +117,7 @@ async def list_pinned_messages(
@router.get('/active', response_model=PinnedMessageResponse | None)
async def get_active_message(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse | None:
"""Get current active pinned message."""
@@ -130,7 +130,7 @@ async def get_active_message(
@router.get('/{message_id}', response_model=PinnedMessageResponse)
async def get_pinned_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Get pinned message by ID."""
@@ -147,7 +147,7 @@ async def get_pinned_message(
@router.post('', response_model=PinnedMessageBroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_pinned_message(
payload: PinnedMessageCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""
@@ -201,7 +201,7 @@ async def create_pinned_message(
async def update_pinned_message(
message_id: int,
payload: PinnedMessageUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Update a pinned message content, media, or settings."""
@@ -240,7 +240,7 @@ async def update_pinned_message(
async def update_pinned_message_settings(
message_id: int,
payload: PinnedMessageSettingsRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Update only pinned message display settings."""
@@ -267,7 +267,7 @@ async def update_pinned_message_settings(
@router.post('/active/deactivate', response_model=PinnedMessageResponse | None)
async def deactivate_active_message(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse | None:
"""Deactivate the current active pinned message without unpinning from users."""
@@ -282,7 +282,7 @@ async def deactivate_active_message(
@router.post('/active/unpin', response_model=PinnedMessageUnpinResponse)
async def unpin_active_message(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageUnpinResponse:
"""Unpin messages from all users and deactivate the active pinned message."""
@@ -311,7 +311,7 @@ async def unpin_active_message(
async def activate_pinned_message(
message_id: int,
broadcast: bool = Query(False),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""
@@ -360,7 +360,7 @@ async def activate_pinned_message(
@router.post('/{message_id}/broadcast', response_model=PinnedMessageBroadcastResponse)
async def broadcast_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""Broadcast a pinned message to all active users."""
@@ -391,7 +391,7 @@ async def broadcast_message(
@router.delete('/{message_id}', status_code=status.HTTP_204_NO_CONTENT, response_model=None)
async def delete_pinned_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:delete')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete a pinned message. Active messages must be deactivated first."""
+227
View File
@@ -0,0 +1,227 @@
"""Admin RBAC access policies management routes."""
from __future__ import annotations
from datetime import datetime
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.rbac import AccessPolicyCRUD, AdminRoleCRUD
from app.database.models import User
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/policies', tags=['Admin Policies'])
# ============ Schemas ============
class PolicyResponse(BaseModel):
"""Access policy response."""
id: int
name: str
description: str | None = None
role_id: int | None = None
role_name: str | None = None
priority: int
effect: str
conditions: dict[str, Any] = Field(default_factory=dict)
resource: str
actions: list[str] = Field(default_factory=list)
is_active: bool
created_by: int | None = None
created_at: datetime | None = None
class PolicyCreateRequest(BaseModel):
"""Create a new access policy."""
name: str = Field(min_length=1, max_length=200)
description: str | None = None
role_id: int | None = None
priority: int = Field(default=0, ge=0, le=1000)
effect: str = Field(pattern=r'^(allow|deny)$')
conditions: dict[str, Any] = Field(default_factory=dict)
resource: str = Field(min_length=1, max_length=100)
actions: list[str] = Field(default_factory=list)
class PolicyUpdateRequest(BaseModel):
"""Update policy fields (all optional)."""
name: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = None
role_id: int | None = None
priority: int | None = Field(default=None, ge=0, le=1000)
effect: str | None = Field(default=None, pattern=r'^(allow|deny)$')
conditions: dict[str, Any] | None = None
resource: str | None = Field(default=None, min_length=1, max_length=100)
actions: list[str] | None = None
is_active: bool | None = None
# ============ Helper Functions ============
async def _policy_to_response(db: AsyncSession, policy) -> PolicyResponse:
"""Convert AccessPolicy model to PolicyResponse with role name."""
role_name = None
if policy.role_id is not None:
role = await AdminRoleCRUD.get_by_id(db, policy.role_id)
if role:
role_name = role.name
return PolicyResponse(
id=policy.id,
name=policy.name,
description=policy.description,
role_id=policy.role_id,
role_name=role_name,
priority=policy.priority,
effect=policy.effect,
conditions=policy.conditions or {},
resource=policy.resource,
actions=policy.actions or [],
is_active=policy.is_active,
created_by=policy.created_by,
created_at=policy.created_at,
)
# ============ Routes ============
@router.get('', response_model=list[PolicyResponse])
async def list_policies(
admin: User = Depends(require_permission('roles:read')),
db: AsyncSession = Depends(get_cabinet_db),
role_id: int | None = None,
):
"""List all access policies. Optionally filter by role_id."""
policies = await AccessPolicyCRUD.get_all(db, role_id=role_id)
return [await _policy_to_response(db, p) for p in policies]
@router.post('', response_model=PolicyResponse, status_code=status.HTTP_201_CREATED)
async def create_policy(
payload: PolicyCreateRequest,
admin: User = Depends(require_permission('roles:create')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new access policy (ABAC rule)."""
# Validate role_id if provided
if payload.role_id is not None:
role = await AdminRoleCRUD.get_by_id(db, payload.role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Referenced role not found',
)
policy = await AccessPolicyCRUD.create(
db,
name=payload.name,
description=payload.description,
role_id=payload.role_id,
priority=payload.priority,
effect=payload.effect,
conditions=payload.conditions,
resource=payload.resource,
actions=payload.actions,
created_by=admin.id,
)
await db.commit()
logger.info(
'Admin created access policy',
admin_id=admin.id,
policy_id=policy.id,
policy_name=policy.name,
effect=policy.effect,
)
return await _policy_to_response(db, policy)
@router.put('/{policy_id}', response_model=PolicyResponse)
async def update_policy(
policy_id: int,
payload: PolicyUpdateRequest,
admin: User = Depends(require_permission('roles:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing access policy."""
existing = await AccessPolicyCRUD.get_by_id(db, policy_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Policy not found',
)
update_data = payload.model_dump(exclude_unset=True)
# Validate role_id if changing
if 'role_id' in update_data and update_data['role_id'] is not None:
role = await AdminRoleCRUD.get_by_id(db, update_data['role_id'])
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Referenced role not found',
)
updated = await AccessPolicyCRUD.update(db, policy_id, **update_data)
if not updated:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Policy not found',
)
await db.commit()
logger.info(
'Admin updated access policy',
admin_id=admin.id,
policy_id=policy_id,
fields=list(update_data.keys()),
)
return await _policy_to_response(db, updated)
@router.delete('/{policy_id}')
async def delete_policy(
policy_id: int,
admin: User = Depends(require_permission('roles:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete an access policy."""
existing = await AccessPolicyCRUD.get_by_id(db, policy_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Policy not found',
)
deleted = await AccessPolicyCRUD.delete(db, policy_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to delete policy',
)
await db.commit()
logger.info(
'Admin deleted access policy',
admin_id=admin.id,
policy_id=policy_id,
policy_name=existing.name,
)
return {'message': 'Policy deleted', 'policy_id': policy_id}
+7 -7
View File
@@ -34,7 +34,7 @@ from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate
from app.handlers.admin.messages import get_custom_users, get_target_users
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -272,7 +272,7 @@ async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]:
@router.get('/templates', response_model=PromoOfferTemplateListResponse)
async def list_templates(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferTemplateListResponse:
"""Get list of promo offer templates."""
@@ -288,7 +288,7 @@ async def list_templates(
@router.get('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
async def get_template(
template_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferTemplateResponse:
"""Get a promo offer template."""
@@ -302,7 +302,7 @@ async def get_template(
async def update_template(
template_id: int,
payload: PromoOfferTemplateUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferTemplateResponse:
"""Update a promo offer template."""
@@ -338,7 +338,7 @@ async def update_template(
@router.get('', response_model=PromoOfferListResponse)
async def list_offers(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
@@ -491,7 +491,7 @@ async def _send_promo_notifications(
@router.post('/broadcast', response_model=PromoOfferBroadcastResponse, status_code=status.HTTP_201_CREATED)
async def broadcast_offer(
payload: PromoOfferBroadcastRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:send')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferBroadcastResponse:
"""Broadcast promo offer to users with optional Telegram notification."""
@@ -605,7 +605,7 @@ async def broadcast_offer(
@router.get('/logs', response_model=PromoOfferLogListResponse)
async def get_logs(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
+12 -12
View File
@@ -30,7 +30,7 @@ from app.database.crud.promocode import (
)
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, PromoGroup, User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
router = APIRouter(prefix='/admin/promocodes', tags=['Admin Promocodes'])
@@ -305,7 +305,7 @@ def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCo
@router.get('', response_model=PromoCodeListResponse)
async def list_promocodes(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
@@ -326,7 +326,7 @@ async def list_promocodes(
@router.get('/{promocode_id}', response_model=PromoCodeDetailResponse)
async def get_promocode(
promocode_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoCodeDetailResponse:
"""Get promocode details with usage statistics."""
@@ -349,7 +349,7 @@ async def get_promocode(
@router.post('', response_model=PromoCodeResponse, status_code=status.HTTP_201_CREATED)
async def create_promocode_endpoint(
payload: PromoCodeCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoCodeResponse:
"""Create a new promocode."""
@@ -399,7 +399,7 @@ async def create_promocode_endpoint(
async def update_promocode_endpoint(
promocode_id: int,
payload: PromoCodeUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoCodeResponse:
"""Update an existing promocode."""
@@ -460,7 +460,7 @@ async def update_promocode_endpoint(
)
async def delete_promocode_endpoint(
promocode_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:delete')),
db: AsyncSession = Depends(get_cabinet_db),
) -> Response:
"""Delete a promocode."""
@@ -486,7 +486,7 @@ class DeactivateDiscountResponse(BaseModel):
@router.post('/deactivate-discount/{user_id}', response_model=DeactivateDiscountResponse)
async def admin_deactivate_discount_promocode(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> DeactivateDiscountResponse:
"""Admin: deactivate a user's active discount promo code."""
@@ -537,7 +537,7 @@ promo_groups_router = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo
@promo_groups_router.get('', response_model=PromoGroupListResponse)
async def list_promo_groups(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_groups:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
@@ -561,7 +561,7 @@ async def list_promo_groups(
@promo_groups_router.get('/{group_id}', response_model=PromoGroupResponse)
async def get_promo_group(
group_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_groups:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoGroupResponse:
"""Get promo group details."""
@@ -576,7 +576,7 @@ async def get_promo_group(
@promo_groups_router.post('', response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
async def create_promo_group_endpoint(
payload: PromoGroupCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_groups:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoGroupResponse:
"""Create a new promo group."""
@@ -608,7 +608,7 @@ async def create_promo_group_endpoint(
async def update_promo_group_endpoint(
group_id: int,
payload: PromoGroupUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_groups:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoGroupResponse:
"""Update a promo group."""
@@ -645,7 +645,7 @@ async def update_promo_group_endpoint(
@promo_groups_router.delete('/{group_id}', status_code=status.HTTP_204_NO_CONTENT)
async def delete_promo_group_endpoint(
group_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_groups:delete')),
db: AsyncSession = Depends(get_cabinet_db),
) -> Response:
"""Delete a promo group."""
+30 -30
View File
@@ -16,7 +16,7 @@ from app.database.crud.server_squad import (
from app.database.models import User
from app.utils.cache import cache
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.remnawave import (
AutoSyncRunResponse,
# Auto Sync
@@ -153,7 +153,7 @@ def _serialize_node(node_data: dict[str, Any]) -> NodeInfo:
@router.get('/status', response_model=RemnaWaveStatusResponse)
async def get_remnawave_status(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> RemnaWaveStatusResponse:
"""Get RemnaWave configuration and connection status."""
service = _get_service()
@@ -176,7 +176,7 @@ async def get_remnawave_status(
@router.get('/system', response_model=SystemStatsResponse)
async def get_system_statistics(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> SystemStatsResponse:
"""Get full system statistics from RemnaWave."""
service = _get_service()
@@ -238,7 +238,7 @@ async def get_system_statistics(
@router.get('/nodes', response_model=NodesListResponse)
async def list_nodes(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> NodesListResponse:
"""Get list of all nodes."""
service = _get_service()
@@ -252,7 +252,7 @@ async def list_nodes(
@router.get('/nodes/overview', response_model=NodesOverview)
async def get_nodes_overview(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> NodesOverview:
"""Get nodes overview with statistics."""
service = _get_service()
@@ -278,7 +278,7 @@ async def get_nodes_overview(
@router.get('/nodes/realtime')
async def get_nodes_realtime(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> list[dict[str, Any]]:
"""Get realtime node usage data."""
service = _get_service()
@@ -290,7 +290,7 @@ async def get_nodes_realtime(
@router.get('/nodes/{node_uuid}', response_model=NodeInfo)
async def get_node_details(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> NodeInfo:
"""Get detailed information about a specific node."""
service = _get_service()
@@ -309,7 +309,7 @@ async def get_node_details(
@router.get('/nodes/{node_uuid}/statistics', response_model=NodeStatisticsResponse)
async def get_node_statistics(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> NodeStatisticsResponse:
"""Get node statistics with usage history."""
service = _get_service()
@@ -335,7 +335,7 @@ async def get_node_usage(
node_uuid: str,
start: datetime | None = Query(default=None),
end: datetime | None = Query(default=None),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> NodeUsageResponse:
"""Get node usage history for a date range."""
service = _get_service()
@@ -358,7 +358,7 @@ async def get_node_usage(
async def perform_node_action(
node_uuid: str,
payload: NodeActionRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> NodeActionResponse:
"""Perform an action on a node (enable/disable/restart)."""
service = _get_service()
@@ -399,7 +399,7 @@ async def perform_node_action(
@router.post('/nodes/restart-all', response_model=NodeActionResponse)
async def restart_all_nodes(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> NodeActionResponse:
"""Restart all nodes."""
service = _get_service()
@@ -421,7 +421,7 @@ async def restart_all_nodes(
@router.get('/squads', response_model=SquadsListResponse)
async def list_squads(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SquadsListResponse:
"""Get list of all squads with local database info."""
@@ -463,7 +463,7 @@ async def list_squads(
@router.get('/squads/{squad_uuid}', response_model=SquadDetailResponse)
async def get_squad_details(
squad_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SquadDetailResponse:
"""Get detailed information about a squad."""
@@ -506,7 +506,7 @@ async def get_squad_details(
@router.post('/squads', response_model=SquadOperationResponse, status_code=status.HTTP_201_CREATED)
async def create_squad(
payload: SquadCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> SquadOperationResponse:
"""Create a new squad in RemnaWave."""
service = _get_service()
@@ -533,7 +533,7 @@ async def create_squad(
async def update_squad(
squad_uuid: str,
payload: SquadUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> SquadOperationResponse:
"""Update a squad in RemnaWave."""
service = _get_service()
@@ -564,7 +564,7 @@ async def update_squad(
async def perform_squad_action(
squad_uuid: str,
payload: SquadActionRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> SquadOperationResponse:
"""Perform an action on a squad."""
service = _get_service()
@@ -609,7 +609,7 @@ async def perform_squad_action(
@router.delete('/squads/{squad_uuid}', response_model=SquadOperationResponse)
async def delete_squad(
squad_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> SquadOperationResponse:
"""Delete a squad."""
service = _get_service()
@@ -632,7 +632,7 @@ async def delete_squad(
@router.get('/squads/{squad_uuid}/migration-preview', response_model=MigrationPreviewResponse)
async def preview_migration(
squad_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> MigrationPreviewResponse:
"""Get migration preview for a squad."""
@@ -657,7 +657,7 @@ async def preview_migration(
@router.post('/squads/migrate', response_model=MigrationResponse)
async def migrate_squad_users(
payload: MigrationRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
db: AsyncSession = Depends(get_cabinet_db),
) -> MigrationResponse:
"""Migrate users from one squad to another."""
@@ -731,7 +731,7 @@ async def migrate_squad_users(
@router.get('/inbounds', response_model=InboundsListResponse)
async def list_inbounds(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> InboundsListResponse:
"""Get list of all available inbounds."""
service = _get_service()
@@ -746,7 +746,7 @@ async def list_inbounds(
@router.get('/sync/auto/status', response_model=AutoSyncStatus)
async def get_auto_sync_status(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> AutoSyncStatus:
"""Get auto sync status."""
if remnawave_sync_service is None:
@@ -775,7 +775,7 @@ async def get_auto_sync_status(
@router.post('/sync/auto/toggle', response_model=SyncResponse)
async def toggle_auto_sync(
payload: AutoSyncToggleRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
) -> SyncResponse:
"""Toggle auto sync on/off."""
if remnawave_sync_service is None:
@@ -811,7 +811,7 @@ async def toggle_auto_sync(
@router.post('/sync/auto/run', response_model=AutoSyncRunResponse)
async def run_auto_sync_now(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
) -> AutoSyncRunResponse:
"""Run auto sync immediately."""
if remnawave_sync_service is None:
@@ -839,7 +839,7 @@ async def run_auto_sync_now(
@router.post('/sync/from-panel', response_model=SyncResponse)
async def sync_from_panel(
payload: SyncMode,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync users from RemnaWave panel to bot."""
@@ -863,7 +863,7 @@ async def sync_from_panel(
@router.post('/sync/to-panel', response_model=SyncResponse)
async def sync_to_panel(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync users from bot to RemnaWave panel."""
@@ -882,7 +882,7 @@ async def sync_to_panel(
@router.post('/sync/servers', response_model=SyncResponse)
async def sync_servers(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync servers/squads from RemnaWave."""
@@ -925,7 +925,7 @@ async def sync_servers(
@router.post('/sync/subscriptions/validate', response_model=SyncResponse)
async def validate_subscriptions(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Validate and fix subscriptions."""
@@ -944,7 +944,7 @@ async def validate_subscriptions(
@router.post('/sync/subscriptions/cleanup', response_model=SyncResponse)
async def cleanup_subscriptions(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Cleanup orphaned subscriptions."""
@@ -963,7 +963,7 @@ async def cleanup_subscriptions(
@router.post('/sync/subscriptions/statuses', response_model=SyncResponse)
async def sync_subscription_statuses(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync subscription statuses."""
@@ -982,7 +982,7 @@ async def sync_subscription_statuses(
@router.get('/sync/recommendations', response_model=SyncResponse)
async def get_sync_recommendations(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Get sync recommendations."""
+490
View File
@@ -0,0 +1,490 @@
"""Admin RBAC roles management routes."""
from __future__ import annotations
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.rbac import AdminRoleCRUD, UserRoleCRUD
from app.database.models import User
from app.services.permission_service import PERMISSION_REGISTRY, get_all_permissions
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/roles', tags=['Admin Roles'])
# ============ Schemas ============
class RoleResponse(BaseModel):
"""Admin role with user count."""
id: int
name: str
description: str | None = None
level: int
permissions: list[str] = Field(default_factory=list)
color: str | None = None
icon: str | None = None
is_system: bool
is_active: bool
user_count: int = 0
created_at: datetime | None = None
class RoleCreateRequest(BaseModel):
"""Create a new custom role."""
name: str = Field(min_length=1, max_length=100)
description: str | None = None
level: int = Field(ge=0, le=998)
permissions: list[str] = Field(default_factory=list)
color: str | None = Field(default=None, max_length=7)
icon: str | None = Field(default=None, max_length=50)
class RoleUpdateRequest(BaseModel):
"""Update role fields (all optional)."""
name: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = None
level: int | None = Field(default=None, ge=0, le=998)
permissions: list[str] | None = None
color: str | None = Field(default=None, max_length=7)
icon: str | None = Field(default=None, max_length=50)
is_active: bool | None = None
class RoleAssignRequest(BaseModel):
"""Assign a role to a user."""
user_id: int
role_id: int
expires_at: datetime | None = None
class PermissionAction(BaseModel):
"""Single permission action within a section."""
action: str
permission: str # e.g. "users:read"
class PermissionSection(BaseModel):
"""Permission section with available actions."""
section: str
actions: list[PermissionAction]
class PermissionRegistryResponse(BaseModel):
"""All available permissions grouped by section."""
sections: list[PermissionSection]
all_permissions: list[str]
class UserRoleResponse(BaseModel):
"""User-role assignment details."""
id: int
user_id: int
role_id: int
role_name: str | None = None
user_telegram_id: int | None = None
user_username: str | None = None
user_first_name: str | None = None
user_email: str | None = None
assigned_by: int | None = None
assigned_at: datetime | None = None
expires_at: datetime | None = None
is_active: bool
class AdminWithRolesResponse(BaseModel):
"""User that has at least one admin role."""
user_id: int
telegram_id: int | None = None
username: str | None = None
first_name: str | None = None
last_name: str | None = None
email: str | None = None
role_names: list[str] = Field(default_factory=list)
# ============ Helper Functions ============
async def _role_to_response(db: AsyncSession, role) -> RoleResponse:
"""Convert AdminRole model to RoleResponse with user count."""
user_count = await AdminRoleCRUD.count_users(db, role.id)
return RoleResponse(
id=role.id,
name=role.name,
description=role.description,
level=role.level,
permissions=role.permissions or [],
color=role.color,
icon=role.icon,
is_system=role.is_system,
is_active=role.is_active,
user_count=user_count,
created_at=role.created_at,
)
async def _get_admin_level(db: AsyncSession, admin: User) -> int:
"""Get the maximum role level of the current admin."""
_perms, _names, max_level = await UserRoleCRUD.get_user_permissions(db, admin.id)
return max_level
def _validate_permissions(permissions: list[str]) -> None:
"""Validate that all provided permissions exist in the registry."""
all_valid = set(get_all_permissions())
# Also allow wildcard patterns
all_valid.add('*:*')
for section in PERMISSION_REGISTRY:
all_valid.add(f'{section}:*')
invalid = [p for p in permissions if p not in all_valid]
if invalid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid permissions: {", ".join(invalid)}',
)
# ============ Routes ============
@router.get('/permissions', response_model=PermissionRegistryResponse)
async def get_permission_registry(
admin: User = Depends(require_permission('roles:read')),
):
"""Get all available permissions grouped by section."""
sections = [
PermissionSection(
section=section,
actions=[PermissionAction(action=action, permission=f'{section}:{action}') for action in actions],
)
for section, actions in PERMISSION_REGISTRY.items()
]
return PermissionRegistryResponse(
sections=sections,
all_permissions=get_all_permissions(),
)
@router.get('/users', response_model=list[AdminWithRolesResponse])
async def list_users_with_roles(
admin: User = Depends(require_permission('roles:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = 100,
offset: int = 0,
):
"""List users that have at least one admin role."""
admins = await UserRoleCRUD.get_all_admins(db, limit=limit, offset=offset)
return [
AdminWithRolesResponse(
user_id=item['user'].id,
telegram_id=item['user'].telegram_id,
username=item['user'].username,
first_name=item['user'].first_name,
last_name=item['user'].last_name,
email=item['user'].email,
role_names=item['role_names'],
)
for item in admins
]
@router.get('', response_model=list[RoleResponse])
async def list_roles(
admin: User = Depends(require_permission('roles:read')),
db: AsyncSession = Depends(get_cabinet_db),
include_inactive: bool = False,
):
"""List all admin roles with user counts."""
roles = await AdminRoleCRUD.get_all(db, include_inactive=include_inactive)
return [await _role_to_response(db, role) for role in roles]
@router.post('', response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
async def create_role(
payload: RoleCreateRequest,
admin: User = Depends(require_permission('roles:create')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new custom admin role."""
# Validate permissions list
_validate_permissions(payload.permissions)
# Hierarchy enforcement: cannot create role with level >= own level
admin_level = await _get_admin_level(db, admin)
if payload.level >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot create a role with level >= your own role level',
)
# Check name uniqueness
existing = await AdminRoleCRUD.get_by_name(db, payload.name)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Role with this name already exists',
)
role = await AdminRoleCRUD.create(
db,
name=payload.name,
description=payload.description,
level=payload.level,
permissions=payload.permissions,
color=payload.color,
icon=payload.icon,
created_by=admin.id,
)
await db.commit()
logger.info('Admin created role', admin_id=admin.id, role_id=role.id, role_name=role.name)
return await _role_to_response(db, role)
@router.put('/{role_id}', response_model=RoleResponse)
async def update_role(
role_id: int,
payload: RoleUpdateRequest,
admin: User = Depends(require_permission('roles:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing admin role."""
role = await AdminRoleCRUD.get_by_id(db, role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Role not found',
)
admin_level = await _get_admin_level(db, admin)
# Cannot edit a role at or above own level (unless it's the same role)
if role.level >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot edit a role at or above your own level',
)
update_data = payload.model_dump(exclude_unset=True)
# Validate level change
if 'level' in update_data and update_data['level'] >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot set role level >= your own role level',
)
# Validate permissions
if 'permissions' in update_data and update_data['permissions'] is not None:
_validate_permissions(update_data['permissions'])
# Check name uniqueness if name is changing
if 'name' in update_data and update_data['name'] != role.name:
existing = await AdminRoleCRUD.get_by_name(db, update_data['name'])
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Role with this name already exists',
)
updated = await AdminRoleCRUD.update(db, role_id, **update_data)
if not updated:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Role not found',
)
await db.commit()
logger.info('Admin updated role', admin_id=admin.id, role_id=role_id, fields=list(update_data.keys()))
return await _role_to_response(db, updated)
@router.delete('/{role_id}')
async def delete_role(
role_id: int,
admin: User = Depends(require_permission('roles:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a custom admin role. System roles cannot be deleted."""
role = await AdminRoleCRUD.get_by_id(db, role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Role not found',
)
if role.is_system:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot delete a system role',
)
admin_level = await _get_admin_level(db, admin)
if role.level >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot delete a role at or above your own level',
)
deleted = await AdminRoleCRUD.delete(db, role_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to delete role',
)
await db.commit()
logger.info('Admin deleted role', admin_id=admin.id, role_id=role_id, role_name=role.name)
return {'message': 'Role deleted', 'role_id': role_id}
@router.post('/assign', response_model=UserRoleResponse, status_code=status.HTTP_201_CREATED)
async def assign_role(
payload: RoleAssignRequest,
admin: User = Depends(require_permission('roles:assign')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Assign a role to a user. Hierarchy enforcement applies."""
role = await AdminRoleCRUD.get_by_id(db, payload.role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Role not found',
)
admin_level = await _get_admin_level(db, admin)
# Cannot assign a role with level >= own level
if role.level >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot assign a role with level >= your own role level',
)
# Verify target user exists
from app.database.crud.user import get_user_by_id
target_user = await get_user_by_id(db, payload.user_id)
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Target user not found',
)
user_role = await UserRoleCRUD.assign_role(
db,
user_id=payload.user_id,
role_id=payload.role_id,
assigned_by=admin.id,
expires_at=payload.expires_at,
)
await db.commit()
logger.info(
'Admin assigned role',
admin_id=admin.id,
target_user_id=payload.user_id,
role_id=payload.role_id,
role_name=role.name,
)
return UserRoleResponse(
id=user_role.id,
user_id=user_role.user_id,
role_id=user_role.role_id,
role_name=role.name,
user_telegram_id=target_user.telegram_id,
user_username=target_user.username,
user_first_name=target_user.first_name,
user_email=target_user.email,
assigned_by=user_role.assigned_by,
assigned_at=user_role.assigned_at,
expires_at=user_role.expires_at,
is_active=user_role.is_active,
)
@router.delete('/assign/{assignment_id}')
async def revoke_role(
assignment_id: int,
admin: User = Depends(require_permission('roles:assign')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Revoke a role assignment. Cannot remove the last superadmin."""
from sqlalchemy import select as sa_select
from app.database.models import UserRole
# Load the assignment to check hierarchy
result = await db.execute(sa_select(UserRole).where(UserRole.id == assignment_id))
user_role = result.scalar_one_or_none()
if not user_role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Role assignment not found',
)
role = await AdminRoleCRUD.get_by_id(db, user_role.role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Associated role not found',
)
admin_level = await _get_admin_level(db, admin)
# Cannot revoke a role at or above own level
if role.level >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot revoke a role at or above your own level',
)
# Protect last superadmin (level 999)
superadmin_level = 999
if role.level == superadmin_level:
superadmin_count = await UserRoleCRUD.get_superadmin_count(db)
if superadmin_count <= 1:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot remove the last superadmin',
)
revoked = await UserRoleCRUD.revoke_role(db, assignment_id)
if not revoked:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to revoke role',
)
await db.commit()
logger.info(
'Admin revoked role assignment',
admin_id=admin.id,
assignment_id=assignment_id,
target_user_id=user_role.user_id,
role_name=role.name,
)
return {'message': 'Role revoked', 'assignment_id': assignment_id}
+8 -8
View File
@@ -16,7 +16,7 @@ from app.database.crud.server_squad import (
from app.database.models import PromoGroup, ServerSquad, Subscription, Tariff, User
from app.services.subscription_service import SubscriptionService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.servers import (
PromoGroupInfo,
ServerDetailResponse,
@@ -66,7 +66,7 @@ async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> list[s
@router.get('', response_model=ServerListResponse)
async def list_servers(
include_unavailable: bool = True,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all servers."""
@@ -103,7 +103,7 @@ async def list_servers(
@router.get('/{server_id}', response_model=ServerDetailResponse)
async def get_server(
server_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed server info."""
@@ -146,7 +146,7 @@ async def get_server(
async def update_existing_server(
server_id: int,
request: ServerUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing server."""
@@ -191,7 +191,7 @@ async def update_existing_server(
@router.post('/{server_id}/toggle', response_model=ServerToggleResponse)
async def toggle_server(
server_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle server availability."""
@@ -218,7 +218,7 @@ async def toggle_server(
@router.post('/{server_id}/trial', response_model=ServerTrialToggleResponse)
async def toggle_server_trial(
server_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle server trial eligibility."""
@@ -245,7 +245,7 @@ async def toggle_server_trial(
@router.get('/{server_id}/stats', response_model=ServerStatsResponse)
async def get_server_stats(
server_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get server statistics."""
@@ -287,7 +287,7 @@ async def get_server_stats(
@router.post('/sync', response_model=ServerSyncResponse)
async def sync_servers(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Sync servers with RemnaWave."""
+6 -6
View File
@@ -13,7 +13,7 @@ from app.services.system_settings_service import (
bot_configuration_service,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -179,7 +179,7 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe
@router.get('/categories', response_model=list[SettingCategorySummary])
async def list_categories(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:read')),
):
"""Get list of setting categories."""
categories = bot_configuration_service.get_categories()
@@ -196,7 +196,7 @@ async def list_categories(
@router.get('', response_model=list[SettingDefinition])
async def list_settings(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:read')),
category: str | None = Query(default=None, alias='category_key'),
):
"""Get list of all settings or settings for a specific category."""
@@ -217,7 +217,7 @@ async def list_settings(
@router.get('/{key}', response_model=SettingDefinition)
async def get_setting(
key: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:read')),
):
"""Get a specific setting by key."""
try:
@@ -232,7 +232,7 @@ async def get_setting(
async def update_setting(
key: str,
payload: SettingUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update a setting value."""
@@ -255,7 +255,7 @@ async def update_setting(
@router.delete('/{key}', response_model=SettingDefinition)
async def reset_setting(
key: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset a setting to its default value."""
+9 -9
View File
@@ -26,7 +26,7 @@ from app.database.models import (
from app.services.remnawave_service import RemnaWaveService
from app.services.version_service import version_service
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -246,7 +246,7 @@ class RecentPaymentsResponse(BaseModel):
@router.get('/dashboard', response_model=DashboardStats)
async def get_dashboard_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get complete dashboard statistics for admin panel."""
@@ -326,7 +326,7 @@ async def get_dashboard_stats(
@router.get('/system-info', response_model=SystemInfoResponse)
async def get_system_info(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get system information for admin dashboard."""
@@ -358,7 +358,7 @@ async def get_system_info(
@router.get('/nodes', response_model=NodesOverview)
async def get_nodes_status(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
):
"""Get status of all nodes."""
try:
@@ -374,7 +374,7 @@ async def get_nodes_status(
@router.post('/nodes/{node_uuid}/restart')
async def restart_node(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
):
"""Restart a node."""
try:
@@ -401,7 +401,7 @@ async def restart_node(
@router.post('/nodes/{node_uuid}/toggle')
async def toggle_node(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
):
"""Enable or disable a node."""
try:
@@ -596,7 +596,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
@router.get('/referrals/top', response_model=TopReferrersResponse)
async def get_top_referrers(
limit: int = 20,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get top referrers with earnings breakdown by period."""
@@ -812,7 +812,7 @@ async def get_top_referrers(
@router.get('/campaigns/top', response_model=TopCampaignsResponse)
async def get_top_campaigns(
limit: int = 20,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get top advertising campaigns with statistics."""
@@ -869,7 +869,7 @@ async def get_top_campaigns(
@router.get('/payments/recent', response_model=RecentPaymentsResponse)
async def get_recent_payments(
limit: int = 50,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get recent payments with user info."""
+11 -11
View File
@@ -19,7 +19,7 @@ from app.database.crud.tariff import (
)
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.tariffs import (
PeriodPrice,
PromoGroupInfo,
@@ -107,7 +107,7 @@ def _period_prices_to_dict(period_prices: list[PeriodPrice]) -> dict:
@router.get('', response_model=TariffListResponse)
async def list_tariffs(
include_inactive: bool = True,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all tariffs."""
@@ -141,7 +141,7 @@ async def list_tariffs(
@router.get('/available-servers', response_model=list[ServerInfo])
async def get_available_servers(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all servers for tariff selection."""
@@ -161,7 +161,7 @@ async def get_available_servers(
@router.put('/order')
async def update_tariff_order(
request: TariffSortOrderRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update the display order of tariffs."""
@@ -176,7 +176,7 @@ async def update_tariff_order(
@router.get('/{tariff_id}', response_model=TariffDetailResponse)
async def get_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed tariff info."""
@@ -246,7 +246,7 @@ async def get_tariff(
@router.post('', response_model=TariffDetailResponse)
async def create_new_tariff(
request: TariffCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:create')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new tariff."""
@@ -307,7 +307,7 @@ async def create_new_tariff(
async def update_existing_tariff(
tariff_id: int,
request: TariffUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing tariff."""
@@ -400,7 +400,7 @@ async def update_existing_tariff(
@router.delete('/{tariff_id}')
async def delete_existing_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a tariff."""
@@ -430,7 +430,7 @@ async def delete_existing_tariff(
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
async def toggle_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle tariff active status."""
@@ -460,7 +460,7 @@ async def toggle_tariff(
@router.post('/{tariff_id}/trial', response_model=TariffTrialResponse)
async def toggle_trial_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle tariff trial availability.
@@ -500,7 +500,7 @@ async def toggle_trial_tariff(
@router.get('/{tariff_id}/stats', response_model=TariffStatsResponse)
async def get_tariff_stats(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get tariff statistics."""
+9 -9
View File
@@ -16,7 +16,7 @@ from app.database.crud.ticket import TicketCRUD
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.database.models import Ticket, TicketMessage, User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.tickets import TicketMessageResponse
@@ -197,7 +197,7 @@ def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) ->
@router.get('/stats', response_model=AdminStatsResponse)
async def get_ticket_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket statistics."""
@@ -222,7 +222,7 @@ async def get_ticket_stats(
@router.get('/settings', response_model=TicketSettingsResponse)
async def get_ticket_settings(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:settings')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket system settings."""
@@ -242,7 +242,7 @@ async def get_ticket_settings(
@router.patch('/settings', response_model=TicketSettingsResponse)
async def update_ticket_settings(
request: TicketSettingsUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:settings')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket system settings."""
@@ -337,7 +337,7 @@ async def get_all_tickets(
status_filter: str | None = Query(None, alias='status', description='Filter by status'),
priority_filter: str | None = Query(None, alias='priority', description='Filter by priority'),
user_id: int | None = Query(None, description='Filter by user ID'),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all tickets for admin."""
@@ -386,7 +386,7 @@ async def get_all_tickets(
@router.get('/{ticket_id}', response_model=AdminTicketDetailResponse)
async def get_ticket_detail(
ticket_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket with all messages for admin."""
@@ -428,7 +428,7 @@ async def get_ticket_detail(
async def reply_to_ticket(
ticket_id: int,
request: AdminReplyRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:reply')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reply to a ticket as admin."""
@@ -497,7 +497,7 @@ async def reply_to_ticket(
async def update_ticket_status(
ticket_id: int,
request: AdminStatusUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:close')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket status."""
@@ -556,7 +556,7 @@ async def update_ticket_status(
async def update_ticket_priority(
ticket_id: int,
request: AdminPriorityUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:close')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket priority."""
+4 -4
View File
@@ -20,7 +20,7 @@ from app.config import settings
from app.database.models import Subscription, Transaction, TransactionType, User
from app.services.remnawave_service import RemnaWaveService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.traffic import (
ExportCsvRequest,
ExportCsvResponse,
@@ -262,7 +262,7 @@ def _build_traffic_items(
@router.get('', response_model=TrafficUsageResponse)
async def get_traffic_usage(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('traffic:read')),
db: AsyncSession = Depends(get_cabinet_db),
period: int = Query(30, ge=1, le=30),
limit: int = Query(50, ge=1, le=200),
@@ -497,7 +497,7 @@ async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict
@router.get('/enrichment', response_model=TrafficEnrichmentResponse)
async def get_traffic_enrichment(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('traffic:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return enrichment data: device counts, spending, dates, last node."""
@@ -530,7 +530,7 @@ async def get_traffic_enrichment(
@router.post('/export-csv', response_model=ExportCsvResponse)
async def export_traffic_csv(
request: ExportCsvRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('traffic:export')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Generate CSV with traffic usage and send to admin's Telegram DM."""
+2 -2
View File
@@ -10,7 +10,7 @@ from pydantic import BaseModel
from app.database.models import User
from app.services.version_service import version_service
from ..dependencies import get_current_admin_user
from ..dependencies import require_permission
logger = structlog.get_logger(__name__)
@@ -93,7 +93,7 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
@router.get('/releases', response_model=ReleasesResponse)
async def get_releases(
current_user: User = Depends(get_current_admin_user),
current_user: User = Depends(require_permission('updates:read')),
) -> ReleasesResponse:
"""Get release information for bot and cabinet."""
# Bot releases
+29 -29
View File
@@ -37,7 +37,7 @@ from app.database.models import (
)
from app.utils.timezone import panel_datetime_to_utc
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.users import (
DeleteDeviceResponse,
DeleteUserRequest,
@@ -355,7 +355,7 @@ async def list_users(
email: str | None = Query(None, max_length=255),
status: UserStatusEnum | None = Query(None),
sort_by: SortByEnum = Query(SortByEnum.CREATED_AT),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -412,7 +412,7 @@ async def list_users(
@router.get('/stats', response_model=UsersStatsResponse)
async def get_users_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get overall users statistics."""
@@ -513,7 +513,7 @@ async def get_users_stats(
@router.get('/{user_id}', response_model=UserDetailResponse)
async def get_user_detail(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed user information by ID."""
@@ -644,7 +644,7 @@ async def get_user_detail(
@router.get('/by-telegram/{telegram_id}', response_model=UserDetailResponse)
async def get_user_by_telegram(
telegram_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user by Telegram ID."""
@@ -663,7 +663,7 @@ async def get_user_by_telegram(
@router.get('/{user_id}/panel-info', response_model=UserPanelInfoResponse)
async def get_user_panel_info(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user panel info from Remnawave (config links, traffic, connection data)."""
@@ -741,7 +741,7 @@ async def get_user_panel_info(
@router.get('/{user_id}/node-usage', response_model=UserNodeUsageResponse)
async def get_user_node_usage(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user per-node traffic usage (always 30 days with daily breakdown)."""
@@ -829,7 +829,7 @@ async def get_user_node_usage(
async def update_user_balance(
user_id: int,
request: UpdateBalanceRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -906,7 +906,7 @@ async def update_user_balance(
async def update_user_subscription(
user_id: int,
request: UpdateSubscriptionRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1273,7 +1273,7 @@ async def update_user_subscription(
async def get_user_available_tariffs(
user_id: int,
include_inactive: bool = Query(False, description='Include inactive tariffs'),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1371,7 +1371,7 @@ async def get_user_available_tariffs(
async def update_user_status(
user_id: int,
request: UpdateUserStatusRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user status (active, blocked, deleted)."""
@@ -1416,7 +1416,7 @@ async def update_user_status(
async def block_user(
user_id: int,
reason: str | None = None,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:block')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Block a user (shortcut for status update)."""
@@ -1427,7 +1427,7 @@ async def block_user(
@router.post('/{user_id}/unblock', response_model=UpdateUserStatusResponse)
async def unblock_user(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:block')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unblock a user (shortcut for status update)."""
@@ -1442,7 +1442,7 @@ async def unblock_user(
async def update_user_restrictions(
user_id: int,
request: UpdateRestrictionsRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user restrictions (topup, subscription)."""
@@ -1490,7 +1490,7 @@ async def update_user_restrictions(
async def update_user_promo_group(
user_id: int,
request: UpdatePromoGroupRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user promo group."""
@@ -1545,7 +1545,7 @@ async def update_user_promo_group(
async def update_user_referral_commission(
user_id: int,
request: UpdateReferralCommissionRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user's individual referral commission percentage."""
@@ -1583,7 +1583,7 @@ async def update_user_referral_commission(
@router.get('/{user_id}/devices', response_model=UserDevicesResponse)
async def get_user_devices(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user devices from Remnawave panel."""
@@ -1637,7 +1637,7 @@ async def get_user_devices(
async def delete_user_device(
user_id: int,
hwid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a single device for user."""
@@ -1668,7 +1668,7 @@ async def delete_user_device(
@router.delete('/{user_id}/devices', response_model=ResetDevicesResponse)
async def reset_user_devices(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset all devices for user."""
@@ -1716,7 +1716,7 @@ async def reset_user_devices(
async def delete_user(
user_id: int,
request: DeleteUserRequest = DeleteUserRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1754,7 +1754,7 @@ async def delete_user(
async def full_delete_user(
user_id: int,
request: FullDeleteUserRequest = FullDeleteUserRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1803,7 +1803,7 @@ async def full_delete_user(
async def reset_user_trial(
user_id: int,
request: ResetTrialRequest = ResetTrialRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1874,7 +1874,7 @@ async def reset_user_trial(
async def reset_user_subscription(
user_id: int,
request: ResetSubscriptionRequest = ResetSubscriptionRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1959,7 +1959,7 @@ async def reset_user_subscription(
async def disable_user(
user_id: int,
request: DisableUserRequest = DisableUserRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:block')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -2036,7 +2036,7 @@ async def get_user_referrals(
user_id: int,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of users referred by this user."""
@@ -2076,7 +2076,7 @@ async def get_user_transactions(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
transaction_type: str | None = Query(None),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user transactions."""
@@ -2133,7 +2133,7 @@ async def get_user_transactions(
@router.get('/{user_id}/sync/status', response_model=PanelSyncStatusResponse)
async def get_user_sync_status(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:sync')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -2290,7 +2290,7 @@ async def get_user_sync_status(
async def sync_user_from_panel(
user_id: int,
request: SyncFromPanelRequest = SyncFromPanelRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:sync')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -2492,7 +2492,7 @@ async def sync_user_from_panel(
async def sync_user_to_panel(
user_id: int,
request: SyncToPanelRequest = SyncToPanelRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:sync')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
+10 -10
View File
@@ -9,7 +9,7 @@ import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.cabinet.dependencies import get_cabinet_db, get_current_admin_user
from app.cabinet.dependencies import get_cabinet_db, require_permission
from app.cabinet.schemas.wheel import (
AdminSpinItem,
AdminSpinsResponse,
@@ -42,7 +42,7 @@ router = APIRouter(prefix='/admin/wheel', tags=['Admin Fortune Wheel'])
@router.get('/config', response_model=AdminWheelConfigResponse)
async def get_admin_wheel_config(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить полную конфигурацию колеса."""
@@ -93,7 +93,7 @@ async def get_admin_wheel_config(
@router.put('/config', response_model=AdminWheelConfigResponse)
async def update_admin_wheel_config(
request: UpdateWheelConfigRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Обновить конфигурацию колеса."""
@@ -155,7 +155,7 @@ async def update_admin_wheel_config(
@router.get('/prizes', response_model=list[WheelPrizeAdminResponse])
async def get_prizes(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить список призов."""
@@ -188,7 +188,7 @@ async def get_prizes(
@router.post('/prizes', response_model=WheelPrizeAdminResponse, status_code=status.HTTP_201_CREATED)
async def create_prize(
request: CreatePrizeRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Создать новый приз."""
@@ -237,7 +237,7 @@ async def create_prize(
async def update_prize(
prize_id: int,
request: UpdatePrizeRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Обновить приз."""
@@ -286,7 +286,7 @@ async def update_prize(
@router.delete('/prizes/{prize_id}', status_code=status.HTTP_204_NO_CONTENT)
async def delete_prize_endpoint(
prize_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Удалить приз."""
@@ -304,7 +304,7 @@ async def delete_prize_endpoint(
@router.post('/prizes/reorder', status_code=status.HTTP_200_OK)
async def reorder_prizes(
request: ReorderPrizesRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Переупорядочить призы."""
@@ -317,7 +317,7 @@ async def reorder_prizes(
async def get_statistics(
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить статистику колеса."""
@@ -344,7 +344,7 @@ async def get_all_spins_endpoint(
date_to: datetime | None = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить все спины с фильтрами."""
+6 -6
View File
@@ -16,7 +16,7 @@ from app.database.models import (
)
from app.services.referral_withdrawal_service import referral_withdrawal_service
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.withdrawals import (
AdminApproveWithdrawalRequest,
AdminRejectWithdrawalRequest,
@@ -49,7 +49,7 @@ async def list_withdrawals(
),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('withdrawals:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all withdrawal requests."""
@@ -123,7 +123,7 @@ async def list_withdrawals(
@router.get('/{withdrawal_id}', response_model=AdminWithdrawalDetailResponse)
async def get_withdrawal_detail(
withdrawal_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('withdrawals:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed withdrawal request with risk analysis."""
@@ -180,7 +180,7 @@ async def get_withdrawal_detail(
async def approve_withdrawal(
withdrawal_id: int,
request: AdminApproveWithdrawalRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('withdrawals:approve')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Approve a withdrawal request."""
@@ -232,7 +232,7 @@ async def approve_withdrawal(
async def reject_withdrawal(
withdrawal_id: int,
request: AdminRejectWithdrawalRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('withdrawals:reject')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reject a withdrawal request."""
@@ -283,7 +283,7 @@ async def reject_withdrawal(
@router.post('/{withdrawal_id}/complete')
async def complete_withdrawal(
withdrawal_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('withdrawals:approve')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark a withdrawal as completed (money transferred)."""
+45 -9
View File
@@ -15,6 +15,7 @@ from app.database.crud.campaign import (
get_campaign_by_start_parameter,
get_campaign_registration_by_user,
)
from app.database.crud.rbac import UserRoleCRUD
from app.database.crud.user import (
clear_email_change_pending,
create_user,
@@ -99,9 +100,17 @@ def _user_to_response(user: User) -> UserResponse:
)
def _create_auth_response(user: User) -> AuthResponse:
"""Create full auth response with tokens."""
access_token = create_access_token(user.id, user.telegram_id)
async def _create_auth_response(user: User, db: AsyncSession) -> AuthResponse:
"""Create full auth response with tokens and RBAC permissions."""
user_permissions, user_role_names, user_role_level = await UserRoleCRUD.get_user_permissions(db, user.id)
access_token = create_access_token(
user.id,
user.telegram_id,
permissions=user_permissions,
roles=user_role_names,
role_level=user_role_level,
)
refresh_token = create_refresh_token(user.id)
expires_in = settings.get_cabinet_access_token_expire_minutes() * 60
@@ -422,7 +431,7 @@ async def auth_telegram(
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
response = _create_auth_response(user)
response = await _create_auth_response(user, db)
# Store refresh token
await _store_refresh_token(db, user.id, response.refresh_token)
@@ -502,7 +511,7 @@ async def auth_telegram_widget(
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
response = _create_auth_response(user)
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process referral code (before campaign bonus, which may also set referrer)
@@ -777,7 +786,7 @@ async def verify_email(
await _sync_subscription_from_panel_by_email(db, user)
# Return auth tokens so user is logged in after verification
response = _create_auth_response(user)
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process campaign bonus
@@ -924,7 +933,7 @@ async def login_email(
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
response = _create_auth_response(user)
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process campaign bonus
@@ -987,7 +996,14 @@ async def refresh_token(
detail='User not found or inactive',
)
access_token = create_access_token(user.id, user.telegram_id)
user_permissions, user_role_names, user_role_level = await UserRoleCRUD.get_user_permissions(db, user.id)
access_token = create_access_token(
user.id,
user.telegram_id,
permissions=user_permissions,
roles=user_role_names,
role_level=user_role_level,
)
expires_in = settings.get_cabinet_access_token_expire_minutes() * 60
return TokenResponse(
@@ -1111,12 +1127,32 @@ async def get_current_user(
return _user_to_response(user)
@router.get('/me/permissions')
async def get_my_permissions(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get current user's RBAC permissions, roles, and level."""
from app.services.permission_service import PermissionService
return await PermissionService.get_user_permissions(db, user.id)
@router.get('/me/is-admin')
async def check_is_admin(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Check if current user is an admin."""
"""Check if current user is an admin (legacy config or RBAC)."""
# Legacy check: config-based admin list
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
if not is_admin:
# RBAC check: user has any active role with level > 0
_permissions, _role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user.id)
if max_level > 0:
is_admin = True
return {'is_admin': is_admin}
+12 -12
View File
@@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import SystemSetting, User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -296,7 +296,7 @@ async def get_logo():
@router.put('/name', response_model=BrandingResponse)
async def update_branding_name(
payload: BrandingNameUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update the project name. Admin only. Empty name allowed (logo only mode)."""
@@ -324,7 +324,7 @@ async def update_branding_name(
@router.post('/logo', response_model=BrandingResponse)
async def upload_logo(
file: UploadFile = File(...),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Upload a custom logo. Admin only."""
@@ -387,7 +387,7 @@ async def upload_logo(
@router.delete('/logo', response_model=BrandingResponse)
async def delete_logo(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete custom logo and revert to letter. Admin only."""
@@ -459,7 +459,7 @@ async def get_theme_colors(
@router.patch('/colors', response_model=ThemeColorsResponse)
async def update_theme_colors(
payload: ThemeColorsUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update theme colors. Admin only. Partial update supported."""
@@ -493,7 +493,7 @@ async def update_theme_colors(
@router.post('/colors/reset', response_model=ThemeColorsResponse)
async def reset_theme_colors(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset theme colors to defaults. Admin only."""
@@ -533,7 +533,7 @@ async def get_enabled_themes(
@router.patch('/themes', response_model=EnabledThemesResponse)
async def update_enabled_themes(
payload: EnabledThemesUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update which themes are enabled. Admin only. At least one theme must be enabled."""
@@ -587,7 +587,7 @@ async def get_animation_enabled(
@router.patch('/animation', response_model=AnimationEnabledResponse)
async def update_animation_enabled(
payload: AnimationEnabledUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update animation enabled setting. Admin only."""
@@ -622,7 +622,7 @@ async def get_fullscreen_enabled(
@router.patch('/fullscreen', response_model=FullscreenEnabledResponse)
async def update_fullscreen_enabled(
payload: FullscreenEnabledUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update fullscreen enabled setting. Admin only."""
@@ -658,7 +658,7 @@ async def get_email_auth_enabled(
@router.patch('/email-auth', response_model=EmailAuthEnabledResponse)
async def update_email_auth_enabled(
payload: EmailAuthEnabledUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update email auth enabled setting. Admin only."""
@@ -694,7 +694,7 @@ async def get_analytics_counters(
@router.patch('/analytics', response_model=AnalyticsCountersResponse)
async def update_analytics_counters(
payload: AnalyticsCountersUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update analytics counter settings. Admin only. Partial update supported."""
@@ -758,7 +758,7 @@ async def get_lite_mode_enabled(
@router.patch('/lite-mode', response_model=LiteModeEnabledResponse)
async def update_lite_mode_enabled(
payload: LiteModeEnabledUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update lite mode enabled setting. Admin only."""
+1 -1
View File
@@ -43,7 +43,7 @@ async def _finalize_oauth_login(
"""Update last login, create tokens, store refresh token."""
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
auth_response = _create_auth_response(user)
auth_response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
# Process referral code (before campaign bonus, which may also set referrer)
+6 -6
View File
@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_admin_user, get_current_cabinet_user
from ..dependencies import get_cabinet_db, get_current_cabinet_user, require_permission
logger = structlog.get_logger(__name__)
@@ -132,7 +132,7 @@ async def get_admin_notifications(
unread_only: bool = Query(False, description='Only return unread notifications'),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket notifications for admins."""
@@ -149,7 +149,7 @@ async def get_admin_notifications(
@admin_router.get('/unread-count', response_model=UnreadCountResponse)
async def get_admin_unread_count(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get unread notifications count for admins."""
@@ -160,7 +160,7 @@ async def get_admin_unread_count(
@admin_router.post('/{notification_id}/read')
async def mark_admin_notification_as_read(
notification_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:settings')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark an admin notification as read."""
@@ -185,7 +185,7 @@ async def mark_admin_notification_as_read(
@admin_router.post('/read-all')
async def mark_all_admin_notifications_as_read(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:settings')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all admin notifications as read."""
@@ -196,7 +196,7 @@ async def mark_all_admin_notifications_as_read(
@admin_router.post('/ticket/{ticket_id}/read')
async def mark_admin_ticket_notifications_as_read(
ticket_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:settings')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all admin notifications for a specific ticket as read."""
+496
View File
@@ -0,0 +1,496 @@
from datetime import UTC, datetime
import structlog
from sqlalchemy import and_, delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import AccessPolicy, AdminAuditLog, AdminRole, User, UserRole
logger = structlog.get_logger(__name__)
# Fields allowed for AdminRole.update()
_ROLE_UPDATABLE_FIELDS = frozenset(
{
'name',
'description',
'level',
'permissions',
'color',
'icon',
'is_active',
}
)
# Fields allowed for AccessPolicy.update()
_POLICY_UPDATABLE_FIELDS = frozenset(
{
'name',
'description',
'role_id',
'priority',
'effect',
'conditions',
'resource',
'actions',
'is_active',
}
)
# Superadmin level constant
_SUPERADMIN_LEVEL = 999
class AdminRoleCRUD:
"""CRUD operations for admin_roles table."""
@staticmethod
async def get_all(db: AsyncSession, *, include_inactive: bool = False) -> list[AdminRole]:
"""Get all admin roles ordered by level descending."""
stmt = select(AdminRole).order_by(AdminRole.level.desc())
if not include_inactive:
stmt = stmt.where(AdminRole.is_active.is_(True))
result = await db.execute(stmt)
return list(result.scalars().all())
@staticmethod
async def get_by_id(db: AsyncSession, role_id: int) -> AdminRole | None:
result = await db.execute(select(AdminRole).where(AdminRole.id == role_id))
return result.scalar_one_or_none()
@staticmethod
async def get_by_name(db: AsyncSession, name: str) -> AdminRole | None:
result = await db.execute(select(AdminRole).where(AdminRole.name == name))
return result.scalar_one_or_none()
@staticmethod
async def create(
db: AsyncSession,
*,
name: str,
description: str | None,
level: int,
permissions: list[str],
color: str | None = None,
icon: str | None = None,
is_system: bool = False,
created_by: int | None = None,
) -> AdminRole:
role = AdminRole(
name=name,
description=description,
level=level,
permissions=permissions,
color=color,
icon=icon,
is_system=is_system,
created_by=created_by,
)
db.add(role)
await db.flush()
await db.refresh(role)
logger.info('Created admin role', role_id=role.id, name=name, level=level)
return role
@staticmethod
async def update(db: AsyncSession, role_id: int, **kwargs: object) -> AdminRole | None:
"""Update only provided fields. Rejects unknown/non-updatable keys."""
role = await AdminRoleCRUD.get_by_id(db, role_id)
if not role:
return None
for key, value in kwargs.items():
if key not in _ROLE_UPDATABLE_FIELDS:
logger.warning('Rejected update of non-updatable AdminRole field', field=key)
continue
setattr(role, key, value)
await db.flush()
await db.refresh(role)
logger.info('Updated admin role', role_id=role_id, fields=list(kwargs.keys()))
return role
@staticmethod
async def delete(db: AsyncSession, role_id: int) -> bool:
"""Delete a role. Returns False if the role is a system role or does not exist.
Cascades are handled by DB-level ON DELETE CASCADE on user_roles and access_policies.
"""
role = await AdminRoleCRUD.get_by_id(db, role_id)
if not role:
return False
if role.is_system:
logger.warning('Attempted to delete system role', role_id=role_id, name=role.name)
return False
# Explicitly delete dependent user_roles and access_policies in application layer
# to keep audit trail clear (DB cascade would also work, but explicit is better)
await db.execute(delete(UserRole).where(UserRole.role_id == role_id))
await db.execute(delete(AccessPolicy).where(AccessPolicy.role_id == role_id))
await db.delete(role)
await db.flush()
logger.info('Deleted admin role', role_id=role_id, name=role.name)
return True
@staticmethod
async def count_users(db: AsyncSession, role_id: int) -> int:
"""Count active user_roles assigned to this role."""
result = await db.execute(
select(func.count(UserRole.id)).where(
UserRole.role_id == role_id,
UserRole.is_active.is_(True),
)
)
return result.scalar() or 0
class UserRoleCRUD:
"""CRUD operations for user_roles table + permission aggregation."""
@staticmethod
async def get_user_roles(db: AsyncSession, user_id: int) -> list[UserRole]:
"""Get active user roles with eager-loaded AdminRole."""
result = await db.execute(
select(UserRole)
.options(selectinload(UserRole.role))
.where(
UserRole.user_id == user_id,
UserRole.is_active.is_(True),
)
)
return list(result.scalars().all())
@staticmethod
async def get_user_permissions(
db: AsyncSession,
user_id: int,
) -> tuple[list[str], list[str], int]:
"""Aggregate permissions from all active, non-expired roles.
Returns:
(sorted_permissions, role_names, max_level)
"""
now = datetime.now(UTC)
result = await db.execute(
select(UserRole)
.options(selectinload(UserRole.role))
.where(
UserRole.user_id == user_id,
UserRole.is_active.is_(True),
)
)
user_roles = result.scalars().all()
permissions: set[str] = set()
role_names: list[str] = []
max_level: int = 0
for ur in user_roles:
# Skip expired assignments
if ur.expires_at is not None and ur.expires_at <= now:
continue
role = ur.role
if role is None or not role.is_active:
continue
permissions.update(role.permissions or [])
role_names.append(role.name)
max_level = max(max_level, role.level)
return sorted(permissions), role_names, max_level
@staticmethod
async def assign_role(
db: AsyncSession,
*,
user_id: int,
role_id: int,
assigned_by: int | None = None,
expires_at: datetime | None = None,
) -> UserRole:
"""Assign a role to a user. Reactivates existing inactive assignment if present."""
# Check for existing assignment (active or inactive) due to unique constraint
result = await db.execute(
select(UserRole).where(
UserRole.user_id == user_id,
UserRole.role_id == role_id,
)
)
existing = result.scalar_one_or_none()
if existing is not None:
existing.is_active = True
existing.assigned_by = assigned_by
existing.assigned_at = datetime.now(UTC)
existing.expires_at = expires_at
await db.flush()
await db.refresh(existing)
logger.info('Reactivated user role', user_role_id=existing.id, user_id=user_id, role_id=role_id)
return existing
user_role = UserRole(
user_id=user_id,
role_id=role_id,
assigned_by=assigned_by,
expires_at=expires_at,
)
db.add(user_role)
await db.flush()
await db.refresh(user_role)
logger.info('Assigned role to user', user_role_id=user_role.id, user_id=user_id, role_id=role_id)
return user_role
@staticmethod
async def revoke_role(db: AsyncSession, user_role_id: int) -> bool:
"""Soft-revoke: set is_active=False. Returns False if not found."""
result = await db.execute(select(UserRole).where(UserRole.id == user_role_id))
user_role = result.scalar_one_or_none()
if not user_role:
return False
user_role.is_active = False
await db.flush()
logger.info(
'Revoked user role', user_role_id=user_role_id, user_id=user_role.user_id, role_id=user_role.role_id
)
return True
@staticmethod
async def get_all_admins(
db: AsyncSession,
*,
limit: int = 100,
offset: int = 0,
) -> list[dict]:
"""Get users that have at least one active role.
Returns list of dicts: [{'user': User, 'role_names': [str, ...]}]
"""
# Subquery: aggregate role names per user
role_agg = (
select(
UserRole.user_id,
func.array_agg(AdminRole.name).label('role_names'),
)
.join(AdminRole, UserRole.role_id == AdminRole.id)
.where(
UserRole.is_active.is_(True),
AdminRole.is_active.is_(True),
)
.group_by(UserRole.user_id)
.subquery()
)
stmt = (
select(User, role_agg.c.role_names)
.join(role_agg, User.id == role_agg.c.user_id)
.order_by(User.id)
.offset(offset)
.limit(limit)
)
result = await db.execute(stmt)
rows = result.all()
return [{'user': row[0], 'role_names': list(row[1] or [])} for row in rows]
@staticmethod
async def get_superadmin_count(db: AsyncSession) -> int:
"""Count users with an active role at superadmin level (999)."""
result = await db.execute(
select(func.count(func.distinct(UserRole.user_id)))
.join(AdminRole, UserRole.role_id == AdminRole.id)
.where(
UserRole.is_active.is_(True),
AdminRole.is_active.is_(True),
AdminRole.level == _SUPERADMIN_LEVEL,
)
)
return result.scalar() or 0
class AccessPolicyCRUD:
"""CRUD operations for access_policies table (ABAC)."""
@staticmethod
async def get_all(
db: AsyncSession,
*,
role_id: int | None = None,
) -> list[AccessPolicy]:
"""Get active policies ordered by priority descending. Optionally filter by role_id."""
stmt = select(AccessPolicy).where(AccessPolicy.is_active.is_(True)).order_by(AccessPolicy.priority.desc())
if role_id is not None:
stmt = stmt.where(AccessPolicy.role_id == role_id)
result = await db.execute(stmt)
return list(result.scalars().all())
@staticmethod
async def get_by_id(db: AsyncSession, policy_id: int) -> AccessPolicy | None:
result = await db.execute(select(AccessPolicy).where(AccessPolicy.id == policy_id))
return result.scalar_one_or_none()
@staticmethod
async def create(db: AsyncSession, **kwargs: object) -> AccessPolicy:
policy = AccessPolicy(**kwargs)
db.add(policy)
await db.flush()
await db.refresh(policy)
logger.info('Created access policy', policy_id=policy.id, name=policy.name, effect=policy.effect)
return policy
@staticmethod
async def update(db: AsyncSession, policy_id: int, **kwargs: object) -> AccessPolicy | None:
"""Update only provided fields. Rejects unknown/non-updatable keys."""
policy = await AccessPolicyCRUD.get_by_id(db, policy_id)
if not policy:
return None
for key, value in kwargs.items():
if key not in _POLICY_UPDATABLE_FIELDS:
logger.warning('Rejected update of non-updatable AccessPolicy field', field=key)
continue
setattr(policy, key, value)
await db.flush()
await db.refresh(policy)
logger.info('Updated access policy', policy_id=policy_id, fields=list(kwargs.keys()))
return policy
@staticmethod
async def delete(db: AsyncSession, policy_id: int) -> bool:
policy = await AccessPolicyCRUD.get_by_id(db, policy_id)
if not policy:
return False
await db.delete(policy)
await db.flush()
logger.info('Deleted access policy', policy_id=policy_id, name=policy.name)
return True
@staticmethod
async def get_policies_for_user(
db: AsyncSession,
role_ids: list[int],
) -> list[AccessPolicy]:
"""Get active policies matching any of the given role_ids OR global (role_id IS NULL).
Ordered by priority descending for correct evaluation order.
"""
if not role_ids:
# Only global policies
stmt = (
select(AccessPolicy)
.where(
AccessPolicy.is_active.is_(True),
AccessPolicy.role_id.is_(None),
)
.order_by(AccessPolicy.priority.desc())
)
else:
stmt = (
select(AccessPolicy)
.where(
AccessPolicy.is_active.is_(True),
or_(
AccessPolicy.role_id.in_(role_ids),
AccessPolicy.role_id.is_(None),
),
)
.order_by(AccessPolicy.priority.desc())
)
result = await db.execute(stmt)
return list(result.scalars().all())
class AuditLogCRUD:
"""Create + filtered query for admin_audit_log table."""
@staticmethod
async def create(
db: AsyncSession,
*,
user_id: int,
action: str,
resource_type: str | None = None,
resource_id: str | None = None,
details: dict | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
status: str = 'success',
request_method: str | None = None,
request_path: str | None = None,
) -> AdminAuditLog:
entry = AdminAuditLog(
user_id=user_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
details=details,
ip_address=ip_address,
user_agent=user_agent,
status=status,
request_method=request_method,
request_path=request_path,
)
db.add(entry)
await db.flush()
await db.refresh(entry)
logger.debug(
'Audit log created',
audit_id=entry.id,
user_id=user_id,
action=action,
status=status,
)
return entry
@staticmethod
async def get_logs(
db: AsyncSession,
*,
user_id: int | None = None,
action: str | None = None,
resource_type: str | None = None,
status: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[AdminAuditLog], int]:
"""Get filtered audit logs with total count.
Returns:
(logs, total_count)
"""
filters = []
if user_id is not None:
filters.append(AdminAuditLog.user_id == user_id)
if action is not None:
filters.append(AdminAuditLog.action == action)
if resource_type is not None:
filters.append(AdminAuditLog.resource_type == resource_type)
if status is not None:
filters.append(AdminAuditLog.status == status)
if date_from is not None:
filters.append(AdminAuditLog.created_at >= date_from)
if date_to is not None:
filters.append(AdminAuditLog.created_at <= date_to)
where_clause = and_(*filters) if filters else True
# Total count
count_result = await db.execute(select(func.count(AdminAuditLog.id)).where(where_clause))
total_count = count_result.scalar() or 0
# Paginated results
stmt = (
select(AdminAuditLog)
.where(where_clause)
.order_by(AdminAuditLog.created_at.desc())
.offset(offset)
.limit(limit)
)
result = await db.execute(stmt)
logs = list(result.scalars().all())
return logs, total_count
+111
View File
@@ -28,6 +28,7 @@ from sqlalchemy import (
TypeDecorator,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Mapped, backref, mapped_column, relationship
from sqlalchemy.sql import func
@@ -1060,6 +1061,7 @@ class User(Base):
promo_group = relationship('PromoGroup', back_populates='users')
user_promo_groups = relationship('UserPromoGroup', back_populates='user', cascade='all, delete-orphan')
poll_responses = relationship('PollResponse', back_populates='user')
admin_roles_rel = relationship('UserRole', back_populates='user')
notification_settings = Column(JSON, nullable=True, default=dict)
last_pinned_message_id = Column(Integer, nullable=True)
@@ -2857,3 +2859,112 @@ class UserChannelSubscription(Base):
f'<UserChannelSubscription telegram_id={self.telegram_id}'
f' channel={self.channel_id!r} member={self.is_member}>'
)
# ── RBAC / ABAC models ──────────────────────────────────────────────────
class AdminRole(Base):
"""Role definition with permission groups for admin cabinet RBAC."""
__tablename__ = 'admin_roles'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(Text, nullable=True)
level = Column(Integer, default=0, nullable=False)
permissions = Column(JSONB, default=list, nullable=False)
color = Column(String(7), nullable=True)
icon = Column(String(50), nullable=True)
is_system = Column(Boolean, default=False, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
created_at = Column(AwareDateTime(), server_default=func.now())
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
creator = relationship('User', foreign_keys=[created_by])
user_roles = relationship('UserRole', back_populates='role')
def __repr__(self) -> str:
return f'<AdminRole id={self.id} name={self.name!r} level={self.level}>'
class UserRole(Base):
"""M2M assignment of users to admin roles."""
__tablename__ = 'user_roles'
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
role_id = Column(Integer, ForeignKey('admin_roles.id', ondelete='CASCADE'), nullable=False)
assigned_by = Column(Integer, ForeignKey('users.id'), nullable=True)
assigned_at = Column(AwareDateTime(), server_default=func.now())
expires_at = Column(AwareDateTime(), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
__table_args__ = (
UniqueConstraint('user_id', 'role_id', name='uq_user_role'),
)
user = relationship('User', foreign_keys=[user_id], back_populates='admin_roles_rel')
role = relationship('AdminRole', back_populates='user_roles')
assigner = relationship('User', foreign_keys=[assigned_by])
def __repr__(self) -> str:
return f'<UserRole id={self.id} user_id={self.user_id} role_id={self.role_id}>'
class AccessPolicy(Base):
"""ABAC attribute-based access policy."""
__tablename__ = 'access_policies'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
role_id = Column(Integer, ForeignKey('admin_roles.id', ondelete='CASCADE'), nullable=True)
priority = Column(Integer, default=0, nullable=False)
effect = Column(String(10), nullable=False) # "allow" / "deny"
conditions = Column(JSONB, default=dict, nullable=False)
resource = Column(String(100), nullable=False)
actions = Column(JSONB, default=list, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
created_at = Column(AwareDateTime(), server_default=func.now())
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
role = relationship('AdminRole')
creator = relationship('User', foreign_keys=[created_by])
def __repr__(self) -> str:
return f'<AccessPolicy id={self.id} name={self.name!r} effect={self.effect!r}>'
class AdminAuditLog(Base):
"""Immutable audit log for admin actions."""
__tablename__ = 'admin_audit_log'
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
action = Column(String(100), nullable=False)
resource_type = Column(String(50), nullable=True)
resource_id = Column(String(100), nullable=True)
details = Column(JSONB, nullable=True)
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
status = Column(String(20), nullable=False)
request_method = Column(String(10), nullable=True)
request_path = Column(Text, nullable=True)
created_at = Column(AwareDateTime(), server_default=func.now())
__table_args__ = (
Index('ix_admin_audit_user_created', 'user_id', 'created_at'),
Index('ix_admin_audit_resource', 'resource_type', 'resource_id'),
Index('ix_admin_audit_created', 'created_at'),
)
user = relationship('User', foreign_keys=[user_id])
def __repr__(self) -> str:
return f'<AdminAuditLog id={self.id} action={self.action!r} status={self.status!r}>'
+318
View File
@@ -0,0 +1,318 @@
"""Permission Engine — RBAC + ABAC evaluation for admin cabinet.
Combines role-based permission checks (fnmatch wildcards) with
attribute-based access policies (time ranges, IP whitelists).
"""
from __future__ import annotations
import ipaddress
from datetime import UTC, datetime
from fnmatch import fnmatch
from typing import TYPE_CHECKING
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.rbac import AccessPolicyCRUD, AuditLogCRUD, UserRoleCRUD
if TYPE_CHECKING:
from app.database.models import AccessPolicy, User
logger = structlog.get_logger(__name__)
# ---------------------------------------------------------------------------
# Permission Registry — section -> available actions
# ---------------------------------------------------------------------------
PERMISSION_REGISTRY: dict[str, list[str]] = {
'users': ['read', 'edit', 'block', 'delete', 'sync'],
'tickets': ['read', 'reply', 'close', 'settings'],
'stats': ['read', 'export'],
'broadcasts': ['read', 'create', 'edit', 'delete', 'send'],
'tariffs': ['read', 'create', 'edit', 'delete'],
'promocodes': ['read', 'create', 'edit', 'delete', 'stats'],
'promo_groups': ['read', 'create', 'edit', 'delete'],
'promo_offers': ['read', 'create', 'edit', 'send'],
'campaigns': ['read', 'create', 'edit', 'delete', 'stats'],
'partners': ['read', 'edit', 'approve', 'revoke', 'settings'],
'withdrawals': ['read', 'approve', 'reject'],
'payments': ['read', 'edit', 'export'],
'payment_methods': ['read', 'edit'],
'servers': ['read', 'edit'],
'remnawave': ['read', 'sync', 'manage'],
'traffic': ['read', 'export'],
'settings': ['read', 'edit'],
'roles': ['read', 'create', 'edit', 'delete', 'assign'],
'audit_log': ['read', 'export'],
'channels': ['read', 'edit'],
'ban_system': ['read', 'edit', 'ban', 'unban'],
'wheel': ['read', 'edit'],
'apps': ['read', 'edit'],
'email_templates': ['read', 'edit'],
'pinned_messages': ['read', 'create', 'edit', 'delete'],
'updates': ['read', 'manage'],
}
# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------
def get_all_permissions() -> list[str]:
"""Return flat list of all permissions: ``['users:read', 'users:edit', ...]``."""
return [f'{section}:{action}' for section, actions in PERMISSION_REGISTRY.items() for action in actions]
def permission_matches(user_perm: str, required_perm: str) -> bool:
"""Check if *user_perm* grants access for *required_perm*.
Wildcard rules (fnmatch):
- ``*:*`` matches everything
- ``users:*`` matches ``users:read``, ``users:edit``, ...
- ``users:read`` matches only ``users:read``
"""
return fnmatch(required_perm, user_perm)
# ---------------------------------------------------------------------------
# Internal ABAC helpers
# ---------------------------------------------------------------------------
def _policy_matches_resource(policy: AccessPolicy, required_perm: str) -> bool:
"""Check if an ABAC policy applies to the requested permission.
``policy.resource`` is the section pattern (e.g. ``users`` or ``*``).
``policy.actions`` is a list of action patterns (e.g. ``['read', '*']``).
"""
if ':' not in required_perm:
return False
section, action = required_perm.split(':', maxsplit=1)
if not fnmatch(section, policy.resource):
return False
policy_actions: list[str] = policy.actions or []
return any(fnmatch(action, pattern) for pattern in policy_actions)
def _evaluate_conditions(
conditions: dict | None,
*,
ip_address: str | None = None,
) -> bool:
"""Evaluate ABAC conditions dict. Returns ``True`` when ALL conditions are met.
Supported keys:
- ``time_range``: ``{"start": "09:00", "end": "18:00"}`` -- current UTC time
must fall within the range (inclusive start, exclusive end).
- ``ip_whitelist``: ``["192.168.1.0/24", "10.0.0.1"]`` -- *ip_address* must
match at least one entry (CIDR network or exact host).
- ``max_actions_per_hour``: reserved for future rate-limit logic; always passes.
"""
if not conditions:
return True
# --- time_range ---
time_range = conditions.get('time_range')
if time_range is not None:
now = datetime.now(UTC).time()
try:
start = datetime.strptime(time_range['start'], '%H:%M').time()
end = datetime.strptime(time_range['end'], '%H:%M').time()
except (KeyError, ValueError) as exc:
logger.warning('Invalid time_range condition', condition=time_range, error=str(exc))
return False
if start <= end:
# Normal range, e.g. 09:00..18:00
if not (start <= now < end):
return False
# Overnight range, e.g. 22:00..06:00
elif not (now >= start or now < end):
return False
# --- ip_whitelist ---
ip_whitelist: list[str] | None = conditions.get('ip_whitelist')
if ip_whitelist is not None:
if ip_address is None:
# No IP provided but whitelist required -- deny
return False
try:
client_ip = ipaddress.ip_address(ip_address)
except ValueError:
logger.warning('Invalid client IP address', ip_address=ip_address)
return False
matched = False
for entry in ip_whitelist:
try:
network = ipaddress.ip_network(entry, strict=False)
if client_ip in network:
matched = True
break
except ValueError:
logger.warning('Invalid IP whitelist entry', entry=entry)
continue
if not matched:
return False
# --- max_actions_per_hour (stub) ---
# Will be implemented with rate-limit counters later.
return True
# ---------------------------------------------------------------------------
# Service class
# ---------------------------------------------------------------------------
class PermissionService:
"""Stateless permission engine combining RBAC + ABAC evaluation."""
@staticmethod
async def check_permission(
db: AsyncSession,
user: User,
required_permission: str,
*,
ip_address: str | None = None,
) -> tuple[bool, str]:
"""Evaluate whether *user* may perform *required_permission*.
Returns ``(allowed, reason)`` tuple.
Algorithm:
1. Aggregate user permissions via ``UserRoleCRUD.get_user_permissions``.
2. Check if any RBAC permission matches the required one (fnmatch).
3. If base RBAC permission is **not** granted -- deny immediately.
4. Fetch ABAC policies applicable to the user's roles.
5. Evaluate matching policies in priority order; **deny wins over allow**
at the same priority level.
"""
# Step 1 -- aggregate RBAC permissions
permissions, role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user.id)
if not permissions:
logger.debug(
'Permission denied: no active roles',
user_id=user.id,
required=required_permission,
)
return False, 'No active roles assigned'
# Step 2 -- RBAC wildcard matching
rbac_granted = any(permission_matches(perm, required_permission) for perm in permissions)
if not rbac_granted:
logger.debug(
'Permission denied: RBAC mismatch',
user_id=user.id,
required=required_permission,
permissions=permissions,
)
return False, 'Permission not granted by any role'
# Step 3 -- load ABAC policies for the user's roles
user_roles = await UserRoleCRUD.get_user_roles(db, user.id)
role_ids = [ur.role_id for ur in user_roles]
policies = await AccessPolicyCRUD.get_policies_for_user(db, role_ids)
if not policies:
# No ABAC policies -- RBAC alone grants access
return True, 'Granted by RBAC'
# Step 4 -- evaluate ABAC policies (highest priority first, already sorted)
explicit_deny = False
deny_reason = ''
for policy in policies:
if not _policy_matches_resource(policy, required_permission):
continue
conditions_met = _evaluate_conditions(
policy.conditions,
ip_address=ip_address,
)
if not conditions_met:
# Conditions not satisfied -- this policy does not apply
continue
if policy.effect == 'deny':
explicit_deny = True
deny_reason = f'Denied by policy: {policy.name}'
logger.debug(
'Permission denied by ABAC policy',
user_id=user.id,
required=required_permission,
policy_id=policy.id,
policy_name=policy.name,
)
# Deny is final -- stop evaluation
break
# effect == 'allow' does not override a prior deny at higher priority,
# but since policies are sorted desc and deny breaks immediately,
# reaching here means no deny has fired yet -- just continue.
if explicit_deny:
return False, deny_reason
return True, 'Granted by RBAC + ABAC'
@staticmethod
async def get_user_permissions(db: AsyncSession, user_id: int) -> dict:
"""Return aggregated permission info for a user.
Returns::
{
'permissions': ['users:read', ...],
'roles': ['editor', 'moderator'],
'role_level': 50,
}
"""
permissions, role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user_id)
return {
'permissions': permissions,
'roles': role_names,
'role_level': max_level,
}
@staticmethod
async def log_action(
db: AsyncSession,
*,
user_id: int,
action: str,
resource_type: str | None = None,
resource_id: str | None = None,
details: dict | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
status: str = 'success',
request_method: str | None = None,
request_path: str | None = None,
) -> None:
"""Persist an admin audit log entry via ``AuditLogCRUD``."""
await AuditLogCRUD.create(
db,
user_id=user_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
details=details,
ip_address=ip_address,
user_agent=user_agent,
status=status,
request_method=request_method,
request_path=request_path,
)
+297
View File
@@ -0,0 +1,297 @@
"""
RBAC bootstrap service.
Auto-assigns the Superadmin role to users listed in ADMIN_IDS / ADMIN_EMAILS
config on bot startup. Runs once during the startup sequence.
"""
from datetime import UTC, datetime
from typing import Final
import structlog
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import AdminRole, User, UserRole
logger = structlog.get_logger(__name__)
SUPERADMIN_ROLE_NAME: Final[str] = 'Superadmin'
# Preset roles seeded on first run
_PRESET_ROLES: list[dict] = [
{
'name': 'Superadmin',
'description': 'Full system access',
'level': 999,
'permissions': ['*:*'],
'color': '#EF4444',
'icon': 'shield',
'is_system': True,
},
{
'name': 'Admin',
'description': 'Administrative access',
'level': 100,
'permissions': [
'users:*',
'tickets:*',
'stats:*',
'broadcasts:*',
'tariffs:*',
'promocodes:*',
'promo_groups:*',
'promo_offers:*',
'campaigns:*',
'partners:*',
'withdrawals:*',
'payments:*',
'payment_methods:*',
'servers:*',
'remnawave:*',
'traffic:*',
'settings:*',
'roles:read',
'roles:create',
'roles:edit',
'roles:assign',
'audit_log:*',
'channels:*',
'ban_system:*',
'wheel:*',
'apps:*',
'email_templates:*',
'pinned_messages:*',
'updates:*',
],
'color': '#F59E0B',
'icon': 'crown',
'is_system': True,
},
{
'name': 'Moderator',
'description': 'User and ticket management',
'level': 50,
'permissions': ['users:read', 'users:edit', 'users:block', 'tickets:*', 'ban_system:*'],
'color': '#3B82F6',
'icon': 'user-shield',
'is_system': True,
},
{
'name': 'Marketer',
'description': 'Marketing tools access',
'level': 30,
'permissions': [
'campaigns:*',
'broadcasts:*',
'promocodes:*',
'promo_offers:*',
'promo_groups:*',
'stats:read',
'pinned_messages:*',
'wheel:*',
],
'color': '#8B5CF6',
'icon': 'megaphone',
'is_system': True,
},
{
'name': 'Support',
'description': 'Ticket support access',
'level': 20,
'permissions': ['tickets:read', 'tickets:reply', 'users:read'],
'color': '#10B981',
'icon': 'headset',
'is_system': True,
},
]
async def _ensure_preset_roles(db: AsyncSession) -> AdminRole | None:
"""Seed preset roles if they don't exist. Returns the Superadmin role."""
superadmin_role: AdminRole | None = None
for preset in _PRESET_ROLES:
result = await db.execute(select(AdminRole).where(AdminRole.name == preset['name']))
existing = result.scalar_one_or_none()
if existing is not None:
if preset['name'] == SUPERADMIN_ROLE_NAME:
superadmin_role = existing
continue
role = AdminRole(
name=preset['name'],
description=preset['description'],
level=preset['level'],
permissions=preset['permissions'],
color=preset['color'],
icon=preset['icon'],
is_system=preset['is_system'],
is_active=True,
)
db.add(role)
await db.flush()
logger.info('Seeded preset role', role_name=preset['name'], role_id=role.id)
if preset['name'] == SUPERADMIN_ROLE_NAME:
superadmin_role = role
return superadmin_role
async def bootstrap_superadmins(db: AsyncSession) -> None:
"""Ensure every user from ADMIN_IDS / ADMIN_EMAILS has the Superadmin role.
Also seeds preset roles on first run.
Idempotent: skips users who already hold an active Superadmin assignment.
Commits only when at least one change was made.
"""
try:
admin_ids = settings.get_admin_ids()
admin_emails = settings.get_admin_emails()
# ── 1. Ensure preset roles exist (seeds on first run) ──────────
superadmin_role = await _ensure_preset_roles(db)
if superadmin_role is None:
logger.error('Failed to resolve Superadmin role after seeding')
return
if not admin_ids and not admin_emails:
logger.debug('No admin IDs or emails configured, skipping superadmin assignment')
await db.commit()
return
role_id: int = superadmin_role.id
assigned_count = 0
# ── 2. Process admin telegram IDs ──────────────────────────────
for telegram_id in admin_ids:
assigned = await _ensure_role_by_telegram_id(db, telegram_id=telegram_id, role_id=role_id)
if assigned:
assigned_count += 1
# ── 3. Process admin emails ────────────────────────────────────
for email in admin_emails:
assigned = await _ensure_role_by_email(db, email=email, role_id=role_id)
if assigned:
assigned_count += 1
# ── 4. Commit all changes ──────────────────────────────────────
await db.commit()
if assigned_count > 0:
logger.info(
'Superadmin bootstrap completed',
assigned_count=assigned_count,
role_id=role_id,
)
else:
logger.debug('Superadmin bootstrap: no new assignments needed')
except Exception:
logger.exception('Failed to bootstrap superadmins, continuing startup')
async def _ensure_role_by_telegram_id(
db: AsyncSession,
*,
telegram_id: int,
role_id: int,
) -> bool:
"""Assign Superadmin role to user found by telegram_id. Returns True if assigned."""
result = await db.execute(select(User).where(User.telegram_id == telegram_id))
user = result.scalar_one_or_none()
if user is None:
logger.debug(
'Admin user not yet registered, skipping',
telegram_id=telegram_id,
)
return False
return await _assign_if_missing(db, user_id=user.id, role_id=role_id, identifier=str(telegram_id))
async def _ensure_role_by_email(
db: AsyncSession,
*,
email: str,
role_id: int,
) -> bool:
"""Assign Superadmin role to user found by email (case-insensitive). Returns True if assigned."""
result = await db.execute(select(User).where(func.lower(User.email) == email.lower()))
user = result.scalar_one_or_none()
if user is None:
logger.debug(
'Admin user (email) not yet registered, skipping',
email=email,
)
return False
return await _assign_if_missing(db, user_id=user.id, role_id=role_id, identifier=email)
async def _assign_if_missing(
db: AsyncSession,
*,
user_id: int,
role_id: int,
identifier: str,
) -> bool:
"""Create or reactivate a UserRole row for this user/role pair.
Handles the unique constraint on (user_id, role_id) by checking for
ANY existing assignment (active or inactive) and reactivating if needed.
Returns True if a new assignment was created or an inactive one was reactivated.
"""
# Check for ANY existing assignment (active or not) to respect unique constraint
result = await db.execute(
select(UserRole).where(
UserRole.user_id == user_id,
UserRole.role_id == role_id,
)
)
existing = result.scalar_one_or_none()
if existing is not None:
if existing.is_active:
logger.debug(
'User already has Superadmin role',
user_id=user_id,
identifier=identifier,
)
return False
# Reactivate previously revoked assignment
existing.is_active = True
existing.assigned_at = datetime.now(UTC)
await db.flush()
logger.info(
'Reactivated Superadmin role for user',
user_id=user_id,
role_id=role_id,
identifier=identifier,
user_role_id=existing.id,
)
return True
user_role = UserRole(
user_id=user_id,
role_id=role_id,
is_active=True,
)
db.add(user_role)
await db.flush()
logger.info(
'Assigned Superadmin role to user',
user_id=user_id,
role_id=role_id,
identifier=identifier,
user_role_id=user_role.id,
)
return True
+15
View File
@@ -217,6 +217,21 @@ async def main():
if not token_ok:
stage.warning('Не удалось создать/проверить дефолтный веб-API токен')
async with timeline.stage(
'RBAC bootstrap',
'🔐',
success_message='RBAC roles and superadmins ready',
) as stage:
try:
from app.database.database import AsyncSessionLocal
from app.services.rbac_bootstrap_service import bootstrap_superadmins
async with AsyncSessionLocal() as db:
await bootstrap_superadmins(db)
except Exception as error:
stage.warning(f'RBAC bootstrap warning: {error}')
logger.error('RBAC bootstrap failed', error=error)
async with timeline.stage(
'Синхронизация тарифов из конфига',
'💰',
@@ -0,0 +1,94 @@
"""add RBAC tables (admin_roles, user_roles, access_policies, admin_audit_log)
Revision ID: 0011
Revises: 0010
Create Date: 2026-02-25
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = '0011'
down_revision: Union[str, None] = '0010'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'admin_roles',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('name', sa.String(100), unique=True, nullable=False),
sa.Column('description', sa.Text, nullable=True),
sa.Column('level', sa.Integer, default=0, nullable=False),
sa.Column('permissions', JSONB, server_default='[]', nullable=False),
sa.Column('color', sa.String(7), nullable=True),
sa.Column('icon', sa.String(50), nullable=True),
sa.Column('is_system', sa.Boolean, server_default=sa.text('false'), nullable=False),
sa.Column('is_active', sa.Boolean, server_default=sa.text('true'), nullable=False),
sa.Column('created_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
'user_roles',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('role_id', sa.Integer, sa.ForeignKey('admin_roles.id', ondelete='CASCADE'), nullable=False),
sa.Column('assigned_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
sa.Column('assigned_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('is_active', sa.Boolean, server_default=sa.text('true'), nullable=False),
sa.UniqueConstraint('user_id', 'role_id', name='uq_user_role'),
)
op.create_table(
'access_policies',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('name', sa.String(200), nullable=False),
sa.Column('description', sa.Text, nullable=True),
sa.Column('role_id', sa.Integer, sa.ForeignKey('admin_roles.id', ondelete='CASCADE'), nullable=True),
sa.Column('priority', sa.Integer, default=0, nullable=False),
sa.Column('effect', sa.String(10), nullable=False),
sa.Column('conditions', JSONB, server_default='{}', nullable=False),
sa.Column('resource', sa.String(100), nullable=False),
sa.Column('actions', JSONB, server_default='[]', nullable=False),
sa.Column('is_active', sa.Boolean, server_default=sa.text('true'), nullable=False),
sa.Column('created_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
'admin_audit_log',
sa.Column('id', sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
sa.Column('action', sa.String(100), nullable=False),
sa.Column('resource_type', sa.String(50), nullable=True),
sa.Column('resource_id', sa.String(100), nullable=True),
sa.Column('details', JSONB, nullable=True),
sa.Column('ip_address', sa.String(45), nullable=True),
sa.Column('user_agent', sa.Text, nullable=True),
sa.Column('status', sa.String(20), nullable=False),
sa.Column('request_method', sa.String(10), nullable=True),
sa.Column('request_path', sa.Text, nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index('ix_admin_audit_user_created', 'admin_audit_log', ['user_id', 'created_at'])
op.create_index('ix_admin_audit_resource', 'admin_audit_log', ['resource_type', 'resource_id'])
op.create_index('ix_admin_audit_created', 'admin_audit_log', ['created_at'])
def downgrade() -> None:
op.drop_index('ix_admin_audit_created', table_name='admin_audit_log')
op.drop_index('ix_admin_audit_resource', table_name='admin_audit_log')
op.drop_index('ix_admin_audit_user_created', table_name='admin_audit_log')
op.drop_table('admin_audit_log')
op.drop_table('access_policies')
op.drop_table('user_roles')
op.drop_table('admin_roles')