Files
remnawave-bedolaga-telegram…/app/services/rbac_bootstrap_service.py
T
2026-03-22 10:51:26 +03:00

426 lines
14 KiB
Python

"""
RBAC bootstrap service.
Auto-assigns the Superadmin role to users listed in ADMIN_IDS / ADMIN_EMAILS
config on bot startup. Runs once during the startup sequence.
"""
from typing import Final
import structlog
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.rbac import SUPERADMIN_LEVEL, UserRoleCRUD
from app.database.models import AdminRole, User, UserRole
logger = structlog.get_logger(__name__)
SUPERADMIN_ROLE_NAME: Final[str] = 'Superadmin'
# Preset roles seeded on first run
_PRESET_ROLES: list[dict] = [
{
'name': 'Superadmin',
'description': 'Full system access',
'level': 999,
'permissions': ['*:*'],
'color': '#EF4444',
'icon': 'shield',
'is_system': True,
},
{
'name': 'Admin',
'description': 'Administrative access',
'level': 100,
'permissions': [
'users:*',
'tickets:*',
'stats:*',
'sales_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:*',
'landings:read',
'landings:create',
'landings:edit',
'landings:delete',
],
'color': '#F59E0B',
'icon': 'crown',
'is_system': True,
},
{
'name': 'Moderator',
'description': 'User and ticket management',
'level': 50,
'permissions': ['users:read', 'users:edit', 'users:block', 'tickets:*', 'ban_system:*'],
'color': '#3B82F6',
'icon': 'user-shield',
'is_system': True,
},
{
'name': 'Marketer',
'description': 'Marketing tools access',
'level': 30,
'permissions': [
'campaigns:*',
'broadcasts:*',
'promocodes:*',
'promo_offers:*',
'promo_groups:*',
'stats:read',
'sales_stats:read',
'pinned_messages:*',
'wheel:*',
],
'color': '#8B5CF6',
'icon': 'megaphone',
'is_system': True,
},
{
'name': 'Support',
'description': 'Ticket support access',
'level': 20,
'permissions': ['tickets:read', 'tickets:reply', 'users:read'],
'color': '#10B981',
'icon': 'headset',
'is_system': True,
},
]
async def _ensure_preset_roles(db: AsyncSession) -> AdminRole | None:
"""Seed preset roles if they don't exist. Returns the Superadmin role.
Системные роли идентифицируются по (is_system=True, level) — это стабильно
даже если админ переименовал роль через UI.
Fallback на поиск по имени для обратной совместимости.
"""
superadmin_role: AdminRole | None = None
for preset in _PRESET_ROLES:
# Сначала ищем по стабильному ключу (is_system + level)
result = await db.execute(
select(AdminRole).where(AdminRole.is_system.is_(True), AdminRole.level == preset['level'])
)
existing = result.scalars().first()
# Fallback: поиск по имени (для ролей, созданных до этого фикса)
if existing is None:
result = await db.execute(select(AdminRole).where(AdminRole.name == preset['name']))
existing = result.scalars().first()
if existing is not None:
if existing.level == SUPERADMIN_LEVEL:
superadmin_role = existing
# Добавить НОВЫЕ permissions из кода, не трогая существующие (админ мог кастомизировать)
if existing.is_system:
current = set(existing.permissions or [])
from_code = set(preset['permissions'])
new_perms = from_code - current
if new_perms:
existing.permissions = list(current | new_perms)
await db.flush()
logger.info(
'Added new permissions to system role',
role_name=existing.name,
role_id=existing.id,
added=sorted(new_perms),
)
continue
role = AdminRole(
name=preset['name'],
description=preset['description'],
level=preset['level'],
permissions=preset['permissions'],
color=preset['color'],
icon=preset['icon'],
is_system=preset['is_system'],
is_active=True,
)
db.add(role)
await db.flush()
logger.info('Seeded preset role', role_name=preset['name'], role_id=role.id)
if preset['name'] == SUPERADMIN_ROLE_NAME:
superadmin_role = role
return superadmin_role
async def bootstrap_superadmins(db: AsyncSession) -> None:
"""Ensure every user from ADMIN_IDS / ADMIN_EMAILS has the Superadmin role.
Also seeds preset roles on first run.
Idempotent: skips users who already hold an active Superadmin assignment.
Commits only when at least one change was made.
"""
try:
admin_ids = settings.get_admin_ids()
admin_emails = settings.get_admin_emails()
# ── 1. Ensure preset roles exist (seeds on first run) ──────────
superadmin_role = await _ensure_preset_roles(db)
if superadmin_role is None:
logger.error('Failed to resolve Superadmin role after seeding')
return
if not admin_ids and not admin_emails:
logger.debug('No admin IDs or emails configured, skipping superadmin assignment')
await db.commit()
# Safety check even when no IDs configured — someone may have cleared them
await _warn_if_no_superadmins(db, admin_ids, admin_emails)
return
role_id: int = superadmin_role.id
assigned_count = 0
# ── 2. Process admin telegram IDs ──────────────────────────────
for telegram_id in admin_ids:
assigned = await _ensure_role_by_telegram_id(db, telegram_id=telegram_id, role_id=role_id)
if assigned:
assigned_count += 1
# ── 3. Process admin emails ────────────────────────────────────
for email in admin_emails:
assigned = await _ensure_role_by_email(db, email=email, role_id=role_id)
if assigned:
assigned_count += 1
# ── 4. Revoke superadmin from users NOT in env ───────────────
revoked_count = await _revoke_stale_superadmins(
db,
role_id=role_id,
admin_ids=admin_ids,
admin_emails=admin_emails,
)
# ── 5. Commit all changes ──────────────────────────────────────
await db.commit()
if assigned_count > 0 or revoked_count > 0:
logger.info(
'Superadmin bootstrap completed',
assigned_count=assigned_count,
revoked_count=revoked_count,
role_id=role_id,
)
else:
logger.debug('Superadmin bootstrap: no changes needed')
# ── 6. Safety: warn if no active superadmins exist ────────────
await _warn_if_no_superadmins(db, admin_ids, admin_emails)
except Exception:
await db.rollback()
logger.exception('Failed to bootstrap superadmins, continuing startup')
async def _revoke_stale_superadmins(
db: AsyncSession,
*,
role_id: int,
admin_ids: list[int],
admin_emails: list[str],
) -> int:
"""Revoke superadmin from users who are no longer in env config.
Env config (ADMIN_IDS / ADMIN_EMAILS) is the single source of truth.
If a user was removed from env, their superadmin DB role is deactivated
on the next bot restart.
Returns the number of revoked assignments.
"""
result = await db.execute(
select(UserRole)
.options(selectinload(UserRole.user))
.where(
UserRole.role_id == role_id,
UserRole.is_active.is_(True),
)
)
active_assignments = result.scalars().all()
admin_ids_set = set(admin_ids)
admin_emails_set = {e.lower() for e in admin_emails}
revoked = 0
for assignment in active_assignments:
user = assignment.user
if user is None:
continue
# Check if user is still in env config.
# email_verified is required — symmetric with _ensure_role_by_email.
in_env_by_id = user.telegram_id is not None and user.telegram_id in admin_ids_set
in_env_by_email = user.email is not None and user.email_verified and user.email.lower() in admin_emails_set
if not in_env_by_id and not in_env_by_email:
assignment.is_active = False
await db.flush()
revoked += 1
logger.warning(
'Revoked Superadmin role: user removed from env config',
user_id=user.id,
telegram_id=user.telegram_id,
email=user.email,
user_role_id=assignment.id,
)
return revoked
async def _warn_if_no_superadmins(
db: AsyncSession,
admin_ids: list[int],
admin_emails: list[str],
) -> None:
"""Log critical/warning if no active superadmin RBAC roles exist in DB."""
active = await UserRoleCRUD.get_superadmin_count(db)
if active > 0:
return
if not admin_ids and not admin_emails:
logger.critical(
'No active superadmins exist and no ADMIN_IDS/ADMIN_EMAILS configured. '
'Cabinet admin access is not possible until this is resolved.',
)
else:
logger.warning(
'No active superadmin RBAC roles in DB. Legacy config admins (ADMIN_IDS/ADMIN_EMAILS) still have access.',
)
async def _ensure_role_by_telegram_id(
db: AsyncSession,
*,
telegram_id: int,
role_id: int,
) -> bool:
"""Assign Superadmin role to user found by telegram_id. Returns True if assigned."""
result = await db.execute(select(User).where(User.telegram_id == telegram_id))
user = result.scalar_one_or_none()
if user is None:
logger.debug(
'Admin user not yet registered, skipping',
telegram_id=telegram_id,
)
return False
return await _assign_if_missing(db, user_id=user.id, role_id=role_id, identifier=str(telegram_id))
async def _ensure_role_by_email(
db: AsyncSession,
*,
email: str,
role_id: int,
) -> bool:
"""Assign Superadmin role to user found by verified email (case-insensitive). Returns True if assigned."""
result = await db.execute(
select(User).where(
func.lower(User.email) == email.lower(),
User.email_verified.is_(True),
)
)
user = result.scalar_one_or_none()
if user is None:
logger.debug(
'Admin user (email) not yet registered or not verified, skipping',
email=email,
)
return False
return await _assign_if_missing(db, user_id=user.id, role_id=role_id, identifier=email)
async def _assign_if_missing(
db: AsyncSession,
*,
user_id: int,
role_id: int,
identifier: str,
) -> bool:
"""Create or reactivate a UserRole row for this user/role pair.
Env config (ADMIN_IDS / ADMIN_EMAILS) is the source of truth for
Superadmin assignments. If a previously revoked assignment exists,
it is reactivated — the env config always wins.
Returns True if a new assignment was created or an inactive one was reactivated.
"""
result = await db.execute(
select(UserRole).where(
UserRole.user_id == user_id,
UserRole.role_id == role_id,
)
)
existing = result.scalar_one_or_none()
if existing is not None:
if existing.is_active:
logger.debug(
'User already has Superadmin role',
user_id=user_id,
identifier=identifier,
)
return False
# Reactivate: env config is the source of truth
existing.is_active = True
await db.flush()
logger.info(
'Reactivated Superadmin role (user is in env config)',
user_id=user_id,
identifier=identifier,
user_role_id=existing.id,
)
return True
user_role = UserRole(
user_id=user_id,
role_id=role_id,
is_active=True,
)
db.add(user_role)
await db.flush()
logger.info(
'Assigned Superadmin role to user',
user_id=user_id,
role_id=role_id,
identifier=identifier,
user_role_id=user_role.id,
)
return True