From 3fee54f657dc6e0db1ec36697850ada2235e6968 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 25 Feb 2026 03:02:40 +0300 Subject: [PATCH] feat: add RBAC + ABAC permission system for admin cabinet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/cabinet/auth/jwt_handler.py | 20 +- app/cabinet/dependencies.py | 99 +++- app/cabinet/routes/__init__.py | 6 + app/cabinet/routes/admin_apps.py | 10 +- app/cabinet/routes/admin_audit_log.py | 196 +++++++ app/cabinet/routes/admin_ban_system.py | 58 +- app/cabinet/routes/admin_broadcasts.py | 24 +- app/cabinet/routes/admin_button_styles.py | 8 +- app/cabinet/routes/admin_campaigns.py | 26 +- app/cabinet/routes/admin_channels.py | 12 +- app/cabinet/routes/admin_email_templates.py | 14 +- app/cabinet/routes/admin_partners.py | 26 +- app/cabinet/routes/admin_payment_methods.py | 12 +- app/cabinet/routes/admin_payments.py | 10 +- app/cabinet/routes/admin_pinned_messages.py | 24 +- app/cabinet/routes/admin_policies.py | 227 ++++++++ app/cabinet/routes/admin_promo_offers.py | 14 +- app/cabinet/routes/admin_promocodes.py | 24 +- app/cabinet/routes/admin_remnawave.py | 60 +-- app/cabinet/routes/admin_roles.py | 490 +++++++++++++++++ app/cabinet/routes/admin_servers.py | 16 +- app/cabinet/routes/admin_settings.py | 12 +- app/cabinet/routes/admin_stats.py | 18 +- app/cabinet/routes/admin_tariffs.py | 22 +- app/cabinet/routes/admin_tickets.py | 18 +- app/cabinet/routes/admin_traffic.py | 8 +- app/cabinet/routes/admin_updates.py | 4 +- app/cabinet/routes/admin_users.py | 58 +- app/cabinet/routes/admin_wheel.py | 20 +- app/cabinet/routes/admin_withdrawals.py | 12 +- app/cabinet/routes/auth.py | 54 +- app/cabinet/routes/branding.py | 24 +- app/cabinet/routes/oauth.py | 2 +- app/cabinet/routes/ticket_notifications.py | 12 +- app/database/crud/rbac.py | 496 ++++++++++++++++++ app/database/models.py | 111 ++++ app/services/permission_service.py | 318 +++++++++++ app/services/rbac_bootstrap_service.py | 297 +++++++++++ main.py | 15 + .../alembic/versions/0011_add_rbac_tables.py | 94 ++++ 40 files changed, 2678 insertions(+), 293 deletions(-) create mode 100644 app/cabinet/routes/admin_audit_log.py create mode 100644 app/cabinet/routes/admin_policies.py create mode 100644 app/cabinet/routes/admin_roles.py create mode 100644 app/database/crud/rbac.py create mode 100644 app/services/permission_service.py create mode 100644 app/services/rbac_bootstrap_service.py create mode 100644 migrations/alembic/versions/0011_add_rbac_tables.py diff --git a/app/cabinet/auth/jwt_handler.py b/app/cabinet/auth/jwt_handler.py index 7100a8f7..3e3fb7d1 100644 --- a/app/cabinet/auth/jwt_handler.py +++ b/app/cabinet/auth/jwt_handler.py @@ -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) diff --git a/app/cabinet/dependencies.py b/app/cabinet/dependencies.py index 17018721..b79c21c9 100644 --- a/app/cabinet/dependencies.py +++ b/app/cabinet/dependencies.py @@ -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: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail='Admin access required', - ) + # 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 - 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 diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index 6e3f9565..74f37eca 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -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) diff --git a/app/cabinet/routes/admin_apps.py b/app/cabinet/routes/admin_apps.py index d80bc473..ed89e293 100644 --- a/app/cabinet/routes/admin_apps.py +++ b/app/cabinet/routes/admin_apps.py @@ -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: diff --git a/app/cabinet/routes/admin_audit_log.py b/app/cabinet/routes/admin_audit_log.py new file mode 100644 index 00000000..f14d48f5 --- /dev/null +++ b/app/cabinet/routes/admin_audit_log.py @@ -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}"'}, + ) diff --git a/app/cabinet/routes/admin_ban_system.py b/app/cabinet/routes/admin_ban_system.py index 000183f8..a6e0fe72 100644 --- a/app/cabinet/routes/admin_ban_system.py +++ b/app/cabinet/routes/admin_ban_system.py @@ -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() diff --git a/app/cabinet/routes/admin_broadcasts.py b/app/cabinet/routes/admin_broadcasts.py index ec06358e..cebc4068 100644 --- a/app/cabinet/routes/admin_broadcasts.py +++ b/app/cabinet/routes/admin_broadcasts.py @@ -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).""" diff --git a/app/cabinet/routes/admin_button_styles.py b/app/cabinet/routes/admin_button_styles.py index d67fbd4a..155809cf 100644 --- a/app/cabinet/routes/admin_button_styles.py +++ b/app/cabinet/routes/admin_button_styles.py @@ -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.""" diff --git a/app/cabinet/routes/admin_campaigns.py b/app/cabinet/routes/admin_campaigns.py index 7d098eec..fdf69fb9 100644 --- a/app/cabinet/routes/admin_campaigns.py +++ b/app/cabinet/routes/admin_campaigns.py @@ -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.""" diff --git a/app/cabinet/routes/admin_channels.py b/app/cabinet/routes/admin_channels.py index 2e3d0b92..e7753677 100644 --- a/app/cabinet/routes/admin_channels.py +++ b/app/cabinet/routes/admin_channels.py @@ -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: diff --git a/app/cabinet/routes/admin_email_templates.py b/app/cabinet/routes/admin_email_templates.py index 01109403..600c9971 100644 --- a/app/cabinet/routes/admin_email_templates.py +++ b/app/cabinet/routes/admin_email_templates.py @@ -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.""" diff --git a/app/cabinet/routes/admin_partners.py b/app/cabinet/routes/admin_partners.py index e7f8548f..b81c103c 100644 --- a/app/cabinet/routes/admin_partners.py +++ b/app/cabinet/routes/admin_partners.py @@ -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.""" diff --git a/app/cabinet/routes/admin_payment_methods.py b/app/cabinet/routes/admin_payment_methods.py index 7de07d48..786133e4 100644 --- a/app/cabinet/routes/admin_payment_methods.py +++ b/app/cabinet/routes/admin_payment_methods.py @@ -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.""" diff --git a/app/cabinet/routes/admin_payments.py b/app/cabinet/routes/admin_payments.py index 10dbf991..a736bbb6 100644 --- a/app/cabinet/routes/admin_payments.py +++ b/app/cabinet/routes/admin_payments.py @@ -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.""" diff --git a/app/cabinet/routes/admin_pinned_messages.py b/app/cabinet/routes/admin_pinned_messages.py index 120fc848..77dc0341 100644 --- a/app/cabinet/routes/admin_pinned_messages.py +++ b/app/cabinet/routes/admin_pinned_messages.py @@ -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.""" diff --git a/app/cabinet/routes/admin_policies.py b/app/cabinet/routes/admin_policies.py new file mode 100644 index 00000000..bcb606a5 --- /dev/null +++ b/app/cabinet/routes/admin_policies.py @@ -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} diff --git a/app/cabinet/routes/admin_promo_offers.py b/app/cabinet/routes/admin_promo_offers.py index 43caf556..a7102219 100644 --- a/app/cabinet/routes/admin_promo_offers.py +++ b/app/cabinet/routes/admin_promo_offers.py @@ -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), diff --git a/app/cabinet/routes/admin_promocodes.py b/app/cabinet/routes/admin_promocodes.py index 190912bf..5562b519 100644 --- a/app/cabinet/routes/admin_promocodes.py +++ b/app/cabinet/routes/admin_promocodes.py @@ -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.""" diff --git a/app/cabinet/routes/admin_remnawave.py b/app/cabinet/routes/admin_remnawave.py index 94669b26..0090ccf8 100644 --- a/app/cabinet/routes/admin_remnawave.py +++ b/app/cabinet/routes/admin_remnawave.py @@ -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.""" diff --git a/app/cabinet/routes/admin_roles.py b/app/cabinet/routes/admin_roles.py new file mode 100644 index 00000000..43b6f034 --- /dev/null +++ b/app/cabinet/routes/admin_roles.py @@ -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} diff --git a/app/cabinet/routes/admin_servers.py b/app/cabinet/routes/admin_servers.py index 898eb432..01c1383a 100644 --- a/app/cabinet/routes/admin_servers.py +++ b/app/cabinet/routes/admin_servers.py @@ -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.""" diff --git a/app/cabinet/routes/admin_settings.py b/app/cabinet/routes/admin_settings.py index de672f57..0041a340 100644 --- a/app/cabinet/routes/admin_settings.py +++ b/app/cabinet/routes/admin_settings.py @@ -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.""" diff --git a/app/cabinet/routes/admin_stats.py b/app/cabinet/routes/admin_stats.py index 4f7136cf..c1a44577 100644 --- a/app/cabinet/routes/admin_stats.py +++ b/app/cabinet/routes/admin_stats.py @@ -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.""" diff --git a/app/cabinet/routes/admin_tariffs.py b/app/cabinet/routes/admin_tariffs.py index 4209fd72..97632c6f 100644 --- a/app/cabinet/routes/admin_tariffs.py +++ b/app/cabinet/routes/admin_tariffs.py @@ -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.""" diff --git a/app/cabinet/routes/admin_tickets.py b/app/cabinet/routes/admin_tickets.py index a0f7cf4f..47cafe14 100644 --- a/app/cabinet/routes/admin_tickets.py +++ b/app/cabinet/routes/admin_tickets.py @@ -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.""" diff --git a/app/cabinet/routes/admin_traffic.py b/app/cabinet/routes/admin_traffic.py index fee0df3c..8c53fac1 100644 --- a/app/cabinet/routes/admin_traffic.py +++ b/app/cabinet/routes/admin_traffic.py @@ -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.""" diff --git a/app/cabinet/routes/admin_updates.py b/app/cabinet/routes/admin_updates.py index 5b3cfd3c..8312b803 100644 --- a/app/cabinet/routes/admin_updates.py +++ b/app/cabinet/routes/admin_updates.py @@ -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 diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index af81e778..36e7d7fe 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -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), ): """ diff --git a/app/cabinet/routes/admin_wheel.py b/app/cabinet/routes/admin_wheel.py index bbef73ec..f7bb9abc 100644 --- a/app/cabinet/routes/admin_wheel.py +++ b/app/cabinet/routes/admin_wheel.py @@ -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), ): """Получить все спины с фильтрами.""" diff --git a/app/cabinet/routes/admin_withdrawals.py b/app/cabinet/routes/admin_withdrawals.py index e0cfa714..9016e78f 100644 --- a/app/cabinet/routes/admin_withdrawals.py +++ b/app/cabinet/routes/admin_withdrawals.py @@ -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).""" diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py index 367f43b7..5b54be1a 100644 --- a/app/cabinet/routes/auth.py +++ b/app/cabinet/routes/auth.py @@ -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} diff --git a/app/cabinet/routes/branding.py b/app/cabinet/routes/branding.py index c36875a4..671b3a1d 100644 --- a/app/cabinet/routes/branding.py +++ b/app/cabinet/routes/branding.py @@ -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.""" diff --git a/app/cabinet/routes/oauth.py b/app/cabinet/routes/oauth.py index 1bfa7f9a..ea963693 100644 --- a/app/cabinet/routes/oauth.py +++ b/app/cabinet/routes/oauth.py @@ -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) diff --git a/app/cabinet/routes/ticket_notifications.py b/app/cabinet/routes/ticket_notifications.py index c636cf64..9a2f0643 100644 --- a/app/cabinet/routes/ticket_notifications.py +++ b/app/cabinet/routes/ticket_notifications.py @@ -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.""" diff --git a/app/database/crud/rbac.py b/app/database/crud/rbac.py new file mode 100644 index 00000000..66407814 --- /dev/null +++ b/app/database/crud/rbac.py @@ -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 diff --git a/app/database/models.py b/app/database/models.py index 576ce0c3..b0c36c12 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -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'' ) + + +# ── 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'' + + +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'' + + +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'' + + +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'' diff --git a/app/services/permission_service.py b/app/services/permission_service.py new file mode 100644 index 00000000..e705d297 --- /dev/null +++ b/app/services/permission_service.py @@ -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, + ) diff --git a/app/services/rbac_bootstrap_service.py b/app/services/rbac_bootstrap_service.py new file mode 100644 index 00000000..68d0eb91 --- /dev/null +++ b/app/services/rbac_bootstrap_service.py @@ -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 diff --git a/main.py b/main.py index 866c578b..14465a9f 100644 --- a/main.py +++ b/main.py @@ -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( 'Синхронизация тарифов из конфига', '💰', diff --git a/migrations/alembic/versions/0011_add_rbac_tables.py b/migrations/alembic/versions/0011_add_rbac_tables.py new file mode 100644 index 00000000..dbe2c44c --- /dev/null +++ b/migrations/alembic/versions/0011_add_rbac_tables.py @@ -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')