mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-04-26 07:56:18 +00:00
style: fix import sorting and formatting after lint
ruff auto-fix for import ordering in cabinet/subscription.py and formatting adjustments across changed files.
This commit is contained in:
@@ -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)
|
||||
|
||||
6
app/external/remnawave_api.py
vendored
6
app/external/remnawave_api.py
vendored
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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} дней'
|
||||
|
||||
@@ -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
|
||||
|
||||
243
docs/plans/2026-02-25-rbac-design.md
Normal file
243
docs/plans/2026-02-25-rbac-design.md
Normal file
@@ -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)
|
||||
1852
docs/plans/2026-02-25-rbac-implementation.md
Normal file
1852
docs/plans/2026-02-25-rbac-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
1163
docs/plans/2026-03-09-gift-subscription-cabinet.md
Normal file
1163
docs/plans/2026-03-09-gift-subscription-cabinet.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user