426 lines
14 KiB
Python
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
|