From f59b215645a2bfdaa4b8cf15043c2d5cfcfd287d Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 12 Mar 2026 22:58:35 +0300 Subject: [PATCH] style: fix import sorting and formatting after lint ruff auto-fix for import ordering in cabinet/subscription.py and formatting adjustments across changed files. --- app/cabinet/routes/subscription.py | 17 +- app/external/remnawave_api.py | 6 +- app/handlers/subscription/purchase.py | 18 +- app/services/monitoring_service.py | 5 +- app/services/pricing_engine.py | 2 +- app/services/recurrent_payment_service.py | 5 +- .../subscription_auto_purchase_service.py | 10 +- app/services/subscription_renewal_service.py | 3 +- app/webapi/routes/miniapp.py | 184 +- docs/plans/2026-02-25-rbac-design.md | 243 +++ docs/plans/2026-02-25-rbac-implementation.md | 1852 +++++++++++++++++ .../2026-03-09-gift-subscription-cabinet.md | 1163 +++++++++++ uv.lock | 2 +- 13 files changed, 3371 insertions(+), 139 deletions(-) create mode 100644 docs/plans/2026-02-25-rbac-design.md create mode 100644 docs/plans/2026-02-25-rbac-implementation.md create mode 100644 docs/plans/2026-03-09-gift-subscription-cabinet.md diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index d0a400de..97b1c616 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -10,8 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.config import PERIOD_PRICES, settings -from app.services.pricing_engine import PricingEngine +from app.config import settings from app.database.crud.server_squad import get_server_squad_by_uuid from app.database.crud.subscription import ( create_paid_subscription, @@ -27,6 +26,7 @@ from app.services.notification_delivery_service import ( NotificationType, notification_delivery_service, ) +from app.services.pricing_engine import PricingEngine from app.services.remnawave_service import RemnaWaveService from app.services.subscription_purchase_service import ( MiniAppSubscriptionPurchaseService, @@ -346,9 +346,7 @@ async def get_renewal_options( if pricing.final_total <= 0 and pricing.base_price <= 0: continue - original_price = ( - pricing.base_price + pricing.servers_price + pricing.traffic_price + pricing.devices_price - ) + original_price = pricing.base_price + pricing.servers_price + pricing.traffic_price + pricing.devices_price combined_discount = 0 if original_price > 0 and original_price != pricing.final_total: combined_discount = int((original_price - pricing.final_total) * 100 / original_price) @@ -390,7 +388,10 @@ async def renew_subscription( # Unified pricing via PricingEngine pricing_engine = PricingEngine() pricing = await pricing_engine.calculate_renewal_price( - db, user.subscription, request.period_days, user=user, + db, + user.subscription, + request.period_days, + user=user, ) price_kopeks = pricing.final_total promo_offer_discount_value = pricing.promo_offer_discount @@ -402,9 +403,7 @@ async def renew_subscription( ) # Combined discount percent for display - original_price_kopeks = ( - pricing.base_price + pricing.servers_price + pricing.traffic_price + pricing.devices_price - ) + original_price_kopeks = pricing.base_price + pricing.servers_price + pricing.traffic_price + pricing.devices_price discount_percent = 0 if original_price_kopeks > 0 and original_price_kopeks != price_kopeks: discount_percent = int((original_price_kopeks - price_kopeks) * 100 / original_price_kopeks) diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index 0a38077a..5bc520c1 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -405,11 +405,7 @@ class RemnaWaveAPI: is_harmless = response.status == 400 and ( 'already enabled' in error_lower or 'already disabled' in error_lower ) - log = ( - logger.warning - if response.status in (502, 503, 504) or is_harmless - else logger.error - ) + log = logger.warning if response.status in (502, 503, 504) or is_harmless else logger.error log('API Error %s: %s', response.status, error_message) log('Response: %s', response_text[:500]) raise RemnaWaveAPIError(error_message, response.status, response_data) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 8e1c169c..008ad5de 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -1605,13 +1605,15 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use for days in available_periods: try: pricing = await pricing_engine.calculate_renewal_price( - db, subscription, days, user=db_user, + db, + subscription, + days, + user=db_user, ) # original = price before ALL discounts, final = price with all discounts total_original_price = ( - pricing.base_price + pricing.servers_price - + pricing.traffic_price + pricing.devices_price + pricing.base_price + pricing.servers_price + pricing.traffic_price + pricing.devices_price ) renewal_prices[days] = { @@ -1742,7 +1744,10 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us try: pricing_engine = PricingEngine() pricing = await pricing_engine.calculate_renewal_price( - db, subscription, days, user=db_user, + db, + subscription, + days, + user=db_user, ) price = pricing.final_total @@ -1987,10 +1992,7 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us success_message += f'\n\n📊 Трафик сброшен до {fixed_limit} ГБ' if promo_offer_discount > 0: - success_message += ( - f' (включая доп. скидку {offer_pct}%:' - f' -{texts.format_price(promo_offer_discount)})' - ) + success_message += f' (включая доп. скидку {offer_pct}%: -{texts.format_price(promo_offer_discount)})' await callback.message.edit_text(success_message, reply_markup=get_back_keyboard(db_user.language)) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 78d2632b..c2944123 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -1057,7 +1057,10 @@ class MonitoringService: pricing_engine = PricingEngine() pricing = await pricing_engine.calculate_renewal_price( - db, subscription, autopay_period, user=user, + db, + subscription, + autopay_period, + user=user, ) renewal_cost = pricing.final_total except Exception as e: diff --git a/app/services/pricing_engine.py b/app/services/pricing_engine.py index 132cf5b3..bc6f95d3 100644 --- a/app/services/pricing_engine.py +++ b/app/services/pricing_engine.py @@ -6,8 +6,8 @@ import structlog from app.config import CLASSIC_PERIOD_PRICES, PERIOD_PRICES, settings from app.database.crud.server_squad import get_server_squad_by_uuid -from app.utils.promo_offer import get_user_active_promo_discount_percent from app.utils.pricing_utils import calculate_months_from_days +from app.utils.promo_offer import get_user_active_promo_discount_percent logger = structlog.get_logger() diff --git a/app/services/recurrent_payment_service.py b/app/services/recurrent_payment_service.py index 86a58c87..1edd27f4 100644 --- a/app/services/recurrent_payment_service.py +++ b/app/services/recurrent_payment_service.py @@ -228,7 +228,10 @@ async def _process_single_subscription( pricing_engine = PricingEngine() pricing = await pricing_engine.calculate_renewal_price( - db, subscription, autopay_period, user=user, + db, + subscription, + autopay_period, + user=user, ) renewal_cost = pricing.final_total except Exception as e: diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 1d761ce7..338bb0b8 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -240,7 +240,10 @@ async def _prepare_auto_extend_context( pricing_engine = PricingEngine() try: pricing = await pricing_engine.calculate_renewal_price( - db, subscription, period_days, user=user, + db, + subscription, + period_days, + user=user, ) price_kopeks = pricing.final_total except Exception as e: @@ -1730,7 +1733,10 @@ async def try_auto_extend_expired_after_topup( pricing_engine = PricingEngine() try: pricing = await pricing_engine.calculate_renewal_price( - db, subscription, period_days, user=user, + db, + subscription, + period_days, + user=user, ) renewal_cost = pricing.final_total except Exception as error: diff --git a/app/services/subscription_renewal_service.py b/app/services/subscription_renewal_service.py index 32b09295..31520d19 100644 --- a/app/services/subscription_renewal_service.py +++ b/app/services/subscription_renewal_service.py @@ -464,8 +464,7 @@ class SubscriptionRenewalService: # Support both SubscriptionRenewalPricing and RenewalPricing consume_promo_offer = bool( - getattr(pricing, 'promo_discount_value', None) - or getattr(pricing, 'promo_offer_discount', None) + getattr(pricing, 'promo_discount_value', None) or getattr(pricing, 'promo_offer_discount', None) ) description_text = description or f'Продление подписки на {period_days} дней' diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 4bfdcfb0..0aee28d1 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -4516,131 +4516,97 @@ async def _prepare_subscription_renewal_options( user: User, subscription: Subscription, ) -> tuple[list[MiniAppSubscriptionRenewalPeriod], dict[str | int, dict[str, Any]], str | None]: + from app.services.pricing_engine import PricingEngine + option_payloads: list[tuple[MiniAppSubscriptionRenewalPeriod, dict[str, Any]]] = [] - # Проверяем, есть ли у подписки тариф (режим тарифов) + # Определяем доступные периоды: из тарифа или из настроек tariff_id = getattr(subscription, 'tariff_id', None) tariff = None if tariff_id: - from app.database.crud.tariff import get_tariff_by_id - tariff = await get_tariff_by_id(db, tariff_id) if tariff and tariff.period_prices: - # Режим тарифов: используем периоды и цены из тарифа - promo_group = ( - user.get_primary_promo_group() - if hasattr(user, 'get_primary_promo_group') - else getattr(user, 'promo_group', None) + available_periods = sorted(int(k) for k in tariff.period_prices.keys()) + else: + available_periods = [p for p in settings.get_available_renewal_periods() if p > 0] + + pricing_engine = PricingEngine() + for period_days in available_periods: + try: + pricing_result = await pricing_engine.calculate_renewal_price( + db, + subscription, + period_days, + user=user, + ) + except Exception as error: # pragma: no cover - defensive logging + logger.warning( + 'Failed to calculate renewal pricing for subscription (period)', + subscription_id=subscription.id, + period_days=period_days, + error=error, + ) + continue + + # Вычисляем оригинальную цену (до скидок) для отображения зачёркнутой цены + original_price = ( + pricing_result.base_price + + pricing_result.servers_price + + pricing_result.traffic_price + + pricing_result.devices_price + + pricing_result.promo_group_discount + + pricing_result.promo_offer_discount + ) + has_discount = original_price > pricing_result.final_total and original_price > 0 + discount_percent = ( + int((original_price - pricing_result.final_total) * 100 / original_price) if has_discount else 0 ) - # Получаем скидки промогруппы по периодам - period_discounts = {} - if promo_group: - raw_discounts = getattr(promo_group, 'period_discounts', None) or {} - for k, v in raw_discounts.items(): - try: - period_discounts[int(k)] = max(0, min(100, int(v))) - except (TypeError, ValueError): - pass + months = max(1, period_days // 30) + per_month = pricing_result.final_total // months if months > 0 else pricing_result.final_total - for period_str, original_price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])): - period_days = int(period_str) + label = format_period_description( + period_days, + getattr(user, 'language', settings.DEFAULT_LANGUAGE), + ) - # Применяем скидку промогруппы - discount_percent = period_discounts.get(period_days, 0) - if discount_percent > 0: - price_kopeks = int(original_price_kopeks * (100 - discount_percent) / 100) - else: - price_kopeks = original_price_kopeks + price_label = settings.format_price(pricing_result.final_total) + original_label = settings.format_price(original_price) if has_discount else None + per_month_label = settings.format_price(per_month) - months = max(1, period_days // 30) - per_month = price_kopeks // months if months > 0 else price_kopeks + period_id = ( + f'tariff_{tariff.id}_{period_days}' if pricing_result.is_tariff_mode and tariff else f'days:{period_days}' + ) - label = format_period_description( - period_days, - getattr(user, 'language', settings.DEFAULT_LANGUAGE), - ) + option_model = MiniAppSubscriptionRenewalPeriod( + id=period_id, + days=period_days, + months=months, + price_kopeks=pricing_result.final_total, + price_label=price_label, + original_price_kopeks=original_price if has_discount else None, + original_price_label=original_label, + discount_percent=discount_percent, + price_per_month_kopeks=per_month, + price_per_month_label=per_month_label, + title=label, + ) - price_label = settings.format_price(price_kopeks) - original_label = settings.format_price(original_price_kopeks) if discount_percent > 0 else None - per_month_label = settings.format_price(per_month) + pricing = { + 'period_id': period_id, + 'period_days': period_days, + 'months': months, + 'final_total': pricing_result.final_total, + 'base_original_total': original_price if has_discount else pricing_result.final_total, + 'overall_discount_percent': discount_percent, + 'per_month': per_month, + 'promo_offer_discount': pricing_result.promo_offer_discount, + } + if pricing_result.is_tariff_mode and tariff: + pricing['tariff_id'] = tariff.id - option_model = MiniAppSubscriptionRenewalPeriod( - id=f'tariff_{tariff.id}_{period_days}', - days=period_days, - months=months, - price_kopeks=price_kopeks, - price_label=price_label, - original_price_kopeks=original_price_kopeks if discount_percent > 0 else None, - original_price_label=original_label, - discount_percent=discount_percent, - price_per_month_kopeks=per_month, - price_per_month_label=per_month_label, - title=label, - ) - - pricing = { - 'period_id': option_model.id, - 'period_days': period_days, - 'months': months, - 'final_total': price_kopeks, - 'base_original_total': original_price_kopeks if discount_percent > 0 else price_kopeks, - 'overall_discount_percent': discount_percent, - 'per_month': per_month, - 'tariff_id': tariff.id, - } - - option_payloads.append((option_model, pricing)) - else: - # Классический режим: используем периоды из настроек - available_periods = [period for period in settings.get_available_renewal_periods() if period > 0] - - for period_days in available_periods: - try: - pricing_model = await _calculate_subscription_renewal_pricing( - db, - user, - subscription, - period_days, - ) - pricing = pricing_model.to_payload() - except Exception as error: # pragma: no cover - defensive logging - logger.warning( - 'Failed to calculate renewal pricing for subscription (period)', - subscription_id=subscription.id, - period_days=period_days, - error=error, - ) - continue - - label = format_period_description( - period_days, - getattr(user, 'language', settings.DEFAULT_LANGUAGE), - ) - - price_label = settings.format_price(pricing['final_total']) - original_label = None - if pricing['base_original_total'] and pricing['base_original_total'] != pricing['final_total']: - original_label = settings.format_price(pricing['base_original_total']) - - per_month_label = settings.format_price(pricing['per_month']) - - option_model = MiniAppSubscriptionRenewalPeriod( - id=pricing['period_id'], - days=period_days, - months=pricing['months'], - price_kopeks=pricing['final_total'], - price_label=price_label, - original_price_kopeks=pricing['base_original_total'], - original_price_label=original_label, - discount_percent=pricing['overall_discount_percent'], - price_per_month_kopeks=pricing['per_month'], - price_per_month_label=per_month_label, - title=label, - ) - - option_payloads.append((option_model, pricing)) + option_payloads.append((option_model, pricing)) if not option_payloads: return [], {}, None diff --git a/docs/plans/2026-02-25-rbac-design.md b/docs/plans/2026-02-25-rbac-design.md new file mode 100644 index 00000000..6c015744 --- /dev/null +++ b/docs/plans/2026-02-25-rbac-design.md @@ -0,0 +1,243 @@ +# RBAC + ABAC Design for Bedolaga Cabinet + +**Date:** 2026-02-25 +**Status:** Approved +**Approach:** Hybrid RBAC + ABAC (Attribute-Based Access Control) + +## Overview + +Full role-based access control with attribute-based policies for the Telegram bot admin cabinet. Replaces the current binary `isAdmin` check (ADMIN_IDS env var) with granular permissions, hierarchical roles, ABAC policy engine, and comprehensive audit logging. + +## Architecture Decisions + +- **Hierarchy:** superadmin > admin > moderator (managed via `level` field) +- **Management:** Through cabinet UI only (no invite links) +- **Audit logging:** ALL admin API calls (GET included) +- **Role templates:** 5 presets (Superadmin, Admin, Moderator, Marketer, Support) + custom roles +- **Assignment:** Via UI, superadmin/admin assigns roles to users from the user list + +## Data Model + +### Tables + +**`admin_roles`** — role definitions with permission groups + +| Column | Type | Description | +|--------|------|-------------| +| id | SERIAL PK | | +| name | VARCHAR(100) UNIQUE | "Moderator", "Marketer" | +| description | TEXT | Human-readable role description | +| level | INTEGER DEFAULT 0 | Hierarchy: 0=viewer, 50=moderator, 100=admin, 999=superadmin | +| permissions | JSONB | ["users:read", "tickets:*", ...] | +| color | VARCHAR(7) | HEX badge color for UI | +| icon | VARCHAR(50) | Icon name for UI | +| is_system | BOOLEAN DEFAULT false | System role, cannot be deleted | +| is_active | BOOLEAN DEFAULT true | Soft disable | +| created_by | BIGINT FK users.id | | +| created_at | TIMESTAMPTZ | | +| updated_at | TIMESTAMPTZ | | + +**`user_roles`** — M2M user-to-role assignment + +| Column | Type | Description | +|--------|------|-------------| +| id | SERIAL PK | | +| user_id | BIGINT FK users.id | | +| role_id | INTEGER FK admin_roles.id | | +| assigned_by | BIGINT FK users.id | Who assigned | +| assigned_at | TIMESTAMPTZ | | +| expires_at | TIMESTAMPTZ NULL | Temporary role (vacation cover, etc.) | +| is_active | BOOLEAN DEFAULT true | | +| UNIQUE(user_id, role_id) | | | + +**`access_policies`** — ABAC attribute-based policies + +| Column | Type | Description | +|--------|------|-------------| +| id | SERIAL PK | | +| name | VARCHAR(200) | Policy name | +| description | TEXT | | +| role_id | INTEGER FK admin_roles.id NULL | Bound to role or global | +| priority | INTEGER DEFAULT 0 | Evaluation order | +| effect | VARCHAR(10) | "allow" or "deny" | +| conditions | JSONB | Attribute conditions (see format below) | +| resource | VARCHAR(100) | "users", "tickets", "*" | +| actions | JSONB | ["read", "edit"] or ["*"] | +| is_active | BOOLEAN DEFAULT true | | +| created_by | BIGINT FK users.id | | +| created_at | TIMESTAMPTZ | | + +**Conditions JSONB format:** +```json +{ + "time_range": {"start": "09:00", "end": "18:00", "timezone": "Europe/Moscow"}, + "ip_whitelist": ["192.168.1.0/24"], + "max_actions_per_hour": 100, + "require_2fa": true, + "user_attributes": {"status": ["active"]} +} +``` + +**`admin_audit_log`** — immutable action log (INSERT only) + +| Column | Type | Description | +|--------|------|-------------| +| id | BIGSERIAL PK | | +| user_id | BIGINT FK users.id | Who acted | +| action | VARCHAR(100) | "users:edit", "roles:create" | +| resource_type | VARCHAR(50) | "user", "role", "ticket" | +| resource_id | VARCHAR(100) NULL | ID of affected resource | +| details | JSONB | Before/after diff | +| ip_address | INET | | +| user_agent | TEXT | | +| status | VARCHAR(20) | "success", "denied", "error" | +| request_method | VARCHAR(10) | GET/POST/PUT/DELETE | +| request_path | TEXT | | +| created_at | TIMESTAMPTZ | | + +### Permission Registry + +Format: `section:action`. Wildcard: `section:*` (all actions), `*:*` (superadmin). + +| Section | Actions | +|---------|---------| +| 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, 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, ban, unban | +| wheel | read, edit | +| apps | read, edit | +| email_templates | read, edit | +| pinned_messages | read, create, edit, delete | +| updates | read, manage | + +### Preset Roles + +1. **Superadmin** (level 999, system): `*:*` +2. **Admin** (level 100, system): all except `roles:delete` on system roles +3. **Moderator** (level 50): `users:read,edit,block`, `tickets:*`, `ban_system:*` +4. **Marketer** (level 30): `campaigns:*`, `broadcasts:*`, `promocodes:*`, `promo_offers:*`, `stats:read`, `pinned_messages:*` +5. **Support** (level 20): `tickets:read,reply`, `users:read` + +## Backend Architecture + +### Policy Engine (PermissionService) + +Evaluation flow: +1. Get user roles (active, not expired) +2. Merge all permissions from roles +3. Check requested permission (exact match or wildcard) +4. If access_policies exist → evaluate conditions (time, IP, rate limit) +5. Deny policies take priority over allow +6. Return: allow/deny + reason + +### FastAPI Dependency + +Replace `get_current_admin_user` with parameterized `require_permission()`: + +```python +def require_permission(*permissions: str): + async def dependency(user=Depends(get_current_cabinet_user), ...): + # Check via PermissionService + # Log to audit_log + # Return user if ok, else 403 + return dependency +``` + +### JWT Enhancement + +Add to JWT payload: +```json +{ + "permissions": ["users:read", "users:edit", "tickets:*"], + "role_level": 50, + "roles": ["Moderator"] +} +``` + +### New API Endpoints + +``` +GET /cabinet/admin/roles — list roles +POST /cabinet/admin/roles — create role +PUT /cabinet/admin/roles/:id — update role +DELETE /cabinet/admin/roles/:id — delete (non-system) +GET /cabinet/admin/roles/users — users with roles +POST /cabinet/admin/roles/assign — assign role to user +DELETE /cabinet/admin/roles/assign/:id — revoke role +GET /cabinet/admin/policies — list policies +POST /cabinet/admin/policies — create policy +PUT /cabinet/admin/policies/:id — update policy +DELETE /cabinet/admin/policies/:id — delete policy +GET /cabinet/admin/audit-log — log with filters +GET /cabinet/admin/audit-log/export — CSV/JSON export +GET /cabinet/auth/me/permissions — current user permissions +``` + +### Audit Middleware + +Logs every request to `/admin/*`: user_id, action, resource, details, IP, status. + +### Hierarchy Rule + +Users can only manage roles with `level` lower than their own. + +## Frontend Architecture + +### Permission Store (Zustand) + +`usePermissionStore`: +- State: permissions[], roles[], roleLevel, isLoading +- Actions: fetchPermissions(), hasPermission(), hasAnyPermission(), hasAllPermissions(), canManageRole() + +### Route Guards + +`PermissionRoute` component replaces `AdminRoute` with permission parameter. + +### PermissionGate Component + +Hides/shows UI elements based on permissions with optional fallback. + +### New Pages + +1. **AdminRoles** — role CRUD with permission matrix +2. **AdminRoleAssign** — assign roles to users +3. **AdminPolicies** — ABAC policy management with visual condition builder +4. **AdminAuditLog** — filterable timeline with before/after diffs + +### i18n + +New keys in all 4 locales (ru, en, zh, fa). + +## Migration Strategy + +1. Auto-create Superadmin role for users from ADMIN_IDS/ADMIN_EMAILS +2. `get_current_admin_user` stays backward-compatible via `require_permission("*:*")` +3. Gradual route migration to `require_permission()` +4. `is_admin` endpoint returns true if user has ANY role (level > 0) + +## Security + +- Backend always re-validates (JWT is UI hint only) +- Rate limiting on RBAC endpoints +- Deny policies win over allow +- Cannot delete last superadmin +- Cannot lower own level +- Audit log is immutable (INSERT only, no UPDATE/DELETE) diff --git a/docs/plans/2026-02-25-rbac-implementation.md b/docs/plans/2026-02-25-rbac-implementation.md new file mode 100644 index 00000000..a4f13d44 --- /dev/null +++ b/docs/plans/2026-02-25-rbac-implementation.md @@ -0,0 +1,1852 @@ +# RBAC + ABAC Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace binary `isAdmin` with granular RBAC+ABAC: roles, permissions, policies, audit logging across backend and frontend. + +**Architecture:** Flat permission model (`section:action` strings) with ABAC policy overlay. Roles group permissions. Hierarchy via `level` field. JWT carries permissions for frontend hints. Backend always re-validates. + +**Tech Stack:** Python 3.13 / FastAPI / SQLAlchemy 2.x async / Alembic (backend), React 19 / TypeScript / Zustand / Tailwind / Radix UI (frontend) + +**Key Paths:** +- Backend: `/Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot/` +- Frontend: `/Users/ea/Desktop/DEV/bedolaga-cabinet/` + +--- + +## Phase 1: Database Models & Migration (Backend) + +### Task 1: Create SQLAlchemy models for RBAC tables + +**Files:** +- Modify: `/Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot/app/database/models.py` (append after existing models) + +**Step 1: Add AdminRole model** + +Add after the last model class in `models.py`: + +```python +class AdminRole(Base): + __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(BigInteger, ForeignKey('users.id'), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + + creator = relationship('User', foreign_keys=[created_by]) + user_roles = relationship('UserRole', back_populates='role', lazy='selectin') +``` + +**Step 2: Add UserRole model** + +```python +class UserRole(Base): + __tablename__ = 'user_roles' + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + role_id = Column(Integer, ForeignKey('admin_roles.id', ondelete='CASCADE'), nullable=False) + assigned_by = Column(BigInteger, ForeignKey('users.id'), nullable=True) + assigned_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + expires_at = Column(DateTime(timezone=True), 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]) +``` + +**Step 3: Add AccessPolicy model** + +```python +class AccessPolicy(Base): + __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" or "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(BigInteger, ForeignKey('users.id'), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + role = relationship('AdminRole') + creator = relationship('User', foreign_keys=[created_by]) +``` + +**Step 4: Add AdminAuditLog model** + +```python +class AdminAuditLog(Base): + __tablename__ = 'admin_audit_log' + + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(BigInteger, 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) # Use String for INET compat + 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(DateTime(timezone=True), server_default=func.now(), nullable=False) + + user = relationship('User') + + __table_args__ = ( + Index('ix_audit_log_user_created', 'user_id', 'created_at'), + Index('ix_audit_log_resource', 'resource_type', 'resource_id'), + Index('ix_audit_log_created', 'created_at'), + ) +``` + +**Step 5: Add relationship to User model** + +Find the `User` class in `models.py` and add: + +```python +admin_roles_rel = relationship('UserRole', back_populates='user', lazy='selectin') +``` + +**Step 6: Commit** + +```bash +git add app/database/models.py +git commit -m "feat: add RBAC database models (AdminRole, UserRole, AccessPolicy, AdminAuditLog)" +``` + +--- + +### Task 2: Create Alembic migration + +**Files:** +- Create: `migrations/alembic/versions/xxxx_add_rbac_tables.py` (generated by alembic) + +**Step 1: Generate migration** + +```bash +cd /Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot +make migration m="add_rbac_tables" +``` + +**Step 2: Edit migration to add preset roles seed data** + +In the generated migration file, add to the `upgrade()` function after table creation: + +```python +# Seed preset roles +op.execute(""" + INSERT INTO admin_roles (name, description, level, permissions, color, icon, is_system, is_active, created_at, updated_at) + VALUES + ('Superadmin', 'Full system access', 999, '["*:*"]'::jsonb, '#EF4444', 'shield', true, true, NOW(), NOW()), + ('Admin', 'Administrative access', 100, '["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:*"]'::jsonb, '#F59E0B', 'crown', true, true, NOW(), NOW()), + ('Moderator', 'User and ticket management', 50, '["users:read", "users:edit", "users:block", "tickets:*", "ban_system:*"]'::jsonb, '#3B82F6', 'user-shield', true, true, NOW(), NOW()), + ('Marketer', 'Marketing tools access', 30, '["campaigns:*", "broadcasts:*", "promocodes:*", "promo_offers:*", "promo_groups:*", "stats:read", "pinned_messages:*", "wheel:*"]'::jsonb, '#8B5CF6', 'megaphone', true, true, NOW(), NOW()), + ('Support', 'Ticket support access', 20, '["tickets:read", "tickets:reply", "users:read"]'::jsonb, '#10B981', 'headset', true, true, NOW(), NOW()) + ON CONFLICT (name) DO NOTHING; +""") +``` + +**Step 3: Run migration** + +```bash +make migrate +``` + +**Step 4: Commit** + +```bash +git add migrations/ +git commit -m "feat: alembic migration for RBAC tables with preset roles" +``` + +--- + +## Phase 2: Backend CRUD Layer + +### Task 3: Create RBAC CRUD operations + +**Files:** +- Create: `/Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot/app/database/crud/rbac.py` + +**Step 1: Write RBAC CRUD** + +```python +from __future__ import annotations + +from datetime import UTC, datetime + +import structlog +from sqlalchemy import delete, func, select, update +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() + + +class AdminRoleCRUD: + @staticmethod + async def get_all(db: AsyncSession, *, include_inactive: bool = False) -> list[AdminRole]: + 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, icon: str | None, + is_system: bool = False, created_by: int | 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() + return role + + @staticmethod + async def update(db: AsyncSession, role_id: int, **kwargs) -> AdminRole | None: + role = await AdminRoleCRUD.get_by_id(db, role_id) + if not role: + return None + for key, value in kwargs.items(): + if hasattr(role, key): + setattr(role, key, value) + role.updated_at = datetime.now(UTC) + await db.flush() + return role + + @staticmethod + async def delete(db: AsyncSession, role_id: int) -> bool: + role = await AdminRoleCRUD.get_by_id(db, role_id) + if not role or role.is_system: + return False + 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() + return True + + @staticmethod + async def count_users(db: AsyncSession, role_id: int) -> int: + result = await db.execute( + select(func.count()).select_from(UserRole) + .where(UserRole.role_id == role_id, UserRole.is_active.is_(True)) + ) + return result.scalar_one() + + +class UserRoleCRUD: + @staticmethod + async def get_user_roles(db: AsyncSession, user_id: int) -> list[UserRole]: + 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]: + """Returns (permissions, role_names, max_level).""" + roles = await UserRoleCRUD.get_user_roles(db, user_id) + now = datetime.now(UTC) + permissions: set[str] = set() + role_names: list[str] = [] + max_level = 0 + for ur in roles: + if ur.expires_at and ur.expires_at < now: + continue + if ur.role and ur.role.is_active: + permissions.update(ur.role.permissions or []) + role_names.append(ur.role.name) + max_level = max(max_level, ur.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, expires_at: datetime | None = None) -> UserRole: + existing = await db.execute( + select(UserRole).where(UserRole.user_id == user_id, UserRole.role_id == role_id) + ) + ur = existing.scalar_one_or_none() + if ur: + ur.is_active = True + ur.assigned_by = assigned_by + ur.expires_at = expires_at + ur.assigned_at = datetime.now(UTC) + await db.flush() + return ur + ur = UserRole( + user_id=user_id, role_id=role_id, + assigned_by=assigned_by, expires_at=expires_at, + ) + db.add(ur) + await db.flush() + return ur + + @staticmethod + async def revoke_role(db: AsyncSession, user_role_id: int) -> bool: + result = await db.execute( + update(UserRole).where(UserRole.id == user_role_id).values(is_active=False) + ) + await db.flush() + return result.rowcount > 0 + + @staticmethod + async def get_all_admins(db: AsyncSession, *, limit: int = 100, offset: int = 0) -> list[dict]: + """Get all users with any active role.""" + stmt = ( + select(User, func.array_agg(AdminRole.name).label('role_names')) + .join(UserRole, UserRole.user_id == User.id) + .join(AdminRole, AdminRole.id == UserRole.role_id) + .where(UserRole.is_active.is_(True), AdminRole.is_active.is_(True)) + .group_by(User.id) + .order_by(User.id) + .limit(limit).offset(offset) + ) + result = await db.execute(stmt) + return [{'user': row[0], 'role_names': row[1]} for row in result.all()] + + @staticmethod + async def get_superadmin_count(db: AsyncSession) -> int: + result = await db.execute( + select(func.count()).select_from(UserRole) + .join(AdminRole, AdminRole.id == UserRole.role_id) + .where(UserRole.is_active.is_(True), AdminRole.level == 999) + ) + return result.scalar_one() + + +class AccessPolicyCRUD: + @staticmethod + async def get_all(db: AsyncSession, *, role_id: int | None = None) -> list[AccessPolicy]: + 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) -> AccessPolicy: + policy = AccessPolicy(**kwargs) + db.add(policy) + await db.flush() + return policy + + @staticmethod + async def update(db: AsyncSession, policy_id: int, **kwargs) -> AccessPolicy | None: + policy = await AccessPolicyCRUD.get_by_id(db, policy_id) + if not policy: + return None + for key, value in kwargs.items(): + if hasattr(policy, key): + setattr(policy, key, value) + await db.flush() + return policy + + @staticmethod + async def delete(db: AsyncSession, policy_id: int) -> bool: + result = await db.execute(delete(AccessPolicy).where(AccessPolicy.id == policy_id)) + await db.flush() + return result.rowcount > 0 + + @staticmethod + async def get_policies_for_user(db: AsyncSession, role_ids: list[int]) -> list[AccessPolicy]: + stmt = ( + select(AccessPolicy) + .where( + AccessPolicy.is_active.is_(True), + 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: + @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: + log = 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(log) + await db.flush() + return log + + @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]: + stmt = select(AdminAuditLog).order_by(AdminAuditLog.created_at.desc()) + count_stmt = select(func.count()).select_from(AdminAuditLog) + + filters = [] + if user_id: + filters.append(AdminAuditLog.user_id == user_id) + if action: + filters.append(AdminAuditLog.action.ilike(f'%{action}%')) + if resource_type: + filters.append(AdminAuditLog.resource_type == resource_type) + if status: + filters.append(AdminAuditLog.status == status) + if date_from: + filters.append(AdminAuditLog.created_at >= date_from) + if date_to: + filters.append(AdminAuditLog.created_at <= date_to) + + for f in filters: + stmt = stmt.where(f) + count_stmt = count_stmt.where(f) + + total = (await db.execute(count_stmt)).scalar_one() + result = await db.execute(stmt.limit(limit).offset(offset)) + return list(result.scalars().all()), total +``` + +**Step 2: Commit** + +```bash +git add app/database/crud/rbac.py +git commit -m "feat: add RBAC CRUD operations (roles, user_roles, policies, audit_log)" +``` + +--- + +## Phase 3: Permission Engine (Backend) + +### Task 4: Create permission registry and service + +**Files:** +- Create: `/Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot/app/services/permission_service.py` + +**Step 1: Write permission registry and evaluation engine** + +```python +from __future__ import annotations + +import ipaddress +from datetime import UTC, datetime +from fnmatch import fnmatch + +import structlog +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.rbac import AccessPolicyCRUD, AuditLogCRUD, UserRoleCRUD +from app.database.models import AccessPolicy, User + +logger = structlog.get_logger() + +# ── Permission Registry ────────────────────────────────────────────── +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', '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', 'ban', 'unban'], + 'wheel': ['read', 'edit'], + 'apps': ['read', 'edit'], + 'email_templates': ['read', 'edit'], + 'pinned_messages': ['read', 'create', 'edit', 'delete'], + 'updates': ['read', 'manage'], +} + + +def get_all_permissions() -> list[str]: + """Return flat list of all valid permissions.""" + result = [] + for section, actions in PERMISSION_REGISTRY.items(): + for action in actions: + result.append(f'{section}:{action}') + return result + + +def permission_matches(user_perm: str, required_perm: str) -> bool: + """Check if user_perm grants access for required_perm. Supports wildcards.""" + if user_perm == '*:*': + return True + return fnmatch(required_perm, user_perm) + + +class PermissionService: + @staticmethod + async def check_permission( + db: AsyncSession, + user: User, + required_permission: str, + *, + ip_address: str | None = None, + ) -> tuple[bool, str]: + """ + Check if user has the required permission. + Returns (allowed: bool, reason: str). + """ + # Get user permissions from roles + permissions, role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user.id) + + # Check if any permission matches + has_base_permission = any( + permission_matches(p, required_permission) for p in permissions + ) + + if not has_base_permission: + return False, f'Permission {required_permission} not granted by roles: {role_names}' + + # Get user's role IDs for policy lookup + user_roles = await UserRoleCRUD.get_user_roles(db, user.id) + role_ids = [ur.role_id for ur in user_roles if ur.role and ur.role.is_active] + + # Evaluate ABAC policies + policies = await AccessPolicyCRUD.get_policies_for_user(db, role_ids) + if not policies: + return True, 'Granted by role permissions' + + section = required_permission.split(':')[0] if ':' in required_permission else required_permission + action = required_permission.split(':')[1] if ':' in required_permission else '*' + + for policy in sorted(policies, key=lambda p: p.priority, reverse=True): + if not _policy_matches_resource(policy, section, action): + continue + + conditions_met = _evaluate_conditions(policy.conditions, ip_address=ip_address) + + if policy.effect == 'deny' and conditions_met: + return False, f'Denied by policy: {policy.name}' + if policy.effect == 'allow' and not conditions_met: + return False, f'Conditions not met for policy: {policy.name}' + + return True, 'Granted' + + @staticmethod + async def get_user_permissions(db: AsyncSession, user_id: int) -> dict: + """Get permissions summary for a user (used by /me/permissions endpoint).""" + 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: + """Write audit log entry.""" + 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, + ) + + +def _policy_matches_resource(policy: AccessPolicy, section: str, action: str) -> bool: + """Check if policy applies to the given resource and action.""" + if policy.resource != '*' and policy.resource != section: + return False + policy_actions = policy.actions or [] + if '*' in policy_actions: + return True + return action in policy_actions + + +def _evaluate_conditions(conditions: dict | None, *, ip_address: str | None = None) -> bool: + """Evaluate ABAC conditions. Returns True if all conditions are met.""" + if not conditions: + return True + + now = datetime.now(UTC) + + # Time range check + if 'time_range' in conditions: + tr = conditions['time_range'] + start_h, start_m = map(int, tr['start'].split(':')) + end_h, end_m = map(int, tr['end'].split(':')) + current_minutes = now.hour * 60 + now.minute + start_minutes = start_h * 60 + start_m + end_minutes = end_h * 60 + end_m + if start_minutes <= end_minutes: + if not (start_minutes <= current_minutes <= end_minutes): + return False + else: # overnight range + if end_minutes < current_minutes < start_minutes: + return False + + # IP whitelist check + if 'ip_whitelist' in conditions and ip_address: + try: + client_ip = ipaddress.ip_address(ip_address) + allowed = False + for network_str in conditions['ip_whitelist']: + if '/' in network_str: + if client_ip in ipaddress.ip_network(network_str, strict=False): + allowed = True + break + elif str(client_ip) == network_str: + allowed = True + break + if not allowed: + return False + except ValueError: + pass + + return True +``` + +**Step 2: Commit** + +```bash +git add app/services/permission_service.py +git commit -m "feat: add PermissionService with ABAC policy engine and permission registry" +``` + +--- + +### Task 5: Create require_permission FastAPI dependency + +**Files:** +- Modify: `/Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot/app/cabinet/dependencies.py` + +**Step 1: Add require_permission dependency function** + +Add after the existing `get_current_admin_user` function (after line ~250): + +```python +from app.services.permission_service import PermissionService + + +def require_permission(*permissions: str): + """ + FastAPI dependency factory that checks user has required permissions. + Usage: Depends(require_permission("users:read")) + """ + async def dependency( + request: Request, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), + ) -> User: + 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: + # Log denied action + 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 + action = permissions[0] if permissions else 'unknown' + await PermissionService.log_action( + db, + user_id=user.id, + action=action, + 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 +``` + +**Step 2: Add Request import** + +At the top of dependencies.py, add: + +```python +from fastapi import Request +``` + +**Step 3: Update get_current_admin_user for backward compatibility** + +Replace the existing `get_current_admin_user` function body to also check RBAC: + +```python +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 both legacy ADMIN_IDS config AND RBAC roles. + """ + # Legacy check: config-based admin + is_legacy_admin = settings.is_admin( + telegram_id=user.telegram_id, + email=user.email if user.email_verified else None, + ) + + if is_legacy_admin: + return user + + # RBAC check: user has any active role + 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', + ) +``` + +**Step 4: Commit** + +```bash +git add app/cabinet/dependencies.py +git commit -m "feat: add require_permission dependency and update get_current_admin_user for RBAC" +``` + +--- + +### Task 6: Enhance JWT with permissions + +**Files:** +- Modify: `/Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot/app/cabinet/auth/jwt_handler.py` + +**Step 1: Update create_access_token signature** + +Change the function to accept optional permissions data: + +```python +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: +``` + +Add to payload before `jwt.encode()`: + +```python +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 +``` + +**Step 2: Update all callers of create_access_token** + +Find all callers (in `auth.py`, `oauth.py`) — they pass positional args `(user.id, user.telegram_id)`. These continue to work because the new params are keyword-only. + +For the auth routes that create tokens, add permission loading. In `/Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot/app/cabinet/routes/auth.py`, after the user is loaded and before token creation, add: + +```python +from app.database.crud.rbac import UserRoleCRUD + +# Load permissions for JWT +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, +) +``` + +This needs to be done in every login endpoint: `login_telegram`, `login_telegram_widget`, `login_email`, `oauth_callback`, `refresh_token`. + +**Step 3: Commit** + +```bash +git add app/cabinet/auth/jwt_handler.py app/cabinet/routes/auth.py app/cabinet/routes/oauth.py +git commit -m "feat: embed permissions, roles, role_level in JWT access token" +``` + +--- + +## Phase 4: Backend RBAC API Routes + +### Task 7: Create RBAC management routes + +**Files:** +- Create: `/Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot/app/cabinet/routes/admin_roles.py` + +**Step 1: Write role management endpoints** + +Create the full file with these endpoints: + +``` +GET /admin/roles — list all roles (require: roles:read) +POST /admin/roles — create role (require: roles:create) +PUT /admin/roles/{id} — update role (require: roles:edit) +DELETE /admin/roles/{id} — delete role (require: roles:delete) +GET /admin/roles/permissions — get permission registry (require: roles:read) +GET /admin/roles/users — list users with roles (require: roles:read) +POST /admin/roles/assign — assign role (require: roles:assign) +DELETE /admin/roles/assign/{id} — revoke role (require: roles:assign) +``` + +Each endpoint uses `Depends(require_permission("roles:xxx"))`. Include: +- Pydantic request/response models (inline in file) +- Level hierarchy enforcement (can't assign roles >= own level) +- Cannot delete system roles +- Cannot remove last superadmin + +**Step 2: Commit** + +```bash +git add app/cabinet/routes/admin_roles.py +git commit -m "feat: add admin role management API routes" +``` + +--- + +### Task 8: Create access policy management routes + +**Files:** +- Create: `/Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot/app/cabinet/routes/admin_policies.py` + +**Step 1: Write policy CRUD endpoints** + +``` +GET /admin/policies — list policies (require: roles:read) +POST /admin/policies — create policy (require: roles:create) +PUT /admin/policies/{id} — update policy (require: roles:edit) +DELETE /admin/policies/{id} — delete policy (require: roles:delete) +``` + +**Step 2: Commit** + +```bash +git add app/cabinet/routes/admin_policies.py +git commit -m "feat: add ABAC policy management API routes" +``` + +--- + +### Task 9: Create audit log routes + +**Files:** +- Create: `/Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot/app/cabinet/routes/admin_audit_log.py` + +**Step 1: Write audit log endpoints** + +``` +GET /admin/audit-log — list logs with filters (require: audit_log:read) +GET /admin/audit-log/export — CSV export (require: audit_log:export) +GET /admin/audit-log/stats — action stats summary (require: audit_log:read) +``` + +Filters: user_id, action, resource_type, status, date_from, date_to. Pagination with limit/offset. + +**Step 2: Commit** + +```bash +git add app/cabinet/routes/admin_audit_log.py +git commit -m "feat: add audit log API routes with filtering and export" +``` + +--- + +### Task 10: Add permissions endpoint and update is-admin + +**Files:** +- Modify: `/Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot/app/cabinet/routes/auth.py` + +**Step 1: Add GET /cabinet/auth/me/permissions endpoint** + +```python +@router.get('/me/permissions') +async def get_my_permissions( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + from app.services.permission_service import PermissionService + return await PermissionService.get_user_permissions(db, user.id) +``` + +**Step 2: Update is-admin endpoint to check RBAC** + +Find the existing `is-admin` endpoint and update it to also check RBAC roles (not just config ADMIN_IDS): + +```python +@router.get('/me/is-admin') +async def check_is_admin( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + is_legacy = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None) + if is_legacy: + return {'is_admin': True} + from app.database.crud.rbac import UserRoleCRUD + _, _, max_level = await UserRoleCRUD.get_user_permissions(db, user.id) + return {'is_admin': max_level > 0} +``` + +**Step 3: Commit** + +```bash +git add app/cabinet/routes/auth.py +git commit -m "feat: add /me/permissions endpoint and update is-admin to check RBAC" +``` + +--- + +### Task 11: Register new routers + +**Files:** +- Modify: `/Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot/app/cabinet/routes/__init__.py` + +**Step 1: Import and include new routers** + +Add imports for the 3 new route files and include them in the cabinet router alongside existing admin routes: + +```python +from .admin_roles import router as admin_roles_router +from .admin_policies import router as admin_policies_router +from .admin_audit_log import router as admin_audit_log_router + +# In the router.include_router section: +router.include_router(admin_roles_router) +router.include_router(admin_policies_router) +router.include_router(admin_audit_log_router) +``` + +**Step 2: Commit** + +```bash +git add app/cabinet/routes/__init__.py +git commit -m "feat: register RBAC route modules in cabinet router" +``` + +--- + +## Phase 5: Migrate Existing Admin Routes to require_permission + +### Task 12: Migrate all 25 admin route files + +**Files:** +- Modify: All `admin_*.py` files in `/Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot/app/cabinet/routes/` + +**Strategy:** For each file, replace `admin: User = Depends(get_current_admin_user)` with `admin: User = Depends(require_permission("section:action"))` where the section and action match the endpoint's purpose. + +**Permission mapping by file:** + +| File | Section | GET → | POST/PUT → | DELETE → | +|------|---------|-------|------------|----------| +| `admin_users.py` | `users` | `users:read` | `users:edit` | `users:delete` | +| `admin_tickets.py` | `tickets` | `tickets:read` | `tickets:reply` / `tickets:close` | — | +| `admin_stats.py` | `stats` | `stats:read` | — | — | +| `admin_broadcasts.py` | `broadcasts` | `broadcasts:read` | `broadcasts:create` / `broadcasts:send` | `broadcasts:delete` | +| `admin_tariffs.py` | `tariffs` | `tariffs:read` | `tariffs:create` / `tariffs:edit` | `tariffs:delete` | +| `admin_promocodes.py` | `promocodes` / `promo_groups` | `:read` | `:create` / `:edit` | `:delete` | +| `admin_promo_offers.py` | `promo_offers` | `:read` | `:create` / `:send` | — | +| `admin_campaigns.py` | `campaigns` | `:read` | `:create` / `:edit` | `:delete` | +| `admin_partners.py` | `partners` | `:read` | `:edit` / `:approve` / `:revoke` | — | +| `admin_withdrawals.py` | `withdrawals` | `:read` | `:approve` / `:reject` | — | +| `admin_payments.py` | `payments` | `payments:read` | — | — | +| `admin_payment_methods.py` | `payment_methods` | `:read` | `:edit` | — | +| `admin_servers.py` | `servers` | `:read` | `:edit` | — | +| `admin_remnawave.py` | `remnawave` | `:read` | `:sync` / `:manage` | — | +| `admin_traffic.py` | `traffic` | `:read` / `:export` | — | — | +| `admin_settings.py` | `settings` | `:read` | `:edit` | — | +| `admin_channels.py` | `channels` | `:read` | `:edit` | — | +| `admin_ban_system.py` | `ban_system` | `:read` | `:ban` / `:unban` | — | +| `admin_wheel.py` | `wheel` | `:read` | `:edit` | — | +| `admin_apps.py` | `apps` | `:read` | `:edit` | — | +| `admin_email_templates.py` | `email_templates` | `:read` | `:edit` | — | +| `admin_pinned_messages.py` | `pinned_messages` | `:read` | `:create` / `:edit` | `:delete` | +| `admin_updates.py` | `updates` | `:read` | `:manage` | — | +| `admin_button_styles.py` | `settings` | `:read` | `:edit` | — | +| `ticket_notifications.py` | `tickets` | `:read` | `:settings` | — | + +**Pattern for each endpoint:** + +Replace: +```python +admin: User = Depends(get_current_admin_user) +``` + +With: +```python +admin: User = Depends(require_permission("section:action")) +``` + +Also add import at top of each file: +```python +from ..dependencies import get_cabinet_db, require_permission +``` + +And remove the `get_current_admin_user` import if it becomes unused. + +**Do this in batches of 5 files, committing after each batch:** + +Batch 1: `admin_users.py`, `admin_tickets.py`, `admin_stats.py`, `admin_broadcasts.py`, `admin_tariffs.py` +Batch 2: `admin_promocodes.py`, `admin_promo_offers.py`, `admin_campaigns.py`, `admin_partners.py`, `admin_withdrawals.py` +Batch 3: `admin_payments.py`, `admin_payment_methods.py`, `admin_servers.py`, `admin_remnawave.py`, `admin_traffic.py` +Batch 4: `admin_settings.py`, `admin_channels.py`, `admin_ban_system.py`, `admin_wheel.py`, `admin_apps.py` +Batch 5: `admin_email_templates.py`, `admin_pinned_messages.py`, `admin_updates.py`, `admin_button_styles.py`, `ticket_notifications.py` + +**Step N: Commit after each batch** + +```bash +git commit -m "feat: migrate admin routes batch N to require_permission RBAC" +``` + +--- + +## Phase 6: Superadmin Auto-Assignment + +### Task 13: Auto-assign Superadmin role on startup + +**Files:** +- Modify: `/Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot/app/database/migrations.py` (or appropriate startup hook) +- Create: `/Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot/app/services/rbac_bootstrap_service.py` + +**Step 1: Write bootstrap service** + +```python +"""Ensure users from ADMIN_IDS/ADMIN_EMAILS have Superadmin role.""" +import structlog +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import AdminRole, User, UserRole + +logger = structlog.get_logger() + + +async def bootstrap_superadmins(db: AsyncSession) -> None: + """Auto-assign Superadmin role to users from ADMIN_IDS config.""" + superadmin = await db.execute( + select(AdminRole).where(AdminRole.name == 'Superadmin') + ) + role = superadmin.scalar_one_or_none() + if not role: + logger.warning('Superadmin role not found, skipping bootstrap') + return + + admin_ids = settings.get_admin_ids() + for tid in admin_ids: + user = await db.execute( + select(User).where(User.telegram_id == tid) + ) + user_obj = user.scalar_one_or_none() + if not user_obj: + continue + + existing = await db.execute( + select(UserRole).where( + UserRole.user_id == user_obj.id, + UserRole.role_id == role.id, + ) + ) + if existing.scalar_one_or_none(): + continue + + db.add(UserRole( + user_id=user_obj.id, + role_id=role.id, + )) + logger.info('Auto-assigned Superadmin role', user_id=user_obj.id, telegram_id=tid) + + await db.commit() +``` + +**Step 2: Call bootstrap on bot startup** + +In the bot startup sequence (after migration), call `bootstrap_superadmins(db)`. + +**Step 3: Commit** + +```bash +git add app/services/rbac_bootstrap_service.py +git commit -m "feat: auto-assign Superadmin role to ADMIN_IDS users on startup" +``` + +--- + +## Phase 7: Frontend — Permission Store & Guards + +### Task 14: Create permission store + +**Files:** +- Create: `/Users/ea/Desktop/DEV/bedolaga-cabinet/src/store/permissions.ts` + +**Step 1: Write Zustand permission store** + +```typescript +import { create } from 'zustand'; +import { apiClient } from '@/api/client'; + +interface PermissionState { + permissions: string[]; + roles: string[]; + roleLevel: number; + isLoaded: boolean; + + fetchPermissions: () => Promise; + hasPermission: (permission: string) => boolean; + hasAnyPermission: (...permissions: string[]) => boolean; + hasAllPermissions: (...permissions: string[]) => boolean; + canManageRole: (level: number) => boolean; + reset: () => void; +} + +function permissionMatches(userPerm: string, required: string): boolean { + if (userPerm === '*:*') return true; + if (userPerm === required) return true; + // Wildcard: "users:*" matches "users:read" + const [userSection, userAction] = userPerm.split(':'); + const [reqSection] = required.split(':'); + if (userSection === reqSection && userAction === '*') return true; + return false; +} + +export const usePermissionStore = create((set, get) => ({ + permissions: [], + roles: [], + roleLevel: 0, + isLoaded: false, + + fetchPermissions: async () => { + try { + const response = await apiClient.get<{ + permissions: string[]; + roles: string[]; + role_level: number; + }>('/cabinet/auth/me/permissions'); + set({ + permissions: response.data.permissions, + roles: response.data.roles, + roleLevel: response.data.role_level, + isLoaded: true, + }); + } catch { + set({ permissions: [], roles: [], roleLevel: 0, isLoaded: true }); + } + }, + + hasPermission: (permission: string) => { + const { permissions } = get(); + return permissions.some((p) => permissionMatches(p, permission)); + }, + + hasAnyPermission: (...perms: string[]) => { + const { hasPermission } = get(); + return perms.some((p) => hasPermission(p)); + }, + + hasAllPermissions: (...perms: string[]) => { + const { hasPermission } = get(); + return perms.every((p) => hasPermission(p)); + }, + + canManageRole: (level: number) => { + return get().roleLevel > level; + }, + + reset: () => { + set({ permissions: [], roles: [], roleLevel: 0, isLoaded: false }); + }, +})); +``` + +**Step 2: Commit** + +```bash +git add src/store/permissions.ts +git commit -m "feat: add Zustand permission store with wildcard matching" +``` + +--- + +### Task 15: Create PermissionRoute and PermissionGate components + +**Files:** +- Create: `/Users/ea/Desktop/DEV/bedolaga-cabinet/src/components/auth/PermissionRoute.tsx` +- Create: `/Users/ea/Desktop/DEV/bedolaga-cabinet/src/components/auth/PermissionGate.tsx` + +**Step 1: Write PermissionRoute** + +```tsx +import { Navigate, useLocation } from 'react-router'; +import { useAuthStore } from '@/store/auth'; +import { usePermissionStore } from '@/store/permissions'; +import { Layout } from '@/components/layout/Layout'; +import { PageLoader } from '@/components/ui/PageLoader'; + +interface PermissionRouteProps { + children: React.ReactNode; + permission?: string; + permissions?: string[]; + requireAll?: boolean; +} + +export function PermissionRoute({ + children, + permission, + permissions, + requireAll = false, +}: PermissionRouteProps) { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const isLoading = useAuthStore((s) => s.isLoading); + const isAdmin = useAuthStore((s) => s.isAdmin); + const { hasPermission, hasAnyPermission, hasAllPermissions, isLoaded } = + usePermissionStore(); + const location = useLocation(); + + if (isLoading || (isAdmin && !isLoaded)) { + return ; + } + + if (!isAuthenticated) { + return ; + } + + if (!isAdmin) { + return ; + } + + // Check specific permissions + const requiredPerms = permissions || (permission ? [permission] : []); + if (requiredPerms.length > 0) { + const hasAccess = requireAll + ? hasAllPermissions(...requiredPerms) + : hasAnyPermission(...requiredPerms); + if (!hasAccess) { + return ; + } + } + + return {children}; +} +``` + +**Step 2: Write PermissionGate** + +```tsx +import { usePermissionStore } from '@/store/permissions'; + +interface PermissionGateProps { + children: React.ReactNode; + permission?: string; + permissions?: string[]; + requireAll?: boolean; + fallback?: React.ReactNode; +} + +export function PermissionGate({ + children, + permission, + permissions, + requireAll = false, + fallback = null, +}: PermissionGateProps) { + const { hasPermission, hasAnyPermission, hasAllPermissions } = + usePermissionStore(); + + const requiredPerms = permissions || (permission ? [permission] : []); + if (requiredPerms.length === 0) return <>{children}; + + const hasAccess = requireAll + ? hasAllPermissions(...requiredPerms) + : hasAnyPermission(...requiredPerms); + + return hasAccess ? <>{children} : <>{fallback}; +} +``` + +**Step 3: Commit** + +```bash +git add src/components/auth/ +git commit -m "feat: add PermissionRoute and PermissionGate components" +``` + +--- + +### Task 16: Integrate permission loading into auth flow + +**Files:** +- Modify: `/Users/ea/Desktop/DEV/bedolaga-cabinet/src/store/auth.ts` + +**Step 1: After `checkAdminStatus()`, fetch permissions if admin** + +In each place where `checkAdminStatus()` is called (lines 165, 189, 204, 256, 271, 286, 307), add after it: + +```typescript +import { usePermissionStore } from '@/store/permissions'; + +// After: await get().checkAdminStatus(); +if (get().isAdmin) { + await usePermissionStore.getState().fetchPermissions(); +} +``` + +**Step 2: On logout, reset permissions** + +In the `logout` action, add: + +```typescript +usePermissionStore.getState().reset(); +``` + +**Step 3: Commit** + +```bash +git add src/store/auth.ts +git commit -m "feat: integrate permission loading into auth flow" +``` + +--- + +### Task 17: Create RBAC API layer + +**Files:** +- Create: `/Users/ea/Desktop/DEV/bedolaga-cabinet/src/api/rbac.ts` + +**Step 1: Write API functions** + +```typescript +import { apiClient } from './client'; + +// Types +export interface AdminRole { + id: number; + name: string; + description: string | null; + level: number; + permissions: string[]; + color: string | null; + icon: string | null; + is_system: boolean; + is_active: boolean; + created_by: number | null; + created_at: string; + updated_at: string; + user_count?: number; +} + +export interface UserRoleAssignment { + id: number; + user_id: number; + role_id: number; + assigned_by: number | null; + assigned_at: string; + expires_at: string | null; + is_active: boolean; + role: AdminRole; + user?: { id: number; telegram_id: number | null; username: string | null; first_name: string | null; email: string | null }; +} + +export interface AccessPolicy { + id: number; + name: string; + description: string | null; + role_id: number | null; + priority: number; + effect: 'allow' | 'deny'; + conditions: Record; + resource: string; + actions: string[]; + is_active: boolean; + created_by: number | null; + created_at: string; +} + +export interface AuditLogEntry { + id: number; + user_id: number; + action: string; + resource_type: string | null; + resource_id: string | null; + details: Record | null; + ip_address: string | null; + user_agent: string | null; + status: string; + request_method: string | null; + request_path: string | null; + created_at: string; + user?: { username: string | null; first_name: string | null }; +} + +export interface PermissionSection { + section: string; + actions: string[]; +} + +// API +export const rbacApi = { + // Roles + getRoles: () => apiClient.get('/cabinet/admin/roles'), + createRole: (data: Partial) => apiClient.post('/cabinet/admin/roles', data), + updateRole: (id: number, data: Partial) => apiClient.put(`/cabinet/admin/roles/${id}`, data), + deleteRole: (id: number) => apiClient.delete(`/cabinet/admin/roles/${id}`), + + // Permission registry + getPermissionRegistry: () => apiClient.get('/cabinet/admin/roles/permissions'), + + // Role assignments + getRoleUsers: (params?: { role_id?: number; limit?: number; offset?: number }) => + apiClient.get<{ items: UserRoleAssignment[]; total: number }>('/cabinet/admin/roles/users', { params }), + assignRole: (data: { user_id: number; role_id: number; expires_at?: string }) => + apiClient.post('/cabinet/admin/roles/assign', data), + revokeRole: (assignmentId: number) => + apiClient.delete(`/cabinet/admin/roles/assign/${assignmentId}`), + + // Policies + getPolicies: () => apiClient.get('/cabinet/admin/policies'), + createPolicy: (data: Partial) => apiClient.post('/cabinet/admin/policies', data), + updatePolicy: (id: number, data: Partial) => apiClient.put(`/cabinet/admin/policies/${id}`, data), + deletePolicy: (id: number) => apiClient.delete(`/cabinet/admin/policies/${id}`), + + // Audit log + getAuditLog: (params: { + user_id?: number; + action?: string; + resource_type?: string; + status?: string; + date_from?: string; + date_to?: string; + limit?: number; + offset?: number; + }) => apiClient.get<{ items: AuditLogEntry[]; total: number }>('/cabinet/admin/audit-log', { params }), + exportAuditLog: (params: Record) => + apiClient.get('/cabinet/admin/audit-log/export', { params, responseType: 'blob' }), +}; +``` + +**Step 2: Commit** + +```bash +git add src/api/rbac.ts +git commit -m "feat: add RBAC API layer with types for roles, policies, audit log" +``` + +--- + +## Phase 8: Frontend — Admin Pages + +### Task 18: Create AdminRoles page + +**Files:** +- Create: `/Users/ea/Desktop/DEV/bedolaga-cabinet/src/pages/AdminRoles.tsx` + +**Step 1: Build role management page** + +Features: +- Table of roles with columns: name (color badge), level, description, user count, system flag, actions +- Create/edit modal with: name, description, level slider, color picker, permission matrix (grouped by section with checkboxes) +- Preset templates: one-click apply Moderator/Marketer/Support permissions +- Delete button (disabled for system roles) +- Permission matrix: rows = sections, columns = actions, checkboxes for each + +Use existing patterns from `AdminPromocodes.tsx` for table/modal structure, `AdminSettings.tsx` for form patterns. + +i18n keys under `admin.roles.*` + +**Step 2: Commit** + +```bash +git add src/pages/AdminRoles.tsx +git commit -m "feat: add AdminRoles page with permission matrix editor" +``` + +--- + +### Task 19: Create AdminRoleAssign page + +**Files:** +- Create: `/Users/ea/Desktop/DEV/bedolaga-cabinet/src/pages/AdminRoleAssign.tsx` + +**Step 1: Build role assignment page** + +Features: +- Search users (reuse pattern from AdminUsers) +- Assign role dropdown +- Optional expiry date picker +- Table of current assignments with revoke button +- Level hierarchy enforcement (can't assign roles >= own level) + +**Step 2: Commit** + +```bash +git add src/pages/AdminRoleAssign.tsx +git commit -m "feat: add AdminRoleAssign page for user role management" +``` + +--- + +### Task 20: Create AdminPolicies page + +**Files:** +- Create: `/Users/ea/Desktop/DEV/bedolaga-cabinet/src/pages/AdminPolicies.tsx` + +**Step 1: Build policy management page** + +Features: +- Table of policies: name, effect (allow/deny badge), resource, actions, role, active toggle +- Create/edit form: + - Name, description + - Effect: allow/deny toggle + - Resource: dropdown of sections + - Actions: multi-select checkboxes + - Role: optional dropdown (global if none) + - Conditions builder: + - Time range: start/end time inputs + - IP whitelist: tag input + - Rate limit: number input + - Priority: number input + +**Step 2: Commit** + +```bash +git add src/pages/AdminPolicies.tsx +git commit -m "feat: add AdminPolicies page with ABAC condition builder" +``` + +--- + +### Task 21: Create AdminAuditLog page + +**Files:** +- Create: `/Users/ea/Desktop/DEV/bedolaga-cabinet/src/pages/AdminAuditLog.tsx` + +**Step 1: Build audit log page** + +Features: +- Filterable table: user, action, resource, status, date range +- Each row shows: timestamp, user avatar/name, action badge, resource, status (success/denied/error), IP +- Expandable row detail: full details JSON, before/after diff, user agent +- CSV export button +- Pagination +- Auto-refresh toggle (poll every 30s) + +Use `@tanstack/react-table` for the table (already in deps). + +**Step 2: Commit** + +```bash +git add src/pages/AdminAuditLog.tsx +git commit -m "feat: add AdminAuditLog page with filters, expandable details, export" +``` + +--- + +## Phase 9: Frontend — Integration + +### Task 22: Update App.tsx routes + +**Files:** +- Modify: `/Users/ea/Desktop/DEV/bedolaga-cabinet/src/App.tsx` + +**Step 1: Add lazy imports for new pages** + +```tsx +const AdminRoles = lazy(() => import('./pages/AdminRoles')); +const AdminRoleAssign = lazy(() => import('./pages/AdminRoleAssign')); +const AdminPolicies = lazy(() => import('./pages/AdminPolicies')); +const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog')); +``` + +**Step 2: Add new routes** + +```tsx +} /> +} /> +} /> +} /> +``` + +**Step 3: Migrate existing admin routes to PermissionRoute** + +Replace all `` wrappers with `` using the same mapping from Task 12. + +Example: +```tsx +// Before: +} /> + +// After: +} /> +``` + +**Step 4: Commit** + +```bash +git add src/App.tsx +git commit -m "feat: migrate all admin routes to PermissionRoute with granular permissions" +``` + +--- + +### Task 23: Update AdminPanel navigation + +**Files:** +- Modify: `/Users/ea/Desktop/DEV/bedolaga-cabinet/src/pages/AdminPanel.tsx` + +**Step 1: Add permission field to AdminItem interface** + +```typescript +interface AdminItem { + to: string; + icon: React.ReactNode; + title: string; + description: string; + permission: string; // NEW +} +``` + +**Step 2: Add permissions to all items** + +Map each item to its required permission (e.g., `/admin/users` → `users:read`). + +**Step 3: Add new RBAC group** + +Add a 6th group "Security" (id: `security`) with items: +- Roles → `/admin/roles` (permission: `roles:read`) +- Role Assignment → `/admin/roles/assign` (permission: `roles:assign`) +- Access Policies → `/admin/policies` (permission: `roles:read`) +- Audit Log → `/admin/audit-log` (permission: `audit_log:read`) + +**Step 4: Filter items by permission** + +In the rendering, filter out items the user doesn't have access to: + +```tsx +const { hasPermission } = usePermissionStore(); + +// In GroupSection: +const visibleItems = group.items.filter(item => hasPermission(item.permission)); +if (visibleItems.length === 0) return null; +``` + +**Step 5: Commit** + +```bash +git add src/pages/AdminPanel.tsx +git commit -m "feat: filter AdminPanel navigation by user permissions, add Security group" +``` + +--- + +### Task 24: Add i18n translations + +**Files:** +- Modify: `/Users/ea/Desktop/DEV/bedolaga-cabinet/src/locales/ru.json` +- Modify: `/Users/ea/Desktop/DEV/bedolaga-cabinet/src/locales/en.json` +- Modify: `/Users/ea/Desktop/DEV/bedolaga-cabinet/src/locales/zh.json` +- Modify: `/Users/ea/Desktop/DEV/bedolaga-cabinet/src/locales/fa.json` + +**Step 1: Add keys for all RBAC pages** + +Key structure: +```json +{ + "admin": { + "groups": { + "security": "Security" + }, + "roles": { + "title": "Roles", + "subtitle": "Manage admin roles and permissions", + "create": "Create Role", + "edit": "Edit Role", + "name": "Role Name", + "description": "Description", + "level": "Access Level", + "permissions": "Permissions", + "system": "System", + "userCount": "Users", + "deleteConfirm": "Delete role '{{name}}'?", + "sections": { ... per section names ... }, + "actions": { ... per action names ... }, + "presets": { + "apply": "Apply Template", + "moderator": "Moderator", + "marketer": "Marketer", + "support": "Support" + } + }, + "roleAssign": { + "title": "Role Assignment", + "assign": "Assign Role", + "revoke": "Revoke", + "expires": "Expires At", + "noExpiry": "No expiry" + }, + "policies": { + "title": "Access Policies", + "create": "Create Policy", + "effect": { "allow": "Allow", "deny": "Deny" }, + "conditions": { + "timeRange": "Time Range", + "ipWhitelist": "IP Whitelist", + "rateLimit": "Rate Limit" + } + }, + "auditLog": { + "title": "Audit Log", + "filters": "Filters", + "export": "Export CSV", + "status": { "success": "Success", "denied": "Denied", "error": "Error" }, + "details": "Details", + "noLogs": "No log entries found" + }, + "permissions": { + "denied": "Access denied", + "deniedMessage": "You don't have permission to access this section" + } + } +} +``` + +Repeat for all 4 locales (ru, en, zh, fa) with translated values. + +**Step 2: Commit** + +```bash +git add src/locales/ +git commit -m "feat: add RBAC i18n translations for all 4 locales" +``` + +--- + +## Phase 10: Testing & Finalization + +### Task 25: Manual integration test checklist + +**Verify the following flows:** + +1. Fresh start: migration creates tables, seeds 5 preset roles +2. Existing ADMIN_IDS users auto-receive Superadmin role on startup +3. Superadmin can see all admin sections +4. Create custom role "Content Manager" with `broadcasts:*`, `pinned_messages:*` +5. Assign role to a test user → they can access broadcasts but not users +6. Create deny policy: "No access after hours" for Moderator role +7. Audit log shows all admin actions with correct user/action/resource +8. Revoke role → user loses admin access immediately +9. JWT contains permissions array, frontend uses it for instant UI filtering +10. Legacy ADMIN_IDS users still work without any role assignment (backward compat) + +### Task 26: Final commit and deploy + +```bash +git add -A +git commit -m "feat: complete RBAC + ABAC system with roles, policies, audit log" +``` + +--- + +## Summary + +| Phase | Tasks | Description | +|-------|-------|-------------| +| 1 | 1-2 | Database models + Alembic migration | +| 2 | 3 | CRUD layer for all 4 tables | +| 3 | 4-6 | Permission engine, FastAPI dependency, JWT | +| 4 | 7-11 | RBAC API routes + router registration | +| 5 | 12 | Migrate 25 admin route files | +| 6 | 13 | Superadmin bootstrap on startup | +| 7 | 14-17 | Frontend: store, guards, API layer | +| 8 | 18-21 | Frontend: 4 new admin pages | +| 9 | 22-24 | Frontend: route integration, AdminPanel, i18n | +| 10 | 25-26 | Testing + deploy | + +**Total: 26 tasks, ~25 files modified, ~8 files created** diff --git a/docs/plans/2026-03-09-gift-subscription-cabinet.md b/docs/plans/2026-03-09-gift-subscription-cabinet.md new file mode 100644 index 00000000..1bfdf1b0 --- /dev/null +++ b/docs/plans/2026-03-09-gift-subscription-cabinet.md @@ -0,0 +1,1163 @@ +# Gift Subscription from Cabinet — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Allow authenticated cabinet users to gift VPN subscriptions to others by Telegram username or email, paying from balance or via payment gateway. + +**Architecture:** Thin cabinet wrapper around existing `GuestPurchaseService`. New `source`/`buyer_user_id` fields on `GuestPurchase` model distinguish cabinet gifts from landing gifts. Admin toggle via `CABINET_GIFT_ENABLED` branding setting. Frontend page at `/gift` with tariff/period selection, recipient input, payment mode choice. + +**Tech Stack:** Python 3.13 / FastAPI / SQLAlchemy 2.x / Alembic (backend), React 19 / TypeScript / Tailwind CSS / TanStack Query / Zustand / Framer Motion (frontend) + +**Repositories:** +- Backend: `/Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot/` +- Frontend: `/Users/ea/Desktop/DEV/bedolaga-cabinet/` + +--- + +## Phase 1: Backend — Model & Migration + +### Task 1: Add source/buyer fields to GuestPurchase model + +**Files:** +- Modify: `app/database/models.py:3072-3110` (GuestPurchase class) + +**Step 1: Add new columns to GuestPurchase model** + +In `app/database/models.py`, after the `is_gift` field (line ~3087), add: + +```python +source = Column(String(20), nullable=False, default='landing', server_default='landing') # 'landing' or 'cabinet' +buyer_user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True) +``` + +Add relationship after existing relationships (line ~3108): + +```python +buyer = relationship('User', foreign_keys=[buyer_user_id], lazy='selectin') +``` + +**Step 2: Add GIFT_PAYMENT to TransactionType enum** + +In `app/database/models.py` at the `TransactionType` enum (line ~129), add: + +```python +GIFT_PAYMENT = 'gift_payment' +``` + +**Step 3: Commit** + +```bash +cd /Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot +git add app/database/models.py +git commit -m "feat: add source and buyer_user_id fields to GuestPurchase model" +``` + +### Task 2: Create Alembic migration + +**Files:** +- Create: `migrations/alembic/versions/XXXX_add_gift_cabinet_fields.py` + +**Step 1: Generate migration** + +```bash +cd /Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot +# Note: requires running PostgreSQL + BOT_TOKEN env var +make migration m="add cabinet gift source and buyer fields" +``` + +**Step 2: Verify the generated migration** + +The migration should contain: +- `op.add_column('guest_purchases', sa.Column('source', sa.String(20), nullable=False, server_default='landing'))` +- `op.add_column('guest_purchases', sa.Column('buyer_user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True))` +- `op.create_index` on `source` column (for filtering cabinet vs landing) + +If autogenerate missed anything, edit manually. + +**Step 3: Commit** + +```bash +git add migrations/ +git commit -m "feat: migration for cabinet gift fields on guest_purchases" +``` + +--- + +## Phase 2: Backend — Admin Toggle + +### Task 3: Add CABINET_GIFT_ENABLED branding toggle + +**Files:** +- Modify: `app/cabinet/routes/branding.py:41,267-276,955-985` + +**Step 1: Add constant and schemas** + +After line 41 (`LITE_MODE_ENABLED_KEY`), add: + +```python +GIFT_ENABLED_KEY = 'CABINET_GIFT_ENABLED' # Stores "true" or "false" +``` + +After `LiteModeEnabledUpdate` class (line ~276), add: + +```python +class GiftEnabledResponse(BaseModel): + """Gift feature enabled setting.""" + enabled: bool = False + + +class GiftEnabledUpdate(BaseModel): + """Request to update gift feature setting.""" + enabled: bool +``` + +**Step 2: Add GET/PATCH endpoints** + +After the lite-mode endpoints (line ~985), add: + +```python +# ============ Gift Feature Routes ============ + + +@router.get('/gift-enabled', response_model=GiftEnabledResponse) +async def get_gift_enabled( + db: AsyncSession = Depends(get_cabinet_db), +): + """Get gift feature enabled setting. Public endpoint.""" + value = await get_setting_value(db, GIFT_ENABLED_KEY) + if value is not None: + enabled = value.lower() == 'true' + return GiftEnabledResponse(enabled=enabled) + return GiftEnabledResponse(enabled=False) + + +@router.patch('/gift-enabled', response_model=GiftEnabledResponse) +async def update_gift_enabled( + payload: GiftEnabledUpdate, + admin: User = Depends(require_permission('settings:edit')), + db: AsyncSession = Depends(get_cabinet_db), +): + """Update gift feature enabled setting. Admin only.""" + await set_setting_value(db, GIFT_ENABLED_KEY, str(payload.enabled).lower()) + logger.info('Admin set gift enabled', telegram_id=admin.telegram_id, enabled=payload.enabled) + return GiftEnabledResponse(enabled=payload.enabled) +``` + +**Step 3: Commit** + +```bash +git add app/cabinet/routes/branding.py +git commit -m "feat: add CABINET_GIFT_ENABLED branding toggle" +``` + +--- + +## Phase 3: Backend — Gift API Routes + +### Task 4: Create gift schemas + +**Files:** +- Create: `app/cabinet/schemas/gift.py` + +**Step 1: Write schemas** + +```python +"""Schemas for cabinet gift subscription feature.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field, model_validator + + +class GiftConfigTariffPeriod(BaseModel): + days: int + price_kopeks: int + price_label: str + original_price_kopeks: int | None = None + discount_percent: int | None = None + + +class GiftConfigTariff(BaseModel): + id: int + name: str + description: str | None = None + traffic_limit_gb: int + device_limit: int + periods: list[GiftConfigTariffPeriod] + + +class GiftConfigPaymentMethod(BaseModel): + method_id: str + display_name: str + description: str | None = None + icon_url: str | None = None + min_amount_kopeks: int | None = None + max_amount_kopeks: int | None = None + sub_options: list[GiftConfigSubOption] | None = None + + +class GiftConfigSubOption(BaseModel): + id: str + name: str + + +class GiftConfigResponse(BaseModel): + is_enabled: bool + tariffs: list[GiftConfigTariff] = [] + payment_methods: list[GiftConfigPaymentMethod] = [] + balance_kopeks: int = 0 + currency_symbol: str = '₽' + + +class GiftPurchaseRequest(BaseModel): + tariff_id: int + period_days: int + recipient_type: str = Field(pattern=r'^(email|telegram)$') + recipient_value: str = Field(min_length=1, max_length=255) + gift_message: str | None = Field(default=None, max_length=1000) + payment_mode: str = Field(pattern=r'^(balance|gateway)$') + payment_method: str | None = Field(default=None, max_length=50) + + @model_validator(mode='after') + def validate_payment(self) -> 'GiftPurchaseRequest': + if self.payment_mode == 'gateway' and not self.payment_method: + raise ValueError('payment_method is required for gateway mode') + return self + + +class GiftPurchaseResponse(BaseModel): + """Response for both balance and gateway modes.""" + status: str # 'delivered', 'pending', 'pending_activation' + purchase_token: str + payment_url: str | None = None # Only for gateway mode + + +class GiftPurchaseStatusResponse(BaseModel): + status: str + is_gift: bool = True + recipient_contact_value: str | None = None + gift_message: str | None = None + tariff_name: str | None = None + period_days: int | None = None +``` + +**Step 2: Commit** + +```bash +git add app/cabinet/schemas/gift.py +git commit -m "feat: add gift purchase Pydantic schemas" +``` + +### Task 5: Create gift routes + +**Files:** +- Create: `app/cabinet/routes/gift.py` + +**Step 1: Write the gift router** + +```python +"""Cabinet gift subscription routes.""" + +from __future__ import annotations + +import re + +import structlog +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.cabinet.auth.dependencies import get_current_cabinet_user +from app.cabinet.schemas.gift import ( + GiftConfigResponse, + GiftConfigSubOption, + GiftConfigPaymentMethod, + GiftConfigTariff, + GiftConfigTariffPeriod, + GiftPurchaseRequest, + GiftPurchaseResponse, + GiftPurchaseStatusResponse, +) +from app.config import settings +from app.database.crud.transaction import create_transaction +from app.database.crud.user import subtract_user_balance +from app.database.models import GuestPurchase, GuestPurchaseStatus, Tariff, TransactionType, User +from app.services.guest_purchase_service import GuestPurchaseService +from app.services.payment_service import PaymentService + +from .branding import GIFT_ENABLED_KEY +from ..dependencies import get_cabinet_db, get_setting_value + +logger = structlog.get_logger() + +router = APIRouter(prefix='/gift', tags=['Cabinet Gift']) + +_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$') +_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{4,31}$') + + +def _validate_recipient(recipient_type: str, value: str) -> None: + """Validate recipient contact format.""" + if recipient_type == 'email': + if not _EMAIL_RE.match(value.strip()): + raise HTTPException(status_code=400, detail='Invalid email format') + elif recipient_type == 'telegram': + clean = value.lstrip('@').strip() + if not _TELEGRAM_RE.match(clean): + raise HTTPException(status_code=400, detail='Invalid Telegram username format') + + +@router.get('/config', response_model=GiftConfigResponse) +async def get_gift_config( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get gift feature config: enabled flag, available tariffs, payment methods, user balance.""" + # Check if gift feature is enabled + gift_value = await get_setting_value(db, GIFT_ENABLED_KEY) + is_enabled = gift_value is not None and gift_value.lower() == 'true' + + if not is_enabled: + return GiftConfigResponse(is_enabled=False, balance_kopeks=user.balance_kopeks) + + # Get active tariffs with period_prices + from sqlalchemy import select + result = await db.execute( + select(Tariff).where( + Tariff.is_active == True, # noqa: E712 + Tariff.is_hidden == False, # noqa: E712 + ).order_by(Tariff.sort_order, Tariff.id) + ) + tariffs = result.scalars().all() + + config_tariffs = [] + for tariff in tariffs: + periods = [] + for days_str, price_kopeks in sorted( + (tariff.period_prices or {}).items(), + key=lambda x: int(x[0]), + ): + days = int(days_str) + periods.append(GiftConfigTariffPeriod( + days=days, + price_kopeks=price_kopeks, + price_label=f'{price_kopeks / 100:.0f} ₽', + )) + if periods: + config_tariffs.append(GiftConfigTariff( + id=tariff.id, + name=tariff.name, + description=getattr(tariff, 'description', None), + traffic_limit_gb=tariff.traffic_limit_gb or 0, + device_limit=tariff.device_limit or 1, + periods=periods, + )) + + # Get payment methods (reuse from balance topup config) + from app.cabinet.routes.balance import _get_available_payment_methods + payment_methods_raw = await _get_available_payment_methods(db) + payment_methods = [ + GiftConfigPaymentMethod( + method_id=m['method_id'], + display_name=m['display_name'], + description=m.get('description'), + icon_url=m.get('icon_url'), + min_amount_kopeks=m.get('min_amount_kopeks'), + max_amount_kopeks=m.get('max_amount_kopeks'), + sub_options=[ + GiftConfigSubOption(id=so['id'], name=so['name']) + for so in (m.get('sub_options') or []) + ] if m.get('sub_options') else None, + ) + for m in payment_methods_raw + ] + + return GiftConfigResponse( + is_enabled=True, + tariffs=config_tariffs, + payment_methods=payment_methods, + balance_kopeks=user.balance_kopeks, + currency_symbol=settings.get_currency_symbol(), + ) + + +@router.post('/purchase', response_model=GiftPurchaseResponse) +async def create_gift_purchase( + request: GiftPurchaseRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Create a gift subscription purchase from cabinet.""" + # 1. Check gift feature enabled + gift_value = await get_setting_value(db, GIFT_ENABLED_KEY) + if not gift_value or gift_value.lower() != 'true': + raise HTTPException(status_code=403, detail='Gift feature is disabled') + + # 2. Validate recipient + _validate_recipient(request.recipient_type, request.recipient_value) + + # 3. Find tariff and validate price + from sqlalchemy import select + result = await db.execute(select(Tariff).where(Tariff.id == request.tariff_id, Tariff.is_active == True)) # noqa: E712 + tariff = result.scalar_one_or_none() + if not tariff: + raise HTTPException(status_code=404, detail='Tariff not found or inactive') + + price_kopeks = tariff.get_price_for_period(request.period_days) + if price_kopeks is None: + raise HTTPException(status_code=400, detail='Invalid period for this tariff') + + # 4. Determine buyer contact info + buyer_contact_type = 'email' if user.email else 'telegram' + buyer_contact_value = user.email or (f'@{user.username}' if user.username else str(user.telegram_id or user.id)) + + # 5. Create GuestPurchase record + guest_purchase_service = GuestPurchaseService() + purchase = await guest_purchase_service.create_purchase( + db=db, + landing=None, # No landing for cabinet gifts + tariff=tariff, + period_days=request.period_days, + amount_kopeks=price_kopeks, + contact_type=buyer_contact_type, + contact_value=buyer_contact_value, + payment_method=request.payment_method or 'balance', + is_gift=True, + gift_recipient_type=request.recipient_type, + gift_recipient_value=request.recipient_value.strip(), + gift_message=request.gift_message, + source='cabinet', + buyer_user_id=user.id, + ) + + # 6. Handle payment mode + if request.payment_mode == 'balance': + # Check balance + if user.balance_kopeks < price_kopeks: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + 'code': 'insufficient_funds', + 'message': f'Insufficient balance. Need {price_kopeks / 100:.0f}, have {user.balance_kopeks / 100:.0f}', + 'required': price_kopeks, + 'available': user.balance_kopeks, + }, + ) + + # Deduct balance + success = await subtract_user_balance( + db=db, + user=user, + amount_kopeks=price_kopeks, + description=f'Gift: {tariff.name} ({request.period_days}d) → {request.recipient_value}', + ) + if not success: + raise HTTPException(status_code=500, detail='Failed to deduct balance') + + # Create transaction + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.GIFT_PAYMENT, + amount_kopeks=price_kopeks, + description=f'Gift subscription: {tariff.name} ({request.period_days}d) → {request.recipient_value}', + ) + + # Mark as paid and fulfill immediately + purchase.status = GuestPurchaseStatus.PAID.value + await db.commit() + + fulfilled = await guest_purchase_service.fulfill_purchase(db, purchase.token) + + return GiftPurchaseResponse( + status=fulfilled.status if fulfilled else 'failed', + purchase_token=purchase.token, + ) + + else: + # Gateway payment — create payment via PaymentService + # Same pattern as balance topup + payment_service = PaymentService() + return_url = f'{settings.get_cabinet_url()}/gift/result?token={purchase.token}' + + # Route to correct payment provider + payment_result = await _create_gift_payment( + payment_service=payment_service, + db=db, + user=user, + purchase=purchase, + payment_method=request.payment_method, + amount_kopeks=price_kopeks, + return_url=return_url, + tariff_name=tariff.name, + period_days=request.period_days, + ) + + return GiftPurchaseResponse( + status='pending', + purchase_token=purchase.token, + payment_url=payment_result['payment_url'], + ) + + +async def _create_gift_payment( + payment_service: PaymentService, + db: AsyncSession, + user: User, + purchase: GuestPurchase, + payment_method: str | None, + amount_kopeks: int, + return_url: str, + tariff_name: str, + period_days: int, +) -> dict: + """Create payment via appropriate payment gateway. + + This mirrors the logic in balance.py topup endpoint but for gift purchases. + The payment webhook will call fulfill_purchase via the existing guest purchase webhook handler. + """ + # Implementation will mirror the balance.py topup pattern: + # Parse payment_method (e.g., 'platega_2' → method='platega', sub_option='2') + # Call the appropriate PaymentService method + # Store purchase.token in payment metadata for webhook correlation + # Return dict with payment_url + raise HTTPException( + status_code=501, + detail='Gateway payment for gifts not yet implemented — use balance mode', + ) + + +@router.get('/purchase/{token}', response_model=GiftPurchaseStatusResponse) +async def get_gift_purchase_status( + token: str, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get gift purchase status. Only accessible by the buyer.""" + from sqlalchemy import select + result = await db.execute( + select(GuestPurchase).where( + GuestPurchase.token == token, + GuestPurchase.buyer_user_id == user.id, + ) + ) + purchase = result.scalar_one_or_none() + if not purchase: + raise HTTPException(status_code=404, detail='Purchase not found') + + tariff_name = None + if purchase.tariff_id: + tariff_result = await db.execute(select(Tariff).where(Tariff.id == purchase.tariff_id)) + tariff = tariff_result.scalar_one_or_none() + tariff_name = tariff.name if tariff else None + + return GiftPurchaseStatusResponse( + status=purchase.status, + is_gift=True, + recipient_contact_value=purchase.gift_recipient_value, + gift_message=purchase.gift_message, + tariff_name=tariff_name, + period_days=purchase.period_days, + ) +``` + +**NOTE:** The `_create_gift_payment` function is a stub for gateway mode. It will be implemented in Task 6 by wiring into the existing payment service pattern from `balance.py`. For the MVP, balance mode works end-to-end. + +**Step 2: Commit** + +```bash +git add app/cabinet/routes/gift.py +git commit -m "feat: add cabinet gift purchase routes" +``` + +### Task 6: Update GuestPurchaseService.create_purchase for cabinet source + +**Files:** +- Modify: `app/services/guest_purchase_service.py:116-160` + +**Step 1: Add source and buyer_user_id parameters** + +Update the `create_purchase` function signature (line ~116) to accept optional new fields: + +```python +async def create_purchase( + db: AsyncSession, + landing: LandingPage | None, # Make nullable for cabinet + tariff: Tariff, + period_days: int, + amount_kopeks: int, + contact_type: str, + contact_value: str, + payment_method: str, + is_gift: bool = False, + gift_recipient_type: str | None = None, + gift_recipient_value: str | None = None, + gift_message: str | None = None, + source: str = 'landing', + buyer_user_id: int | None = None, + commit: bool = True, +) -> GuestPurchase: +``` + +In the function body where `GuestPurchase(...)` is constructed, add: + +```python +source=source, +buyer_user_id=buyer_user_id, +landing_id=landing.id if landing else None, +``` + +**Step 2: Commit** + +```bash +git add app/services/guest_purchase_service.py +git commit -m "feat: extend create_purchase to support cabinet source" +``` + +### Task 7: Register gift router + +**Files:** +- Modify: `app/cabinet/routes/__init__.py:56-87` + +**Step 1: Import and include the gift router** + +After line 56 (`from .wheel import router as wheel_router`), add: + +```python +from .gift import router as gift_router +``` + +After line 87 (`router.include_router(wheel_router)`), add: + +```python +# Gift routes +router.include_router(gift_router) +``` + +**Step 2: Commit** + +```bash +git add app/cabinet/routes/__init__.py +git commit -m "feat: register gift router in cabinet" +``` + +--- + +## Phase 4: Frontend — API & Feature Flag + +### Task 8: Create gift API client + +**Files:** +- Create: `bedolaga-cabinet/src/api/gift.ts` + +**Step 1: Write the API module** + +```typescript +import apiClient from './client'; + +// Types + +export interface GiftTariffPeriod { + days: number; + price_kopeks: number; + price_label: string; + original_price_kopeks: number | null; + discount_percent: number | null; +} + +export interface GiftTariff { + id: number; + name: string; + description: string | null; + traffic_limit_gb: number; + device_limit: number; + periods: GiftTariffPeriod[]; +} + +export interface GiftPaymentMethodSubOption { + id: string; + name: string; +} + +export interface GiftPaymentMethod { + method_id: string; + display_name: string; + description: string | null; + icon_url: string | null; + min_amount_kopeks: number | null; + max_amount_kopeks: number | null; + sub_options: GiftPaymentMethodSubOption[] | null; +} + +export interface GiftConfig { + is_enabled: boolean; + tariffs: GiftTariff[]; + payment_methods: GiftPaymentMethod[]; + balance_kopeks: number; + currency_symbol: string; +} + +export interface GiftPurchaseRequest { + tariff_id: number; + period_days: number; + recipient_type: 'email' | 'telegram'; + recipient_value: string; + gift_message?: string; + payment_mode: 'balance' | 'gateway'; + payment_method?: string; +} + +export interface GiftPurchaseResponse { + status: string; + purchase_token: string; + payment_url: string | null; +} + +export interface GiftPurchaseStatus { + status: string; + is_gift: boolean; + recipient_contact_value: string | null; + gift_message: string | null; + tariff_name: string | null; + period_days: number | null; +} + +// API + +export const giftApi = { + getConfig: async (): Promise => { + const { data } = await apiClient.get('/cabinet/gift/config'); + return data; + }, + + createPurchase: async (request: GiftPurchaseRequest): Promise => { + const { data } = await apiClient.post('/cabinet/gift/purchase', request); + return data; + }, + + getPurchaseStatus: async (token: string): Promise => { + const { data } = await apiClient.get(`/cabinet/gift/purchase/${token}`); + return data; + }, +}; +``` + +**Step 2: Commit** + +```bash +cd /Users/ea/Desktop/DEV/bedolaga-cabinet +git add src/api/gift.ts +git commit -m "feat: add gift subscription API client" +``` + +### Task 9: Add gift feature flag + +**Files:** +- Modify: `bedolaga-cabinet/src/hooks/useFeatureFlags.ts` +- Modify: `bedolaga-cabinet/src/api/branding.ts` + +**Step 1: Add branding type and API call** + +In `src/api/branding.ts`, after the `EmailAuthEnabled` interface (line ~24), add: + +```typescript +export interface GiftEnabled { + enabled: boolean; +} +``` + +Add to the `brandingApi` object: + +```typescript +getGiftEnabled: async (): Promise => { + const { data } = await apiClient.get('/cabinet/branding/gift-enabled'); + return data; +}, +``` + +**Step 2: Update useFeatureFlags** + +In `src/hooks/useFeatureFlags.ts`, add import and query: + +```typescript +import { brandingApi } from '@/api/branding'; +``` + +Inside `useFeatureFlags()`, after the polls query, add: + +```typescript +const { data: giftConfig } = useQuery({ + queryKey: ['gift-enabled'], + queryFn: brandingApi.getGiftEnabled, + enabled: isAuthenticated, + staleTime: 60000, + retry: false, +}); +``` + +Update the return: + +```typescript +return { + referralEnabled: referralTerms?.is_enabled, + wheelEnabled: wheelConfig?.is_enabled, + hasContests: (contestsCount?.count ?? 0) > 0, + hasPolls: (pollsCount?.count ?? 0) > 0, + giftEnabled: giftConfig?.enabled, +}; +``` + +**Step 3: Commit** + +```bash +git add src/hooks/useFeatureFlags.ts src/api/branding.ts +git commit -m "feat: add giftEnabled feature flag" +``` + +--- + +## Phase 5: Frontend — Gift Page + +### Task 10: Create GiftSubscription page + +**Files:** +- Create: `bedolaga-cabinet/src/pages/GiftSubscription.tsx` + +**Step 1: Write the page component** + +This page reuses patterns from `QuickPurchase.tsx` — period tabs, tariff cards, payment method cards — but adapted for cabinet context (authenticated user, balance payment, glass theme). + +Key differences from QuickPurchase: +- No buyer contact field (user is authenticated) +- Payment mode toggle: "From balance" / "Via payment gateway" +- Shows user balance prominently +- Uses cabinet glass theme styling (not landing dark theme) +- No landing-specific features (custom CSS, backgrounds, discount banners) + +The page should contain these sections: +1. Page title with gift icon +2. Period pill tabs (from active tariffs) +3. Tariff radio cards (filtered by selected period) +4. Recipient input (email/@telegram with auto-detect) +5. Gift message textarea (optional, 1000 char limit) +6. Payment mode toggle +7. Payment method cards (only when gateway mode selected) +8. Summary card with price, balance info, and "Gift" button + +Use existing component patterns: +- Period pills: same `rounded-full px-4 py-2` style as QuickPurchase `PeriodTabs` +- Tariff cards: same radio-button card pattern as QuickPurchase `TariffCard` +- Payment methods: same pattern as QuickPurchase `PaymentMethodCard` +- Input fields: `rounded-xl border border-dark-700/50 bg-dark-800/50` style +- Glass theme: use `getGlassColors(isDark)` for consistent look +- Animations: Framer Motion `motion.div` with stagger + +**Important implementation notes:** +- Use `useCurrency()` hook for price formatting +- Use `usePlatform()` for haptic feedback on button clicks +- Use `useTranslation()` with `gift.*` keys +- Use `useMutation` for purchase, handle 402 (insufficient funds) specially +- For balance mode: on success, invalidate `['balance']` query cache +- For gateway mode: redirect to `payment_url`, then poll on `/gift/result` + +**Step 2: Commit** + +```bash +git add src/pages/GiftSubscription.tsx +git commit -m "feat: add GiftSubscription page" +``` + +### Task 11: Create GiftResult page (for gateway payments) + +**Files:** +- Create: `bedolaga-cabinet/src/pages/GiftResult.tsx` + +**Step 1: Write the result page** + +Pattern from `PurchaseSuccess.tsx` — polling purchase status every 3 seconds until terminal state. + +States to handle: +- `pending` / `paid` — show spinner + "Processing..." +- `delivered` — success screen with confetti/checkmark, show recipient, tariff, period +- `pending_activation` — show that recipient has active subscription, gift pending +- `failed` — error screen with retry suggestion + +Read `token` from URL search params: `?token=xxx` + +**Step 2: Commit** + +```bash +git add src/pages/GiftResult.tsx +git commit -m "feat: add GiftResult page for gateway payment status" +``` + +### Task 12: Add routes to App.tsx + +**Files:** +- Modify: `bedolaga-cabinet/src/App.tsx` + +**Step 1: Add lazy imports** + +After line 38 (`const Wheel = lazy(...)`) add: + +```typescript +const GiftSubscription = lazy(() => import('./pages/GiftSubscription')); +const GiftResult = lazy(() => import('./pages/GiftResult')); +``` + +**Step 2: Add protected routes** + +After the `/wheel` route block (line ~421), add: + +```tsx + + + + + + } +/> + + + + + + } +/> +``` + +**Step 3: Commit** + +```bash +git add src/App.tsx +git commit -m "feat: add /gift and /gift/result routes" +``` + +--- + +## Phase 6: Frontend — Navigation + +### Task 13: Add gift link to navigation + +**Files:** +- Modify: `bedolaga-cabinet/src/components/layout/AppShell/AppShell.tsx:206,337-351` +- Modify: `bedolaga-cabinet/src/components/layout/AppShell/AppHeader.tsx:158-168` + +**Step 1: Destructure giftEnabled from useFeatureFlags** + +In `AppShell.tsx` line 206, add `giftEnabled`: + +```typescript +const { referralEnabled, wheelEnabled, hasContests, hasPolls, giftEnabled } = useFeatureFlags(); +``` + +**Step 2: Add desktop nav link** + +After the referral nav link block (line ~351), add: + +```tsx +{giftEnabled && ( + + + {t('nav.gift')} + +)} +``` + +Add `GiftIcon` component near the other icon components (line ~30): + +```tsx +const GiftIcon = ({ className }: { className?: string }) => ( + + + +); +``` + +**Step 3: Pass giftEnabled to AppHeader** + +Add `giftEnabled` to `AppHeader` props (line ~406-418): + +```tsx + +``` + +**Step 4: Add to hamburger menu in AppHeader.tsx** + +In `AppHeader.tsx`, add to `navItems` array (line ~168), after referral: + +```typescript +...(giftEnabled ? [{ path: '/gift', label: t('nav.gift'), icon: GiftIcon }] : []), +``` + +Add the same `GiftIcon` component to `AppHeader.tsx`. + +**Step 5: Commit** + +```bash +git add src/components/layout/AppShell/AppShell.tsx src/components/layout/AppShell/AppHeader.tsx +git commit -m "feat: add gift nav link to desktop and mobile navigation" +``` + +--- + +## Phase 7: Frontend — Internationalization + +### Task 14: Add i18n translations + +**Files:** +- Modify: `bedolaga-cabinet/src/locales/ru.json` +- Modify: `bedolaga-cabinet/src/locales/en.json` +- Modify: `bedolaga-cabinet/src/locales/zh.json` +- Modify: `bedolaga-cabinet/src/locales/fa.json` + +**Step 1: Add nav key to all locales** + +In the `nav` section of each locale, add: + +```json +"gift": "Подарить подписку" // ru +"gift": "Gift subscription" // en +"gift": "赠送订阅" // zh +"gift": "اشتراک هدیه" // fa +``` + +**Step 2: Add gift section to all locales** + +Add `gift` section (Russian example — translate for others): + +```json +"gift": { + "title": "Подарить подписку", + "subtitle": "Отправьте VPN-подписку в подарок", + "choosePeriod": "Выберите период", + "chooseTariff": "Выберите тариф", + "recipient": "Получатель", + "recipientPlaceholder": "Email или @telegram", + "recipientHint": "Введите email или юзернейм в Telegram", + "giftMessage": "Поздравление", + "giftMessagePlaceholder": "Добавьте личное сообщение (необязательно)", + "paymentMode": "Способ оплаты", + "fromBalance": "С баланса", + "viaGateway": "Через платёжку", + "yourBalance": "Ваш баланс", + "insufficientBalance": "Недостаточно средств", + "topUpBalance": "Пополнить баланс", + "total": "Итого", + "giftButton": "Подарить", + "sending": "Отправляем подарок...", + "successTitle": "Подарок отправлен!", + "successDesc": "Получатель {{contact}} получит уведомление", + "pendingTitle": "Ожидание оплаты", + "pendingDesc": "Завершите оплату в платёжной системе", + "pendingActivationTitle": "Ожидает активации", + "pendingActivationDesc": "У получателя есть активная подписка. Подарок ожидает активации.", + "failedTitle": "Ошибка", + "failedDesc": "Не удалось отправить подарок. Попробуйте снова.", + "backToGift": "Вернуться", + "gb": "ГБ", + "devices": "устройств", + "paymentMethod": "Способ оплаты", + "processing": "Обработка..." +} +``` + +**Step 3: Commit** + +```bash +git add src/locales/ +git commit -m "feat: add gift subscription i18n translations" +``` + +--- + +## Phase 8: Integration & Polish + +### Task 15: Wire up gateway payment for gifts (optional MVP+) + +**Files:** +- Modify: `app/cabinet/routes/gift.py` (the `_create_gift_payment` stub) +- Modify: Payment webhook handlers to recognize gift purchases + +This task wires the payment gateway for gift purchases. For MVP, balance mode is sufficient. Implement this when balance-only is validated. + +The approach: +1. In `_create_gift_payment`, parse `payment_method` string (e.g., `platega_2`) into method + sub-option +2. Call `PaymentService.create_*_payment()` with gift-specific metadata including `purchase.token` +3. In the payment webhook handler (e.g., `app/external/payment_webhooks.py`), when payment succeeds, check if metadata contains a gift purchase token +4. If yes, call `guest_purchase_service.fulfill_purchase(db, token)` to deliver the gift + +### Task 16: Admin settings UI for gift toggle + +**Files:** +- Modify: `bedolaga-cabinet/src/pages/AdminSettings.tsx` (if settings are listed there) + +Add a toggle for "Gift subscriptions in cabinet" that calls `PATCH /cabinet/branding/gift-enabled`. + +This follows the same pattern as the existing lite-mode or animation toggles in admin settings. + +### Task 17: Final verification + +**Step 1: Run backend linting** + +```bash +cd /Users/ea/Desktop/DEV/remnawave-bedolaga-telegram-bot +make lint +make fix # if needed +``` + +**Step 2: Run frontend type check** + +```bash +cd /Users/ea/Desktop/DEV/bedolaga-cabinet +npx tsc --noEmit +``` + +**Step 3: Test the flow manually** + +1. Enable gift feature: `PATCH /cabinet/branding/gift-enabled` → `{"enabled": true}` +2. Open cabinet → verify "Gift" appears in nav +3. Go to `/gift` → select tariff, period, enter recipient +4. Purchase with balance → verify success +5. Verify recipient receives notification (Telegram or email) +6. Disable feature → verify nav link disappears + +--- + +## Execution Order & Dependencies + +``` +Phase 1 (Model) → Task 1, 2 (sequential) +Phase 2 (Toggle) → Task 3 (independent) +Phase 3 (Routes) → Task 4, 5, 6, 7 (sequential, depends on Phase 1) +Phase 4 (API/Flag) → Task 8, 9 (parallel, depends on Phase 2-3) +Phase 5 (Pages) → Task 10, 11 (parallel, depends on Phase 4) +Phase 6 (Nav) → Task 13 (depends on Phase 4) +Phase 7 (i18n) → Task 14 (independent, can run anytime) +Phase 8 (Polish) → Task 15-17 (depends on all above) +``` + +**Parallelizable groups:** +- Tasks 1-3 can be done together (model + toggle = independent) +- Tasks 8+9 can be parallelized +- Tasks 10+11+14 can be parallelized +- Task 13 can run with 10+11 + +**Estimated tasks:** 17 tasks across 8 phases diff --git a/uv.lock b/uv.lock index d18e534c..5c3c373a 100644 --- a/uv.lock +++ b/uv.lock @@ -1115,7 +1115,7 @@ wheels = [ [[package]] name = "remnawave-bedolaga-telegram-bot" -version = "3.29.0" +version = "3.30.0" source = { virtual = "." } dependencies = [ { name = "aiogram" },