{username}
-Is Sudo: {is_sudo}
-Is Disabled: {is_disabled}
+Role: {role}
+Status: {status}
Used Traffic: {used_traffic}
➖➖➖➖➖➖➖➖➖
By: #{by}
@@ -92,8 +92,8 @@
#Modify_Admin
➖➖➖➖➖➖➖➖➖
Username: {username}
-Is Sudo: {is_sudo}
-Is Disabled: {is_disabled}
+Role: {role}
+Status: {status}
Used Traffic: {used_traffic}
➖➖➖➖➖➖➖➖➖
By: #{by}
@@ -113,6 +113,16 @@
By: #{by}
"""
+ADMIN_USAGE_LIMIT_REACHED = """
+⚠️ #Admin_Usage_Limit_Warning
+➖➖➖➖➖➖➖➖➖
+Username: {username}
+Used Traffic: {used_traffic}
+Data Limit: {data_limit}
+Usage: {usage_percentage}%
+Reached Threshold: {threshold}%
+"""
+
ADMIN_LOGIN = """
#Login_Attempt
Status: {status}
@@ -298,7 +308,7 @@
➖➖➖➖➖➖➖➖➖
Name: {name}
Inbound Tags: {inbound_tags}
-Is Disabled: {is_disabled}
+Status: {status}
➖➖➖➖➖➖➖➖➖
ID: {id}
By: #{by}
@@ -309,7 +319,7 @@
➖➖➖➖➖➖➖➖➖
Name: {name}
Inbound Tags: {inbound_tags}
-Is Disabled: {is_disabled}
+Status: {status}
➖➖➖➖➖➖➖➖➖
ID: {id}
By: #{by}
@@ -322,3 +332,32 @@
➖➖➖➖➖➖➖➖➖
By: #{by}
"""
+
+CREATE_ADMIN_ROLE = """
+#Create_Admin_Role
+➖➖➖➖➖➖➖➖➖
+Name: {name}
+Is Owner: {is_owner}
+➖➖➖➖➖➖➖➖➖
+ID: {id}
+By: #{by}
+"""
+
+MODIFY_ADMIN_ROLE = """
+#Modify_Admin_Role
+➖➖➖➖➖➖➖➖➖
+Name: {name}
+Is Owner: {is_owner}
+➖➖➖➖➖➖➖➖➖
+ID: {id}
+By: #{by}
+"""
+
+REMOVE_ADMIN_ROLE = """
+#Remove_Admin_Role
+➖➖➖➖➖➖➖➖➖
+Name: {name}
+➖➖➖➖➖➖➖➖➖
+ID: {id}
+By: #{by}
+"""
diff --git a/app/operation/__init__.py b/app/operation/__init__.py
index 346232920..461475482 100644
--- a/app/operation/__init__.py
+++ b/app/operation/__init__.py
@@ -25,6 +25,7 @@
from app.models.group import BulkGroup
from app.models.user import UserCreate, UserModify
from app.utils.helpers import ensure_datetime_timezone
+from app.operation.permissions import get_scope_admin_id
from app.utils.jwt import get_subscription_payload
@@ -175,13 +176,10 @@ async def get_validated_user(
load_next_plan=load_next_plan,
load_usage_logs=load_usage_logs,
load_groups=load_groups,
+ admin_id=get_scope_admin_id(admin, "users", "read"),
)
if not db_user:
await self.raise_error(message="User not found", code=404)
-
- if not (admin.is_sudo or db_user.admin_id == admin.id):
- await self.raise_error(message="You're not allowed", code=403)
-
return db_user
async def get_validated_user_by_id(
@@ -194,6 +192,8 @@ async def get_validated_user_by_id(
load_next_plan: bool = True,
load_usage_logs: bool = True,
load_groups: bool = True,
+ scope_resource: str = "users",
+ scope_action: str = "read",
) -> User:
db_user = await get_user_by_id(
db,
@@ -202,13 +202,10 @@ async def get_validated_user_by_id(
load_next_plan=load_next_plan,
load_usage_logs=load_usage_logs,
load_groups=load_groups,
+ admin_id=get_scope_admin_id(admin, scope_resource, scope_action),
)
if not db_user:
await self.raise_error(message="User not found", code=404)
-
- if not (admin.is_sudo or db_user.admin_id == admin.id):
- await self.raise_error(message="You're not allowed", code=403)
-
return db_user
async def get_validated_admin(self, db: AsyncSession, username: str) -> DBAdmin:
diff --git a/app/operation/admin.py b/app/operation/admin.py
index 30b31d2c9..7bf98b5ef 100644
--- a/app/operation/admin.py
+++ b/app/operation/admin.py
@@ -20,28 +20,26 @@
)
from app.db.crud.bulk import activate_all_disabled_users, disable_all_active_users
from app.db.crud.user import get_users, remove_users
-from app.db.models import Admin
+from app.models.user import UserListQuery
+from app.db.models import Admin, AdminStatus, UserStatus
from app.models.admin import (
AdminCreate,
- AdminListQuery,
- BulkAdminsActionResponse,
AdminDetails,
+ AdminListQuery,
AdminModify,
- AdminSimpleListQuery,
AdminSimple,
+ AdminSimpleListQuery,
AdminsResponse,
AdminsSimpleResponse,
AdminUsageQuery,
BulkAdminSelection,
+ BulkAdminsActionResponse,
RemoveAdminsResponse,
)
-from app.models.user import UserListQuery
-from app.node.sync import (
- sync_users,
- remove_user as sync_remove_user,
-)
from app.models.stats import Period, UserUsageStatsList
-from app.operation import BaseOperation, OperatorType
+from app.node.sync import remove_user as sync_remove_user, remove_users as sync_remove_users, sync_users
+from app.operation import BaseOperation
+from app.operation.permissions import enforce_permission, PermissionDenied
from app.operation.user import UserOperation
from app.utils.logger import get_logger
@@ -50,14 +48,18 @@
class AdminOperation(BaseOperation):
@staticmethod
- def _is_non_blocking_sync_operator(operator_type: OperatorType) -> bool:
- return operator_type in (OperatorType.API, OperatorType.WEB)
+ def _is_owner_admin(db_admin: Admin) -> bool:
+ return db_admin.role_id == 1 or bool(db_admin.role and db_admin.role.is_owner)
+
+ async def _ensure_owner_target_access(self, db_admin: Admin, current_admin: AdminDetails) -> None:
+ if not current_admin.is_owner and self._is_owner_admin(db_admin):
+ await self.raise_error(message="Owner account is not accessible.", code=403)
async def create_admin(self, db: AsyncSession, new_admin: AdminCreate, admin: AdminDetails) -> AdminDetails:
- """Create a new admin if the current admin has sudo privileges."""
- if self.operator_type != OperatorType.CLI and new_admin.is_sudo:
+ """Create a new admin."""
+ if new_admin.role_id == 1:
await self.raise_error(
- message="Creating sudo admin via API is not allowed. Use pasarguard cli / tui.", code=403
+ message="Owner role cannot be assigned via this endpoint. Use the setup flow.", code=403
)
if new_admin.telegram_id is not None:
@@ -70,40 +72,42 @@ async def create_admin(self, db: AsyncSession, new_admin: AdminCreate, admin: Ad
except IntegrityError:
await self.raise_error(message="Admin already exists", code=409, db=db)
- if self.operator_type != OperatorType.CLI:
- logger.info(f'New admin "{db_admin.username}" with id "{db_admin.id}" added by admin "{admin.username}"')
- new_admin = AdminDetails.model_validate(db_admin)
- asyncio.create_task(notification.create_admin(new_admin, admin.username))
-
+ logger.info(f'New admin "{db_admin.username}" with id "{db_admin.id}" added by admin "{admin.username}"')
+ new_admin_details = AdminDetails.model_validate(db_admin)
+ asyncio.create_task(notification.create_admin(new_admin_details, admin.username))
return db_admin
async def modify_admin(
self, db: AsyncSession, username: str, modified_admin: AdminModify, current_admin: AdminDetails
) -> AdminDetails:
warnings.warn(
- "modify_admin(username, ...) is deprecated and will be removed in v6.0.0. "
- "Use modify_admin_by_id(admin_id, ...).",
+ "modify_admin(username, ...) is deprecated. Use modify_admin_by_id(admin_id, ...).",
DeprecationWarning,
stacklevel=2,
)
db_admin = await self.get_validated_admin(db, username=username)
+ await self._ensure_owner_target_access(db_admin, current_admin)
return await self._modify_admin(db, db_admin, modified_admin, current_admin)
async def _modify_admin(
self, db: AsyncSession, db_admin: Admin, modified_admin: AdminModify, current_admin: AdminDetails
) -> AdminDetails:
"""Modify an existing admin's details."""
- if self.operator_type != OperatorType.CLI and not db_admin.is_sudo and modified_admin.is_sudo:
- await self.raise_error(
- message="Promoting admin to sudo via API is not allowed. Use pasarguard cli / tui instead.", code=403
- )
+ # Owner can only be modified by themselves — not by other admins via normal routes
+ if db_admin.role_id == 1 and (current_admin.id is None or db_admin.id != current_admin.id):
+ await self.raise_error(message="Owner cannot be modified via this endpoint. Use the setup flow.", code=403)
- if self.operator_type != OperatorType.CLI and db_admin.is_sudo and db_admin.username != current_admin.username:
+ if modified_admin.role_id == 1:
await self.raise_error(
- message="You're not allowed to modify sudoer's account. Use pasarguard cli / tui instead.", code=403
+ message="Owner role cannot be assigned via this endpoint. Use the setup flow.", code=403
)
- if db_admin.username == current_admin.username and modified_admin.is_disabled is True:
+ # Non-owner admins cannot modify other admins with equal or higher role level (role_id <= 2)
+ # Only the owner can modify administrators (role_id=2)
+ if not current_admin.is_owner and db_admin.id != current_admin.id and db_admin.role_id <= 2:
+ await self.raise_error(message="You're not allowed to modify an administrator account.", code=403)
+
+ if db_admin.username == current_admin.username and modified_admin.status == AdminStatus.disabled:
await self.raise_error(message="You're not allowed to disable your own account.", code=403)
if modified_admin.telegram_id is not None:
@@ -113,42 +117,54 @@ async def _modify_admin(
if existing_admins:
await self.raise_error(message="Telegram ID is already assigned to another admin.", code=409, db=db)
+ old_status = db_admin.status
db_admin = await update_admin(db, db_admin, modified_admin)
- if self.operator_type != OperatorType.CLI:
- logger.info(
- f'Admin "{db_admin.username}" with id "{db_admin.id}" modified by admin "{current_admin.username}"'
- )
+ # Sync users to nodes if admin status changed due to data_limit change
+ if modified_admin.data_limit is not None:
+ if old_status != AdminStatus.limited and db_admin.status == AdminStatus.limited:
+ # active → limited: remove active/on_hold users from nodes
+ users = await get_users(
+ db, query=UserListQuery(status=[UserStatus.active, UserStatus.on_hold]), admin=db_admin
+ )
+ await sync_remove_users(users)
+ elif old_status == AdminStatus.limited and db_admin.status == AdminStatus.active:
+ # limited → active: re-sync all users to nodes
+ # Pass empty set — this admin is now active, no exclusion needed
+ users = await get_users(db, query=UserListQuery(), admin=db_admin)
+ await sync_users(users)
- modified_admin = AdminDetails.model_validate(db_admin)
- asyncio.create_task(notification.modify_admin(modified_admin, current_admin.username))
- return modified_admin
+ logger.info(f'Admin "{db_admin.username}" with id "{db_admin.id}" modified by admin "{current_admin.username}"')
+
+ modified_admin_details = AdminDetails.model_validate(db_admin)
+ asyncio.create_task(notification.modify_admin(modified_admin_details, current_admin.username))
+ return modified_admin_details
async def modify_admin_by_id(
self, db: AsyncSession, admin_id: int, modified_admin: AdminModify, current_admin: AdminDetails
) -> AdminDetails:
db_admin = await self.get_validated_admin_by_id(db, admin_id)
+ await self._ensure_owner_target_access(db_admin, current_admin)
return await self._modify_admin(db, db_admin, modified_admin, current_admin)
async def remove_admin(self, db: AsyncSession, username: str, current_admin: AdminDetails | None = None):
warnings.warn(
- "remove_admin(username, ...) is deprecated and will be removed in v6.0.0. "
- "Use remove_admin_by_id(admin_id, ...).",
+ "remove_admin(username, ...) is deprecated. Use remove_admin_by_id(admin_id, ...).",
DeprecationWarning,
stacklevel=2,
)
db_admin = await self.get_validated_admin(db, username=username)
+ if current_admin is not None:
+ await self._ensure_owner_target_access(db_admin, current_admin)
await self._remove_admin(db, db_admin, current_admin)
async def _remove_admin(self, db: AsyncSession, db_admin: Admin, current_admin: AdminDetails | None = None):
"""Remove an admin from the database."""
- if self.operator_type != OperatorType.CLI and db_admin.is_sudo:
- await self.raise_error(
- message="You're not allowed to remove sudoer's account. Use pasarguard cli / tui instead.", code=403
- )
+ if db_admin.role_id == 1:
+ await self.raise_error(message="Owner cannot be deleted via this endpoint. Use the setup flow.", code=403)
await remove_admin(db, db_admin)
- if self.operator_type != OperatorType.CLI:
+ if current_admin:
logger.info(
f'Admin "{db_admin.username}" with id "{db_admin.id}" deleted by admin "{current_admin.username}"'
)
@@ -156,42 +172,27 @@ async def _remove_admin(self, db: AsyncSession, db_admin: Admin, current_admin:
async def remove_admin_by_id(self, db: AsyncSession, admin_id: int, current_admin: AdminDetails | None = None):
db_admin = await self.get_validated_admin_by_id(db, admin_id)
+ if current_admin is not None:
+ await self._ensure_owner_target_access(db_admin, current_admin)
await self._remove_admin(db, db_admin, current_admin)
- async def get_admins(
- self,
- db: AsyncSession,
- query: AdminListQuery,
- ) -> AdminsResponse:
- use_compact = self.operator_type in (OperatorType.API, OperatorType.WEB)
- admins, total, active, disabled = await get_admins(
+ async def get_admins(self, db: AsyncSession, query: AdminListQuery, admin: AdminDetails) -> AdminsResponse:
+ """Retrieve a list of admins with optional filters and pagination."""
+ admins, total, active, disabled, limited = await get_admins(
db,
query,
return_with_count=True,
- compact=use_compact,
+ compact=True,
+ include_owner=admin.is_owner,
)
-
- if self.operator_type in (OperatorType.API, OperatorType.WEB):
- return AdminsResponse(
- admins=admins,
- total=total,
- active=active,
- disabled=disabled,
- )
- return admins # type: ignore[return-value]
+ return AdminsResponse(admins=admins, total=total, active=active, disabled=disabled, limited=limited)
async def get_admins_simple(
- self,
- db: AsyncSession,
- query: AdminSimpleListQuery,
+ self, db: AsyncSession, query: AdminSimpleListQuery, admin: AdminDetails
) -> AdminsSimpleResponse:
- """Get lightweight admin list with only id and username"""
- # Call CRUD function
- rows, total = await get_admins_simple(db=db, query=query)
-
- # Convert tuples to Pydantic models
+ """Get lightweight admin list with only id and username."""
+ rows, total = await get_admins_simple(db=db, query=query, include_owner=admin.is_owner)
admins = [AdminSimple(id=row[0], username=row[1]) for row in rows]
-
return AdminsSimpleResponse(admins=admins, total=total)
async def get_admins_count(self, db: AsyncSession) -> int:
@@ -199,73 +200,60 @@ async def get_admins_count(self, db: AsyncSession) -> int:
async def disable_all_active_users(self, db: AsyncSession, username: str, admin: AdminDetails):
warnings.warn(
- "disable_all_active_users(username, ...) is deprecated and will be removed in v6.0.0. "
- "Use disable_all_active_users_by_id(admin_id, ...).",
+ "disable_all_active_users(username, ...) is deprecated. Use disable_all_active_users_by_id(admin_id, ...).",
DeprecationWarning,
stacklevel=2,
)
db_admin = await self.get_validated_admin(db, username=username)
+ await self._ensure_owner_target_access(db_admin, admin)
await self._disable_all_active_users_for_admin(db, db_admin, admin)
async def _disable_all_active_users_for_admin(self, db: AsyncSession, db_admin: Admin, admin: AdminDetails):
- """Disable all active users under a specific admin"""
- if db_admin.is_sudo:
- await self.raise_error(message="You're not allowed to disable sudo admin users.", code=403)
-
+ """Disable all active users under a specific admin."""
await disable_all_active_users(db=db, admin=db_admin)
-
users = await get_users(db, query=UserListQuery(), admin=db_admin)
await sync_users(users)
-
logger.info(f'Admin "{db_admin.username}" users has been disabled by admin "{admin.username}"')
async def disable_all_active_users_by_id(self, db: AsyncSession, admin_id: int, admin: AdminDetails):
db_admin = await self.get_validated_admin_by_id(db, admin_id)
+ await self._ensure_owner_target_access(db_admin, admin)
await self._disable_all_active_users_for_admin(db, db_admin, admin)
async def activate_all_disabled_users(self, db: AsyncSession, username: str, admin: AdminDetails):
warnings.warn(
- "activate_all_disabled_users(username, ...) is deprecated and will be removed in v6.0.0. "
- "Use activate_all_disabled_users_by_id(admin_id, ...).",
+ "activate_all_disabled_users(username, ...) is deprecated. Use activate_all_disabled_users_by_id(admin_id, ...).",
DeprecationWarning,
stacklevel=2,
)
db_admin = await self.get_validated_admin(db, username=username)
+ await self._ensure_owner_target_access(db_admin, admin)
await self._activate_all_disabled_users_for_admin(db, db_admin, admin)
async def _activate_all_disabled_users_for_admin(self, db: AsyncSession, db_admin: Admin, admin: AdminDetails):
- """Enable all active users under a specific admin"""
- if db_admin.is_sudo:
- await self.raise_error(message="You're not allowed to enable sudo admin users.", code=403)
-
+ """Activate all disabled users under a specific admin."""
await activate_all_disabled_users(db=db, admin=db_admin)
-
users = await get_users(db, query=UserListQuery(), admin=db_admin)
await sync_users(users)
-
logger.info(f'Admin "{db_admin.username}" users has been activated by admin "{admin.username}"')
async def activate_all_disabled_users_by_id(self, db: AsyncSession, admin_id: int, admin: AdminDetails):
db_admin = await self.get_validated_admin_by_id(db, admin_id)
+ await self._ensure_owner_target_access(db_admin, admin)
await self._activate_all_disabled_users_for_admin(db, db_admin, admin)
async def remove_all_users(self, db: AsyncSession, username: str, admin: AdminDetails) -> int:
warnings.warn(
- "remove_all_users(username, ...) is deprecated and will be removed in v6.0.0. "
- "Use remove_all_users_by_id(admin_id, ...).",
+ "remove_all_users(username, ...) is deprecated. Use remove_all_users_by_id(admin_id, ...).",
DeprecationWarning,
stacklevel=2,
)
db_admin = await self.get_validated_admin(db, username=username)
+ await self._ensure_owner_target_access(db_admin, admin)
return await self._remove_all_users_for_admin(db, db_admin, admin)
async def _remove_all_users_for_admin(self, db: AsyncSession, db_admin: Admin, admin: AdminDetails) -> int:
"""Delete all users that belong to the specified admin."""
- target_username = db_admin.username
-
- if self.operator_type != OperatorType.CLI and db_admin.is_sudo:
- await self.raise_error(message="You're not allowed to delete sudo admin users.", code=403)
-
users = await get_users(db, query=UserListQuery(), admin=db_admin)
if not users:
return 0
@@ -276,57 +264,59 @@ async def _remove_all_users_for_admin(self, db: AsyncSession, db_admin: Admin, a
await remove_users(db, users)
for user in serialized_users:
await sync_remove_user(user)
-
for user in serialized_users:
asyncio.create_task(notification.remove_user(user, admin))
logger.info(
- f'Admin "{admin.username}" deleted {len(serialized_users)} users belonging to admin "{target_username}"'
+ f'Admin "{admin.username}" deleted {len(serialized_users)} users belonging to admin "{db_admin.username}"'
)
return len(serialized_users)
async def remove_all_users_by_id(self, db: AsyncSession, admin_id: int, admin: AdminDetails) -> int:
db_admin = await self.get_validated_admin_by_id(db, admin_id)
+ await self._ensure_owner_target_access(db_admin, admin)
return await self._remove_all_users_for_admin(db, db_admin, admin)
async def reset_admin_usage(self, db: AsyncSession, username: str, admin: AdminDetails) -> AdminDetails:
warnings.warn(
- "reset_admin_usage(username, ...) is deprecated and will be removed in v6.0.0. "
- "Use reset_admin_usage_by_id(admin_id, ...).",
+ "reset_admin_usage(username, ...) is deprecated. Use reset_admin_usage_by_id(admin_id, ...).",
DeprecationWarning,
stacklevel=2,
)
db_admin = await self.get_validated_admin(db, username=username)
+ await self._ensure_owner_target_access(db_admin, admin)
return await self._reset_admin_usage(db, db_admin, admin)
async def _reset_admin_usage(self, db: AsyncSession, db_admin: Admin, admin: AdminDetails) -> AdminDetails:
+ """Reset an admin's traffic usage and log the action."""
+ old_status = db_admin.status
db_admin = await reset_admin_usage(db, db_admin=db_admin)
- if self.operator_type != OperatorType.CLI:
- logger.info(f'Admin "{db_admin.username}" usage has been reset by admin "{admin.username}"')
+ # If admin was limited and is now active, re-sync all users to nodes
+ if old_status == AdminStatus.limited and db_admin.status == AdminStatus.active:
+ users = await get_users(db, query=UserListQuery(), admin=db_admin)
+ await sync_users(users)
+
+ logger.info(f'Admin "{db_admin.username}" usage has been reset by admin "{admin.username}"')
reseted_admin_details = AdminDetails.model_validate(db_admin)
asyncio.create_task(notification.admin_usage_reset(reseted_admin_details, admin.username))
-
return reseted_admin_details
async def reset_admin_usage_by_id(self, db: AsyncSession, admin_id: int, admin: AdminDetails) -> AdminDetails:
db_admin = await self.get_validated_admin_by_id(db, admin_id)
+ await self._ensure_owner_target_access(db_admin, admin)
return await self._reset_admin_usage(db, db_admin, admin)
async def get_admin_usage(
- self,
- db: AsyncSession,
- username: str,
- admin: AdminDetails,
- query: AdminUsageQuery,
+ self, db: AsyncSession, username: str, admin: AdminDetails, query: AdminUsageQuery
) -> UserUsageStatsList:
warnings.warn(
- "get_admin_usage(username, ...) is deprecated and will be removed in v6.0.0. "
- "Use get_admin_usage_by_id(admin_id, ...).",
+ "get_admin_usage(username, ...) is deprecated. Use get_admin_usage_by_id(admin_id, ...).",
DeprecationWarning,
stacklevel=2,
)
db_admin = await self.get_validated_admin(db, username=username)
+ await self._ensure_owner_target_access(db_admin, admin)
return await self._get_admin_usage(
db,
db_admin,
@@ -352,9 +342,15 @@ async def _get_admin_usage(
"""Get aggregated usage for an admin's users."""
start, end = await self.validate_dates(start, end, True)
- if not admin.is_sudo:
- if db_admin.username != admin.username:
+ is_self = db_admin.username == admin.username
+ if not is_self:
+ # Non-self access requires admins.read permission
+ try:
+ enforce_permission(admin, "admins", "read")
+ except PermissionDenied:
await self.raise_error(message="You're not allowed", code=403)
+ else:
+ # Self-access: restrict to own data only (no cross-node filtering)
node_id = None
group_by_node = False
@@ -369,13 +365,10 @@ async def _get_admin_usage(
)
async def get_admin_usage_by_id(
- self,
- db: AsyncSession,
- admin_id: int,
- admin: AdminDetails,
- query: AdminUsageQuery,
+ self, db: AsyncSession, admin_id: int, admin: AdminDetails, query: AdminUsageQuery
) -> UserUsageStatsList:
db_admin = await self.get_validated_admin_by_id(db, admin_id)
+ await self._ensure_owner_target_access(db_admin, admin)
return await self._get_admin_usage(
db,
db_admin,
@@ -390,90 +383,61 @@ async def get_admin_usage_by_id(
async def bulk_remove_admins(
self, db: AsyncSession, bulk_admins: BulkAdminSelection, admin: AdminDetails
) -> RemoveAdminsResponse:
- """Remove multiple admins by username"""
- db_admins = []
- for username in bulk_admins.usernames:
- db_admin = await self.get_validated_admin(db, username)
- if self.operator_type != OperatorType.CLI and db_admin.is_sudo:
- await self.raise_error(
- message=f"You're not allowed to remove sudo admin {username}. Use pasarguard cli / tui instead.",
- code=403,
- )
- db_admins.append(db_admin)
-
- usernames = [admin_obj.username for admin_obj in db_admins]
- admin_ids = [admin_obj.id for admin_obj in db_admins]
+ """Remove multiple admins by ID."""
+ db_admins = await self._get_validated_bulk_admins(db, bulk_admins.ids, admin)
+ if any(self._is_owner_admin(db_admin) for db_admin in db_admins):
+ await self.raise_error(message="Owner cannot be deleted via this endpoint. Use the setup flow.", code=403)
- # Batch delete using CRUD function
+ usernames = [a.username for a in db_admins]
+ admin_ids = [a.id for a in db_admins]
await remove_admins(db, admin_ids)
- if self.operator_type != OperatorType.CLI:
- for username in usernames:
- logger.info(f'Admin "{username}" deleted by admin "{admin.username}"')
- asyncio.create_task(notification.remove_admin(username, admin.username))
+ for username in usernames:
+ logger.info(f'Admin "{username}" deleted by admin "{admin.username}"')
+ asyncio.create_task(notification.remove_admin(username, admin.username))
return RemoveAdminsResponse(admins=usernames, count=len(db_admins))
@staticmethod
- def _build_bulk_action_response(admins: list[AdminDetails | AdminSimple | Admin]) -> BulkAdminsActionResponse:
- usernames = [admin.username for admin in admins]
+ def _build_bulk_action_response(admins: list) -> BulkAdminsActionResponse:
+ usernames = [a.username for a in admins]
return BulkAdminsActionResponse(admins=usernames, count=len(usernames))
async def _get_validated_bulk_admins(
- self,
- db: AsyncSession,
- usernames: list[str] | set[str],
+ self, db: AsyncSession, ids: list[int] | set[int], current_admin: AdminDetails
) -> list[Admin]:
- db_admins: list[Admin] = []
- for username in usernames:
- db_admins.append(await self.get_validated_admin(db, username=username))
- return db_admins
+ if not ids:
+ return []
- async def _ensure_can_change_admin_status(
- self,
- db_admin: Admin,
- current_admin: AdminDetails,
- *,
- is_disabled: bool,
- ) -> None:
- if self.operator_type != OperatorType.CLI and db_admin.is_sudo and db_admin.username != current_admin.username:
- await self.raise_error(
- message="You're not allowed to modify sudoer's account. Use pasarguard cli / tui instead.",
- code=403,
- )
+ ids_list = list(ids)
- if is_disabled and db_admin.username == current_admin.username:
- await self.raise_error(message="You're not allowed to disable your own account.", code=403)
+ admins = await get_admins(db, AdminListQuery(ids=ids_list, limit=len(ids_list)))
- async def _ensure_can_manage_admin_users(self, db_admin: Admin, *, action: str) -> None:
- if not db_admin.is_sudo:
- return
+ # Verify every requested ID was found (mirrors the 404 in get_validated_admin_by_id)
+ found_ids = {a.id for a in admins}
+ missing = set(ids_list) - found_ids
+ if missing:
+ await self.raise_error(message="Admin not found", code=404)
- messages = {
- "disable": "You're not allowed to disable sudo admin users.",
- "activate": "You're not allowed to enable sudo admin users.",
- "remove": "You're not allowed to delete sudo admin users.",
- }
- await self.raise_error(message=messages[action], code=403)
+ for db_admin in admins:
+ await self._ensure_owner_target_access(db_admin, current_admin)
+
+ return admins
async def bulk_set_admins_disabled(
- self,
- db: AsyncSession,
- bulk_admins: BulkAdminSelection,
- current_admin: AdminDetails,
- *,
- is_disabled: bool,
+ self, db: AsyncSession, bulk_admins: BulkAdminSelection, current_admin: AdminDetails, *, is_disabled: bool
) -> BulkAdminsActionResponse:
- db_admins = await self._get_validated_bulk_admins(db, bulk_admins.usernames)
+ """Enable or disable selected admins in bulk."""
+ db_admins = await self._get_validated_bulk_admins(db, bulk_admins.ids, current_admin)
+ target_status = AdminStatus.disabled if is_disabled else AdminStatus.active
for db_admin in db_admins:
- await self._ensure_can_change_admin_status(db_admin, current_admin, is_disabled=is_disabled)
-
- admins_to_update = [db_admin for db_admin in db_admins if db_admin.is_disabled != is_disabled]
+ if is_disabled and db_admin.username == current_admin.username:
+ await self.raise_error(message="You're not allowed to disable your own account.", code=403)
+ admins_to_update = [a for a in db_admins if a.status != target_status]
for db_admin in admins_to_update:
- db_admin.is_disabled = is_disabled
-
+ db_admin.status = target_status
await db.commit()
for db_admin in admins_to_update:
@@ -488,51 +452,38 @@ async def bulk_set_admins_disabled(
async def bulk_reset_admins_usage(
self, db: AsyncSession, bulk_admins: BulkAdminSelection, admin: AdminDetails
) -> BulkAdminsActionResponse:
- db_admins = await self._get_validated_bulk_admins(db, bulk_admins.usernames)
-
+ """Reset usage for selected admins by ID."""
+ db_admins = await self._get_validated_bulk_admins(db, bulk_admins.ids, admin)
for db_admin in db_admins:
db_admin = await reset_admin_usage(db, db_admin=db_admin)
reseted_admin = AdminDetails.model_validate(db_admin)
asyncio.create_task(notification.admin_usage_reset(reseted_admin, admin.username))
logger.info(f'Admin "{db_admin.username}" usage has been reset by admin "{admin.username}"')
-
return self._build_bulk_action_response(db_admins)
async def bulk_disable_all_active_users_for_admins(
self, db: AsyncSession, bulk_admins: BulkAdminSelection, admin: AdminDetails
) -> BulkAdminsActionResponse:
- db_admins = await self._get_validated_bulk_admins(db, bulk_admins.usernames)
-
- for db_admin in db_admins:
- await self._ensure_can_manage_admin_users(db_admin, action="disable")
-
+ """Disable all active users under selected admins."""
+ db_admins = await self._get_validated_bulk_admins(db, bulk_admins.ids, admin)
for db_admin in db_admins:
await self._disable_all_active_users_for_admin(db, db_admin, admin)
-
return self._build_bulk_action_response(db_admins)
async def bulk_activate_all_disabled_users_for_admins(
self, db: AsyncSession, bulk_admins: BulkAdminSelection, admin: AdminDetails
) -> BulkAdminsActionResponse:
- db_admins = await self._get_validated_bulk_admins(db, bulk_admins.usernames)
-
- for db_admin in db_admins:
- await self._ensure_can_manage_admin_users(db_admin, action="activate")
-
+ """Activate all disabled users under selected admins."""
+ db_admins = await self._get_validated_bulk_admins(db, bulk_admins.ids, admin)
for db_admin in db_admins:
await self._activate_all_disabled_users_for_admin(db, db_admin, admin)
-
return self._build_bulk_action_response(db_admins)
async def bulk_remove_all_users_for_admins(
self, db: AsyncSession, bulk_admins: BulkAdminSelection, admin: AdminDetails
) -> BulkAdminsActionResponse:
- db_admins = await self._get_validated_bulk_admins(db, bulk_admins.usernames)
-
- for db_admin in db_admins:
- await self._ensure_can_manage_admin_users(db_admin, action="remove")
-
+ """Remove all users under selected admins."""
+ db_admins = await self._get_validated_bulk_admins(db, bulk_admins.ids, admin)
for db_admin in db_admins:
await self._remove_all_users_for_admin(db, db_admin, admin)
-
return self._build_bulk_action_response(db_admins)
diff --git a/app/operation/admin_role.py b/app/operation/admin_role.py
new file mode 100644
index 000000000..853167349
--- /dev/null
+++ b/app/operation/admin_role.py
@@ -0,0 +1,113 @@
+import asyncio
+
+from sqlalchemy.exc import IntegrityError
+
+from app import notification
+from app.db import AsyncSession
+from app.db.crud.admin_role import (
+ count_admins_by_role,
+ create_role,
+ delete_role,
+ get_role,
+ get_roles,
+ get_roles_simple,
+ modify_role,
+)
+from app.models.admin import AdminDetails
+from app.models.admin_role import (
+ AdminRoleCreate,
+ AdminRoleListQuery,
+ AdminRoleModify,
+ AdminRoleResponse,
+ AdminRolesResponse,
+ AdminRoleSimple,
+ AdminRolesSimpleResponse,
+)
+from app.operation import BaseOperation
+from app.utils.logger import get_logger
+
+logger = get_logger("admin-role-operation")
+
+
+class AdminRoleOperation(BaseOperation):
+ async def get_roles(self, db: AsyncSession, query: AdminRoleListQuery) -> AdminRolesResponse:
+ """List all roles with optional search and pagination."""
+ roles, total = await get_roles(db, query)
+ return AdminRolesResponse(
+ roles=[AdminRoleResponse.model_validate(r) for r in roles],
+ total=total,
+ )
+
+ async def get_roles_simple(self, db: AsyncSession) -> AdminRolesSimpleResponse:
+ """List all roles as lightweight id/name/is_owner tuples."""
+ rows = await get_roles_simple(db)
+ return AdminRolesSimpleResponse(
+ roles=[AdminRoleSimple(id=row[0], name=row[1], is_owner=row[2]) for row in rows],
+ total=len(rows),
+ )
+
+ async def get_role(self, db: AsyncSession, role_id: int) -> AdminRoleResponse:
+ """Fetch a single role by ID."""
+ role = await get_role(db, role_id)
+ if role is None:
+ await self.raise_error(message="Role not found", code=404)
+ return AdminRoleResponse.model_validate(role)
+
+ async def create_role(self, db: AsyncSession, data: AdminRoleCreate, admin: AdminDetails) -> AdminRoleResponse:
+ """Create a new role."""
+ try:
+ role = await create_role(db, data)
+ await db.commit()
+ await db.refresh(role)
+ except IntegrityError:
+ await self.raise_error(message="Role with this name already exists", code=409, db=db)
+
+ logger.info(f'Role "{role.name}" created by admin "{admin.username}"')
+ asyncio.create_task(notification.create_admin_role(AdminRoleResponse.model_validate(role), admin.username))
+ return AdminRoleResponse.model_validate(role)
+
+ async def modify_role(
+ self, db: AsyncSession, role_id: int, data: AdminRoleModify, admin: AdminDetails
+ ) -> AdminRoleResponse:
+ """Modify an existing role. Owner role cannot be modified."""
+ role = await get_role(db, role_id)
+ if role is None:
+ await self.raise_error(message="Role not found", code=404)
+
+ try:
+ role = await modify_role(db, role, data)
+ await db.commit()
+ except ValueError as e:
+ await self.raise_error(message=str(e), code=403)
+ except IntegrityError:
+ await self.raise_error(message="Role with this name already exists", code=409, db=db)
+
+ logger.info(f'Role "{role.name}" modified by admin "{admin.username}"')
+ response = AdminRoleResponse.model_validate(role)
+ asyncio.create_task(notification.modify_admin_role(response, admin.username))
+ return response
+
+ async def delete_role(self, db: AsyncSession, role_id: int, admin: AdminDetails) -> None:
+ """Delete a role. Built-in roles (id 1, 2, 3) cannot be deleted."""
+ role = await get_role(db, role_id)
+ if role is None:
+ await self.raise_error(message="Role not found", code=404)
+
+ if role.is_builtin:
+ await self.raise_error(message=f"Cannot delete built-in role '{role.name}'", code=403)
+
+ count = await count_admins_by_role(db, role_id)
+ if count > 0:
+ await self.raise_error(
+ message=f"Cannot delete role '{role.name}': {count} admin(s) are assigned to it",
+ code=409,
+ )
+
+ try:
+ await delete_role(db, role)
+ await db.commit()
+ except ValueError as e:
+ await self.raise_error(message=str(e), code=403)
+
+ logger.info(f'Role "{role.name}" deleted by admin "{admin.username}"')
+ asyncio.create_task(notification.remove_admin_role(AdminRoleResponse.model_validate(role), admin.username))
diff --git a/app/operation/client_template.py b/app/operation/client_template.py
index 4dc41cb21..062bef28c 100644
--- a/app/operation/client_template.py
+++ b/app/operation/client_template.py
@@ -186,12 +186,21 @@ async def bulk_remove_client_templates(
self, db: AsyncSession, bulk_templates: BulkClientTemplateSelection, admin: AdminDetails
) -> RemoveClientTemplatesResponse:
"""Remove multiple client templates by ID - fast batch delete"""
- db_templates = []
+ ids_list = list(bulk_templates.ids)
+ db_templates_list, _ = await get_client_templates(
+ db, ClientTemplateListQuery(ids=ids_list, limit=len(ids_list))
+ )
+
+ found_ids = {t.id for t in db_templates_list}
+ missing = set(ids_list) - found_ids
+ if missing:
+ await self.raise_error(message="Client template not found", code=404)
+
+ db_templates = list(db_templates_list)
templates_by_type = {}
- # Validate all templates exist and can be deleted
- for template_id in bulk_templates.ids:
- db_template = await self.get_validated_client_template(db, template_id)
+ # Validate all templates can be deleted
+ for db_template in db_templates:
template_type = ClientTemplateType(db_template.template_type)
if db_template.is_system:
@@ -201,7 +210,6 @@ async def bulk_remove_client_templates(
if template_type not in templates_by_type:
templates_by_type[template_type] = []
templates_by_type[template_type].append(db_template)
- db_templates.append(db_template)
# Validate we won't leave any type without templates
for template_type, templates_of_type in templates_by_type.items():
diff --git a/app/operation/core.py b/app/operation/core.py
index aaabf1323..203b48964 100644
--- a/app/operation/core.py
+++ b/app/operation/core.py
@@ -110,15 +110,20 @@ async def bulk_remove_cores(
self, db: AsyncSession, bulk_cores: BulkCoreSelection, admin: AdminDetails
) -> RemoveCoresResponse:
"""Remove multiple cores by ID"""
- db_cores = []
- for core_id in bulk_cores.ids:
- if core_id == 1:
+ ids_list = list(bulk_cores.ids)
+ db_cores_list, _ = await get_core_configs(db, CoreListQuery(ids=ids_list, limit=len(ids_list)))
+
+ found_ids = {c.id for c in db_cores_list}
+ missing = set(ids_list) - found_ids
+ if missing:
+ await self.raise_error(message="Core not found", code=404)
+
+ for db_core in db_cores_list:
+ if db_core.id == 1:
await self.raise_error(message="Cannot delete default core config", code=403)
- db_core = await self.get_validated_core_config(db, core_id)
- db_cores.append(db_core)
- core_ids = [c.id for c in db_cores]
- core_names = [c.name for c in db_cores]
+ core_ids = [c.id for c in db_cores_list]
+ core_names = [c.name for c in db_cores_list]
# Batch delete using CRUD function
await remove_cores(db, core_ids)
@@ -131,4 +136,4 @@ async def bulk_remove_cores(
await host_manager.setup_local(db)
- return RemoveCoresResponse(cores=core_names, count=len(db_cores))
+ return RemoveCoresResponse(cores=core_names, count=len(db_cores_list))
diff --git a/app/operation/group.py b/app/operation/group.py
index 078bed51d..c6dedc93c 100644
--- a/app/operation/group.py
+++ b/app/operation/group.py
@@ -6,6 +6,7 @@
from app.db.crud.group import (
create_group,
get_group,
+ get_groups_by_ids,
get_groups_simple,
load_group_attrs,
modify_group,
@@ -32,15 +33,24 @@
from app.models.user import BulkOperationDryRunResponse, UserListQuery
from app.node.sync import sync_users
from app.operation import BaseOperation, OperatorType
+from app.operation.permissions import apply_group_access
from app.utils.logger import get_logger
logger = get_logger("group-operation")
class GroupOperation(BaseOperation):
+ async def _get_group_with_access(self, db: AsyncSession, group_id: int, admin: Admin) -> Group:
+ """Fetch a group, returning 404 if it doesn't exist or is outside the admin's allowed set."""
+ allowed = apply_group_access(admin, [group_id])
+ # If allowed is an empty list, the id was filtered out → not accessible
+ if allowed is not None and group_id not in allowed:
+ await self.raise_error("Group not found", 404)
+ db_group = await self.get_validated_group(db, group_id)
+ return db_group
+
async def create_group(self, db: AsyncSession, new_group: GroupCreate, admin: Admin) -> Group:
await self.check_inbound_tags(new_group.inbound_tags)
-
db_group = await create_group(db, new_group)
group = GroupResponse.model_validate(db_group)
@@ -50,7 +60,8 @@ async def create_group(self, db: AsyncSession, new_group: GroupCreate, admin: Ad
logger.info(f'Group "{group.name}" created by admin "{admin.username}"')
return group
- async def get_all_groups(self, db: AsyncSession, query: GroupListQuery) -> GroupsResponse:
+ async def get_all_groups(self, db: AsyncSession, query: GroupListQuery, admin: Admin) -> GroupsResponse:
+ query.ids = apply_group_access(admin, query.ids)
db_groups, count = await get_group(db, query)
return GroupsResponse(groups=db_groups, total=count)
@@ -58,18 +69,16 @@ async def get_groups_simple(
self,
db: AsyncSession,
query: GroupSimpleListQuery,
+ admin: Admin,
) -> GroupsSimpleResponse:
"""Get lightweight group list with only id and name"""
- # Call CRUD function
+ query.ids = apply_group_access(admin, query.ids)
rows, total = await get_groups_simple(db=db, query=query)
-
- # Convert tuples to Pydantic models
groups = [GroupSimple(id=row[0], name=row[1]) for row in rows]
-
return GroupsSimpleResponse(groups=groups, total=total)
async def modify_group(self, db: AsyncSession, group_id: int, modified_group: GroupModify, admin: Admin) -> Group:
- db_group = await self.get_validated_group(db, group_id)
+ db_group = await self._get_group_with_access(db, group_id, admin)
if modified_group.inbound_tags is not None:
await self.check_inbound_tags(modified_group.inbound_tags)
db_group = await modify_group(db, db_group, modified_group)
@@ -88,7 +97,7 @@ async def modify_group(self, db: AsyncSession, group_id: int, modified_group: Gr
return group
async def remove_group(self, db: AsyncSession, group_id: int, admin: Admin) -> None:
- db_group = await self.get_validated_group(db, group_id)
+ db_group = await self._get_group_with_access(db, group_id, admin)
users = await get_users(db, query=UserListQuery(group_ids=[db_group.id]))
username_list = [user.username for user in users]
@@ -132,15 +141,17 @@ async def bulk_remove_groups_by_id(
self, db: AsyncSession, bulk_groups: BulkGroupSelection, admin: Admin
) -> RemoveGroupsResponse:
"""Remove multiple groups by ID"""
- db_groups = []
- all_affected_usernames = set()
+ requested_ids = list(bulk_groups.ids)
+ allowed_ids = apply_group_access(admin, requested_ids)
+ # Fetch all allowed groups in one query
+ db_groups = await get_groups_by_ids(db, allowed_ids or [], load_users=False, load_inbounds=False)
+ # Verify all requested ids were found and accessible
+ found_ids = {g.id for g in db_groups}
+ for gid in requested_ids:
+ if gid not in found_ids:
+ await self.raise_error("Group not found", 404)
- # Validate all groups exist
- for group_id in bulk_groups.ids:
- db_group = await self.get_validated_group(db, group_id)
- db_groups.append(db_group)
-
- # Get all affected users before deletion
+ all_affected_usernames = set()
for db_group in db_groups:
users = await get_users(db, query=UserListQuery(group_ids=[db_group.id]))
all_affected_usernames.update(user.username for user in users)
@@ -148,15 +159,12 @@ async def bulk_remove_groups_by_id(
group_ids = [g.id for g in db_groups]
group_names = [g.name for g in db_groups]
- # Batch delete using CRUD function
await remove_groups(db, group_ids)
- # Sync affected users
if all_affected_usernames:
users = await get_users(db, query=UserListQuery(username=list(all_affected_usernames)))
await sync_users(users)
- # Log and notify
for name, group_id in zip(group_names, group_ids):
logger.info(f'Group "{name}" deleted by admin "{admin.username}"')
asyncio.create_task(notification.remove_group(group_id, admin.username))
@@ -176,9 +184,13 @@ async def bulk_set_groups_disabled(
*,
is_disabled: bool,
) -> BulkGroupsActionResponse:
- db_groups = []
- for group_id in bulk_groups.ids:
- db_groups.append(await self.get_validated_group(db, group_id))
+ requested_ids = list(bulk_groups.ids)
+ allowed_ids = apply_group_access(admin, requested_ids)
+ db_groups = await get_groups_by_ids(db, allowed_ids or [], load_users=False, load_inbounds=False)
+ found_ids = {g.id for g in db_groups}
+ for gid in requested_ids:
+ if gid not in found_ids:
+ await self.raise_error("Group not found", 404)
groups_to_update = [db_group for db_group in db_groups if db_group.is_disabled != is_disabled]
diff --git a/app/operation/host.py b/app/operation/host.py
index e600f9535..ca7e59b7c 100644
--- a/app/operation/host.py
+++ b/app/operation/host.py
@@ -139,10 +139,13 @@ async def bulk_remove_hosts(
self, db: AsyncSession, bulk_hosts: BulkHostSelection, admin: AdminDetails
) -> RemoveHostsResponse:
"""Remove multiple hosts by ID"""
- db_hosts = []
- for host_id in bulk_hosts.ids:
- db_host = await self.get_validated_host(db, host_id)
- db_hosts.append(db_host)
+ ids_list = list(bulk_hosts.ids)
+ db_hosts = await get_hosts(db, HostListQuery(ids=ids_list, limit=len(ids_list)))
+
+ found_ids = {h.id for h in db_hosts}
+ missing = set(ids_list) - found_ids
+ if missing:
+ await self.raise_error(message="Host not found", code=404)
host_ids = [h.id for h in db_hosts]
@@ -171,9 +174,13 @@ async def bulk_set_hosts_disabled(
*,
is_disabled: bool,
) -> BulkHostsActionResponse:
- db_hosts = []
- for host_id in bulk_hosts.ids:
- db_hosts.append(await self.get_validated_host(db, host_id))
+ ids_list = list(bulk_hosts.ids)
+ db_hosts = await get_hosts(db, HostListQuery(ids=ids_list, limit=len(ids_list)))
+
+ found_ids = {h.id for h in db_hosts}
+ missing = set(ids_list) - found_ids
+ if missing:
+ await self.raise_error(message="Host not found", code=404)
hosts_to_update = [db_host for db_host in db_hosts if db_host.is_disabled != is_disabled]
diff --git a/app/operation/node.py b/app/operation/node.py
index b679399f1..fca4dda06 100644
--- a/app/operation/node.py
+++ b/app/operation/node.py
@@ -1006,10 +1006,18 @@ async def bulk_remove_nodes(
return RemoveNodesResponse(nodes=node_names, count=len(db_nodes))
async def _get_validated_nodes(self, db: AsyncSession, node_ids: list[int] | set[int]) -> list[Node]:
- nodes: list[Node] = []
- for node_id in node_ids:
- nodes.append(await self.get_validated_node(db, node_id))
- return nodes
+ if not node_ids:
+ return []
+
+ ids_list = list(node_ids)
+ db_nodes, _ = await get_nodes(db, NodeListQuery(ids=ids_list, limit=len(ids_list)))
+
+ found_ids = {n.id for n in db_nodes}
+ missing = set(ids_list) - found_ids
+ if missing:
+ await self.raise_error(message="Node not found", code=404)
+
+ return list(db_nodes)
@staticmethod
def _build_bulk_action_response(nodes: list[Node | NodeResponse]) -> BulkNodesActionResponse:
diff --git a/app/operation/permissions.py b/app/operation/permissions.py
new file mode 100644
index 000000000..df6368a28
--- /dev/null
+++ b/app/operation/permissions.py
@@ -0,0 +1,180 @@
+from functools import wraps
+
+from app.models.admin import AdminDetails
+from app.models.admin_role import PermissionScope, RoleLimits
+
+
+class PermissionDenied(Exception):
+ def __init__(self, detail: str = "Permission denied"):
+ self.detail = detail
+ super().__init__(detail)
+
+
+class LimitExceeded(Exception):
+ def __init__(self, detail: str):
+ self.detail = detail
+ super().__init__(detail)
+
+
+def _resolve_scope(action_perm) -> PermissionScope | None:
+ """Return PermissionScope if the action value is a scoped permission, else None."""
+ if isinstance(action_perm, dict):
+ raw = action_perm.get("scope")
+ if raw is not None:
+ return PermissionScope(raw)
+ return None
+
+
+def _get_resource_action(admin: AdminDetails, resource: str, action: str):
+ """Return the action permission value for resource+action, or None if missing."""
+ permissions = admin.role.permissions if admin.role else None
+ resource_perms = permissions.get(resource) if permissions else None
+ return (resource_perms or {}).get(action) if resource_perms is not None else None
+
+
+_READ_ACTIONS = frozenset({"read", "read_simple", "read_general", "logs", "stats"})
+
+
+def enforce_permission(admin: AdminDetails, resource: str, action: str) -> None:
+ """
+ Check if admin has permission for resource+action.
+ Raises PermissionDenied if not allowed.
+
+ Resolution order:
+ 1. role.is_owner → ALLOW unconditionally
+ 2. admin.is_limited:
+ - role.disabled_when_limited=True → DENY all actions
+ - role.disabled_when_limited=False → DENY write actions, allow read actions
+ 3. permissions[resource][action]:
+ - missing → DENY
+ - True → ALLOW
+ - {scope: NONE (0)} → DENY (explicitly disabled)
+ - {scope: OWN (1)} → ALLOW (scope enforced separately)
+ - {scope: ALL (2)} → ALLOW
+ """
+ if admin.is_owner:
+ return
+
+ if admin.is_limited:
+ if admin.role and admin.role.disabled_when_limited:
+ raise PermissionDenied("Admin is limited — all access blocked")
+ if action not in _READ_ACTIONS:
+ raise PermissionDenied("Admin is limited — write actions blocked")
+
+ action_perm = _get_resource_action(admin, resource, action)
+ if not action_perm:
+ raise PermissionDenied(f"Permission denied: {resource}.{action}")
+
+ scope = _resolve_scope(action_perm)
+ if scope is PermissionScope.NONE:
+ raise PermissionDenied(f"Permission denied: {resource}.{action}")
+
+
+def enforce_scope(admin: AdminDetails, resource: str, action: str, target_admin_id: int | None) -> None:
+ """
+ Enforce scope restriction (users resource only). Call AFTER enforce_permission.
+ Raises PermissionDenied if scope is OWN and target doesn't belong to this admin.
+ """
+ if admin.is_owner:
+ return
+
+ action_perm = _get_resource_action(admin, resource, action)
+ if _resolve_scope(action_perm) is PermissionScope.OWN and target_admin_id != admin.id:
+ raise PermissionDenied(f"Permission denied: {resource}.{action} (scope: own)")
+
+
+def is_scope_all(admin: AdminDetails, resource: str, action: str) -> bool:
+ """
+ Return True if the action has scope=ALL or True (no scope restriction).
+ Used to gate operations that require all-user access.
+ """
+ if admin.is_owner:
+ return True
+ action_perm = _get_resource_action(admin, resource, action)
+ if action_perm is None:
+ return False
+ scope = _resolve_scope(action_perm)
+ if scope is None:
+ # True = allowed with no scope restriction = effectively all
+ return action_perm is True
+ return scope is PermissionScope.ALL
+
+
+def get_scope_admin_id(admin: AdminDetails, resource: str, action: str) -> int | None:
+ """
+ Return admin.id if scope=OWN, else None.
+ Pass as admin_id to CRUD queries so the DB enforces scope.
+ """
+ if admin.is_owner:
+ return None
+ if admin.role is None:
+ return None
+ action_perm = _get_resource_action(admin, resource, action)
+ if _resolve_scope(action_perm) is PermissionScope.OWN:
+ return admin.id
+ return None
+
+
+def get_effective_limits(admin: AdminDetails) -> RoleLimits:
+ """
+ Merge role limits with per-admin permission_overrides.
+ Non-null override values win over role limits.
+ """
+ base = admin.role.limits if admin.role else RoleLimits()
+ overrides = admin.permission_overrides
+
+ if overrides is None:
+ return base
+
+ return base.model_copy(
+ update={k: getattr(overrides, k) for k in overrides.model_fields_set if getattr(overrides, k) is not None}
+ )
+
+
+def get_allowed_group_ids(admin: AdminDetails) -> list[int] | None:
+ """None means all groups allowed (owner or no restriction)."""
+ if admin.is_owner or admin.role is None:
+ return None
+ return admin.role.access.allowed_group_ids
+
+
+def get_allowed_template_ids(admin: AdminDetails) -> list[int] | None:
+ """None means all templates allowed (owner or no restriction)."""
+ if admin.is_owner or admin.role is None:
+ return None
+ return admin.role.access.allowed_template_ids
+
+
+def _intersect_ids(requested: list[int] | None, allowed: list[int] | None) -> list[int] | None:
+ if allowed is None:
+ return requested
+ if requested is None:
+ return allowed
+ return [i for i in requested if i in set(allowed)]
+
+
+def apply_group_access(admin: AdminDetails, ids: list[int] | None) -> list[int] | None:
+ """Intersect requested ids with admin's allowed_group_ids."""
+ return _intersect_ids(ids, get_allowed_group_ids(admin))
+
+
+def apply_template_access(admin: AdminDetails, ids: list[int] | None) -> list[int] | None:
+ """Intersect requested ids with admin's allowed_template_ids."""
+ return _intersect_ids(ids, get_allowed_template_ids(admin))
+
+
+def check_permission(resource: str, action: str):
+ """
+ Decorator for operation-layer methods.
+ Signature: async def method(self, db, *args, admin: AdminDetails, **kwargs)
+ """
+
+ def decorator(func):
+ @wraps(func)
+ async def wrapper(self, db, *args, admin: AdminDetails, **kwargs):
+ enforce_permission(admin, resource, action)
+ return await func(self, db, *args, admin=admin, **kwargs)
+
+ return wrapper
+
+ return decorator
diff --git a/app/operation/system.py b/app/operation/system.py
index 8f3223a85..2e969670c 100644
--- a/app/operation/system.py
+++ b/app/operation/system.py
@@ -10,6 +10,7 @@
from app.db.models import UserStatus
from app.models.admin import AdminDetails
from app.models.system import InboundSummary, SystemStats
+from app.operation.permissions import enforce_permission, PermissionDenied
from app.utils.system import cpu_usage, disk_usage, get_uptime, memory_usage
from . import BaseOperation
@@ -25,10 +26,26 @@ async def get_system_stats(db: AsyncSession, admin: AdminDetails, admin_username
disk_task = asyncio.create_task(asyncio.to_thread(disk_usage))
uptime_task = asyncio.create_task(asyncio.to_thread(get_uptime))
- admin_param = None
- if admin.is_sudo and admin_username:
- admin_param = await get_admin(db, admin_username, load_users=False, load_usage_logs=False)
- elif not admin.is_sudo:
+ # Determine which admin's stats to show:
+ # - Owner with no admin_username: global system stats (all users)
+ # - Owner with admin_username: that admin's stats
+ # - Non-owner with admins.read + admin_username: that admin's stats
+ # - Non-owner (any other case): scoped to their own users only
+ admin_param: AdminDetails | None = None
+ if admin_username:
+ can_read_admins = False
+ if not admin.is_owner:
+ try:
+ enforce_permission(admin, "admins", "read")
+ can_read_admins = True
+ except PermissionDenied:
+ can_read_admins = False
+ if admin.is_owner or can_read_admins:
+ admin_param = await get_admin(db, admin_username, load_users=False, load_usage_logs=False)
+ else:
+ admin_param = admin
+ elif not admin.is_owner:
+ # Non-owner without an explicit target only sees their own stats
admin_param = admin
system_task = None
diff --git a/app/operation/user.py b/app/operation/user.py
index 63a406de5..e57151bcc 100644
--- a/app/operation/user.py
+++ b/app/operation/user.py
@@ -3,7 +3,7 @@
import secrets
import warnings
from collections import Counter
-from datetime import datetime as dt, timedelta as td, timezone as tz
+from datetime import datetime, datetime as dt, timedelta as td, timezone, timezone as tz
from fastapi import HTTPException
from pydantic import ValidationError
@@ -22,6 +22,7 @@
update_users_expire,
update_users_proxy_settings,
)
+from app.db.crud.hwid import get_user_hwid_count
from app.db.crud.user import (
bulk_reset_user_data_usage,
bulk_revoke_user_sub,
@@ -34,6 +35,7 @@
get_user_count_metric_stats,
get_user_usages,
get_users,
+ get_users_count_by_admin,
get_users_simple,
get_users_sub_update_list,
get_users_subscription_agent_counts,
@@ -88,11 +90,21 @@
UserUsageQuery,
WireGuardPeerIPsReallocateResponse,
)
-from app.node.sync import remove_user as sync_remove_user, sync_user, sync_users
+from app.node.sync import remove_user as sync_remove_user, sync_users, sync_user
from app.operation import BaseOperation, OperatorType
+from app.operation.permissions import (
+ PermissionDenied,
+ apply_template_access,
+ enforce_permission,
+ get_effective_limits,
+ get_scope_admin_id,
+ is_scope_all,
+)
from app.settings import hwid_settings, subscription_settings
from app.utils.jwt import create_subscription_token
from app.utils.logger import get_logger
+from app.utils.helpers import fix_datetime_timezone
+from app.utils.system import readable_duration, readable_size
from app.utils.wireguard import (
build_wireguard_peer_ip_allocator,
bulk_reallocate_wireguard_peer_ips as run_bulk_reallocate_wireguard_peer_ips,
@@ -103,6 +115,16 @@
)
from config import subscription_env_settings, usage_settings, wireguard_settings
+
+def _has_permission(admin: AdminDetails, resource: str, action: str) -> bool:
+ """Return True if admin has the given resource+action permission (no scope check)."""
+ try:
+ enforce_permission(admin, resource, action)
+ return True
+ except PermissionDenied:
+ return False
+
+
logger = get_logger("user-operation")
_USER_AGENT_SPLIT_RE = re.compile(r"[;/\s\(\)]+")
@@ -236,10 +258,35 @@ async def _persist_bulk_users(
db_admin,
users_to_create: list[UserCreate],
groups: list,
+ *,
+ skip_per_user_limits: bool = False,
) -> list[str]:
if not users_to_create:
return []
+ # Enforce limits first — before any expensive wireguard/proxy work
+ if not admin.is_owner:
+ limits = get_effective_limits(admin)
+ if limits.max_users is not None:
+ current_count = await get_users_count_by_admin(db, admin.id)
+ if current_count + len(users_to_create) > limits.max_users:
+ await self.raise_error(
+ message=f"Bulk create would exceed user limit ({limits.max_users})", code=400, db=db
+ )
+
+ if not skip_per_user_limits:
+ for user_to_create in users_to_create:
+ await self._enforce_user_limits(
+ db,
+ admin,
+ data_limit=user_to_create.data_limit,
+ expire=user_to_create.expire,
+ on_hold_expire_duration=user_to_create.on_hold_expire_duration,
+ hwid_limit=user_to_create.hwid_limit,
+ data_limit_reset_strategy=user_to_create.data_limit_reset_strategy,
+ next_plan=user_to_create.next_plan,
+ )
+
wireguard_tags = await get_wireguard_tags_from_groups(groups)
use_shared_allocator = bool(wireguard_tags) and wireguard_settings.enabled
@@ -311,18 +358,123 @@ async def _prepare_user_proxy_settings(
except ValueError as exc:
await self.raise_error(message=str(exc), code=400, db=db)
- async def create_user(self, db: AsyncSession, new_user: UserCreate, admin: AdminDetails) -> UserResponse:
+ async def _get_validated_template_with_access(
+ self, db: AsyncSession, template_id: int, admin: AdminDetails
+ ) -> UserTemplate:
+ """Fetch a user template and verify the admin is allowed to access it via allowed_template_ids."""
+ allowed = apply_template_access(admin, [template_id])
+ if allowed is not None and template_id not in allowed:
+ await self.raise_error("User Template not found", 404)
+ return await self.get_validated_user_template(db, template_id)
+
+ async def _enforce_user_limits(
+ self,
+ db: AsyncSession,
+ admin: AdminDetails,
+ *,
+ data_limit: int | None = None,
+ expire: dt | int | None = None,
+ on_hold_expire_duration: int | None = None,
+ hwid_limit: int | None = None,
+ data_limit_reset_strategy=None,
+ next_plan=None,
+ check_max_users: bool = False,
+ ) -> None:
+ """Enforce role-level limits and feature flags. No-op for owner."""
+ if admin.is_owner:
+ return
+
+ limits = get_effective_limits(admin)
+
+ if check_max_users and limits.max_users is not None:
+ current_count = await get_users_count_by_admin(db, admin.id)
+ if current_count >= limits.max_users:
+ await self.raise_error(message=f"User limit reached ({limits.max_users})", code=400, db=db)
+
+ if data_limit is not None and data_limit > 0:
+ if limits.data_limit_min is not None and data_limit < limits.data_limit_min:
+ await self.raise_error(
+ message=f"Data limit must be at least {readable_size(limits.data_limit_min)}", code=400, db=db
+ )
+ if limits.data_limit_max is not None and data_limit > limits.data_limit_max:
+ await self.raise_error(
+ message=f"Data limit cannot exceed {readable_size(limits.data_limit_max)}", code=400, db=db
+ )
+
+ if expire is not None and expire != 0:
+ expire_dt = fix_datetime_timezone(expire)
+ seconds = (expire_dt - datetime.now(timezone.utc)).total_seconds()
+ if limits.expire_min is not None and seconds < limits.expire_min:
+ await self.raise_error(
+ message=f"Expire must be at least {readable_duration(limits.expire_min)} from now",
+ code=400,
+ db=db,
+ )
+ if limits.expire_max is not None and seconds > limits.expire_max:
+ await self.raise_error(
+ message=f"Expire cannot exceed {readable_duration(limits.expire_max)} from now",
+ code=400,
+ db=db,
+ )
+
+ if on_hold_expire_duration is not None and on_hold_expire_duration != 0:
+ if limits.expire_min is not None and on_hold_expire_duration < limits.expire_min:
+ await self.raise_error(
+ message=f"On-hold duration must be at least {readable_duration(limits.expire_min)}",
+ code=400,
+ db=db,
+ )
+ if limits.expire_max is not None and on_hold_expire_duration > limits.expire_max:
+ await self.raise_error(
+ message=f"On-hold duration cannot exceed {readable_duration(limits.expire_max)}",
+ code=400,
+ db=db,
+ )
+
+ if hwid_limit is not None:
+ if limits.min_hwid_per_user is not None and hwid_limit < limits.min_hwid_per_user:
+ await self.raise_error(
+ message=f"HWID limit must be at least {limits.min_hwid_per_user}", code=400, db=db
+ )
+ if limits.max_hwid_per_user is not None and hwid_limit > limits.max_hwid_per_user:
+ await self.raise_error(message=f"HWID limit cannot exceed {limits.max_hwid_per_user}", code=400, db=db)
+
+ features = admin.role.features if admin.role else None
+ if features is not None:
+ if data_limit_reset_strategy is not None and not features.can_use_reset_strategy:
+ strategy_val = getattr(data_limit_reset_strategy, "value", str(data_limit_reset_strategy))
+ if strategy_val != "no_reset":
+ await self.raise_error(message="Reset strategy is not allowed for your role", code=403, db=db)
+ if next_plan is not None and not features.can_use_next_plan:
+ await self.raise_error(message="Next plan is not allowed for your role", code=403, db=db)
+
+ async def create_user(
+ self, db: AsyncSession, new_user: UserCreate, admin: AdminDetails, *, skip_role_limits: bool = False
+ ) -> UserResponse:
hwid_conf = await hwid_settings()
if new_user.hwid_limit is None:
new_user.hwid_limit = hwid_conf.fallback_limit
- if new_user.hwid_limit is not None and not admin.is_sudo:
+ if new_user.hwid_limit is not None and not admin.is_owner:
if new_user.hwid_limit < hwid_conf.min_limit:
await self.raise_error(message=f"HWID limit cannot be less than {hwid_conf.min_limit}", code=400, db=db)
if hwid_conf.max_limit > 0 and (new_user.hwid_limit > hwid_conf.max_limit or new_user.hwid_limit == 0):
await self.raise_error(message=f"HWID limit cannot exceed {hwid_conf.max_limit}", code=400, db=db)
+ if not skip_role_limits:
+ await self._enforce_user_limits(
+ db,
+ admin,
+ data_limit=new_user.data_limit,
+ expire=new_user.expire,
+ on_hold_expire_duration=new_user.on_hold_expire_duration,
+ hwid_limit=new_user.hwid_limit,
+ data_limit_reset_strategy=new_user.data_limit_reset_strategy,
+ next_plan=new_user.next_plan,
+ check_max_users=True,
+ )
+
if new_user.next_plan is not None and new_user.next_plan.user_template_id is not None:
await self.get_validated_user_template(db, new_user.next_plan.user_template_id)
@@ -344,11 +496,15 @@ async def create_user(self, db: AsyncSession, new_user: UserCreate, admin: Admin
return user
async def _prepare_modified_user(
- self, db: AsyncSession, db_user: User, modified_user: UserModify, admin: AdminDetails
+ self,
+ db: AsyncSession,
+ db_user: User,
+ modified_user: UserModify,
+ admin: AdminDetails,
+ *,
+ skip_role_limits: bool = False,
):
if modified_user.hwid_limit is not None and modified_user.hwid_limit > 0:
- from app.db.crud.hwid import get_user_hwid_count
-
current_count = await get_user_hwid_count(db, db_user.id)
if current_count > modified_user.hwid_limit:
await self.raise_error(
@@ -357,7 +513,7 @@ async def _prepare_modified_user(
db=db,
)
- if modified_user.hwid_limit is not None and not admin.is_sudo:
+ if modified_user.hwid_limit is not None and not admin.is_owner:
hwid_conf = await hwid_settings()
if modified_user.hwid_limit < hwid_conf.min_limit:
await self.raise_error(message=f"HWID limit cannot be less than {hwid_conf.min_limit}", code=400, db=db)
@@ -366,6 +522,18 @@ async def _prepare_modified_user(
):
await self.raise_error(message=f"HWID limit cannot exceed {hwid_conf.max_limit}", code=400, db=db)
+ if not skip_role_limits:
+ await self._enforce_user_limits(
+ db,
+ admin,
+ data_limit=modified_user.data_limit,
+ expire=modified_user.expire,
+ on_hold_expire_duration=modified_user.on_hold_expire_duration,
+ hwid_limit=modified_user.hwid_limit,
+ data_limit_reset_strategy=modified_user.data_limit_reset_strategy,
+ next_plan=modified_user.next_plan,
+ )
+
validated_groups = None
if modified_user.group_ids:
validated_groups = await self.validate_all_groups(db, modified_user)
@@ -426,9 +594,17 @@ async def _apply_modified_user(
return user
async def _modify_user(
- self, db: AsyncSession, db_user: User, modified_user: UserModify, admin: AdminDetails
+ self,
+ db: AsyncSession,
+ db_user: User,
+ modified_user: UserModify,
+ admin: AdminDetails,
+ *,
+ skip_role_limits: bool = False,
) -> UserNotificationResponse:
- validated_groups = await self._prepare_modified_user(db, db_user, modified_user, admin)
+ validated_groups = await self._prepare_modified_user(
+ db, db_user, modified_user, admin, skip_role_limits=skip_role_limits
+ )
return await self._apply_modified_user(db, db_user, modified_user, admin, validated_groups=validated_groups)
async def modify_user(
@@ -484,19 +660,27 @@ async def _get_validated_users_by_ids(
load_usage_logs: bool = True,
load_groups: bool = True,
) -> list[User]:
- users: list[User] = []
- for user_id in user_ids:
- users.append(
- await self.get_validated_user_by_id(
- db,
- user_id,
- admin,
- load_admin=load_admin,
- load_next_plan=load_next_plan,
- load_usage_logs=load_usage_logs,
- load_groups=load_groups,
- )
- )
+ if not user_ids:
+ return []
+
+ ids_list = list(user_ids)
+
+ # Replicate the scope filter that get_validated_user_by_id applies per-user:
+ # non-owners with scope=OWN can only see their own users.
+ scope_admin_id = get_scope_admin_id(admin, "users", "read")
+ query = UserListQuery(
+ ids=ids_list,
+ admin_ids=[scope_admin_id] if scope_admin_id is not None else None,
+ limit=len(ids_list),
+ )
+ users = await get_users(db, query=query)
+
+ # Verify every requested ID was found (mirrors the 404 in get_validated_user_by_id)
+ found_ids = {user.id for user in users}
+ missing = set(ids_list) - found_ids
+ if missing:
+ await self.raise_error(message="User not found", code=404)
+
return users
@staticmethod
@@ -647,11 +831,10 @@ async def bulk_enable_users(
return self._build_bulk_action_response(users)
async def reset_users_data_usage(self, db: AsyncSession, admin: AdminDetails):
- """Reset all users data usage"""
- db_admin = await self.get_validated_admin(db, admin.username)
+ """Reset all users data usage — requires scope=all, resets every user."""
await reset_all_users_data_usage(
db=db,
- admin=db_admin,
+ admin=None, # None = all admins, not filtered by owner
clean_chart_data=usage_settings.reset_user_usage_clean_chart_data,
)
@@ -745,7 +928,7 @@ async def _get_user_usage(
) -> UserUsageStatsList:
start, end = await self.validate_dates(start, end, True)
- if not admin.is_sudo:
+ if not _has_permission(admin, "nodes", "stats"):
node_id = None
group_by_node = False
@@ -815,7 +998,8 @@ async def get_users(
query: UserListQuery,
) -> UsersResponse:
"""Get all users"""
- if not admin.is_sudo:
+ scope_admin_id = get_scope_admin_id(admin, "users", "read_simple")
+ if scope_admin_id is not None:
query = query.model_copy(update={"owner": [admin.username], "admin_ids": None})
users, count = await get_users(
@@ -842,9 +1026,11 @@ async def get_users_simple(
query: UserSimpleListQuery,
) -> UsersSimpleResponse:
"""Get lightweight user list with only id and username"""
- # Authorization: non-sudo admins see only their users
+ scope_admin_id = get_scope_admin_id(admin, "users", "read")
admin_filter = (
- None if admin.is_sudo else await get_admin(db, admin.username, load_users=False, load_usage_logs=False)
+ None
+ if scope_admin_id is None
+ else await get_admin(db, admin.username, load_users=False, load_usage_logs=False)
)
# Call CRUD function
@@ -870,17 +1056,20 @@ async def get_users_usage(
node_id = query.node_id
group_by_node = query.group_by_node
- if not admin.is_sudo:
+ can_use_node_scope = _has_permission(admin, "nodes", "stats")
+ if not can_use_node_scope:
node_id = None
group_by_node = False
+ admins_filter = query.owner if is_scope_all(admin, "users", "read") else [admin.username]
+
return await get_all_users_usages(
db=db,
start=start,
end=end,
period=query.period,
node_id=node_id,
- admins=query.owner if admin.is_sudo else [admin.username],
+ admins=admins_filter,
group_by_node=group_by_node,
)
@@ -896,7 +1085,8 @@ async def get_users_count_metric(
node_id = query.node_id
group_by_node = query.group_by_node
- if not admin.is_sudo:
+ can_use_node_scope = _has_permission(admin, "nodes", "stats")
+ if not can_use_node_scope:
node_id = None
group_by_node = False
@@ -905,9 +1095,11 @@ async def get_users_count_metric(
except ValueError as exc:
await self.raise_error(message=str(exc), code=400)
+ admins_filter = query.owner if is_scope_all(admin, "users", "read") else [admin.username]
+
return await get_user_count_metric_stats(
db=db,
- admins=query.owner if admin.is_sudo else [admin.username],
+ admins=admins_filter,
start=start,
end=end,
period=query.period,
@@ -1047,7 +1239,7 @@ def _build_user_create_from_template(
async def create_user_from_template(
self, db: AsyncSession, new_template_user: CreateUserFromTemplate, admin: AdminDetails
) -> UserResponse:
- user_template = await self.get_validated_user_template(db, new_template_user.user_template_id)
+ user_template = await self._get_validated_template_with_access(db, new_template_user.user_template_id, admin)
if user_template.is_disabled:
await self.raise_error("this template is disabled", 403)
@@ -1057,13 +1249,15 @@ async def create_user_from_template(
except HTTPException as exc:
raise exc
- return await self.create_user(db, new_user, admin)
+ # Template defines data_limit/expire/etc — only check max_users
+ await self._enforce_user_limits(db, admin, check_max_users=True)
+ return await self.create_user(db, new_user, admin, skip_role_limits=True)
async def _modify_user_with_template(
self, db: AsyncSession, db_user: User, modified_template: ModifyUserByTemplate, admin: AdminDetails
) -> UserResponse:
original_status = db_user.status
- user_template = await self.get_validated_user_template(db, modified_template.user_template_id)
+ user_template = await self._get_validated_template_with_access(db, modified_template.user_template_id, admin)
if user_template.is_disabled:
await self.raise_error("this template is disabled", 403)
@@ -1078,7 +1272,7 @@ async def _modify_user_with_template(
await self.raise_error(message=error_messages, code=400)
modify_user = self.apply_settings(modify_user, user_template)
- validated_groups = await self._prepare_modified_user(db, db_user, modify_user, admin)
+ validated_groups = await self._prepare_modified_user(db, db_user, modify_user, admin, skip_role_limits=True)
if user_template.reset_usages:
suppress_reset_status_change = (
@@ -1115,7 +1309,7 @@ async def bulk_create_users_from_template(
self, db: AsyncSession, bulk_users: BulkUsersFromTemplate, admin: AdminDetails
) -> BulkUsersCreateResponse:
template_payload = bulk_users
- user_template = await self.get_validated_user_template(db, template_payload.user_template_id)
+ user_template = await self._get_validated_template_with_access(db, template_payload.user_template_id, admin)
if user_template.is_disabled:
await self.raise_error("this template is disabled", 403)
@@ -1159,7 +1353,9 @@ def builder(username: str):
groups = await self.validate_all_groups(db, users_to_create[0])
db_admin = await get_admin(db, admin.username, load_users=False, load_usage_logs=False)
- subscription_urls = await self._persist_bulk_users(db, admin, db_admin, users_to_create, groups)
+ subscription_urls = await self._persist_bulk_users(
+ db, admin, db_admin, users_to_create, groups, skip_per_user_limits=True
+ )
return BulkUsersCreateResponse(subscription_urls=subscription_urls, created=len(subscription_urls))
@@ -1170,7 +1366,7 @@ async def bulk_apply_template_to_users(
admin: AdminDetails,
) -> BulkUsersActionResponse:
db_users = await self._get_validated_users_by_ids(db, body.ids, admin, load_usage_logs=False)
- user_template = await self.get_validated_user_template(db, body.user_template_id)
+ user_template = await self._get_validated_template_with_access(db, body.user_template_id, admin)
if user_template.is_disabled:
await self.raise_error("this template is disabled", 403)
@@ -1200,7 +1396,7 @@ async def bulk_apply_template_to_users(
emit_status_change_notification=not suppress_reset_status_change,
)
- modified_users.append(await self._modify_user(db, db_user, modify_user, admin))
+ modified_users.append(await self._modify_user(db, db_user, modify_user, admin, skip_role_limits=True))
return self._build_bulk_action_response(modified_users)
@@ -1245,7 +1441,7 @@ async def bulk_reallocate_wireguard_peer_ips(
users = await get_bulk_wireguard_peer_ip_users(
db,
body,
- admin_id=None if admin.is_sudo else admin.id,
+ admin_id=get_scope_admin_id(admin, "users", "update"),
)
out = await run_bulk_reallocate_wireguard_peer_ips(
@@ -1305,12 +1501,18 @@ async def get_users_sub_update_chart(
return self._build_user_agent_chart(agent_counts)
if admin_id:
- if not admin.is_sudo and admin_id != admin.id:
+ can_read_admins = False
+ try:
+ enforce_permission(admin, "admins", "read")
+ can_read_admins = True
+ except PermissionDenied:
+ pass
+ if not can_read_admins and admin_id != admin.id:
await self.raise_error(message="You're not allowed", code=403)
- elif admin.is_sudo and admin_id != admin.id:
+ elif can_read_admins and admin_id != admin.id:
await self.get_validated_admin_by_id(db, admin_id)
else:
- admin_id = None if admin.is_sudo else admin.id
+ admin_id = get_scope_admin_id(admin, "users", "read")
agent_counts = await get_users_subscription_agent_counts(db, admin_id=admin_id)
return self._build_user_agent_chart(agent_counts)
diff --git a/app/operation/user_template.py b/app/operation/user_template.py
index 0c8ddc39b..f06b92476 100644
--- a/app/operation/user_template.py
+++ b/app/operation/user_template.py
@@ -3,7 +3,7 @@
from app.db import AsyncSession
import asyncio
-from app.db.models import Admin
+from app.db.models import Admin, UserTemplate
from app.db.crud.user_template import (
create_user_template,
get_user_templates,
@@ -14,6 +14,7 @@
remove_user_templates,
)
from app.operation import BaseOperation
+from app.operation.permissions import apply_template_access
from app.models.user_template import (
BulkUserTemplatesActionResponse,
BulkUserTemplateSelection,
@@ -33,6 +34,13 @@
class UserTemplateOperation(BaseOperation):
+ async def _get_template_with_access(self, db: AsyncSession, template_id: int, admin: Admin) -> UserTemplate:
+ """Fetch a user template, returning 404 if outside the admin's allowed set."""
+ allowed = apply_template_access(admin, [template_id])
+ if allowed is not None and template_id not in allowed:
+ await self.raise_error("User Template not found", 404)
+ return await self.get_validated_user_template(db, template_id)
+
async def create_user_template(
self, db: AsyncSession, new_user_template: UserTemplateCreate, admin: Admin
) -> UserTemplateResponse:
@@ -53,7 +61,7 @@ async def create_user_template(
async def modify_user_template(
self, db: AsyncSession, template_id: int, modified_user_template: UserTemplateModify, admin: Admin
) -> UserTemplateResponse:
- db_user_template = await self.get_validated_user_template(db, template_id)
+ db_user_template = await self._get_template_with_access(db, template_id, admin)
if modified_user_template.group_ids:
for group_id in modified_user_template.group_ids:
await self.get_validated_group(db, group_id)
@@ -70,33 +78,39 @@ async def modify_user_template(
return db_user_template
async def remove_user_template(self, db: AsyncSession, template_id: int, admin: Admin) -> None:
- db_user_template = await self.get_validated_user_template(db, template_id)
+ db_user_template = await self._get_template_with_access(db, template_id, admin)
await remove_user_template(db, db_user_template)
logger.info(f'User template "{db_user_template.name}" deleted by admin "{admin.username}"')
asyncio.create_task(notification.remove_user_template(db_user_template.name, admin.username))
- async def get_user_templates(self, db: AsyncSession, query: UserTemplateListQuery) -> list[UserTemplateResponse]:
+ async def get_user_templates(
+ self, db: AsyncSession, query: UserTemplateListQuery, admin: Admin
+ ) -> list[UserTemplateResponse]:
+ query.ids = apply_template_access(admin, query.ids)
return await get_user_templates(db, query)
async def get_user_templates_simple(
- self, db: AsyncSession, query: UserTemplateSimpleListQuery
+ self, db: AsyncSession, query: UserTemplateSimpleListQuery, admin: Admin
) -> UserTemplatesSimpleResponse:
"""Get lightweight user template list with only id and name"""
+ query.ids = apply_template_access(admin, query.ids)
rows, total = await get_user_templates_simple(db=db, query=query)
-
templates = [UserTemplateSimple(id=row[0], name=row[1]) for row in rows]
-
return UserTemplatesSimpleResponse(templates=templates, total=total)
async def bulk_remove_user_templates(
self, db: AsyncSession, bulk_templates: BulkUserTemplateSelection, admin: Admin
) -> RemoveUserTemplatesResponse:
"""Remove multiple user templates by ID"""
- db_templates = []
- for template_id in bulk_templates.ids:
- db_template = await self.get_validated_user_template(db, template_id)
- db_templates.append(db_template)
+ requested_ids = list(bulk_templates.ids)
+ allowed_ids = apply_template_access(admin, requested_ids)
+ # Fetch all in one query
+ db_templates = await get_user_templates(db, UserTemplateListQuery(ids=allowed_ids or []))
+ found_ids = {t.id for t in db_templates}
+ for tid in requested_ids:
+ if tid not in found_ids:
+ await self.raise_error("User Template not found", 404)
template_ids = [t.id for t in db_templates]
template_names = [t.name for t in db_templates]
@@ -124,9 +138,13 @@ async def bulk_set_user_templates_disabled(
*,
is_disabled: bool,
) -> BulkUserTemplatesActionResponse:
- db_templates = []
- for template_id in bulk_templates.ids:
- db_templates.append(await self.get_validated_user_template(db, template_id))
+ requested_ids = list(bulk_templates.ids)
+ allowed_ids = apply_template_access(admin, requested_ids)
+ db_templates = await get_user_templates(db, UserTemplateListQuery(ids=allowed_ids or []))
+ found_ids = {t.id for t in db_templates}
+ for tid in requested_ids:
+ if tid not in found_ids:
+ await self.raise_error("User Template not found", 404)
templates_to_update = [db_template for db_template in db_templates if db_template.is_disabled != is_disabled]
diff --git a/app/routers/__init__.py b/app/routers/__init__.py
index 3f547ceb5..18dcc6d3a 100644
--- a/app/routers/__init__.py
+++ b/app/routers/__init__.py
@@ -2,6 +2,7 @@
from . import (
admin,
+ admin_role,
core,
client_template,
group,
@@ -9,6 +10,7 @@
host,
node,
settings,
+ setup,
subscription,
system,
user,
@@ -21,6 +23,8 @@
routers = [
home.router,
admin.router,
+ admin_role.router,
+ setup.router,
system.router,
settings.router,
group.router,
diff --git a/app/routers/admin.py b/app/routers/admin.py
index 36d0ef608..f5d49fadb 100644
--- a/app/routers/admin.py
+++ b/app/routers/admin.py
@@ -12,6 +12,7 @@
AdminListQuery,
AdminModify,
AdminSimpleListQuery,
+ AdminStatus,
AdminsResponse,
AdminsSimpleResponse,
AdminUsageQuery,
@@ -25,11 +26,12 @@
from app.operation.admin import AdminOperation
from app.utils import responses
from app.utils.jwt import create_admin_token
+from app.utils.request import get_client_ip
from .authentication import (
- check_sudo_admin,
get_current,
get_current_with_metrics,
+ require_permission,
validate_admin,
validate_mini_app_admin,
)
@@ -39,74 +41,56 @@
admin_operator = AdminOperation(operator_type=OperatorType.API)
-def get_client_ip(request: Request) -> str:
- """Extract the client's IP address from the request."""
- if request.client:
- return request.client.host
- return "Unknown"
-
-
@router.post("/token", response_model=Token)
async def admin_token(
request: Request, form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)
):
"""Authenticate an admin and issue a token."""
client_ip = get_client_ip(request)
-
db_admin = await validate_admin(db, form_data.username, form_data.password)
if not db_admin:
asyncio.create_task(notification.admin_login(form_data.username, form_data.password, client_ip, False))
raise HTTPException(
- status_code=401,
- detail="Incorrect username or password",
- headers={"WWW-Authenticate": "Bearer"},
+ status_code=401, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}
)
- if db_admin.is_disabled:
+ if db_admin.status == AdminStatus.disabled:
asyncio.create_task(notification.admin_login(form_data.username, form_data.password, client_ip, False))
raise HTTPException(
- status_code=403,
- detail="your account has been disabled",
- headers={"WWW-Authenticate": "Bearer"},
+ status_code=403, detail="your account has been disabled", headers={"WWW-Authenticate": "Bearer"}
)
asyncio.create_task(notification.admin_login(db_admin.username, "", client_ip, True))
- return Token(access_token=await create_admin_token(db_admin.id, form_data.username, db_admin.is_sudo))
+ return Token(access_token=await create_admin_token(db_admin.id, form_data.username))
@router.post("/miniapp/token", responses={409: responses._409})
async def admin_mini_app_token(
request: Request, x_telegram_authorization: str = Header(), db: AsyncSession = Depends(get_db)
):
- """Authenticate an admin and issue a token."""
-
+ """Authenticate an admin via Telegram MiniApp and issue a token."""
client_ip = get_client_ip(request)
-
db_admin = await validate_mini_app_admin(db, x_telegram_authorization)
if not db_admin:
+ raise HTTPException(status_code=401, detail="admin not found.", headers={"WWW-Authenticate": "Bearer"})
+ if db_admin.status == AdminStatus.disabled:
raise HTTPException(
- status_code=401,
- detail="admin not found.",
- headers={"WWW-Authenticate": "Bearer"},
- )
- if db_admin.is_disabled:
- raise HTTPException(
- status_code=403,
- detail="your account has been disabled",
- headers={"WWW-Authenticate": "Bearer"},
+ status_code=403, detail="your account has been disabled", headers={"WWW-Authenticate": "Bearer"}
)
asyncio.create_task(notification.admin_login(db_admin.username, "", client_ip, True))
- return Token(access_token=await create_admin_token(db_admin.id, db_admin.username, db_admin.is_sudo))
+ return Token(access_token=await create_admin_token(db_admin.id, db_admin.username))
@router.post(
"",
response_model=AdminDetails,
- responses={201: {"description": "Admin created successfully"}, 409: responses._409},
status_code=status.HTTP_201_CREATED,
+ responses={201: {"description": "Admin created successfully"}, 409: responses._409},
)
async def create_admin(
- new_admin: AdminCreate, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ new_admin: AdminCreate,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("admins", "create")),
):
- """Create a new admin if the current admin has sudo privileges."""
+ """Create a new admin."""
return await admin_operator.create_admin(db, new_admin=new_admin, admin=admin)
@@ -119,7 +103,7 @@ async def modify_admin(
username: str,
modified_admin: AdminModify,
db: AsyncSession = Depends(get_db),
- current_admin: AdminDetails = Depends(check_sudo_admin),
+ current_admin: AdminDetails = Depends(require_permission("admins", "update")),
):
"""Modify an existing admin's details."""
return await admin_operator.modify_admin(
@@ -136,13 +120,10 @@ async def modify_admin_by_username(
username: str,
modified_admin: AdminModify,
db: AsyncSession = Depends(get_db),
- current_admin: AdminDetails = Depends(check_sudo_admin),
+ current_admin: AdminDetails = Depends(require_permission("admins", "update")),
):
return await admin_operator.modify_admin(
- db,
- username=username,
- modified_admin=modified_admin,
- current_admin=current_admin,
+ db, username=username, modified_admin=modified_admin, current_admin=current_admin
)
@@ -155,19 +136,18 @@ async def modify_admin_by_id(
admin_id: int,
modified_admin: AdminModify,
db: AsyncSession = Depends(get_db),
- current_admin: AdminDetails = Depends(check_sudo_admin),
+ current_admin: AdminDetails = Depends(require_permission("admins", "update")),
):
return await admin_operator.modify_admin_by_id(
- db,
- admin_id=admin_id,
- modified_admin=modified_admin,
- current_admin=current_admin,
+ db, admin_id=admin_id, modified_admin=modified_admin, current_admin=current_admin
)
@router.delete("/{username}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_admin(
- username: str, db: AsyncSession = Depends(get_db), current_admin: AdminDetails = Depends(check_sudo_admin)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ current_admin: AdminDetails = Depends(require_permission("admins", "delete")),
):
"""Remove an admin from the database."""
await admin_operator.remove_admin(db, username=username, current_admin=current_admin)
@@ -176,7 +156,9 @@ async def remove_admin(
@router.delete("/by-username/{username}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_admin_by_username(
- username: str, db: AsyncSession = Depends(get_db), current_admin: AdminDetails = Depends(check_sudo_admin)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ current_admin: AdminDetails = Depends(require_permission("admins", "delete")),
):
await admin_operator.remove_admin(db, username=username, current_admin=current_admin)
return {}
@@ -184,7 +166,9 @@ async def remove_admin_by_username(
@router.delete("/by-id/{admin_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_admin_by_id(
- admin_id: int, db: AsyncSession = Depends(get_db), current_admin: AdminDetails = Depends(check_sudo_admin)
+ admin_id: int,
+ db: AsyncSession = Depends(get_db),
+ current_admin: AdminDetails = Depends(require_permission("admins", "delete")),
):
await admin_operator.remove_admin_by_id(db, admin_id=admin_id, current_admin=current_admin)
return {}
@@ -200,10 +184,10 @@ def get_current_admin(admin: AdminDetails = Depends(get_current_with_metrics)):
async def get_admins(
query: Annotated[AdminListQuery, Depends(get_admin_list_query)],
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("admins", "read")),
):
"""Fetch a list of admins with optional filters for pagination and username."""
- return await admin_operator.get_admins(db, query=query)
+ return await admin_operator.get_admins(db, query=query, admin=admin)
@router.get(
@@ -211,14 +195,14 @@ async def get_admins(
response_model=AdminsSimpleResponse,
summary="Get lightweight admin list",
description="Returns only id and username for admins. Optimized for dropdowns and autocomplete.",
- dependencies=[Depends(check_sudo_admin)],
)
async def get_admins_simple(
query: Annotated[AdminSimpleListQuery, Depends(get_admin_simple_list_query)],
db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("admins", "read_simple")),
):
- """Get lightweight admin list with only id and username"""
- return await admin_operator.get_admins_simple(db=db, query=query)
+ """Get lightweight admin list with only id and username."""
+ return await admin_operator.get_admins_simple(db=db, query=query, admin=admin)
@router.get(
@@ -245,7 +229,7 @@ async def get_admin_usage_by_username(
username: str,
query: Annotated[AdminUsageQuery, Depends(get_admin_usage_query)],
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("admins", "read")),
):
return await admin_operator.get_admin_usage(db, username=username, admin=admin, query=query)
@@ -259,23 +243,27 @@ async def get_admin_usage_by_id(
admin_id: int,
query: Annotated[AdminUsageQuery, Depends(get_admin_usage_query)],
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("admins", "read")),
):
return await admin_operator.get_admin_usage_by_id(db, admin_id=admin_id, admin=admin, query=query)
@router.post("/{username}/users/disable", responses={404: responses._404})
async def disable_all_active_users(
- username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("admins", "update")),
):
- """Disable all active users under a specific admin"""
+ """Disable all active users under a specific admin."""
await admin_operator.disable_all_active_users(db, username=username, admin=admin)
return {}
@router.post("/by-username/{username}/users/disable", responses={404: responses._404})
async def disable_all_active_users_by_username(
- username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("admins", "update")),
):
await admin_operator.disable_all_active_users(db, username=username, admin=admin)
return {}
@@ -283,7 +271,9 @@ async def disable_all_active_users_by_username(
@router.post("/by-id/{admin_id}/users/disable", responses={404: responses._404})
async def disable_all_active_users_by_id(
- admin_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ admin_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("admins", "update")),
):
await admin_operator.disable_all_active_users_by_id(db, admin_id=admin_id, admin=admin)
return {}
@@ -291,16 +281,20 @@ async def disable_all_active_users_by_id(
@router.post("/{username}/users/activate", responses={404: responses._404})
async def activate_all_disabled_users(
- username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("admins", "update")),
):
- """Activate all disabled users under a specific admin"""
+ """Activate all disabled users under a specific admin."""
await admin_operator.activate_all_disabled_users(db, username=username, admin=admin)
return {}
@router.post("/by-username/{username}/users/activate", responses={404: responses._404})
async def activate_all_disabled_users_by_username(
- username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("admins", "update")),
):
await admin_operator.activate_all_disabled_users(db, username=username, admin=admin)
return {}
@@ -308,7 +302,9 @@ async def activate_all_disabled_users_by_username(
@router.post("/by-id/{admin_id}/users/activate", responses={404: responses._404})
async def activate_all_disabled_users_by_id(
- admin_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ admin_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("admins", "update")),
):
await admin_operator.activate_all_disabled_users_by_id(db, admin_id=admin_id, admin=admin)
return {}
@@ -316,7 +312,9 @@ async def activate_all_disabled_users_by_id(
@router.delete("/{username}/users", responses={403: responses._403, 404: responses._404})
async def remove_all_users(
- username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("admins", "delete")),
):
"""Remove all users under a specific admin."""
deleted = await admin_operator.remove_all_users(db, username=username, admin=admin)
@@ -325,7 +323,9 @@ async def remove_all_users(
@router.delete("/by-username/{username}/users", responses={403: responses._403, 404: responses._404})
async def remove_all_users_by_username(
- username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("admins", "delete")),
):
deleted = await admin_operator.remove_all_users(db, username=username, admin=admin)
return {"detail": f"operation has been successfuly done {deleted} users deleted"}
@@ -333,7 +333,9 @@ async def remove_all_users_by_username(
@router.delete("/by-id/{admin_id}/users", responses={403: responses._403, 404: responses._404})
async def remove_all_users_by_id(
- admin_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ admin_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("admins", "delete")),
):
deleted = await admin_operator.remove_all_users_by_id(db, admin_id=admin_id, admin=admin)
return {"detail": f"operation has been successfuly done {deleted} users deleted"}
@@ -341,7 +343,9 @@ async def remove_all_users_by_id(
@router.post("/{username}/reset", response_model=AdminDetails, responses={404: responses._404})
async def reset_admin_usage(
- username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("admins", "reset_usage")),
):
"""Resets usage of admin."""
return await admin_operator.reset_admin_usage(db, username=username, admin=admin)
@@ -349,14 +353,18 @@ async def reset_admin_usage(
@router.post("/by-username/{username}/reset", response_model=AdminDetails, responses={404: responses._404})
async def reset_admin_usage_by_username(
- username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("admins", "reset_usage")),
):
return await admin_operator.reset_admin_usage(db, username=username, admin=admin)
@router.post("/by-id/{admin_id}/reset", response_model=AdminDetails, responses={404: responses._404})
async def reset_admin_usage_by_id(
- admin_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ admin_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("admins", "reset_usage")),
):
return await admin_operator.reset_admin_usage_by_id(db, admin_id=admin_id, admin=admin)
@@ -369,9 +377,9 @@ async def reset_admin_usage_by_id(
async def bulk_delete_admins(
bulk_admins: BulkAdminSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("admins", "delete")),
):
- """Delete selected admins by username."""
+ """Delete selected admins by ID."""
return await admin_operator.bulk_remove_admins(db, bulk_admins, admin)
@@ -383,9 +391,9 @@ async def bulk_delete_admins(
async def bulk_reset_admins_usage(
bulk_admins: BulkAdminSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("admins", "reset_usage")),
):
- """Reset usage for selected admins by username."""
+ """Reset usage for selected admins by ID."""
return await admin_operator.bulk_reset_admins_usage(db, bulk_admins, admin)
@@ -397,9 +405,9 @@ async def bulk_reset_admins_usage(
async def bulk_disable_admins(
bulk_admins: BulkAdminSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("admins", "update")),
):
- """Disable selected admins by username."""
+ """Disable selected admins by ID."""
return await admin_operator.bulk_set_admins_disabled(db, bulk_admins, admin, is_disabled=True)
@@ -411,9 +419,9 @@ async def bulk_disable_admins(
async def bulk_enable_admins(
bulk_admins: BulkAdminSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("admins", "update")),
):
- """Enable selected admins by username."""
+ """Enable selected admins by ID."""
return await admin_operator.bulk_set_admins_disabled(db, bulk_admins, admin, is_disabled=False)
@@ -425,7 +433,7 @@ async def bulk_enable_admins(
async def bulk_disable_all_active_users(
bulk_admins: BulkAdminSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("admins", "update")),
):
"""Disable all active users under selected admins."""
return await admin_operator.bulk_disable_all_active_users_for_admins(db, bulk_admins, admin)
@@ -439,7 +447,7 @@ async def bulk_disable_all_active_users(
async def bulk_activate_all_disabled_users(
bulk_admins: BulkAdminSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("admins", "update")),
):
"""Activate all disabled users under selected admins."""
return await admin_operator.bulk_activate_all_disabled_users_for_admins(db, bulk_admins, admin)
@@ -453,7 +461,7 @@ async def bulk_activate_all_disabled_users(
async def bulk_remove_all_users(
bulk_admins: BulkAdminSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("admins", "delete")),
):
"""Remove all users under selected admins."""
return await admin_operator.bulk_remove_all_users_for_admins(db, bulk_admins, admin)
diff --git a/app/routers/admin_role.py b/app/routers/admin_role.py
new file mode 100644
index 000000000..a0428f58b
--- /dev/null
+++ b/app/routers/admin_role.py
@@ -0,0 +1,101 @@
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, status
+
+from app.db import AsyncSession, get_db
+from app.models.admin import AdminDetails
+from app.models.admin_role import (
+ AdminRoleCreate,
+ AdminRoleListQuery,
+ AdminRoleModify,
+ AdminRoleResponse,
+ AdminRolesResponse,
+ AdminRolesSimpleResponse,
+)
+from app.operation import OperatorType
+from app.operation.admin_role import AdminRoleOperation
+from app.utils import responses
+
+from .authentication import require_owner, require_permission
+from .dependencies import get_admin_role_list_query
+
+router = APIRouter(
+ tags=["Admin Roles"],
+ prefix="/api/admin-role",
+ responses={401: responses._401, 403: responses._403},
+)
+role_operator = AdminRoleOperation(operator_type=OperatorType.API)
+
+
+@router.get("s", response_model=AdminRolesResponse)
+async def get_roles(
+ query: Annotated[AdminRoleListQuery, Depends(get_admin_role_list_query)],
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(require_permission("admin_roles", "read")),
+):
+ """List all roles."""
+ return await role_operator.get_roles(db, query)
+
+
+@router.get("s/simple", response_model=AdminRolesSimpleResponse)
+async def get_roles_simple(
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(require_permission("admin_roles", "read_simple")),
+):
+ """List all roles as lightweight id/name/is_owner tuples."""
+ return await role_operator.get_roles_simple(db)
+
+
+@router.get("/{role_id}", response_model=AdminRoleResponse, responses={404: responses._404})
+async def get_role(
+ role_id: int,
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(require_permission("admin_roles", "read")),
+):
+ """Get a role by ID."""
+ return await role_operator.get_role(db, role_id)
+
+
+@router.post(
+ "",
+ response_model=AdminRoleResponse,
+ status_code=status.HTTP_201_CREATED,
+ responses={409: responses._409},
+)
+async def create_role(
+ data: AdminRoleCreate,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_owner),
+):
+ """Create a new role. Owner only."""
+ return await role_operator.create_role(db, data, admin)
+
+
+@router.put(
+ "/{role_id}",
+ response_model=AdminRoleResponse,
+ responses={403: responses._403, 404: responses._404, 409: responses._409},
+)
+async def modify_role(
+ role_id: int,
+ data: AdminRoleModify,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_owner),
+):
+ """Modify a role. Owner only. Owner role cannot be modified."""
+ return await role_operator.modify_role(db, role_id, data, admin)
+
+
+@router.delete(
+ "/{role_id}",
+ status_code=status.HTTP_204_NO_CONTENT,
+ responses={403: responses._403, 404: responses._404, 409: responses._409},
+)
+async def delete_role(
+ role_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_owner),
+):
+ """Delete a role. Owner only. Built-in roles and in-use roles cannot be deleted."""
+ await role_operator.delete_role(db, role_id, admin)
+ return {}
diff --git a/app/routers/authentication.py b/app/routers/authentication.py
index 4fc5b9e6f..b7bdb2150 100644
--- a/app/routers/authentication.py
+++ b/app/routers/authentication.py
@@ -3,7 +3,6 @@
from aiogram.utils.web_app import WebAppInitData, safe_parse_webapp_init_data
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
-
from sqlalchemy import func, select
from app.db import AsyncSession, get_db
@@ -14,14 +13,25 @@
get_admin_by_telegram_id,
)
from app.db.models import Admin, AdminUsageLogs, User
-from app.models.admin import AdminDetails, AdminValidationResult, verify_password
+from app.models.admin import AdminDetails, AdminRoleData, AdminStatus, AdminValidationResult, verify_password
+from app.models.admin_role import RoleAccess, RoleFeatures, RoleLimits, RolePermissions
from app.models.settings import Telegram
+from app.operation.permissions import PermissionDenied, enforce_permission, is_scope_all
from app.settings import telegram_settings
from app.utils.jwt import get_admin_payload
from config import auth_settings, runtime_settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/token")
+# Owner-level role data given to env admins — full permissions, bypasses all checks
+_ENV_ADMIN_ROLE = AdminRoleData(
+ is_owner=True,
+ permissions=RolePermissions(), # is_owner=True bypasses permission checks entirely
+ limits=RoleLimits(),
+ features=RoleFeatures(),
+ access=RoleAccess(),
+)
+
def _build_admin_details(
db_admin: Admin,
@@ -30,13 +40,14 @@ def _build_admin_details(
reseted_usage: int | None = None,
) -> AdminDetails:
used_traffic = int(db_admin.used_traffic or 0)
+ role = AdminRoleData.model_validate(db_admin.role) if db_admin.role is not None else None
return AdminDetails(
id=db_admin.id,
username=db_admin.username,
- is_sudo=db_admin.is_sudo,
total_users=int(total_users or 0),
used_traffic=used_traffic,
- is_disabled=db_admin.is_disabled,
+ data_limit=db_admin.data_limit,
+ status=db_admin.status,
telegram_id=db_admin.telegram_id,
discord_webhook=db_admin.discord_webhook,
sub_domain=db_admin.sub_domain,
@@ -47,6 +58,10 @@ def _build_admin_details(
discord_id=db_admin.discord_id,
sub_template=db_admin.sub_template,
lifetime_used_traffic=None if reseted_usage is None else int(reseted_usage or 0) + used_traffic,
+ role=role,
+ permission_overrides=RoleLimits.model_validate(db_admin.permission_overrides)
+ if db_admin.permission_overrides
+ else None,
)
@@ -61,7 +76,7 @@ def _is_token_valid_for_admin(db_admin: Admin, payload: dict) -> bool:
async def get_admin(db: AsyncSession, token: str) -> AdminDetails | None:
payload = await get_admin_payload(token)
if not payload:
- return
+ return None
db_admin = None
if payload.get("admin_id") is not None:
@@ -72,18 +87,20 @@ async def get_admin(db: AsyncSession, token: str) -> AdminDetails | None:
if db_admin:
if not _is_token_valid_for_admin(db_admin, payload):
- return
-
+ return None
return _build_admin_details(db_admin)
- elif payload["username"] in auth_settings.sudoers and payload["is_sudo"] is True:
- return AdminDetails(username=payload["username"], is_sudo=True)
+ # Env admin fallback — no DB record, but username is a known env admin
+ if payload["username"] in auth_settings.sudoers:
+ return AdminDetails(username=payload["username"], role=_ENV_ADMIN_ROLE)
+
+ return None
async def get_admin_with_metrics(db: AsyncSession, token: str) -> AdminDetails | None:
payload = await get_admin_payload(token)
if not payload:
- return
+ return None
total_users_subquery = (
select(func.count(User.id)).where(User.admin_id == Admin.id).correlate(Admin).scalar_subquery()
@@ -94,36 +111,27 @@ async def get_admin_with_metrics(db: AsyncSession, token: str) -> AdminDetails |
.correlate(Admin)
.scalar_subquery()
)
+
+ base_stmt = select(Admin, total_users_subquery, reseted_usage_subquery)
+
if payload.get("admin_id") is not None:
- admin_row = (
- await db.execute(
- select(Admin, total_users_subquery, reseted_usage_subquery).where(Admin.id == payload["admin_id"])
- )
- ).one_or_none()
+ admin_row = (await db.execute(base_stmt.where(Admin.id == payload["admin_id"]))).one_or_none()
if admin_row is None:
- admin_row = (
- await db.execute(
- select(Admin, total_users_subquery, reseted_usage_subquery).where(
- Admin.username == payload["username"]
- )
- )
- ).one_or_none()
+ admin_row = (await db.execute(base_stmt.where(Admin.username == payload["username"]))).one_or_none()
else:
- admin_row = (
- await db.execute(
- select(Admin, total_users_subquery, reseted_usage_subquery).where(Admin.username == payload["username"])
- )
- ).one_or_none()
+ admin_row = (await db.execute(base_stmt.where(Admin.username == payload["username"]))).one_or_none()
if admin_row:
db_admin, total_users, reseted_usage = admin_row
if not _is_token_valid_for_admin(db_admin, payload):
- return
-
+ return None
return _build_admin_details(db_admin, total_users=total_users, reseted_usage=reseted_usage)
- elif payload["username"] in auth_settings.sudoers and payload["is_sudo"] is True:
- return AdminDetails(username=payload["username"], is_sudo=True)
+ # Env admin fallback — no DB record, but username is a known env admin
+ if payload["username"] in auth_settings.sudoers:
+ return AdminDetails(username=payload["username"], role=_ENV_ADMIN_ROLE)
+
+ return None
async def get_current(db: AsyncSession = Depends(get_db), token: str = Depends(oauth2_scheme)):
@@ -134,13 +142,12 @@ async def get_current(db: AsyncSession = Depends(get_db), token: str = Depends(o
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
- if admin.is_disabled:
+ if admin.status == AdminStatus.disabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="your account has been disabled",
headers={"WWW-Authenticate": "Bearer"},
)
-
return admin
@@ -152,39 +159,75 @@ async def get_current_with_metrics(db: AsyncSession = Depends(get_db), token: st
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
- if admin.is_disabled:
+ if admin.status == AdminStatus.disabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="your account has been disabled",
headers={"WWW-Authenticate": "Bearer"},
)
-
return admin
-async def check_sudo_admin(admin: AdminDetails = Depends(get_current)):
- if not admin.is_sudo:
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You're not allowed")
+def require_permission(resource: str, action: str):
+ """FastAPI dependency factory — checks RBAC permission for resource+action."""
+
+ async def _check(admin: AdminDetails = Depends(get_current)):
+ try:
+ enforce_permission(admin, resource, action)
+ except PermissionDenied as e:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
+ return admin
+
+ return _check
+
+
+def require_scope_all(resource: str, action: str):
+ """
+ FastAPI dependency factory — checks RBAC permission AND requires scope=all (or owner).
+ Used for operations that affect all users regardless of ownership.
+ """
+
+ async def _check(admin: AdminDetails = Depends(get_current)):
+ try:
+ enforce_permission(admin, resource, action)
+ except PermissionDenied as e:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
+
+ # Scope check: must be owner or have scope=ALL (or True = no scope restriction)
+ if not is_scope_all(admin, resource, action):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=f"Permission denied: {resource}.{action} requires scope=all",
+ )
+ return admin
+
+ return _check
+
+
+async def require_owner(admin: AdminDetails = Depends(get_current)):
+ """FastAPI dependency — allows only the owner (is_owner=True)."""
+ if not admin.is_owner:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can perform this action")
return admin
async def validate_admin(db: AsyncSession, username: str, password: str) -> AdminValidationResult | None:
- """Validate admin credentials with environment variables or database."""
-
+ """Validate admin credentials against the database, with env admin fallback."""
db_admin = await get_admin_by_username(db, username, load_users=False, load_usage_logs=False)
if db_admin and await verify_password(password, db_admin.hashed_password):
return AdminValidationResult(
id=db_admin.id,
username=db_admin.username,
- is_sudo=db_admin.is_sudo,
- is_disabled=db_admin.is_disabled,
+ status=db_admin.status,
)
+ # Env admin fallback — only allowed in debug/testing
if not db_admin and auth_settings.sudoers.get(username) == password:
if not runtime_settings.debug:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="env admin not allowed in production")
+ return AdminValidationResult(username=username, status=AdminStatus.active)
- return AdminValidationResult(username=username, is_sudo=True, is_disabled=False)
+ return None
async def validate_mini_app_admin(db: AsyncSession, token: str) -> AdminValidationResult | None:
@@ -218,6 +261,6 @@ async def validate_mini_app_admin(db: AsyncSession, token: str) -> AdminValidati
return AdminValidationResult(
id=db_admin.id,
username=db_admin.username,
- is_sudo=db_admin.is_sudo,
- is_disabled=db_admin.is_disabled,
+ status=db_admin.status,
)
+ return None
diff --git a/app/routers/client_template.py b/app/routers/client_template.py
index 4e2e35561..e9a9183f6 100644
--- a/app/routers/client_template.py
+++ b/app/routers/client_template.py
@@ -15,7 +15,7 @@
from app.operation.client_template import ClientTemplateOperation
from app.utils import responses
-from .authentication import check_sudo_admin, get_current
+from .authentication import require_permission
from .dependencies import get_client_template_list_query, get_client_template_simple_list_query
router = APIRouter(
@@ -31,7 +31,7 @@
async def create_client_template(
new_template: ClientTemplateCreate,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("client_templates", "create")),
):
return await client_template_operator.create_client_template(db, new_template, admin)
@@ -40,7 +40,7 @@ async def create_client_template(
async def get_client_template(
template_id: int,
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(get_current),
+ _: AdminDetails = Depends(require_permission("client_templates", "read")),
):
return await client_template_operator.get_validated_client_template(db, template_id)
@@ -50,7 +50,7 @@ async def modify_client_template(
template_id: int,
modified_template: ClientTemplateModify,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("client_templates", "update")),
):
return await client_template_operator.modify_client_template(db, template_id, modified_template, admin)
@@ -59,7 +59,7 @@ async def modify_client_template(
async def remove_client_template(
template_id: int,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("client_templates", "delete")),
):
await client_template_operator.remove_client_template(db, template_id, admin)
return {}
@@ -69,7 +69,7 @@ async def remove_client_template(
async def get_client_templates(
query=Depends(get_client_template_list_query),
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(get_current),
+ _: AdminDetails = Depends(require_permission("client_templates", "read")),
):
return await client_template_operator.get_client_templates(db, query=query)
@@ -78,7 +78,7 @@ async def get_client_templates(
async def get_client_templates_simple(
query=Depends(get_client_template_simple_list_query),
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(get_current),
+ _: AdminDetails = Depends(require_permission("client_templates", "read_simple")),
):
return await client_template_operator.get_client_templates_simple(db=db, query=query)
@@ -91,7 +91,7 @@ async def get_client_templates_simple(
async def bulk_delete_client_templates(
bulk_templates: BulkClientTemplateSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("client_templates", "delete")),
):
"""Delete selected client templates by ID."""
return await client_template_operator.bulk_remove_client_templates(db, bulk_templates, admin)
diff --git a/app/routers/core.py b/app/routers/core.py
index 5623bd0db..26f3100da 100644
--- a/app/routers/core.py
+++ b/app/routers/core.py
@@ -15,7 +15,7 @@
from app.operation.node import NodeOperation
from app.utils import responses
-from .authentication import check_sudo_admin
+from .authentication import require_permission
from .dependencies import get_core_list_query, get_core_simple_list_query
core_operator = CoreOperation(operator_type=OperatorType.API)
@@ -25,7 +25,9 @@
@router.post("", response_model=CoreResponse, status_code=status.HTTP_201_CREATED)
async def create_core_config(
- new_core: CoreCreate, admin: AdminDetails = Depends(check_sudo_admin), db: AsyncSession = Depends(get_db)
+ new_core: CoreCreate,
+ admin: AdminDetails = Depends(require_permission("cores", "create")),
+ db: AsyncSession = Depends(get_db),
):
"""Create a new core configuration."""
return await core_operator.create_core(db, new_core, admin)
@@ -33,7 +35,7 @@ async def create_core_config(
@router.get("/{core_id}", response_model=CoreResponse)
async def get_core_config(
- core_id: int, _: AdminDetails = Depends(check_sudo_admin), db: AsyncSession = Depends(get_db)
+ core_id: int, _: AdminDetails = Depends(require_permission("cores", "read")), db: AsyncSession = Depends(get_db)
) -> dict:
"""Get a core configuration by its ID."""
return await core_operator.get_validated_core_config(db, core_id)
@@ -44,7 +46,7 @@ async def modify_core_config(
core_id: int,
restart_nodes: bool,
modified_core: CoreCreate,
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("cores", "update")),
db: AsyncSession = Depends(get_db),
):
"""Update an existing core configuration."""
@@ -60,7 +62,7 @@ async def modify_core_config(
async def delete_core_config(
core_id: int,
restart_nodes: bool = False,
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("cores", "delete")),
db: AsyncSession = Depends(get_db),
):
"""Delete a core configuration."""
@@ -75,7 +77,7 @@ async def delete_core_config(
@router.get("s", response_model=CoreResponseList)
async def get_all_cores(
query=Depends(get_core_list_query),
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_permission("cores", "read")),
db: AsyncSession = Depends(get_db),
):
"""Get a list of all core configurations."""
@@ -90,7 +92,7 @@ async def get_all_cores(
)
async def get_cores_simple(
query=Depends(get_core_simple_list_query),
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_permission("cores", "read_simple")),
db: AsyncSession = Depends(get_db),
):
"""Get lightweight core list with only id and name"""
@@ -99,7 +101,9 @@ async def get_cores_simple(
@router.post("/{core_id}/restart", status_code=status.HTTP_204_NO_CONTENT)
async def restart_core(
- core_id: int, admin: AdminDetails = Depends(check_sudo_admin), db: AsyncSession = Depends(get_db)
+ core_id: int,
+ admin: AdminDetails = Depends(require_permission("cores", "update")),
+ db: AsyncSession = Depends(get_db),
):
"""restart nodes related to the core config"""
@@ -115,7 +119,7 @@ async def restart_core(
async def bulk_delete_cores(
bulk_cores: BulkCoreSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("cores", "delete")),
):
"""Delete selected cores by ID."""
return await core_operator.bulk_remove_cores(db, bulk_cores, admin)
diff --git a/app/routers/dependencies/__init__.py b/app/routers/dependencies/__init__.py
index 4324c2fc7..f091ff4e6 100644
--- a/app/routers/dependencies/__init__.py
+++ b/app/routers/dependencies/__init__.py
@@ -1,4 +1,5 @@
from .admin import get_admin_list_query, get_admin_simple_list_query, get_admin_usage_query
+from .admin_role import get_admin_role_list_query
from .client_template import get_client_template_list_query, get_client_template_simple_list_query
from .core import get_core_list_query, get_core_simple_list_query
from .group import get_group_list_query, get_group_simple_list_query
@@ -25,6 +26,8 @@
"get_admin_list_query",
"get_admin_simple_list_query",
"get_admin_usage_query",
+ # admin_role
+ "get_admin_role_list_query",
# client_template
"get_client_template_list_query",
"get_client_template_simple_list_query",
diff --git a/app/routers/dependencies/admin_role.py b/app/routers/dependencies/admin_role.py
new file mode 100644
index 000000000..21a41f94d
--- /dev/null
+++ b/app/routers/dependencies/admin_role.py
@@ -0,0 +1,15 @@
+from fastapi import Query
+
+from app.models.admin_role import AdminRoleListQuery
+
+from ._common import make_query_dependency, query_param
+
+get_admin_role_list_query = make_query_dependency(
+ AdminRoleListQuery,
+ field_overrides={
+ "search": Query(None),
+ "offset": Query(None),
+ "limit": Query(None),
+ "sort": query_param(str | None, None),
+ },
+)
diff --git a/app/routers/group.py b/app/routers/group.py
index 9d6981c69..a6ced4efb 100644
--- a/app/routers/group.py
+++ b/app/routers/group.py
@@ -22,7 +22,7 @@
from app.utils import responses
from .dependencies import get_group_list_query, get_group_simple_list_query
-from .authentication import check_sudo_admin, get_current
+from .authentication import require_permission
router = APIRouter(prefix="/api/group", tags=["Groups"], responses={401: responses._401, 403: responses._403})
group_operator = GroupOperation(OperatorType.API)
@@ -33,10 +33,12 @@
response_model=GroupResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new group",
- description="Creates a new group in the system. Only sudo administrators can create groups.",
+ description="Creates a new group in the system. Only authorized administrators can create groups.",
)
async def create_group(
- new_group: GroupCreate, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ new_group: GroupCreate,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("groups", "create")),
):
"""
Create a new group in the system.
@@ -53,7 +55,7 @@ async def create_group(
Raises:
401: Unauthorized - If not authenticated
- 403: Forbidden - If not sudo admin
+ 403: Forbidden - If not authorized admin
"""
return await group_operator.create_group(db, new_group, admin)
@@ -67,7 +69,7 @@ async def create_group(
async def get_all_groups(
query: Annotated[GroupListQuery, Depends(get_group_list_query)],
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("groups", "read")),
):
"""
Retrieve a list of all groups with optional pagination.
@@ -87,7 +89,7 @@ async def get_all_groups(
Raises:
401: Unauthorized - If not authenticated
"""
- return await group_operator.get_all_groups(db, query)
+ return await group_operator.get_all_groups(db, query, admin)
@router.get(
@@ -99,10 +101,10 @@ async def get_all_groups(
async def get_groups_simple(
query: Annotated[GroupSimpleListQuery, Depends(get_group_simple_list_query)],
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("groups", "read_simple")),
):
"""Get lightweight group list with only id and name"""
- return await group_operator.get_groups_simple(db=db, query=query)
+ return await group_operator.get_groups_simple(db=db, query=query, admin=admin)
@router.get(
@@ -112,7 +114,11 @@ async def get_groups_simple(
description="Retrieves detailed information about a specific group by its ID.",
responses={404: responses._404},
)
-async def get_group(group_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(get_current)):
+async def get_group(
+ group_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("groups", "read")),
+):
"""
Get a specific group by its **ID**.
@@ -129,21 +135,21 @@ async def get_group(group_id: int, db: AsyncSession = Depends(get_db), _: AdminD
Raises:
404: Not Found - If group doesn't exist
"""
- return await group_operator.get_validated_group(db, group_id)
+ return await group_operator._get_group_with_access(db, group_id, admin)
@router.put(
"/{group_id}",
response_model=GroupResponse,
summary="Modify group",
- description="Updates an existing group's information. Only sudo administrators can modify groups.",
+ description="Updates an existing group's information. Only authorized administrators can modify groups.",
responses={404: responses._404},
)
async def modify_group(
group_id: int,
modified_group: GroupModify,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("groups", "update")),
):
"""
Modify an existing group's information.
@@ -160,7 +166,7 @@ async def modify_group(
Raises:
401: Unauthorized - If not authenticated
- 403: Forbidden - If not sudo admin
+ 403: Forbidden - If not authorized admin
404: Not Found - If group doesn't exist
"""
return await group_operator.modify_group(db, group_id, modified_group, admin)
@@ -170,11 +176,13 @@ async def modify_group(
"/{group_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Remove group",
- description="Deletes a group from the system. Only sudo administrators can delete groups.",
+ description="Deletes a group from the system. Only authorized administrators can delete groups.",
responses={404: responses._404},
)
async def remove_group(
- group_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ group_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("groups", "delete")),
):
"""
Remove a group by its **ID**.
@@ -184,7 +192,7 @@ async def remove_group(
Raises:
401: Unauthorized - If not authenticated
- 403: Forbidden - If not sudo admin
+ 403: Forbidden - If not authorized admin
404: Not Found - If group doesn't exist
"""
await group_operator.remove_group(db, group_id, admin)
@@ -197,7 +205,9 @@ async def remove_group(
response_description="Success confirmation",
)
async def bulk_add_groups_to_users(
- bulk_group: BulkGroup, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)
+ bulk_group: BulkGroup,
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(require_permission("groups", "update")),
):
"""
Bulk assign groups to multiple users, users under specific admins, or all users.
@@ -220,7 +230,9 @@ async def bulk_add_groups_to_users(
response_description="Success confirmation",
)
async def bulk_remove_users_from_groups(
- bulk_group: BulkGroup, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)
+ bulk_group: BulkGroup,
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(require_permission("groups", "update")),
):
"""
Bulk remove groups from multiple users, users under specific admins, or all users.
@@ -245,7 +257,7 @@ async def bulk_remove_users_from_groups(
async def bulk_delete_groups(
bulk_groups: BulkGroupSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("groups", "delete")),
):
"""Delete selected groups by ID."""
return await group_operator.bulk_remove_groups_by_id(db, bulk_groups, admin)
@@ -259,7 +271,7 @@ async def bulk_delete_groups(
async def bulk_disable_groups(
bulk_groups: BulkGroupSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("groups", "update")),
):
"""Disable selected groups by ID."""
return await group_operator.bulk_set_groups_disabled(db, bulk_groups, admin, is_disabled=True)
@@ -273,7 +285,7 @@ async def bulk_disable_groups(
async def bulk_enable_groups(
bulk_groups: BulkGroupSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("groups", "update")),
):
"""Enable selected groups by ID."""
return await group_operator.bulk_set_groups_disabled(db, bulk_groups, admin, is_disabled=False)
diff --git a/app/routers/host.py b/app/routers/host.py
index 4c7192adb..98e7d994d 100644
--- a/app/routers/host.py
+++ b/app/routers/host.py
@@ -7,7 +7,7 @@
from app.operation.host import HostOperation
from app.utils import responses
-from .authentication import check_sudo_admin
+from .authentication import require_permission
from .dependencies import get_host_list_query
host_operator = HostOperation(operator_type=OperatorType.API)
@@ -15,7 +15,9 @@
@router.get("/{host_id}", response_model=BaseHost)
-async def get_host(host_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)):
+async def get_host(
+ host_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(require_permission("hosts", "read"))
+):
"""
get host by **id**
"""
@@ -26,7 +28,7 @@ async def get_host(host_id: int, db: AsyncSession = Depends(get_db), _: AdminDet
async def get_hosts(
query=Depends(get_host_list_query),
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_permission("hosts", "read")),
):
"""
Get proxy hosts.
@@ -36,7 +38,9 @@ async def get_hosts(
@router.post("/", response_model=BaseHost, status_code=status.HTTP_201_CREATED)
async def create_host(
- new_host: CreateHost, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ new_host: CreateHost,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("hosts", "create")),
):
"""
create a new host
@@ -51,7 +55,7 @@ async def modify_host(
host_id: int,
modified_host: CreateHost,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("hosts", "update")),
):
"""
modify host by **id**
@@ -67,7 +71,9 @@ async def modify_host(
status_code=status.HTTP_204_NO_CONTENT,
)
async def remove_host(
- host_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ host_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("hosts", "update")),
):
"""
remove host by **id**
@@ -80,7 +86,7 @@ async def remove_host(
async def modify_hosts(
modified_hosts: list[CreateHost],
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("hosts", "update")),
):
"""
Modify proxy hosts and update the configuration.
@@ -96,7 +102,7 @@ async def modify_hosts(
async def bulk_delete_hosts(
bulk_hosts: BulkHostSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("hosts", "update")),
):
"""Delete selected hosts by ID."""
return await host_operator.bulk_remove_hosts(db, bulk_hosts, admin)
@@ -110,7 +116,7 @@ async def bulk_delete_hosts(
async def bulk_disable_hosts(
bulk_hosts: BulkHostSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("hosts", "update")),
):
"""Disable selected hosts by ID."""
return await host_operator.bulk_set_hosts_disabled(db, bulk_hosts, admin, is_disabled=True)
@@ -124,7 +130,7 @@ async def bulk_disable_hosts(
async def bulk_enable_hosts(
bulk_hosts: BulkHostSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("hosts", "update")),
):
"""Enable selected hosts by ID."""
return await host_operator.bulk_set_hosts_disabled(db, bulk_hosts, admin, is_disabled=False)
diff --git a/app/routers/hwid.py b/app/routers/hwid.py
index b55f18f07..31a5c8847 100644
--- a/app/routers/hwid.py
+++ b/app/routers/hwid.py
@@ -6,7 +6,7 @@
from app.operation import OperatorType
from app.operation.hwid import HWIDOperation
from app.utils import responses
-from .authentication import get_current
+from .authentication import require_permission
hwid_operator = HWIDOperation(operator_type=OperatorType.API)
router = APIRouter(tags=["User HWID"], prefix="/api/user", responses={401: responses._401})
@@ -17,7 +17,9 @@
response_model=UserHWIDListResponse,
responses={403: responses._403, 404: responses._404},
)
-async def get_user_hwids(user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)):
+async def get_user_hwids(
+ user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(require_permission("hwids", "read"))
+):
"""Get user's registered hardware IDs"""
return await hwid_operator.get_user_hwids(db, user_id=user_id, admin=admin)
@@ -27,7 +29,10 @@ async def get_user_hwids(user_id: int, db: AsyncSession = Depends(get_db), admin
responses={403: responses._403, 404: responses._404},
)
async def delete_user_hwid(
- user_id: int, hwid: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+ user_id: int,
+ hwid: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("hwids", "delete")),
):
"""Delete a specific hardware ID from user"""
return await hwid_operator.delete_user_hwid(db, user_id=user_id, hwid=hwid, admin=admin)
@@ -38,7 +43,9 @@ async def delete_user_hwid(
responses={403: responses._403, 404: responses._404},
)
async def reset_user_hwids(
- user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+ user_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("hwids", "delete")),
):
"""Delete all hardware IDs for user"""
return await hwid_operator.reset_user_hwids(db, user_id=user_id, admin=admin)
diff --git a/app/routers/node.py b/app/routers/node.py
index da7512745..68b39212f 100644
--- a/app/routers/node.py
+++ b/app/routers/node.py
@@ -45,7 +45,7 @@
from app.utils.logger import get_logger
from config import runtime_settings
-from .authentication import check_sudo_admin
+from .authentication import require_permission
from .dependencies import (
get_node_clear_usage_query,
get_node_list_query,
@@ -133,7 +133,7 @@ async def event_generator() -> AsyncGenerator[str, None]:
@router.get("/settings", response_model=NodeSettings)
-async def get_node_settings(_: AdminDetails = Depends(check_sudo_admin)):
+async def get_node_settings(_: AdminDetails = Depends(require_permission("nodes", "read"))):
"""Retrieve the current node settings."""
return NodeSettings()
@@ -142,7 +142,7 @@ async def get_node_settings(_: AdminDetails = Depends(check_sudo_admin)):
async def get_usage(
query: Annotated[NodeUsageQuery, Depends(get_node_usage_query)],
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_permission("nodes", "stats")),
):
"""Retrieve usage statistics for nodes within a specified date range."""
return await node_operator.get_usage(db=db, query=query)
@@ -153,7 +153,7 @@ async def get_user_count_metric(
metric: UserCountMetric,
query: Annotated[NodeUsageQuery, Depends(get_node_usage_query)],
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_permission("nodes", "stats")),
):
"""Retrieve one user activity/status count metric from node user usage rows."""
try:
@@ -168,9 +168,9 @@ async def get_user_count_metric(
async def get_nodes(
query: Annotated[NodeListQuery, Depends(get_node_list_query)],
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_permission("nodes", "read")),
):
- """Retrieve a list of all nodes. Accessible only to sudo admins."""
+ """Retrieve a list of all nodes. Accessible only to authorized admins."""
return await node_operator.get_db_nodes(db=db, query=query)
@@ -184,7 +184,7 @@ async def get_nodes(
async def get_nodes_simple(
query: Annotated[NodeSimpleListQuery, Depends(get_node_simple_list_query)],
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_permission("nodes", "read_simple")),
):
"""Get lightweight node list with only id and name"""
return await node_operator.get_nodes_simple(db=db, query=query)
@@ -194,7 +194,7 @@ async def get_nodes_simple(
async def reconnect_all_node(
core_id: int | None = None,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("nodes", "reconnect")),
):
"""
Trigger reconnection for all nodes or a specific core.
@@ -210,20 +210,28 @@ async def reconnect_all_node(
status_code=status.HTTP_201_CREATED,
)
async def create_node(
- new_node: NodeCreate, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ new_node: NodeCreate,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("nodes", "create")),
):
"""Create a new node to the database."""
return await node_operator.create_node(db, new_node, admin)
@router.get("/{node_id}", response_model=NodeResponse)
-async def get_node(node_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)):
+async def get_node(
+ node_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(require_permission("nodes", "read"))
+):
"""Retrieve details of a specific node by its ID."""
return await node_operator.get_validated_node(db=db, node_id=node_id)
@router.post("/{node_id}/update")
-async def update_node(node_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)):
+async def update_node(
+ node_id: int,
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(require_permission("nodes", "update_core")),
+):
return await node_operator.update_node(db=db, node_id=node_id)
@@ -232,7 +240,7 @@ async def update_core(
node_id: int,
node_core_update: NodeCoreUpdate,
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_permission("nodes", "update_core")),
):
return await node_operator.update_core(db=db, node_id=node_id, node_core_update=node_core_update)
@@ -242,7 +250,7 @@ async def update_geofiles(
node_id: int,
node_geofiles_update: NodeGeoFilesUpdate,
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_permission("nodes", "update_core")),
):
return await node_operator.update_geofiles(db=db, node_id=node_id, node_geofiles_update=node_geofiles_update)
@@ -252,29 +260,33 @@ async def modify_node(
modified_node: NodeModify,
node_id: int,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("nodes", "update")),
):
- """Modify a node's details. Only accessible to sudo admins."""
+ """Modify a node's details. Only accessible to authorized admins."""
return await node_operator.modify_node(db, node_id=node_id, modified_node=modified_node, admin=admin)
@router.post("/{node_id}/reset", response_model=NodeResponse)
async def reset_node_usage(
- node_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ node_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("nodes", "update")),
):
"""
Reset node traffic usage (uplink and downlink).
Creates a log entry in node_usage_reset_logs table.
- Only accessible to sudo admins.
+ Only accessible to authorized admins.
"""
return await node_operator.reset_node_usage(db, node_id=node_id, admin=admin)
@router.post("/{node_id}/reconnect")
async def reconnect_node(
- node_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ node_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("nodes", "reconnect")),
):
- """Trigger a reconnection for the specified node. Only accessible to sudo admins."""
+ """Trigger a reconnection for the specified node. Only accessible to authorized admins."""
await node_operator.restart_node(db, node_id, admin)
return {}
@@ -284,14 +296,16 @@ async def sync_node(
node_id: int,
flush_users: bool = False,
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_permission("nodes", "update")),
):
return await node_operator.sync_node_users(db, node_id=node_id, flush_users=flush_users)
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_node(
- node_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ node_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("nodes", "delete")),
):
"""Remove a node and remove it from xray in the background."""
await node_operator.remove_node(db=db, node_id=node_id, admin=admin)
@@ -299,7 +313,7 @@ async def remove_node(
@router.get("/{node_id}/logs")
-async def node_logs(node_id: int, request: Request, _: AdminDetails = Depends(check_sudo_admin)):
+async def node_logs(node_id: int, request: Request, _: AdminDetails = Depends(require_permission("nodes", "logs"))):
"""
Stream logs for a specific node as Server-Sent Events.
"""
@@ -311,13 +325,13 @@ async def get_node_stats_periodic(
node_id: int,
query: Annotated[NodeStatsPeriodQuery, Depends(get_node_stats_period_query)],
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_permission("nodes", "stats")),
):
return await node_operator.get_node_stats_periodic(db, node_id=node_id, query=query)
@router.get("/{node_id}/realtime_stats", response_model=NodeRealtimeStats)
-async def realtime_node_stats(node_id: int, _: AdminDetails = Depends(check_sudo_admin)):
+async def realtime_node_stats(node_id: int, _: AdminDetails = Depends(require_permission("nodes", "stats"))):
"""Retrieve node real-time statistics."""
return await node_operator.get_node_system_stats(node_id=node_id)
@@ -327,21 +341,21 @@ async def node_outbounds_latency(
node_id: int,
name: str = "",
timeout: int | None = None,
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_permission("nodes", "stats")),
):
"""Retrieve outbound latency for one outbound or all outbounds of a node."""
return await node_operator.get_outbounds_latency(node_id=node_id, name=name, timeout=timeout)
@router.get("s/realtime_stats", response_model=dict[int, NodeRealtimeStats | None])
-async def realtime_nodes_stats(_: AdminDetails = Depends(check_sudo_admin)):
+async def realtime_nodes_stats(_: AdminDetails = Depends(require_permission("nodes", "stats"))):
"""Retrieve nodes real-time statistics."""
return await node_operator.get_nodes_system_stats()
@router.get("/online_stats/{username}/ip", response_model=UserIPListAll)
async def user_online_ip_list_all_nodes(
- username: str, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)
+ username: str, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(require_permission("nodes", "stats"))
):
"""Retrieve user ips from all nodes."""
return await node_operator.get_user_ip_list_all_nodes(db=db, username=username)
@@ -349,7 +363,10 @@ async def user_online_ip_list_all_nodes(
@router.get("/{node_id}/online_stats/{username}", response_model=dict[int, int])
async def user_online_stats(
- node_id: int, username: str, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)
+ node_id: int,
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(require_permission("nodes", "stats")),
):
"""Retrieve user online stats by node."""
return await node_operator.get_user_online_stats_by_node(db=db, node_id=node_id, username=username)
@@ -357,7 +374,10 @@ async def user_online_stats(
@router.get("/{node_id}/online_stats/{username}/ip", response_model=UserIPList)
async def user_online_ip_list(
- node_id: int, username: str, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)
+ node_id: int,
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(require_permission("nodes", "stats")),
):
"""Retrieve user ips by node."""
return await node_operator.get_user_ip_list_by_node(db=db, node_id=node_id, username=username)
@@ -371,7 +391,7 @@ async def clear_usage_data(
table: UsageTable,
query: Annotated[NodeClearUsageQuery, Depends(get_node_clear_usage_query)],
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_permission("nodes", "delete")),
):
"""
Deletes **all rows** from the selected usage data table. Use with caution.
@@ -397,7 +417,7 @@ async def clear_usage_data(
async def bulk_delete_nodes(
bulk_nodes: BulkNodeSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("nodes", "delete")),
):
"""Delete selected nodes by ID."""
return await node_operator.bulk_remove_nodes(db, bulk_nodes, admin)
@@ -411,7 +431,7 @@ async def bulk_delete_nodes(
async def bulk_disable_nodes(
bulk_nodes: BulkNodeSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("nodes", "update")),
):
"""Disable selected nodes by ID."""
return await node_operator.bulk_set_nodes_status(db, bulk_nodes, admin, status=NodeStatus.disabled)
@@ -425,7 +445,7 @@ async def bulk_disable_nodes(
async def bulk_enable_nodes(
bulk_nodes: BulkNodeSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("nodes", "update")),
):
"""Enable selected nodes by ID."""
return await node_operator.bulk_set_nodes_status(db, bulk_nodes, admin, status=NodeStatus.connected)
@@ -439,7 +459,7 @@ async def bulk_enable_nodes(
async def bulk_reset_nodes_usage(
bulk_nodes: BulkNodeSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("nodes", "update")),
):
"""Reset usage for selected nodes by ID."""
return await node_operator.bulk_reset_nodes_usage(db, bulk_nodes, admin)
@@ -453,7 +473,7 @@ async def bulk_reset_nodes_usage(
async def bulk_reconnect_nodes(
bulk_nodes: BulkNodeSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("nodes", "reconnect")),
):
"""Reconnect selected nodes by ID."""
return await node_operator.bulk_restart_nodes(db, bulk_nodes, admin)
@@ -467,7 +487,7 @@ async def bulk_reconnect_nodes(
async def bulk_update_nodes(
bulk_nodes: BulkNodeSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("nodes", "update_core")),
):
"""Update selected nodes by ID."""
return await node_operator.bulk_update_nodes(db, bulk_nodes, admin)
diff --git a/app/routers/settings.py b/app/routers/settings.py
index 57991bcf3..bf3077899 100644
--- a/app/routers/settings.py
+++ b/app/routers/settings.py
@@ -6,22 +6,26 @@
from app.operation.settings import SettingsOperation
from app.utils import responses
-from .authentication import check_sudo_admin, get_current
+from .authentication import require_permission
settings_operator = SettingsOperation(operator_type=OperatorType.API)
router = APIRouter(tags=["Settings"], prefix="/api/settings", responses={401: responses._401, 403: responses._403})
@router.get("", response_model=SettingsSchema)
-async def get_settings(db: AsyncSession = Depends(get_db), _=Depends(check_sudo_admin)):
+async def get_settings(db: AsyncSession = Depends(get_db), _=Depends(require_permission("settings", "read"))):
return await settings_operator.get_settings(db)
@router.get("/general", response_model=General)
-async def get_general_settings(db: AsyncSession = Depends(get_db), _=Depends(get_current)):
+async def get_general_settings(
+ db: AsyncSession = Depends(get_db), _=Depends(require_permission("settings", "read_general"))
+):
return await settings_operator.get_general_settings(db)
@router.put("", response_model=SettingsSchema)
-async def modify_settings(modify: SettingsSchema, db: AsyncSession = Depends(get_db), _=Depends(check_sudo_admin)):
+async def modify_settings(
+ modify: SettingsSchema, db: AsyncSession = Depends(get_db), _=Depends(require_permission("settings", "update"))
+):
return await settings_operator.modify_settings(db, modify)
diff --git a/app/routers/setup.py b/app/routers/setup.py
new file mode 100644
index 000000000..0a68f2472
--- /dev/null
+++ b/app/routers/setup.py
@@ -0,0 +1,98 @@
+from fastapi import APIRouter, Depends, HTTPException, Request, status
+from fastapi.responses import Response
+
+from app.db import AsyncSession, get_db
+from app.db.crud.admin import create_admin, get_owner, remove_admin, update_owner_password
+from app.db.crud.temp_key import TempKeyConsumeError, consume_temp_key
+from app.models.admin import AdminCreate, AdminDetails
+from app.models.setup import OwnerCreateRequest, OwnerDeleteRequest, OwnerResetRequest
+from app.utils import responses
+from app.utils.request import get_client_ip
+
+router = APIRouter(tags=["Setup"], prefix="/api/setup")
+
+
+async def _consume_key_or_raise(db: AsyncSession, key_str: str, action: str, request: Request) -> None:
+ try:
+ await consume_temp_key(db, key_str, action=action, ip=get_client_ip(request))
+ except TempKeyConsumeError as exc:
+ status_code = status.HTTP_400_BAD_REQUEST if exc.detail == "invalid key" else status.HTTP_410_GONE
+ raise HTTPException(status_code=status_code, detail=exc.detail) from exc
+
+
+@router.post(
+ "/owner",
+ response_model=AdminDetails,
+ status_code=status.HTTP_201_CREATED,
+ responses={
+ 400: responses._400,
+ 409: responses._409,
+ 410: {"description": "Key already used or expired"},
+ },
+)
+async def create_owner(
+ body: OwnerCreateRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+):
+ """Create the owner admin using a one-time temp key."""
+ if await get_owner(db) is not None:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="owner already exists")
+
+ await _consume_key_or_raise(db, body.key, action="create_owner", request=request)
+
+ db_admin = await create_admin(
+ db,
+ AdminCreate(username=body.username, password=body.password, role_id=1),
+ )
+ return AdminDetails.model_validate(db_admin)
+
+
+@router.patch(
+ "/owner",
+ response_model=AdminDetails,
+ responses={
+ 400: responses._400,
+ 404: responses._404,
+ 410: {"description": "Key already used or expired"},
+ },
+)
+async def reset_owner_password(
+ body: OwnerResetRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+):
+ """Reset the owner admin's password using a one-time temp key."""
+ await _consume_key_or_raise(db, body.key, action="reset_owner", request=request)
+
+ owner = await get_owner(db)
+ if owner is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="owner not found")
+
+ owner = await update_owner_password(db, owner, body.password)
+ return AdminDetails.model_validate(owner)
+
+
+@router.delete(
+ "/owner",
+ status_code=status.HTTP_204_NO_CONTENT,
+ responses={
+ 400: responses._400,
+ 404: responses._404,
+ 410: {"description": "Key already used or expired"},
+ },
+)
+async def delete_owner(
+ body: OwnerDeleteRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+):
+ """Delete the owner admin using a one-time temp key."""
+ await _consume_key_or_raise(db, body.key, action="delete_owner", request=request)
+
+ owner = await get_owner(db)
+ if owner is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="owner not found")
+
+ await remove_admin(db, owner)
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
diff --git a/app/routers/system.py b/app/routers/system.py
index 4ec7ba36e..e849048f9 100644
--- a/app/routers/system.py
+++ b/app/routers/system.py
@@ -20,7 +20,7 @@
from app.utils.logger import EndpointFilter, get_logger
from config import telegram_env_settings
-from .authentication import get_current
+from .authentication import require_permission
system_operator = SystemOperation(operator_type=OperatorType.API)
router = APIRouter(tags=["System"], prefix="/api", responses={401: responses._401})
@@ -33,20 +33,22 @@
@router.get("/system", response_model=SystemStats)
async def get_system_stats(
- admin_username: str | None = None, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+ admin_username: str | None = None,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("system", "read")),
):
"""Fetch system stats including memory, CPU, disk, and user metrics."""
return await system_operator.get_system_stats(db, admin=admin, admin_username=admin_username)
@router.get("/inbounds", response_model=list[str])
-async def get_inbounds(_: AdminDetails = Depends(get_current)):
+async def get_inbounds(_: AdminDetails = Depends(require_permission("system", "read"))):
"""Retrieve inbound configurations grouped by protocol."""
return await system_operator.get_inbounds()
@router.get("/inbounds/details", response_model=list[InboundSummary])
-async def get_inbound_details(_: AdminDetails = Depends(get_current)):
+async def get_inbound_details(_: AdminDetails = Depends(require_permission("system", "read"))):
"""Retrieve lightweight inbound metadata for dashboard forms."""
return await system_operator.get_inbound_details()
@@ -64,7 +66,7 @@ async def _measure_worker_health(request_coro) -> WorkerHealth:
@router.get("/workers/health", response_model=WorkersHealth)
-async def get_workers_health(_: AdminDetails = Depends(get_current)):
+async def get_workers_health(_: AdminDetails = Depends(require_permission("system", "read"))):
if not is_nats_enabled():
disabled = WorkerHealth(status="disabled")
return WorkersHealth(scheduler=disabled, node=disabled)
diff --git a/app/routers/user.py b/app/routers/user.py
index b99935191..91b9a96e8 100644
--- a/app/routers/user.py
+++ b/app/routers/user.py
@@ -9,7 +9,6 @@
UserCountMetric,
UserCountMetricStatsList,
UserUsageStatsList,
- validate_user_count_metric_scope,
)
from app.models.user import (
BulkUser,
@@ -51,7 +50,7 @@
get_users_usage_query,
)
-from .authentication import check_sudo_admin, get_current
+from .authentication import require_permission, require_scope_all
user_operator = UserOperation(operator_type=OperatorType.API)
node_operator = NodeOperation(operator_type=OperatorType.API)
@@ -66,7 +65,9 @@
status_code=status.HTTP_201_CREATED,
)
async def create_user(
- new_user: UserCreate, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+ new_user: UserCreate,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("users", "create")),
):
"""
Create a new user
@@ -96,7 +97,7 @@ async def modify_user(
username: str,
modified_user: UserModify,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "update")),
):
"""
Modify an existing user
@@ -127,7 +128,7 @@ async def modify_user_by_username(
username: str,
modified_user: UserModify,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "update")),
):
return await user_operator.modify_user(db, username=username, modified_user=modified_user, admin=admin)
@@ -141,7 +142,7 @@ async def modify_user_by_id(
user_id: int,
modified_user: UserModify,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "update")),
):
return await user_operator.modify_user_by_id(db, user_id=user_id, modified_user=modified_user, admin=admin)
@@ -149,7 +150,11 @@ async def modify_user_by_id(
@router.delete(
"/{username}", responses={403: responses._403, 404: responses._404}, status_code=status.HTTP_204_NO_CONTENT
)
-async def remove_user(username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)):
+async def remove_user(
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("users", "delete")),
+):
"""Remove a user"""
return await user_operator.remove_user(db, username=username, admin=admin)
@@ -160,7 +165,9 @@ async def remove_user(username: str, db: AsyncSession = Depends(get_db), admin:
status_code=status.HTTP_204_NO_CONTENT,
)
async def remove_user_by_username(
- username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("users", "delete")),
):
return await user_operator.remove_user(db, username=username, admin=admin)
@@ -171,14 +178,18 @@ async def remove_user_by_username(
status_code=status.HTTP_204_NO_CONTENT,
)
async def remove_user_by_id(
- user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+ user_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("users", "delete")),
):
return await user_operator.remove_user_by_id(db, user_id=user_id, admin=admin)
@router.post("/{username}/reset", response_model=UserResponse, responses={403: responses._403, 404: responses._404})
async def reset_user_data_usage(
- username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("users", "reset_usage")),
):
"""Reset user data usage"""
return await user_operator.reset_user_data_usage(db, username=username, admin=admin)
@@ -190,7 +201,9 @@ async def reset_user_data_usage(
responses={403: responses._403, 404: responses._404},
)
async def reset_user_data_usage_by_username(
- username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("users", "reset_usage")),
):
return await user_operator.reset_user_data_usage(db, username=username, admin=admin)
@@ -199,7 +212,9 @@ async def reset_user_data_usage_by_username(
"/by-id/{user_id}/reset", response_model=UserResponse, responses={403: responses._403, 404: responses._404}
)
async def reset_user_data_usage_by_id(
- user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+ user_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("users", "reset_usage")),
):
return await user_operator.reset_user_data_usage_by_id(db, user_id=user_id, admin=admin)
@@ -208,7 +223,9 @@ async def reset_user_data_usage_by_id(
"/{username}/revoke_sub", response_model=UserResponse, responses={403: responses._403, 404: responses._404}
)
async def revoke_user_subscription(
- username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("users", "revoke_sub")),
):
"""Revoke users subscription (Subscription link and proxies)"""
return await user_operator.revoke_user_sub(db, username=username, admin=admin)
@@ -220,7 +237,9 @@ async def revoke_user_subscription(
responses={403: responses._403, 404: responses._404},
)
async def revoke_user_subscription_by_username(
- username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("users", "revoke_sub")),
):
return await user_operator.revoke_user_sub(db, username=username, admin=admin)
@@ -231,13 +250,17 @@ async def revoke_user_subscription_by_username(
responses={403: responses._403, 404: responses._404},
)
async def revoke_user_subscription_by_id(
- user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+ user_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("users", "revoke_sub")),
):
return await user_operator.revoke_user_sub_by_id(db, user_id=user_id, admin=admin)
@router.post("s/reset", responses={403: responses._403, 404: responses._404})
-async def reset_users_data_usage(db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)):
+async def reset_users_data_usage(
+ db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(require_scope_all("users", "reset_usage"))
+):
"""Reset all users data usage"""
await user_operator.reset_users_data_usage(db, admin)
await node_operator.restart_all_node(db, admin)
@@ -254,7 +277,7 @@ async def get_users_sub_update_chart(
username: str | None = None,
admin_id: int | None = None,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "read")),
):
"""Get subscription agent distribution percentages (optionally filtered by user_id/username)."""
return await user_operator.get_users_sub_update_chart(
@@ -271,7 +294,7 @@ async def set_owner(
username: str,
admin_username: str,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("users", "set_owner")),
):
"""Set a new owner (admin) for a user."""
return await user_operator.set_owner(db, username=username, admin_username=admin_username, admin=admin)
@@ -282,7 +305,7 @@ async def set_owner_by_username(
username: str,
admin_username: str,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("users", "set_owner")),
):
return await user_operator.set_owner(db, username=username, admin_username=admin_username, admin=admin)
@@ -292,7 +315,7 @@ async def set_owner_by_id(
user_id: int,
admin_username: str,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("users", "set_owner")),
):
return await user_operator.set_owner_by_id(db, user_id=user_id, admin_username=admin_username, admin=admin)
@@ -301,7 +324,9 @@ async def set_owner_by_id(
"/{username}/active_next", response_model=UserResponse, responses={403: responses._403, 404: responses._404}
)
async def active_next_plan(
- username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("users", "activate_next_plan")),
):
"""Reset user by next plan"""
return await user_operator.active_next_plan(db, username=username, admin=admin)
@@ -313,7 +338,9 @@ async def active_next_plan(
responses={403: responses._403, 404: responses._404},
)
async def active_next_plan_by_username(
- username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("users", "activate_next_plan")),
):
return await user_operator.active_next_plan(db, username=username, admin=admin)
@@ -322,13 +349,19 @@ async def active_next_plan_by_username(
"/by-id/{user_id}/active_next", response_model=UserResponse, responses={403: responses._403, 404: responses._404}
)
async def active_next_plan_by_id(
- user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+ user_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("users", "activate_next_plan")),
):
return await user_operator.active_next_plan_by_id(db, user_id=user_id, admin=admin)
@router.get("/{username}", response_model=UserResponse, responses={403: responses._403, 404: responses._404})
-async def get_user(username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)):
+async def get_user(
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("users", "read")),
+):
"""Get user information"""
return await user_operator.get_user(db=db, username=username, admin=admin)
@@ -337,13 +370,17 @@ async def get_user(username: str, db: AsyncSession = Depends(get_db), admin: Adm
"/by-username/{username}", response_model=UserResponse, responses={403: responses._403, 404: responses._404}
)
async def get_user_by_username(
- username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+ username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("users", "read")),
):
return await user_operator.get_user(db=db, username=username, admin=admin)
@router.get("/by-id/{user_id}", response_model=UserResponse, responses={403: responses._403, 404: responses._404})
-async def get_user_by_id(user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)):
+async def get_user_by_id(
+ user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(require_permission("users", "read"))
+):
return await user_operator.get_user_by_id(db=db, user_id=user_id, admin=admin)
@@ -356,7 +393,7 @@ async def get_user_subscription_by_id(
user_id: int,
client_type: ConfigFormat,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "read")),
):
"""Get a user's subscription content in the requested format."""
return await subscription_operator.user_subscription_by_id(
@@ -378,7 +415,7 @@ async def get_user_sub_update_list(
offset: int = 0,
limit: int = 10,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "read")),
):
"""Get user subscription agent list"""
return await user_operator.get_users_sub_update_list(db, username=username, admin=admin, offset=offset, limit=limit)
@@ -394,7 +431,7 @@ async def get_user_sub_update_list_by_username(
offset: int = 0,
limit: int = 10,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "read")),
):
return await user_operator.get_users_sub_update_list(db, username=username, admin=admin, offset=offset, limit=limit)
@@ -409,7 +446,7 @@ async def get_user_sub_update_list_by_id(
offset: int = 0,
limit: int = 10,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "read")),
):
return await user_operator.get_users_sub_update_list_by_id(
db,
@@ -426,7 +463,7 @@ async def get_user_sub_update_list_by_id(
async def get_users(
query: Annotated[UserListQuery, Depends(get_user_list_query)],
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "read")),
):
"""Get all users"""
return await user_operator.get_users(db=db, admin=admin, query=query)
@@ -442,7 +479,7 @@ async def get_users(
async def get_users_simple(
query: Annotated[UserSimpleListQuery, Depends(get_user_simple_list_query)],
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "read_simple")),
):
"""Get lightweight user list with only id and username"""
return await user_operator.get_users_simple(db=db, admin=admin, query=query)
@@ -455,7 +492,7 @@ async def get_user_usage(
username: str,
query: Annotated[UserUsageQuery, Depends(get_user_usage_query)],
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "read")),
):
"""Get users usage"""
return await user_operator.get_user_usage(db, username=username, admin=admin, query=query)
@@ -470,7 +507,7 @@ async def get_user_usage_by_username(
username: str,
query: Annotated[UserUsageQuery, Depends(get_user_usage_query)],
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "read")),
):
return await user_operator.get_user_usage(db, username=username, admin=admin, query=query)
@@ -484,7 +521,7 @@ async def get_user_usage_by_id(
user_id: int,
query: Annotated[UserUsageQuery, Depends(get_user_usage_query)],
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "read")),
):
return await user_operator.get_user_usage_by_id(db, user_id=user_id, admin=admin, query=query)
@@ -493,7 +530,7 @@ async def get_user_usage_by_id(
async def get_users_usage(
query: Annotated[UsersUsageQuery, Depends(get_users_usage_query)],
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "read")),
):
"""Get all users usage"""
return await user_operator.get_users_usage(db, admin=admin, query=query)
@@ -504,18 +541,9 @@ async def get_users_count_metric(
metric: UserCountMetric,
query: Annotated[UsersUsageQuery, Depends(get_users_usage_query)],
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "read")),
):
"""Get one users activity/status count metric from usage rows."""
- try:
- validate_user_count_metric_scope(
- metric,
- node_id=query.node_id if admin.is_sudo else None,
- group_by_node=query.group_by_node if admin.is_sudo else False,
- )
- except ValueError as exc:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
-
return await user_operator.get_users_count_metric(db, admin=admin, metric=metric, query=query)
@@ -523,7 +551,7 @@ async def get_users_count_metric(
async def get_expired_users(
query: Annotated[ExpiredUsersQuery, Depends(get_expired_users_query)],
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_scope_all("users", "read")),
):
"""
Get cleanup-target users in the specified scope.
@@ -541,7 +569,7 @@ async def get_expired_users(
async def delete_expired_users(
query: Annotated[ExpiredUsersQuery, Depends(get_expired_users_query)],
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_scope_all("users", "delete")),
):
"""
Delete cleanup-target users in the specified scope.
@@ -562,7 +590,7 @@ async def delete_expired_users(
async def bulk_delete_users(
bulk_users: BulkUsersSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "delete")),
):
"""Delete selected users by ID."""
return await user_operator.bulk_remove_users(db, bulk_users, admin)
@@ -576,7 +604,7 @@ async def bulk_delete_users(
async def bulk_reset_users_data_usage(
bulk_users: BulkUsersSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "reset_usage")),
):
"""Reset usage for selected users by ID."""
return await user_operator.bulk_reset_user_data_usage(db, bulk_users, admin)
@@ -590,7 +618,7 @@ async def bulk_reset_users_data_usage(
async def bulk_disable_users(
bulk_users: BulkUsersSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "update")),
):
"""Disable selected users by ID."""
return await user_operator.bulk_disable_users(db, bulk_users, admin)
@@ -604,7 +632,7 @@ async def bulk_disable_users(
async def bulk_enable_users(
bulk_users: BulkUsersSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "update")),
):
"""Enable selected users by ID."""
return await user_operator.bulk_enable_users(db, bulk_users, admin)
@@ -618,7 +646,7 @@ async def bulk_enable_users(
async def bulk_revoke_users_subscription(
bulk_users: BulkUsersSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "revoke_sub")),
):
"""Revoke subscriptions for selected users by ID."""
return await user_operator.bulk_revoke_user_sub(db, bulk_users, admin)
@@ -632,7 +660,7 @@ async def bulk_revoke_users_subscription(
async def bulk_set_owner(
bulk_users: BulkUsersSetOwner,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("users", "set_owner")),
):
"""Set a new owner for selected users by ID."""
return await user_operator.bulk_set_owner(db, bulk_users, admin)
@@ -642,7 +670,7 @@ async def bulk_set_owner(
async def create_user_from_template(
new_template_user: CreateUserFromTemplate,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "create")),
):
return await user_operator.create_user_from_template(db, new_template_user, admin)
@@ -656,7 +684,7 @@ async def create_user_from_template(
async def bulk_create_users_from_template(
bulk_template_users: BulkUsersFromTemplate,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "create")),
):
"""
Bulk create users from a template using configurable username strategies.
@@ -679,7 +707,7 @@ async def bulk_create_users_from_template(
async def bulk_apply_template_to_users(
body: BulkUsersApplyTemplate,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "update")),
):
"""Apply a user template to selected existing users by ID."""
return await user_operator.bulk_apply_template_to_users(db, body, admin)
@@ -690,7 +718,7 @@ async def modify_user_with_template(
username: str,
modify_template_user: ModifyUserByTemplate,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "update")),
):
return await user_operator.modify_user_with_template(db, username, modify_template_user, admin)
@@ -700,7 +728,7 @@ async def modify_user_with_template_by_username(
username: str,
modify_template_user: ModifyUserByTemplate,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "update")),
):
return await user_operator.modify_user_with_template(db, username, modify_template_user, admin)
@@ -710,7 +738,7 @@ async def modify_user_with_template_by_id(
user_id: int,
modify_template_user: ModifyUserByTemplate,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("users", "update")),
):
return await user_operator.modify_user_with_template_by_id(db, user_id, modify_template_user, admin)
@@ -719,7 +747,7 @@ async def modify_user_with_template_by_id(
async def bulk_modify_users_expire(
bulk_model: BulkUser,
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_scope_all("users", "update")),
):
"""
Bulk expire users based on the provided criteria.
@@ -741,7 +769,7 @@ async def bulk_modify_users_expire(
async def bulk_modify_users_datalimit(
bulk_model: BulkUser,
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_scope_all("users", "update")),
):
"""
Bulk modify users' data limit based on the provided criteria.
@@ -763,7 +791,7 @@ async def bulk_modify_users_datalimit(
async def bulk_modify_users_proxy_settings(
bulk_model: BulkUsersProxy,
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(check_sudo_admin),
+ _: AdminDetails = Depends(require_scope_all("users", "update")),
):
return await user_operator.bulk_modify_proxy_settings(db, bulk_model)
@@ -772,12 +800,12 @@ async def bulk_modify_users_proxy_settings(
"s/bulk/wireguard/reallocate-peer-ips",
response_model=WireGuardPeerIPsReallocateResponse,
summary="Bulk reallocate WireGuard peer IPs",
- description="Same scoping as other bulk user actions (users, admins, group_ids, optional status filter). Non-sudo admins only affect their own users.",
+ description="Same scoping as other bulk user actions (users, admins, group_ids, optional status filter). non-owner admins only affect their own users.",
)
async def bulk_reallocate_wireguard_peer_ips(
body: BulkWireGuardPeerIPs,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_scope_all("users", "update")),
):
if not body.dry_run and not body.confirm:
raise HTTPException(
diff --git a/app/routers/user_template.py b/app/routers/user_template.py
index 0ece310bd..1e3db54f0 100644
--- a/app/routers/user_template.py
+++ b/app/routers/user_template.py
@@ -2,7 +2,7 @@
from app.db import AsyncSession, get_db
from app.models.admin import AdminDetails
-from .authentication import check_sudo_admin, get_current
+from .authentication import require_permission
from app.models.user_template import (
BulkUserTemplatesActionResponse,
BulkUserTemplateSelection,
@@ -28,7 +28,7 @@
async def create_user_template(
new_user_template: UserTemplateCreate,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("templates", "create")),
):
"""
Create a new user template
@@ -43,10 +43,12 @@ async def create_user_template(
@router.get("/{template_id}", response_model=UserTemplateResponse)
async def get_user_template(
- template_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(get_current)
+ template_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("templates", "read")),
):
"""Get User Template information with id"""
- return await template_operator.get_validated_user_template(db, template_id)
+ return await template_operator._get_template_with_access(db, template_id, admin)
@router.put("/{template_id}", response_model=UserTemplateResponse, responses={403: responses._403})
@@ -54,7 +56,7 @@ async def modify_user_template(
template_id: int,
modify_user_template: UserTemplateModify,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("templates", "update")),
):
"""
Modify User Template
@@ -69,7 +71,9 @@ async def modify_user_template(
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, responses={403: responses._403})
async def remove_user_template(
- template_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+ template_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("templates", "delete")),
):
"""Remove a User Template by its ID"""
await template_operator.remove_user_template(db, template_id, admin)
@@ -80,10 +84,10 @@ async def remove_user_template(
async def get_user_templates(
query=Depends(get_user_template_list_query),
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("templates", "read")),
):
"""Get a list of User Templates with optional pagination"""
- return await template_operator.get_user_templates(db, query)
+ return await template_operator.get_user_templates(db, query, admin)
@router.get(
@@ -95,10 +99,10 @@ async def get_user_templates(
async def get_user_templates_simple(
query=Depends(get_user_template_simple_list_query),
db: AsyncSession = Depends(get_db),
- _: AdminDetails = Depends(get_current),
+ admin: AdminDetails = Depends(require_permission("templates", "read_simple")),
):
"""Get lightweight user template list with only id and name"""
- return await template_operator.get_user_templates_simple(db=db, query=query)
+ return await template_operator.get_user_templates_simple(db=db, query=query, admin=admin)
@router.post(
@@ -109,7 +113,7 @@ async def get_user_templates_simple(
async def bulk_delete_user_templates(
bulk_templates: BulkUserTemplateSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("templates", "delete")),
):
"""Delete selected user templates by ID."""
return await template_operator.bulk_remove_user_templates(db, bulk_templates, admin)
@@ -123,7 +127,7 @@ async def bulk_delete_user_templates(
async def bulk_disable_user_templates(
bulk_templates: BulkUserTemplateSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("templates", "update")),
):
"""Disable selected user templates by ID."""
return await template_operator.bulk_set_user_templates_disabled(db, bulk_templates, admin, is_disabled=True)
@@ -137,7 +141,7 @@ async def bulk_disable_user_templates(
async def bulk_enable_user_templates(
bulk_templates: BulkUserTemplateSelection,
db: AsyncSession = Depends(get_db),
- admin: AdminDetails = Depends(check_sudo_admin),
+ admin: AdminDetails = Depends(require_permission("templates", "update")),
):
"""Enable selected user templates by ID."""
return await template_operator.bulk_set_user_templates_disabled(db, bulk_templates, admin, is_disabled=False)
diff --git a/app/subscription/singbox.py b/app/subscription/singbox.py
index 69469538e..a2b87abb0 100644
--- a/app/subscription/singbox.py
+++ b/app/subscription/singbox.py
@@ -166,13 +166,15 @@ def _transport_ws(self, config: WebSocketTransportConfig, path: str) -> dict:
def _transport_grpc(self, config: GRPCTransportConfig, path: str) -> dict:
"""Handle GRPC transport - only gets GRPC config"""
- return self._normalize_and_remove_none_values({
- "type": "grpc",
- "service_name": path,
- "idle_timeout": f"{config.idle_timeout}s" if config.idle_timeout else "15s",
- "ping_timeout": f"{config.health_check_timeout}s" if config.health_check_timeout else "15s",
- "permit_without_stream": config.permit_without_stream,
- })
+ return self._normalize_and_remove_none_values(
+ {
+ "type": "grpc",
+ "service_name": path,
+ "idle_timeout": f"{config.idle_timeout}s" if config.idle_timeout else "15s",
+ "ping_timeout": f"{config.health_check_timeout}s" if config.health_check_timeout else "15s",
+ "permit_without_stream": config.permit_without_stream,
+ }
+ )
def _transport_httpupgrade(self, config: WebSocketTransportConfig, path: str) -> dict:
"""Handle HTTPUpgrade transport - only gets WS config (similar to WS)"""
diff --git a/app/subscription/xray.py b/app/subscription/xray.py
index 789710f42..bccd3c6c4 100644
--- a/app/subscription/xray.py
+++ b/app/subscription/xray.py
@@ -274,23 +274,27 @@ def _transport_quic(self, config: QUICTransportConfig, path: str) -> dict:
"""Handle QUIC transport - only gets QUIC config"""
host = config.host if isinstance(config.host, str) else (config.host[0] if config.host else "")
- return self._normalize_and_remove_none_values({
- "security": host,
- "header": {"type": config.header_type},
- "key": path,
- })
+ return self._normalize_and_remove_none_values(
+ {
+ "security": host,
+ "header": {"type": config.header_type},
+ "key": path,
+ }
+ )
def _transport_kcp(self, config: KCPTransportConfig, path: str) -> dict:
"""Handle KCP transport - only gets KCP config"""
- return self._normalize_and_remove_none_values({
- "mtu": config.mtu if config.mtu is not None else 1350,
- "tti": config.tti if config.tti is not None else 50,
- "uplinkCapacity": config.uplink_capacity if config.uplink_capacity is not None else 5,
- "downlinkCapacity": config.downlink_capacity if config.downlink_capacity is not None else 20,
- "congestion": config.congestion,
- "readBufferSize": config.read_buffer_size if config.read_buffer_size is not None else 2,
- "writeBufferSize": config.write_buffer_size if config.write_buffer_size is not None else 2,
- })
+ return self._normalize_and_remove_none_values(
+ {
+ "mtu": config.mtu if config.mtu is not None else 1350,
+ "tti": config.tti if config.tti is not None else 50,
+ "uplinkCapacity": config.uplink_capacity if config.uplink_capacity is not None else 5,
+ "downlinkCapacity": config.downlink_capacity if config.downlink_capacity is not None else 20,
+ "congestion": config.congestion,
+ "readBufferSize": config.read_buffer_size if config.read_buffer_size is not None else 2,
+ "writeBufferSize": config.write_buffer_size if config.write_buffer_size is not None else 2,
+ }
+ )
def _apply_transport(self, network: str, inbound: SubscriptionInboundData, path: str) -> dict | None:
"""Apply transport settings using registry pattern"""
@@ -306,15 +310,17 @@ def _apply_tls(self, tls_config: TLSConfig, security: str) -> dict:
sni = tls_config.sni if isinstance(tls_config.sni, str) else (tls_config.sni[0] if tls_config.sni else None)
if security == "reality":
- return self._normalize_and_remove_none_values({
- "serverName": sni,
- "fingerprint": tls_config.fingerprint,
- "show": False,
- "publicKey": tls_config.reality_public_key,
- "shortId": tls_config.reality_short_id,
- "spiderX": tls_config.reality_spx,
- "mldsa65Verify": tls_config.mldsa65_verify,
- })
+ return self._normalize_and_remove_none_values(
+ {
+ "serverName": sni,
+ "fingerprint": tls_config.fingerprint,
+ "show": False,
+ "publicKey": tls_config.reality_public_key,
+ "shortId": tls_config.reality_short_id,
+ "spiderX": tls_config.reality_spx,
+ "mldsa65Verify": tls_config.mldsa65_verify,
+ }
+ )
else: # tls
config = {
"serverName": sni,
@@ -383,11 +389,13 @@ def _download_config(self, download_settings: SubscriptionInboundData, link_form
sockopt=sockopt,
)
- return self._normalize_and_remove_none_values({
- "address": download_settings.address,
- "port": self._select_port(download_settings.port),
- **stream_settings,
- })
+ return self._normalize_and_remove_none_values(
+ {
+ "address": download_settings.address,
+ "port": self._select_port(download_settings.port),
+ **stream_settings,
+ }
+ )
# ========== Protocol Builders (Registry Methods) ==========
diff --git a/app/telegram/handlers/admin/bulk_actions.py b/app/telegram/handlers/admin/bulk_actions.py
index 114434fe9..5f7a62114 100644
--- a/app/telegram/handlers/admin/bulk_actions.py
+++ b/app/telegram/handlers/admin/bulk_actions.py
@@ -23,6 +23,7 @@
)
from app.telegram.keyboards.confim_action import ConfirmAction
from app.telegram.utils import forms
+from app.telegram.utils.filters import IsScopeAll, HasPermission
from app.telegram.utils.shared import add_to_messages_to_delete, delete_messages
from app.telegram.utils.texts import Message as Texts
@@ -58,12 +59,16 @@ def _chunk_subscription_urls(urls: list[str], limit: int = 3800) -> list[str]:
return chunks
-@router.callback_query(AdminPanel.Callback.filter(AdminPanelAction.bulk_actions == F.action))
-async def bulk_actions(event: CallbackQuery):
+@router.callback_query(
+ IsScopeAll("users", "update"), AdminPanel.Callback.filter(AdminPanelAction.bulk_actions == F.action)
+)
+async def bulk_actions(event: CallbackQuery, admin: AdminDetails):
await event.message.edit_text(Texts.choose_action, reply_markup=BulkActionPanel().as_markup())
-@router.callback_query(BulkActionPanel.Callback.filter(BulkAction.create_from_template == F.action))
+@router.callback_query(
+ HasPermission("users", "create"), BulkActionPanel.Callback.filter(BulkAction.create_from_template == F.action)
+)
async def bulk_create_from_template(event: CallbackQuery, db: AsyncSession, state: FSMContext):
templates = await user_templates.get_user_templates(db, UserTemplateListQuery())
if not templates:
@@ -242,7 +247,9 @@ async def _perform_bulk_creation(
await target.answer(chunk)
-@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.delete_expired == F.action) & ~F.amount))
+@router.callback_query(
+ IsScopeAll("users", "delete"), BulkActionPanel.Callback.filter((BulkAction.delete_expired == F.action) & ~F.amount)
+)
async def delete_expired(event: CallbackQuery, state: FSMContext):
try:
await event.message.delete()
@@ -276,7 +283,9 @@ async def process_expire_before(event: Message, state: FSMContext):
)
-@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.delete_expired == F.action) & F.amount))
+@router.callback_query(
+ IsScopeAll("users", "delete"), BulkActionPanel.Callback.filter((BulkAction.delete_expired == F.action) & F.amount)
+)
async def delete_expired_done(
event: CallbackQuery, db: AsyncSession, admin: AdminDetails, callback_data: BulkActionPanel.Callback
):
@@ -293,7 +302,9 @@ async def delete_expired_done(
await event.message.edit_text(Texts.choose_action, reply_markup=BulkActionPanel().as_markup())
-@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.modify_expiry == F.action) & ~F.amount))
+@router.callback_query(
+ IsScopeAll("users", "update"), BulkActionPanel.Callback.filter((BulkAction.modify_expiry == F.action) & ~F.amount)
+)
async def modify_expiry(event: CallbackQuery, state: FSMContext):
try:
await event.message.delete()
@@ -329,14 +340,21 @@ async def process_expiry(event: Message, state: FSMContext):
)
-@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.modify_expiry == F.action) & F.amount))
-async def modify_expiry_done(event: CallbackQuery, db: AsyncSession, callback_data: BulkActionPanel.Callback):
+@router.callback_query(
+ IsScopeAll("users", "update"), BulkActionPanel.Callback.filter((BulkAction.modify_expiry == F.action) & F.amount)
+)
+async def modify_expiry_done(
+ event: CallbackQuery, db: AsyncSession, admin: AdminDetails, callback_data: BulkActionPanel.Callback
+):
result = await user_operations.bulk_modify_expire(db, BulkUser(amount=int(callback_data.amount) * 86400))
await event.answer(Texts.users_expiry_changed(result, int(callback_data.amount)))
await event.message.edit_text(Texts.choose_action, reply_markup=BulkActionPanel().as_markup())
-@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.modify_data_limit == F.action) & ~F.amount))
+@router.callback_query(
+ IsScopeAll("users", "update"),
+ BulkActionPanel.Callback.filter((BulkAction.modify_data_limit == F.action) & ~F.amount),
+)
async def modify_data_limit(event: CallbackQuery, state: FSMContext):
try:
await event.message.delete()
@@ -372,8 +390,13 @@ async def process_data_limit(event: Message, state: FSMContext):
)
-@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.modify_data_limit == F.action) & F.amount))
-async def modify_data_limit_done(event: CallbackQuery, db: AsyncSession, callback_data: BulkActionPanel.Callback):
+@router.callback_query(
+ IsScopeAll("users", "update"),
+ BulkActionPanel.Callback.filter((BulkAction.modify_data_limit == F.action) & F.amount),
+)
+async def modify_data_limit_done(
+ event: CallbackQuery, db: AsyncSession, admin: AdminDetails, callback_data: BulkActionPanel.Callback
+):
result = await user_operations.bulk_modify_datalimit(db, BulkUser(amount=int(callback_data.amount) * (1024**3)))
await event.answer(Texts.users_data_limit_changed(result, int(callback_data.amount)))
await event.message.edit_text(Texts.choose_action, reply_markup=BulkActionPanel().as_markup())
diff --git a/app/telegram/handlers/admin/main_menu.py b/app/telegram/handlers/admin/main_menu.py
index c1b2718c2..efb6baa0f 100644
--- a/app/telegram/handlers/admin/main_menu.py
+++ b/app/telegram/handlers/admin/main_menu.py
@@ -10,7 +10,7 @@
from app.operation.system import SystemOperation
from app.settings import telegram_settings
from app.telegram.keyboards.admin import AdminPanel, AdminPanelAction
-from app.telegram.utils.filters import IsAdminFilter, IsAdminSUDO
+from app.telegram.utils.filters import HasPermission, IsAdminFilter
from app.telegram.utils.texts import Message as Texts
system_operator = SystemOperation(OperatorType.TELEGRAM)
@@ -19,58 +19,53 @@
router = Router(name="main_menu")
+async def _render_main_menu(event: CallbackQuery, db: AsyncSession, admin: AdminDetails):
+ """Render the main admin panel with permission-aware keyboard."""
+ stats = await system_operator.get_system_stats(db, admin)
+ settings = await telegram_settings()
+ return AdminPanel(
+ admin=admin,
+ panel_url=settings.mini_app_web_url if settings.mini_app_login else None,
+ ).as_markup(), Texts.start(stats)
+
+
@router.callback_query(IsAdminFilter(), AdminPanel.Callback.filter(AdminPanelAction.refresh == F.action))
async def reload_data(event: CallbackQuery, db: AsyncSession, admin: AdminDetails):
- stats = await system_operator.get_system_stats(db, admin)
+ markup, text = await _render_main_menu(event, db, admin)
try:
- settings = await telegram_settings()
- await event.message.edit_text(
- text=Texts.start(stats),
- reply_markup=AdminPanel(
- is_sudo=admin.is_sudo, panel_url=settings.mini_app_web_url if settings.mini_app_login else None
- ).as_markup(),
- )
+ await event.message.edit_text(text=text, reply_markup=markup)
except TelegramBadRequest:
pass
-
await event.answer(Texts.refreshed)
-@router.callback_query(IsAdminSUDO(), AdminPanel.Callback.filter(AdminPanelAction.sync_users == F.action))
+@router.callback_query(
+ HasPermission("nodes", "reconnect"),
+ AdminPanel.Callback.filter(AdminPanelAction.sync_users == F.action),
+)
async def sync_users(event: CallbackQuery, db: AsyncSession, admin: AdminDetails):
await event.answer(Texts.syncing)
nodes_response = await node_operator.get_db_nodes(db, NodeListQuery())
for node in nodes_response.nodes:
await node_operator.sync_node_users(db, node.id, flush_users=True)
+ markup, text = await _render_main_menu(event, db, admin)
try:
- stats = await system_operator.get_system_stats(db, admin)
- settings = await telegram_settings()
- await event.message.edit_text(
- text=Texts.start(stats),
- reply_markup=AdminPanel(
- is_sudo=admin.is_sudo, panel_url=settings.mini_app_web_url if settings.mini_app_login else None
- ).as_markup(),
- )
+ await event.message.edit_text(text=text, reply_markup=markup)
except TelegramBadRequest:
pass
-
await event.answer(Texts.synced)
-@router.callback_query(IsAdminSUDO(), AdminPanel.Callback.filter(AdminPanelAction.reconnect_all_nodes == F.action))
+@router.callback_query(
+ HasPermission("nodes", "reconnect"),
+ AdminPanel.Callback.filter(AdminPanelAction.reconnect_all_nodes == F.action),
+)
async def reconnect_all_nodes(event: CallbackQuery, db: AsyncSession, admin: AdminDetails):
await event.answer(Texts.reconnecting_nodes)
await node_operator.restart_all_node(db=db, admin=admin)
+ markup, text = await _render_main_menu(event, db, admin)
try:
- stats = await system_operator.get_system_stats(db, admin)
- settings = await telegram_settings()
- await event.message.edit_text(
- text=Texts.start(stats),
- reply_markup=AdminPanel(
- is_sudo=admin.is_sudo, panel_url=settings.mini_app_web_url if settings.mini_app_login else None
- ).as_markup(),
- )
+ await event.message.edit_text(text=text, reply_markup=markup)
except TelegramBadRequest:
pass
-
await event.answer(Texts.nodes_reconnected)
diff --git a/app/telegram/handlers/admin/user.py b/app/telegram/handlers/admin/user.py
index e89d8c1c8..61a4ddc96 100644
--- a/app/telegram/handlers/admin/user.py
+++ b/app/telegram/handlers/admin/user.py
@@ -2,7 +2,9 @@
from datetime import datetime as dt, timedelta as td
from io import BytesIO
-from aiogram import Router, F
+from aiogram import F, Router
+from aiogram.exceptions import TelegramBadRequest
+from aiogram.fsm.context import FSMContext
from aiogram.types import (
BufferedInputFile,
CallbackQuery,
@@ -11,13 +13,12 @@
InputTextMessageContent,
Message,
)
-from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
-from aiogram.exceptions import TelegramBadRequest
from app.db.models import UserStatus
-from app.models.settings import ConfigFormat
+from app.models.admin import AdminDetails
from app.models.group import GroupListQuery
+from app.models.settings import ConfigFormat
from app.models.user import (
CreateUserFromTemplate,
ModifyUserByTemplate,
@@ -29,18 +30,19 @@
from app.models.user_template import UserTemplateListQuery
from app.models.validators import UserValidator
from app.operation import OperatorType
+from app.operation.group import GroupOperation
+from app.operation.permissions import PermissionDenied, enforce_permission, get_scope_admin_id
from app.operation.subscription import SubscriptionOperation
from app.operation.user import UserOperation
-from app.operation.group import GroupOperation
from app.operation.user_template import UserTemplateOperation
-from app.telegram.keyboards.group import SelectGroupAction, GroupsSelector
-from app.telegram.utils import forms
-from app.models.admin import AdminDetails
from app.telegram.keyboards.admin import AdminPanel, AdminPanelAction, InlineQuerySearch
from app.telegram.keyboards.base import CancelKeyboard
-from app.telegram.utils.texts import Message as Texts
-from app.telegram.keyboards.user import UserPanel, UserPanelAction, ChooseStatus, ChooseTemplate, RandomUsername
+from app.telegram.keyboards.group import GroupsSelector, SelectGroupAction
+from app.telegram.keyboards.user import ChooseStatus, ChooseTemplate, RandomUsername, UserPanel, UserPanelAction
+from app.telegram.utils import forms
+from app.telegram.utils.filters import HasPermission
from app.telegram.utils.shared import add_to_messages_to_delete, delete_messages
+from app.telegram.utils.texts import Message as Texts
user_operations = UserOperation(OperatorType.TELEGRAM)
subscription_operations = SubscriptionOperation(OperatorType.TELEGRAM)
@@ -52,6 +54,7 @@
@router.callback_query(
+ HasPermission("users", "create"),
AdminPanel.Callback.filter(AdminPanelAction.create_user == F.action),
)
async def create_user(event: CallbackQuery, state: FSMContext):
@@ -219,7 +222,9 @@ async def select_groups(
)
-@router.callback_query(GroupsSelector.Callback.filter(SelectGroupAction.create == F.action))
+@router.callback_query(
+ HasPermission("users", "create"), GroupsSelector.Callback.filter(SelectGroupAction.create == F.action)
+)
async def process_done(event: CallbackQuery, db: AsyncSession, admin: AdminDetails, state: FSMContext):
data = await state.get_data()
if not data.get("group_ids", []):
@@ -247,10 +252,14 @@ async def process_done(event: CallbackQuery, db: AsyncSession, admin: AdminDetai
user = await user_operations.create_user(db, new_user, admin)
groups = await user_operations.validate_all_groups(db, user)
await event.answer(Texts.user_created)
- return await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ return await event.message.edit_text(
+ Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()
+ )
-@router.callback_query(UserPanel.Callback.filter(UserPanelAction.modify_groups == F.action))
+@router.callback_query(
+ HasPermission("users", "update"), UserPanel.Callback.filter(UserPanelAction.modify_groups == F.action)
+)
async def modify_groups(
event: CallbackQuery, db: AsyncSession, state: FSMContext, callback_data: UserPanel.Callback, admin: AdminDetails
):
@@ -271,7 +280,9 @@ async def modify_groups(
)
-@router.callback_query(GroupsSelector.Callback.filter(SelectGroupAction.modify == F.action))
+@router.callback_query(
+ HasPermission("users", "update"), GroupsSelector.Callback.filter(SelectGroupAction.modify == F.action)
+)
async def modify_groups_done(event: CallbackQuery, db: AsyncSession, admin: AdminDetails, state: FSMContext):
data = await state.get_data()
if not data.get("group_ids", []):
@@ -288,10 +299,14 @@ async def modify_groups_done(event: CallbackQuery, db: AsyncSession, admin: Admi
groups = await user_operations.validate_all_groups(db, user)
await delete_messages(event, state)
await state.clear()
- await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ await event.message.edit_text(
+ Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()
+ )
-@router.callback_query(UserPanel.Callback.filter(UserPanelAction.modify_expiry == F.action))
+@router.callback_query(
+ HasPermission("users", "update"), UserPanel.Callback.filter(UserPanelAction.modify_expiry == F.action)
+)
async def modify_expiry(event: CallbackQuery, callback_data: UserPanel.Callback, state: FSMContext):
await state.set_state(forms.ModifyUser.new_expiry)
await state.update_data(user_id=callback_data.user_id)
@@ -334,10 +349,12 @@ async def modify_expiry_done(event: Message, state: FSMContext, db: AsyncSession
modified_user = UserModify(expire=(dt.now() + td(days=duration)) if duration else 0)
user = await user_operations.modify_user(db, user.username, modified_user, admin)
groups = await user_operations.validate_all_groups(db, user)
- await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup())
-@router.callback_query(UserPanel.Callback.filter(UserPanelAction.modify_data_limit == F.action))
+@router.callback_query(
+ HasPermission("users", "update"), UserPanel.Callback.filter(UserPanelAction.modify_data_limit == F.action)
+)
async def modify_data_limit(event: CallbackQuery, callback_data: UserPanel.Callback, state: FSMContext):
await state.set_state(forms.ModifyUser.new_data_limit)
await state.update_data(user_id=callback_data.user_id)
@@ -374,10 +391,12 @@ async def modify_data_limit_done(event: Message, state: FSMContext, db: AsyncSes
modified_user = UserModify(data_limit=data_limit * 1024**3)
user = await user_operations.modify_user(db, user.username, modified_user, admin)
groups = await user_operations.validate_all_groups(db, user)
- await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup())
-@router.callback_query(UserPanel.Callback.filter(UserPanelAction.modify_note == F.action))
+@router.callback_query(
+ HasPermission("users", "update"), UserPanel.Callback.filter(UserPanelAction.modify_note == F.action)
+)
async def modify_note(event: CallbackQuery, callback_data: UserPanel.Callback, state: FSMContext):
await state.set_state(forms.ModifyUser.new_note)
await state.update_data(user_id=callback_data.user_id)
@@ -407,10 +426,10 @@ async def modify_note_done(event: Message, state: FSMContext, db: AsyncSession,
modified_user = UserModify(note=note)
user = await user_operations.modify_user(db, user.username, modified_user, admin)
groups = await user_operations.validate_all_groups(db, user)
- await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup())
-@router.callback_query(UserPanel.Callback.filter(UserPanelAction.disable == F.action))
+@router.callback_query(HasPermission("users", "update"), UserPanel.Callback.filter(UserPanelAction.disable == F.action))
async def disable_user(event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback):
user = await user_operations.get_user_by_id(db, callback_data.user_id, admin)
modified_user = UserModify(**user.model_dump())
@@ -418,18 +437,23 @@ async def disable_user(event: CallbackQuery, admin: AdminDetails, db: AsyncSessi
user = await user_operations.modify_user(db, user.username, modified_user, admin)
await event.answer(f"User {user.username} has been disabled.")
groups = await user_operations.validate_all_groups(db, user)
- await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ await event.message.edit_text(
+ Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()
+ )
-@router.callback_query(UserPanel.Callback.filter(UserPanelAction.delete == F.action))
+@router.callback_query(HasPermission("users", "delete"), UserPanel.Callback.filter(UserPanelAction.delete == F.action))
async def delete_user(event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback):
- user = await user_operations.get_user_by_id(db, callback_data.user_id, admin)
- await user_operations.remove_user(db, user.username, admin)
+ try:
+ user = await user_operations.get_user_by_id(db, callback_data.user_id, admin)
+ await user_operations.remove_user(db, user.username, admin)
+ except (ValueError, PermissionDenied) as e:
+ return await event.answer(str(e), show_alert=True)
await event.answer(Texts.user_deleted(user.username))
- await event.message.edit_text(Texts.user_deleted(user.username), reply_markup=AdminPanel(admin.is_sudo).as_markup())
+ await event.message.edit_text(Texts.user_deleted(user.username), reply_markup=AdminPanel(admin=admin).as_markup())
-@router.callback_query(UserPanel.Callback.filter(UserPanelAction.enable == F.action))
+@router.callback_query(HasPermission("users", "update"), UserPanel.Callback.filter(UserPanelAction.enable == F.action))
async def enable_user(event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback):
user = await user_operations.get_user_by_id(db, callback_data.user_id, admin)
modified_user = UserModify(**user.model_dump())
@@ -437,28 +461,41 @@ async def enable_user(event: CallbackQuery, admin: AdminDetails, db: AsyncSessio
user = await user_operations.modify_user(db, user.username, modified_user, admin)
await event.answer(Texts.user_enabled(user.username))
groups = await user_operations.validate_all_groups(db, user)
- await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ await event.message.edit_text(
+ Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()
+ )
-@router.callback_query(UserPanel.Callback.filter(UserPanelAction.revoke_sub == F.action))
+@router.callback_query(
+ HasPermission("users", "revoke_sub"), UserPanel.Callback.filter(UserPanelAction.revoke_sub == F.action)
+)
async def revoke_sub(event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback):
user = await user_operations.get_user_by_id(db, callback_data.user_id, admin)
user = await user_operations.revoke_user_sub(db, user.username, admin)
await event.answer(Texts.user_sub_revoked(user.username))
groups = await user_operations.validate_all_groups(db, user)
- await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ await event.message.edit_text(
+ Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()
+ )
-@router.callback_query(UserPanel.Callback.filter(UserPanelAction.reset_usage == F.action))
+@router.callback_query(
+ HasPermission("users", "reset_usage"), UserPanel.Callback.filter(UserPanelAction.reset_usage == F.action)
+)
async def reset_usage(event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback):
user = await user_operations.get_user_by_id(db, callback_data.user_id, admin)
user = await user_operations.reset_user_data_usage(db, user.username, admin)
await event.answer(Texts.user_reset_usage(user.username))
groups = await user_operations.validate_all_groups(db, user)
- await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ await event.message.edit_text(
+ Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()
+ )
-@router.callback_query(UserPanel.Callback.filter(UserPanelAction.activate_next_plan == F.action))
+@router.callback_query(
+ HasPermission("users", "activate_next_plan"),
+ UserPanel.Callback.filter(UserPanelAction.activate_next_plan == F.action),
+)
async def activate_next_plan(
event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback
):
@@ -466,10 +503,14 @@ async def activate_next_plan(
user = await user_operations.active_next_plan(db, user.username, admin)
await event.answer(Texts.user_next_plan_activated(user.username))
groups = await user_operations.validate_all_groups(db, user)
- await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ await event.message.edit_text(
+ Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()
+ )
-@router.callback_query(UserPanel.Callback.filter(UserPanelAction.modify_with_template == F.action))
+@router.callback_query(
+ HasPermission("users", "update"), UserPanel.Callback.filter(UserPanelAction.modify_with_template == F.action)
+)
async def modify_with_template(event: CallbackQuery, db: AsyncSession, callback_data: UserPanel.Callback):
templates = await user_templates.get_user_templates(db, UserTemplateListQuery())
if not templates:
@@ -480,7 +521,7 @@ async def modify_with_template(event: CallbackQuery, db: AsyncSession, callback_
)
-@router.callback_query(ChooseTemplate.Callback.filter(F.user_id))
+@router.callback_query(HasPermission("users", "update"), ChooseTemplate.Callback.filter(F.user_id))
async def modify_with_template_done(
event: CallbackQuery, db: AsyncSession, admin: AdminDetails, callback_data: ChooseTemplate.Callback
):
@@ -492,10 +533,15 @@ async def modify_with_template_done(
admin,
)
groups = await user_operations.validate_all_groups(db, user)
- return await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ return await event.message.edit_text(
+ Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()
+ )
-@router.callback_query(AdminPanel.Callback.filter(AdminPanelAction.create_user_from_template == F.action))
+@router.callback_query(
+ HasPermission("users", "create"),
+ AdminPanel.Callback.filter(AdminPanelAction.create_user_from_template == F.action),
+)
async def create_user_from_template(event: CallbackQuery, db: AsyncSession):
templates = await user_templates.get_user_templates(db, UserTemplateListQuery())
if not templates:
@@ -503,7 +549,7 @@ async def create_user_from_template(event: CallbackQuery, db: AsyncSession):
await event.message.edit_text(Texts.choose_a_template, reply_markup=ChooseTemplate(templates).as_markup())
-@router.callback_query(ChooseTemplate.Callback.filter(~F.username))
+@router.callback_query(HasPermission("users", "create"), ChooseTemplate.Callback.filter(~F.username))
async def create_user_from_template_username(
event: CallbackQuery, state: FSMContext, callback_data: ChooseTemplate.Callback
):
@@ -568,9 +614,13 @@ async def create_user_from_template_choose(
)
groups = await user_operations.validate_all_groups(db, user)
if isinstance(event, Message):
- return await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ return await event.answer(
+ Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()
+ )
else:
- return await event.message.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ return await event.message.answer(
+ Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()
+ )
@router.message(F.text.contains("/sub/"))
@@ -580,16 +630,24 @@ async def get_user_by_sub(event: Message, db: AsyncSession, admin: AdminDetails)
try:
db_user = await user_operations.get_validated_sub(db, token)
user = await user_operations.validate_user(db_user)
- if user.admin and user.admin.username != admin.username and not admin.is_sudo:
+ try:
+ enforce_permission(admin, "users", "read")
+ except PermissionDenied:
+ return await event.reply(Texts.user_not_found)
+
+ scope_id = get_scope_admin_id(admin, "users", "read")
+ if scope_id is not None and user.admin.id != scope_id:
return await event.reply(Texts.user_not_found)
except ValueError:
return await event.reply(Texts.user_not_found)
groups = await user_operations.validate_all_groups(db, user)
- await event.reply(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ await event.reply(Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup())
-@router.callback_query(UserPanel.Callback.filter(UserPanelAction.v2ray_links == F.action))
+@router.callback_query(
+ HasPermission("users", "read"), UserPanel.Callback.filter(UserPanelAction.v2ray_links == F.action)
+)
async def get_v2ray_links(
event: CallbackQuery, db: AsyncSession, admin: AdminDetails, callback_data: UserPanel.Callback
):
@@ -621,8 +679,8 @@ async def get_v2ray_links(
await event.answer()
-@router.message(F.text)
-@router.callback_query(UserPanel.Callback.filter(UserPanelAction.show == F.action))
+@router.message(HasPermission("users", "read"), F.text)
+@router.callback_query(HasPermission("users", "read"), UserPanel.Callback.filter(UserPanelAction.show == F.action))
async def get_user(event: Message | CallbackQuery, admin: AdminDetails, db: AsyncSession, **kwargs):
"""get exact user, otherwise not found"""
try:
@@ -638,9 +696,11 @@ async def get_user(event: Message | CallbackQuery, admin: AdminDetails, db: Asyn
groups = await user_operations.validate_all_groups(db, user)
if isinstance(event, Message):
- await event.reply(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ await event.reply(Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup())
else:
- await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ await event.message.edit_text(
+ Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()
+ )
@router.inline_query()
diff --git a/app/telegram/handlers/base.py b/app/telegram/handlers/base.py
index b4036e4cf..e856265ea 100644
--- a/app/telegram/handlers/base.py
+++ b/app/telegram/handlers/base.py
@@ -44,7 +44,7 @@ async def command_start_handler(
return await message.edit_text(
text=Texts.start(stats),
reply_markup=AdminPanel(
- is_sudo=admin.is_sudo,
+ admin=admin,
panel_url=settings.mini_app_web_url if settings.mini_app_login else None,
).as_markup(),
)
@@ -53,7 +53,7 @@ async def command_start_handler(
await message.answer(
text=Texts.start(stats),
reply_markup=AdminPanel(
- is_sudo=admin.is_sudo, panel_url=settings.mini_app_web_url if settings.mini_app_login else None
+ admin=admin, panel_url=settings.mini_app_web_url if settings.mini_app_login else None
).as_markup(),
)
else:
diff --git a/app/telegram/keyboards/admin.py b/app/telegram/keyboards/admin.py
index b59cc067b..ed3417a2e 100644
--- a/app/telegram/keyboards/admin.py
+++ b/app/telegram/keyboards/admin.py
@@ -3,6 +3,8 @@
from aiogram.utils.keyboard import InlineKeyboardBuilder, WebAppInfo
from aiogram.filters.callback_data import CallbackData
+from app.models.admin import AdminDetails
+from app.operation.permissions import enforce_permission, is_scope_all, PermissionDenied
from app.telegram.utils.texts import Button as Texts
@@ -15,36 +17,61 @@ class AdminPanelAction(str, Enum):
bulk_actions = "bulk_actions"
+def _has_permission(admin: AdminDetails | None, resource: str, action: str) -> bool:
+ """Return True if admin has the given permission."""
+ if not admin:
+ return False
+ try:
+ enforce_permission(admin, resource, action)
+ return True
+ except PermissionDenied:
+ return False
+
+
class AdminPanel(InlineKeyboardBuilder):
class Callback(CallbackData, prefix="panel"):
action: AdminPanelAction
- def __init__(self, is_sudo: bool = False, panel_url: str = None, *args, **kwargs):
+ def __init__(self, admin: AdminDetails | None = None, panel_url: str = None, *args, **kwargs):
super().__init__(*args, **kwargs)
adjust = []
+
if panel_url and panel_url.startswith("https://"):
self.button(text=Texts.open_panel, web_app=WebAppInfo(url=panel_url))
adjust.append(1)
self.button(text=Texts.refresh_data, callback_data=self.Callback(action=AdminPanelAction.refresh))
- if is_sudo:
+
+ can_read_nodes = _has_permission(admin, "nodes", "reconnect")
+ can_read_users = _has_permission(admin, "users", "read")
+ can_create_users = _has_permission(admin, "users", "create")
+ # bulk_actions requires scope=all on users.update
+ can_bulk = is_scope_all(admin, "users", "update") if admin else False
+
+ if can_read_nodes:
self.button(text=Texts.sync_users, callback_data=self.Callback(action=AdminPanelAction.sync_users))
self.button(
text=Texts.reconnect_all_nodes,
callback_data=self.Callback(action=AdminPanelAction.reconnect_all_nodes),
)
+ adjust = adjust + [1, 2]
+
+ if can_read_users:
self.button(text=Texts.users, switch_inline_query_current_chat="")
+ adjust.append(1)
+
+ if can_bulk:
self.button(text=Texts.bulk_actions, callback_data=self.Callback(action=AdminPanelAction.bulk_actions))
- adjust = adjust + [2] * 2 + [1]
- else:
- self.button(text=Texts.users, switch_inline_query_current_chat="")
- adjust = adjust + [1] * 2
- self.button(text=Texts.create_user, callback_data=self.Callback(action=AdminPanelAction.create_user))
- self.button(
- text=Texts.create_user_from_template,
- callback_data=self.Callback(action=AdminPanelAction.create_user_from_template),
- )
- adjust = adjust + [1] * 2
+ adjust.append(1)
+
+ if can_create_users:
+ self.button(text=Texts.create_user, callback_data=self.Callback(action=AdminPanelAction.create_user))
+ self.button(
+ text=Texts.create_user_from_template,
+ callback_data=self.Callback(action=AdminPanelAction.create_user_from_template),
+ )
+ adjust = adjust + [1, 1]
+
self.adjust(*adjust)
diff --git a/app/telegram/keyboards/user.py b/app/telegram/keyboards/user.py
index 912f2f6ca..99870e6df 100644
--- a/app/telegram/keyboards/user.py
+++ b/app/telegram/keyboards/user.py
@@ -5,14 +5,27 @@
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.filters.callback_data import CallbackData
+from app.models.admin import AdminDetails
from app.models.user import UserResponse, UserStatus
from app.models.user_template import UserTemplate
+from app.operation.permissions import enforce_permission, PermissionDenied
from app.telegram.utils.texts import Button as Texts
from .base import CancelAction, CancelKeyboard
from .confim_action import ConfirmAction
+def _has_permission(admin: AdminDetails | None, resource: str, action: str) -> bool:
+ """Return True if admin has the given permission."""
+ if not admin:
+ return False
+ try:
+ enforce_permission(admin, resource, action)
+ return True
+ except PermissionDenied:
+ return False
+
+
class UserPanelAction(str, Enum):
show = "show"
disable = "disable"
@@ -34,60 +47,73 @@ class Callback(CallbackData, prefix="user"):
user_id: int
action: UserPanelAction = UserPanelAction.show
- def __init__(self, user: UserResponse, *args, **kwargs):
+ def __init__(self, user: UserResponse, admin: AdminDetails | None = None, *args, **kwargs):
super().__init__(*args, **kwargs)
- if user.status == UserStatus.active:
+ can_modify = _has_permission(admin, "users", "update")
+ can_delete = _has_permission(admin, "users", "delete")
+ can_revoke_sub = _has_permission(admin, "users", "revoke_sub")
+ can_reset_usage = _has_permission(admin, "users", "reset_usage")
+ can_activate_next_plan = _has_permission(admin, "users", "activate_next_plan")
+
+ if can_modify:
+ if user.status == UserStatus.active:
+ self.button(
+ text=Texts.disable,
+ callback_data=ConfirmAction.Callback(
+ action=self.Callback(action=UserPanelAction.disable, user_id=user.id).pack(),
+ cancel=self.Callback(user_id=user.id).pack(),
+ ),
+ )
+ else:
+ self.button(
+ text=Texts.enable,
+ callback_data=ConfirmAction.Callback(
+ action=self.Callback(action=UserPanelAction.enable, user_id=user.id).pack(),
+ cancel=self.Callback(user_id=user.id).pack(),
+ ),
+ )
+ if can_delete:
self.button(
- text=Texts.disable,
+ text=Texts.delete,
callback_data=ConfirmAction.Callback(
- action=self.Callback(action=UserPanelAction.disable, user_id=user.id).pack(),
+ action=self.Callback(action=UserPanelAction.delete, user_id=user.id).pack(),
cancel=self.Callback(user_id=user.id).pack(),
),
)
- else:
+ if can_revoke_sub:
self.button(
- text=Texts.enable,
+ text=Texts.revoke_sub,
callback_data=ConfirmAction.Callback(
- action=self.Callback(action=UserPanelAction.enable, user_id=user.id).pack(),
+ action=self.Callback(action=UserPanelAction.revoke_sub, user_id=user.id).pack(),
cancel=self.Callback(user_id=user.id).pack(),
),
)
- self.button(
- text=Texts.delete,
- callback_data=ConfirmAction.Callback(
- action=self.Callback(action=UserPanelAction.delete, user_id=user.id).pack(),
- cancel=self.Callback(user_id=user.id).pack(),
- ),
- )
- self.button(
- text=Texts.revoke_sub,
- callback_data=ConfirmAction.Callback(
- action=self.Callback(action=UserPanelAction.revoke_sub, user_id=user.id).pack(),
- cancel=self.Callback(user_id=user.id).pack(),
- ),
- )
- self.button(
- text=Texts.reset_usage,
- callback_data=ConfirmAction.Callback(
- action=self.Callback(action=UserPanelAction.reset_usage, user_id=user.id).pack(),
- cancel=self.Callback(user_id=user.id).pack(),
- ),
- )
- self.button(
- text=Texts.modify_data_limit,
- callback_data=self.Callback(action=UserPanelAction.modify_data_limit, user_id=user.id),
- )
- self.button(
- text=Texts.modify_expiry,
- callback_data=self.Callback(action=UserPanelAction.modify_expiry, user_id=user.id),
- )
- self.button(
- text=Texts.modify_note, callback_data=self.Callback(action=UserPanelAction.modify_note, user_id=user.id)
- )
- self.button(
- text=Texts.modify_groups, callback_data=self.Callback(action=UserPanelAction.modify_groups, user_id=user.id)
- )
- if not user.next_plan:
+ if can_reset_usage:
+ self.button(
+ text=Texts.reset_usage,
+ callback_data=ConfirmAction.Callback(
+ action=self.Callback(action=UserPanelAction.reset_usage, user_id=user.id).pack(),
+ cancel=self.Callback(user_id=user.id).pack(),
+ ),
+ )
+ if can_modify:
+ self.button(
+ text=Texts.modify_data_limit,
+ callback_data=self.Callback(action=UserPanelAction.modify_data_limit, user_id=user.id),
+ )
+ self.button(
+ text=Texts.modify_expiry,
+ callback_data=self.Callback(action=UserPanelAction.modify_expiry, user_id=user.id),
+ )
+ self.button(
+ text=Texts.modify_note,
+ callback_data=self.Callback(action=UserPanelAction.modify_note, user_id=user.id),
+ )
+ self.button(
+ text=Texts.modify_groups,
+ callback_data=self.Callback(action=UserPanelAction.modify_groups, user_id=user.id),
+ )
+ if can_activate_next_plan and not user.next_plan:
self.button(
text=Texts.activate_next_plan,
callback_data=ConfirmAction.Callback(
@@ -95,11 +121,11 @@ def __init__(self, user: UserResponse, *args, **kwargs):
cancel=self.Callback(user_id=user.id).pack(),
),
)
-
- self.button(
- text=Texts.modify_with_template,
- callback_data=self.Callback(action=UserPanelAction.modify_with_template, user_id=user.id),
- )
+ if can_modify:
+ self.button(
+ text=Texts.modify_with_template,
+ callback_data=self.Callback(action=UserPanelAction.modify_with_template, user_id=user.id),
+ )
self.button(text=Texts.subscription_url, copy_text=CopyTextButton(text=user.subscription_url))
self.button(
diff --git a/app/telegram/middlewares/acl.py b/app/telegram/middlewares/acl.py
index 631166b61..722b61e43 100644
--- a/app/telegram/middlewares/acl.py
+++ b/app/telegram/middlewares/acl.py
@@ -5,6 +5,7 @@
from app.db import GetDB
from app.db.crud.admin import get_admin_by_telegram_id
+from app.db.models import AdminStatus
from app.models.admin import AdminDetails
from app.settings import telegram_settings
from app.models.settings import Telegram
@@ -20,7 +21,7 @@ async def __call__(
settings: Telegram = await telegram_settings()
admin = await get_admin_by_telegram_id(db, user_id)
if admin:
- if admin.is_disabled:
+ if admin.status == AdminStatus.disabled:
if settings.for_admins_only:
return
data["admin"] = None
diff --git a/app/telegram/utils/filters.py b/app/telegram/utils/filters.py
index b3fda4272..b1a4ada11 100644
--- a/app/telegram/utils/filters.py
+++ b/app/telegram/utils/filters.py
@@ -1,13 +1,47 @@
from aiogram.filters import Filter
from app.models.admin import AdminDetails
+from app.operation.permissions import enforce_permission, is_scope_all, PermissionDenied
class IsAdminFilter(Filter):
+ """Passes if the user is a known, non-disabled admin."""
+
async def __call__(self, _, admin: AdminDetails | None = None) -> bool:
return bool(admin)
-class IsAdminSUDO(Filter):
+class HasPermission(Filter):
+ """
+ RBAC filter — passes if the admin has the given resource+action permission.
+ Usage: HasPermission("users", "create")
+ """
+
+ def __init__(self, resource: str, action: str):
+ self.resource = resource
+ self.action = action
+
+ async def __call__(self, _, admin: AdminDetails | None = None) -> bool:
+ if not admin:
+ return False
+ try:
+ enforce_permission(admin, self.resource, self.action)
+ return True
+ except PermissionDenied:
+ return False
+
+
+class IsScopeAll(Filter):
+ """
+ RBAC filter — passes only if the admin has scope=ALL (or is owner) for resource+action.
+ Usage: IsScopeAll("users", "update")
+ """
+
+ def __init__(self, resource: str, action: str):
+ self.resource = resource
+ self.action = action
+
async def __call__(self, _, admin: AdminDetails | None = None) -> bool:
- return admin.is_sudo if admin else False
+ if not admin:
+ return False
+ return is_scope_all(admin, self.resource, self.action)
diff --git a/app/utils/jwt.py b/app/utils/jwt.py
index f00f30b43..2efa14451 100644
--- a/app/utils/jwt.py
+++ b/app/utils/jwt.py
@@ -18,8 +18,8 @@ async def get_secret_key():
return key
-async def create_admin_token(admin_id: int | None, username: str, is_sudo=False) -> str:
- data = {"sub": username, "access": "sudo" if is_sudo else "admin", "iat": datetime.now(timezone.utc)}
+async def create_admin_token(admin_id: int | None, username: str) -> str:
+ data = {"sub": username, "access": "admin", "iat": datetime.now(timezone.utc)}
if admin_id is not None:
data["aid"] = int(admin_id)
if jwt_settings.access_token_expire_minutes > 0:
@@ -50,7 +50,6 @@ async def get_admin_payload(token: str) -> dict | None:
return {
"admin_id": admin_id,
"username": username,
- "is_sudo": access == "sudo",
"created_at": created_at,
}
except jwt.exceptions.PyJWTError:
diff --git a/app/utils/request.py b/app/utils/request.py
new file mode 100644
index 000000000..ca5397488
--- /dev/null
+++ b/app/utils/request.py
@@ -0,0 +1,8 @@
+from fastapi import Request
+
+
+def get_client_ip(request: Request) -> str:
+ """Extract the client's IP address from the request."""
+ if request.client:
+ return request.client.host
+ return "Unknown"
diff --git a/app/utils/system.py b/app/utils/system.py
index e14d8afa5..bf3227742 100644
--- a/app/utils/system.py
+++ b/app/utils/system.py
@@ -75,3 +75,33 @@ def readable_size(size_bytes):
p = math.pow(1024, i)
s = round(size_bytes / p, 2)
return f"{s} {size_name[i]}"
+
+
+def readable_duration(seconds: int | float) -> str:
+ """Format a duration (in seconds) as a human-readable string.
+
+ Mirrors :func:`readable_size`: caller always passes seconds, this picks the
+ largest natural unit (years, months, days, hours, minutes, seconds) and
+ pluralizes correctly.
+ """
+ if not seconds or seconds <= 0:
+ return "0 seconds"
+
+ units = (
+ ("year", 31_536_000), # 365 days
+ ("month", 2_592_000), # 30 days
+ ("day", 86_400),
+ ("hour", 3_600),
+ ("minute", 60),
+ ("second", 1),
+ )
+
+ for label, factor in units:
+ if seconds >= factor:
+ amount = seconds / factor
+ if amount % 1 == 0:
+ amount_int = int(amount)
+ return f"{amount_int} {label}" if amount_int == 1 else f"{amount_int} {label}s"
+ return f"{amount:.2f} {label}s"
+
+ return f"{seconds} seconds"
diff --git a/cli/__init__.py b/cli/__init__.py
index bba064b08..89ff812bd 100644
--- a/cli/__init__.py
+++ b/cli/__init__.py
@@ -8,30 +8,18 @@
from rich.console import Console
from rich.table import Table
-from app.models.admin import AdminDetails
from app.operation import OperatorType
from app.operation.admin import AdminOperation
-from app.operation.system import SystemOperation
# Initialize console for rich output
console = Console()
-# system admin for CLI operations
-SYSTEM_ADMIN = AdminDetails(
- username="cli", is_sudo=True, telegram_id=None, discord_webhook=None, notification_enable=None
-)
-
def get_admin_operation() -> AdminOperation:
"""Get admin operation instance."""
return AdminOperation(OperatorType.CLI)
-def get_system_operation() -> SystemOperation:
- """Get node operation instance."""
- return SystemOperation(OperatorType.CLI)
-
-
class BaseCLI:
"""Base class for CLI operations."""
diff --git a/cli/admin.py b/cli/admin.py
index da7d820ee..8680e74e5 100644
--- a/cli/admin.py
+++ b/cli/admin.py
@@ -1,392 +1,21 @@
"""
-Admin CLI Module
-
-Handles admin account management through the command line interface.
+Admin CLI Module — generate-temp-key only
"""
-import typer
-from pydantic import ValidationError
-from typing_extensions import Annotated
-
+import asyncio
from app.db.base import GetDB
-from app.models.admin import AdminCreate, AdminListQuery, AdminModify
-from app.models.notification_enable import UserNotificationEnable
-from app.utils.system import readable_size
-from cli import SYSTEM_ADMIN, BaseCLI, console, get_admin_operation
-
-
-class AdminCLI(BaseCLI):
- """Admin CLI operations."""
-
- async def list_admins(self, db):
- """List all admin accounts."""
- admin_op = get_admin_operation()
- admins = await admin_op.get_admins(db, AdminListQuery())
-
- if not admins:
- self.console.print("[yellow]No admins found[/yellow]")
- return
-
- table = self.create_table(
- "Admin Accounts",
- [
- {"name": "Username", "style": "cyan"},
- {"name": "Is Sudo", "style": "green"},
- {"name": "Used Traffic", "style": "blue"},
- {"name": "Is Disabled", "style": "red"},
- ],
- )
-
- for admin in admins:
- table.add_row(
- admin.username,
- "✓" if admin.is_sudo else "✗",
- readable_size(admin.used_traffic),
- "✓" if admin.is_disabled else "✗",
- )
-
- self.console.print(table)
-
- async def create_admin(self, db, username: str, is_sudo: bool):
- """Create a new admin account."""
- admin_op = get_admin_operation()
-
- # Check if admin already exists
- admins = await admin_op.get_admins(db, AdminListQuery())
- if any(admin.username == username for admin in admins):
- self.console.print(f"[red]Admin '{username}' already exists[/red]")
- return
-
- while True:
- # Get password
- password = typer.prompt("Password", hide_input=True)
- if not password:
- self.console.print("[red]Password is required[/red]")
- continue
-
- confirm_password = typer.prompt("Confirm Password", hide_input=True)
- if password != confirm_password:
- self.console.print("[red]Passwords do not match[/red]")
- continue
-
- try:
- # Notification preferences setup
- self.console.print("\n[cyan]Notification Preferences:[/cyan]")
- enable_notifications = typer.confirm("Enable user notifications for this admin?", default=False)
-
- if enable_notifications:
- self.console.print("[yellow]Select which notification types to enable:[/yellow]")
- notif_create = typer.confirm(" User Create?", default=False)
- notif_modify = typer.confirm(" User Modify?", default=False)
- notif_delete = typer.confirm(" User Delete?", default=False)
- notif_status_change = typer.confirm(" Status Change?", default=False)
- notif_reset_data = typer.confirm(" Reset Data Usage?", default=False)
- notif_data_reset_by_next = typer.confirm(" Data Reset By Next?", default=False)
- notif_sub_revoked = typer.confirm(" Subscription Revoked?", default=False)
- else:
- notif_create = notif_modify = notif_delete = notif_status_change = False
- notif_reset_data = notif_data_reset_by_next = notif_sub_revoked = False
-
- notification_enable = UserNotificationEnable(
- create=notif_create,
- modify=notif_modify,
- delete=notif_delete,
- status_change=notif_status_change,
- reset_data_usage=notif_reset_data,
- data_reset_by_next=notif_data_reset_by_next,
- subscription_revoked=notif_sub_revoked,
- )
-
- # Create admin
- new_admin = AdminCreate(
- username=username, password=password, is_sudo=is_sudo, notification_enable=notification_enable
- )
- await admin_op.create_admin(db, new_admin, SYSTEM_ADMIN)
- self.console.print(f"[green]Admin '{username}' created successfully[/green]")
- break
- except ValidationError as e:
- self.format_cli_validation_error(e)
- continue
- except Exception as e:
- self.console.print(f"[red]Error creating admin: {e}[/red]")
- break
-
- async def delete_admin(self, db, username: str):
- """Delete an admin account."""
- admin_op = get_admin_operation()
-
- # Check if admin exists
- admins = await admin_op.get_admins(db, AdminListQuery())
- target_admin = next((admin for admin in admins if admin.username == username), None)
- if not target_admin:
- self.console.print(f"[red]Admin '{username}' not found[/red]")
- return
-
- user_count = len(target_admin.users or [])
-
- if typer.confirm(f"Are you sure you want to delete admin '{username}'?"):
- if user_count > 0:
- message = (
- f"Admin '{username}' owns {user_count} users. Delete all of their users before removing the admin?"
- )
- delete_users = typer.confirm(message, default=False)
- if delete_users:
- try:
- await admin_op.remove_all_users_by_id(db, target_admin.id, SYSTEM_ADMIN)
- self.console.print(f"[green]Deleted {user_count} users belonging to admin '{username}'[/green]")
- except Exception as e:
- self.console.print(f"[red]Error deleting users: {e}[/red]")
- return
-
- try:
- await admin_op.remove_admin_by_id(db, target_admin.id, SYSTEM_ADMIN)
- self.console.print(f"[green]Admin '{username}' deleted successfully[/green]")
- except Exception as e:
- self.console.print(f"[red]Error deleting admin: {e}[/red]")
-
- async def delete_admin_users(self, db, username: str):
- """Delete all users belonging to an admin."""
- admin_op = get_admin_operation()
-
- admins = await admin_op.get_admins(db, AdminListQuery())
- target_admin = next((admin for admin in admins if admin.username == username), None)
- if not target_admin:
- self.console.print(f"[red]Admin '{username}' not found[/red]")
- return
-
- if not typer.confirm(
- f"Delete all users belonging to admin '{username}'? This action cannot be undone.", default=False
- ):
- self.console.print("[yellow]Operation cancelled[/yellow]")
- return
-
- try:
- deleted = await admin_op.remove_all_users_by_id(db, target_admin.id, SYSTEM_ADMIN)
- if deleted == 0:
- self.console.print(f"[yellow]Admin '{username}' has no users to delete[/yellow]")
- else:
- self.console.print(f"[green]Deleted {deleted} users belonging to admin '{username}'[/green]")
- except Exception as e:
- self.console.print(f"[red]Error deleting users: {e}[/red]")
-
- async def modify_admin(self, db, username: str, disable: bool):
- """Modify an admin account."""
- admin_op = get_admin_operation()
-
- # Check if admin exists
- admins = await admin_op.get_admins(db, AdminListQuery())
- if not any(admin.username == username for admin in admins):
- self.console.print(f"[red]Admin '{username}' not found[/red]")
- return
-
- # Get the current admin details
- current_admin = next(admin for admin in admins if admin.username == username)
+from app.db.crud.temp_key import create_temp_key
+from cli import console
- self.console.print(f"[yellow]Modifying admin '{username}'[/yellow]")
- self.console.print("[cyan]Current settings:[/cyan]")
- self.console.print(f" Username: {current_admin.username}")
- self.console.print(f" Is Sudo: {'✓' if current_admin.is_sudo else '✗'}")
- self.console.print(f" Is Disabled: {'✓' if current_admin.is_disabled else '✗'}")
- # Display current notification settings
- if current_admin.notification_enable is None:
- self.console.print(" Notifications: [yellow]Legacy (Receiving ALL)[/yellow]")
- else:
- notif = current_admin.notification_enable
- self.console.print(" Notifications:")
- self.console.print(f" User Create: {'✓' if notif['create'] else '✗'}")
- self.console.print(f" User Modify: {'✓' if notif['modify'] else '✗'}")
- self.console.print(f" User Delete: {'✓' if notif['delete'] else '✗'}")
- self.console.print(f" Status Change: {'✓' if notif['status_change'] else '✗'}")
- self.console.print(f" Reset Data Usage: {'✓' if notif['reset_data_usage'] else '✗'}")
- self.console.print(f" Data Reset By Next: {'✓' if notif['data_reset_by_next'] else '✗'}")
- self.console.print(f" Subscription Revoked: {'✓' if notif['subscription_revoked'] else '✗'}")
-
- new_password = None
- is_sudo = current_admin.is_sudo
- is_disabled = current_admin.is_disabled
- notification_enable = current_admin.notification_enable
-
- # Password modification
- if typer.confirm("Do you want to change the password?"):
- new_password = typer.prompt("New password", hide_input=True)
- confirm_password = typer.prompt("Confirm Password", hide_input=True)
- if new_password != confirm_password:
- self.console.print("[red]Passwords do not match[/red]")
- return
-
- # Sudo status modification
- if typer.confirm(f"Do you want to change sudo status? (Current: {'✓' if current_admin.is_sudo else '✗'})"):
- is_sudo = typer.confirm("Make this admin a sudo admin?")
-
- # Disabled status modification
- if disable is not None:
- is_disabled = disable
- elif typer.confirm(
- f"Do you want to change disabled status? (Current: {'✓' if current_admin.is_disabled else '✗'})"
- ):
- is_disabled = typer.confirm("Disable this admin account?")
-
- # Notification preferences modification (skip for legacy admins with None)
- if current_admin.notification_enable is not None and typer.confirm(
- "Do you want to modify notification preferences?"
- ):
- self.console.print("\n[cyan]Notification Preferences:[/cyan]")
- enable_notifications = typer.confirm(
- "Enable user notifications for this admin?",
- default=any(
- [
- current_admin.notification_enable["create"],
- current_admin.notification_enable["modify"],
- current_admin.notification_enable["delete"],
- current_admin.notification_enable["status_change"],
- current_admin.notification_enable["reset_data_usage"],
- current_admin.notification_enable["data_reset_by_next"],
- current_admin.notification_enable["subscription_revoked"],
- ]
- ),
- )
-
- if enable_notifications:
- self.console.print("[yellow]Select which notification types to enable:[/yellow]")
- notif_create = typer.confirm(" User Create?", default=current_admin.notification_enable["create"])
- notif_modify = typer.confirm(" User Modify?", default=current_admin.notification_enable["modify"])
- notif_delete = typer.confirm(" User Delete?", default=current_admin.notification_enable["delete"])
- notif_status_change = typer.confirm(
- " Status Change?", default=current_admin.notification_enable["status_change"]
- )
- notif_reset_data = typer.confirm(
- " Reset Data Usage?", default=current_admin.notification_enable["reset_data_usage"]
- )
- notif_data_reset_by_next = typer.confirm(
- " Data Reset By Next?", default=current_admin.notification_enable["data_reset_by_next"]
- )
- notif_sub_revoked = typer.confirm(
- " Subscription Revoked?", default=current_admin.notification_enable["subscription_revoked"]
- )
- else:
- notif_create = notif_modify = notif_delete = notif_status_change = False
- notif_reset_data = notif_data_reset_by_next = notif_sub_revoked = False
-
- notification_enable = UserNotificationEnable(
- create=notif_create,
- modify=notif_modify,
- delete=notif_delete,
- status_change=notif_status_change,
- reset_data_usage=notif_reset_data,
- data_reset_by_next=notif_data_reset_by_next,
- subscription_revoked=notif_sub_revoked,
- )
-
- # Confirm changes
- self.console.print("\n[cyan]Summary of changes:[/cyan]")
- if new_password:
- self.console.print(" Password: [yellow]Will be updated[/yellow]")
- if is_sudo != current_admin.is_sudo:
- self.console.print(f" Is Sudo: {'✓' if is_sudo else '✗'} [yellow](changed)[/yellow]")
- if is_disabled != current_admin.is_disabled:
- self.console.print(f" Is Disabled: {'✓' if is_disabled else '✗'} [yellow](changed)[/yellow]")
- if notification_enable != current_admin.notification_enable:
- self.console.print(" Notifications: [yellow](changed)[/yellow]")
-
- if typer.confirm("Do you want to apply these changes?"):
- try:
- # Interactive modification
- modified_admin = AdminModify(
- is_sudo=is_sudo,
- password=new_password,
- is_disabled=is_disabled,
- notification_enable=notification_enable,
- )
- await admin_op.modify_admin_by_id(db, current_admin.id, modified_admin, SYSTEM_ADMIN)
- self.console.print(f"[green]Admin '{username}' modified successfully[/green]")
- except Exception as e:
- self.console.print(f"[red]Error modifying admin: {e}[/red]")
- else:
- self.console.print("[yellow]Modification cancelled[/yellow]")
-
- async def reset_admin_usage(self, db, username: str):
- """Reset admin usage statistics."""
- admin_op = get_admin_operation()
-
- # Check if admin exists
- admins = await admin_op.get_admins(db, AdminListQuery())
- if not any(admin.username == username for admin in admins):
- self.console.print(f"[red]Admin '{username}' not found[/red]")
- return
-
- if typer.confirm(f"Are you sure you want to reset usage for admin '{username}'?"):
- try:
- target_admin = next(admin for admin in admins if admin.username == username)
- await admin_op.reset_admin_usage_by_id(db, target_admin.id, SYSTEM_ADMIN)
- self.console.print(f"[green]Usage reset for admin '{username}'[/green]")
- except Exception as e:
- self.console.print(f"[red]Error resetting usage: {e}[/red]")
-
-
-admin_cli = AdminCLI()
-
-
-# CLI commands
-async def list_admins():
- """List all admin accounts."""
- async with GetDB() as db:
- try:
- await admin_cli.list_admins(db)
- except Exception as e:
- console.print(f"[red]Error: {e}[/red]")
-
-
-async def create_admin(
- username: str,
- sudo: Annotated[bool, typer.Option(False, "--sudo", "-s", help="Create a sudo admin.")] = False,
-):
- """Create a new admin account."""
- async with GetDB() as db:
- try:
- if not sudo:
- sudo = typer.confirm("Make this admin a sudo admin?")
- await admin_cli.create_admin(db, username, sudo)
- except Exception as e:
- console.print(f"[red]Error: {e}[/red]")
-
-
-async def delete_admin(username: str):
- """Delete an admin account."""
+async def _generate_temp_key():
async with GetDB() as db:
- try:
- await admin_cli.delete_admin(db, username)
- except Exception as e:
- console.print(f"[red]Error: {e}[/red]")
+ key = await create_temp_key(db)
+ console.print(f"[bold green]Temp key:[/bold green] {key.key}")
+ console.print(f"[yellow]Expires at:[/yellow] {key.expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
+ console.print("[dim]This key is valid for 5 minutes and can only be used once.[/dim]")
-async def delete_admin_users(username: str):
- """Delete all users belonging to an admin."""
- async with GetDB() as db:
- try:
- await admin_cli.delete_admin_users(db, username)
- except Exception as e:
- console.print(f"[red]Error: {e}[/red]")
-
-
-async def modify_admin(
- username: str,
- disable: Annotated[bool, typer.Option(..., "--disable", help="Disable or enable the admin account.")] = None,
-):
- """Modify an admin account."""
- async with GetDB() as db:
- try:
- if disable is None:
- disable = typer.confirm("Do you want to disable this admin?")
- await admin_cli.modify_admin(db, username, disable)
- except Exception as e:
- console.print(f"[red]Error: {e}[/red]")
-
-
-async def reset_admin_usage(username: str):
- """Reset admin usage statistics."""
- async with GetDB() as db:
- try:
- await admin_cli.reset_admin_usage(db, username)
- except Exception as e:
- console.print(f"[red]Error: {e}[/red]")
+def generate_temp_key():
+ """Generate a one-time temp key for owner setup operations."""
+ asyncio.run(_generate_temp_key())
diff --git a/cli/main.py b/cli/main.py
index 052fb1096..22e51c98e 100644
--- a/cli/main.py
+++ b/cli/main.py
@@ -1,28 +1,24 @@
#!/usr/bin/env python3
-"""
-PasarGuard CLI - Command Line Interface for PasarGuard Management
-
-A modern, type-safe CLI built with Typer for managing PasarGuard instances.
-"""
-
-import asyncio
-from typing import Optional
+"""PasarGuard CLI"""
import typer
-
from cli import console
-from cli.admin import create_admin, delete_admin, delete_admin_users, list_admins, modify_admin, reset_admin_usage
-from cli.system import show_status
+from cli.admin import generate_temp_key
-# Initialize Typer app
app = typer.Typer(
name="PasarGuard",
- help="PasarGuard CLI - Command Line Interface for PasarGuard Management",
+ help="PasarGuard CLI",
add_completion=False,
rich_markup_mode="rich",
)
+@app.command("generate-temp-key")
+def cmd_generate_temp_key():
+ """Generate a one-time temp key for owner setup (create/reset/delete)."""
+ generate_temp_key()
+
+
@app.command()
def version():
"""Show PasarGuard version."""
@@ -31,40 +27,5 @@ def version():
console.print(f"[bold blue]PasarGuard[/bold blue] version [bold green]{__version__}[/bold green]")
-@app.command()
-def admins(
- list: bool = typer.Option(False, "--list", "-l", help="List all admins"),
- create: Optional[str] = typer.Option(None, "--create", "-c", help="Create new admin"),
- sudo: bool = typer.Option(False, "--sudo", "-s", help="Create a sudo admin."),
- delete: Optional[str] = typer.Option(None, "--delete", "-d", help="Delete admin"),
- delete_users: Optional[str] = typer.Option(
- None, "--delete-users", "-u", help="Delete all users belonging to an admin"
- ),
- modify: Optional[str] = typer.Option(None, "--modify", "-m", help="Modify admin"),
- disable: Optional[bool] = typer.Option(None, "--disable", help="Disable or enable the admin account."),
- reset_usage: Optional[str] = typer.Option(None, "--reset-usage", "-r", help="Reset admin usage"),
-):
- """List & manage admin accounts."""
-
- if list or not any([create, delete, delete_users, modify, reset_usage]):
- asyncio.run(list_admins())
- elif create:
- asyncio.run(create_admin(create, sudo))
- elif delete:
- asyncio.run(delete_admin(delete))
- elif delete_users:
- asyncio.run(delete_admin_users(delete_users))
- elif modify:
- asyncio.run(modify_admin(modify, disable))
- elif reset_usage:
- asyncio.run(reset_admin_usage(reset_usage))
-
-
-@app.command()
-def system():
- """Show system status."""
- asyncio.run(show_status())
-
-
if __name__ == "__main__":
app()
diff --git a/cli/system.py b/cli/system.py
deleted file mode 100644
index d1d8c5c91..000000000
--- a/cli/system.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""
-System CLI Module
-
-Handles system status and information through the command line interface.
-"""
-
-from rich.panel import Panel
-
-from app.db.base import GetDB
-from app.utils.system import readable_size
-from cli import SYSTEM_ADMIN, BaseCLI, console, get_system_operation
-
-
-class SystemCLI(BaseCLI):
- """System CLI operations."""
-
- async def show_status(self, db):
- """Show system status."""
- system_op = get_system_operation()
- stats = await system_op.get_system_stats(db, SYSTEM_ADMIN)
-
- status_text = (
- f"[bold]System Statistics[/bold]\n\n"
- f"CPU Usage: [green]{stats.cpu_usage:.1f}%[/green]\n"
- f"Memory Usage: [green]{stats.mem_used / stats.mem_total * 100:.1f}%[/green] "
- f"Disk Usage: [green]{stats.disk_used / stats.disk_total * 100:.1f}%[/green] "
- f"([cyan]{readable_size(stats.mem_used)}[/cyan] / [cyan]{readable_size(stats.mem_total)}[/cyan])\n"
- f"Disk Usage: [green]{stats.disk_used / stats.disk_total * 100:.1f}%[/green] "
- f"([cyan]{readable_size(stats.disk_used)}[/cyan] / [cyan]{readable_size(stats.disk_total)}[/cyan])\n"
- f"CPU Cores: [magenta]{stats.cpu_cores}[/magenta]\n"
- f"Total Users: [blue]{stats.total_user}[/blue]\n"
- f"Active Users: [green]{stats.active_users}[/green]\n"
- f"Online Users: [yellow]{stats.online_users}[/yellow]\n"
- f"On Hold Users: [yellow]{stats.on_hold_users}[/yellow]\n"
- f"Disabled Users: [red]{stats.disabled_users}[/red]\n"
- f"Expired Users: [red]{stats.expired_users}[/red]\n"
- f"Limited Users: [yellow]{stats.limited_users}[/yellow]\n"
- f"Data Usage (In): [blue]{readable_size(stats.incoming_bandwidth)}[/blue]\n"
- f"Data Usage (Out): [blue]{readable_size(stats.outgoing_bandwidth)}[/blue]\n"
- )
-
- panel = Panel(
- status_text,
- title="System Information",
- border_style="blue",
- )
-
- self.console.print(panel)
-
-
-# CLI commands
-async def show_status():
- """Show system status."""
- system_cli = SystemCLI()
- async with GetDB() as db:
- try:
- await system_cli.show_status(db)
- except Exception as e:
- console.print(f"[red]Error: {e}[/red]")
diff --git a/config.py b/config.py
index 157d6e968..6f7521a30 100644
--- a/config.py
+++ b/config.py
@@ -177,6 +177,7 @@ class JobSettings(EnvSettings):
record_node_usages_interval: int = Field(default=30, validation_alias="JOB_RECORD_NODE_USAGES_INTERVAL")
record_user_usages_interval: int = Field(default=10, validation_alias="JOB_RECORD_USER_USAGES_INTERVAL")
review_users_interval: int = Field(default=30, validation_alias="JOB_REVIEW_USERS_INTERVAL")
+ review_admin_limits_interval: int = Field(default=10, validation_alias="JOB_REVIEW_ADMIN_LIMITS_INTERVAL")
send_notifications_interval: int = Field(default=30, validation_alias="JOB_SEND_NOTIFICATIONS_INTERVAL")
gather_nodes_stats_interval: int = Field(default=25, validation_alias="JOB_GATHER_NODES_STATS_INTERVAL")
remove_old_inbounds_interval: int = Field(default=600, validation_alias="JOB_REMOVE_OLD_INBOUNDS_INTERVAL")
diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json
index 1d511d83f..8e4dbde0d 100644
--- a/dashboard/public/statics/locales/en.json
+++ b/dashboard/public/statics/locales/en.json
@@ -155,8 +155,10 @@
"cancelSuccess": "Changes cancelled and original settings restored",
"filterTitle": "Filter Notifications",
"filterDescription": "Select events that should trigger notifications for important system activities",
+ "toggleAll": "Toggle all notifications",
"types": {
"admin": "Admins",
+ "adminRole": "Admin roles",
"core": "Cores",
"group": "Groups",
"host": "Hosts",
@@ -173,9 +175,11 @@
"delete": "Delete",
"resetUsage": "Reset Usage",
"login": "Login",
+ "usageLimitWarning": "Usage Limit Warning",
"modifyHosts": "Modify Hosts",
"connect": "Connect",
"error": "Error",
+ "limited": "Limited",
"statusChange": "Status Change",
"resetDataUsage": "Reset Data Usage",
"dataResetByNext": "Data Reset By Next",
@@ -204,6 +208,12 @@
"title": "Channel Overrides",
"description": "Override the default destinations for specific notification events. Leave fields empty to inherit the global configuration.",
"hint": "Leave these fields empty to fall back to the main notification settings for this event."
+ },
+ "usageLimitWarnings": {
+ "thresholds": "Warning Thresholds",
+ "description": "Send admin data-limit warnings when usage reaches these percentages.",
+ "addThreshold": "Add Threshold",
+ "empty": "No thresholds configured"
}
},
"subscriptions": {
@@ -639,9 +649,11 @@
"description": "Manage system administrators",
"createAdmin": "Create Admin",
"editAdmin": "Modify Admin",
+ "profileSection": "Profile & contact",
"deleteAdmin": "Remove Admin",
"username": "Username",
"password": "Password",
+ "dataLimit": "Admin data limit",
"isSudo": "Super Admin",
"createdAt": "Created At",
"actions": "Actions",
@@ -686,11 +698,14 @@
"create": "Create Admin",
"status": "Status",
"role": "Role",
+ "permissionOverrides": "Permission overrides",
+ "permissionOverridesHint": "Leave empty to inherit limits from the selected role. Set to 0 to disable.",
"total.users": "Total Users",
"used.traffic": "Used Traffic",
"total": "Total Admins",
"active": "Active Admins",
"disable": "Disabled Admins",
+ "limited": "Limited Admins",
"telegramId": "Telegram ID",
"discord": "Discord Webhook",
"supportUrl": "Support URL",
@@ -725,6 +740,185 @@
"no_traffic": "No traffic data available"
}
},
+ "adminRoles": {
+ "title": "Admin Roles",
+ "description": "Manage dashboard roles and RBAC permissions",
+ "createRole": "Create role",
+ "editRole": "Edit role",
+ "viewRole": "View role",
+ "readOnlyHint": "This is a built-in role. You can review its configuration but cannot modify it.",
+ "modalDescription": "Configure permissions, limits, features and access for this role.",
+ "currentRoleUnavailable": "Current role unavailable",
+ "loadFallback": "Using built-in roles",
+ "id": "ID",
+ "ownerRole": "owner",
+ "builtInRole": "built-in",
+ "protectedRole": "Protected role",
+ "protectedRoleHint": "Built-in and owner roles cannot be modified.",
+ "permissions": "Permissions",
+ "limits": "Limits",
+ "limitsAndFeatures": "Limits & Features",
+ "limitsHint": "Leave empty to inherit defaults. Set to 0 to disable.",
+ "roleFormHint": "Scoped user actions use none, own, or all. Other actions are boolean toggles.",
+ "scopedActionInfo": "Choose ownership scope: none denies access, own limits to the admin's own users, all grants full access.",
+ "allowAll": "Allow all",
+ "features": "Features",
+ "access": "Access",
+ "permissionCount": "{{count}} permissions",
+ "limitFeatureCount": "{{limits}} limits, {{features}} feature flags",
+ "unlimited": "Unlimited",
+ "empty": "No roles",
+ "emptyDescription": "Create a role to assign granular permissions, limits, features, and access restrictions to admins.",
+ "noSearchResults": "No roles match your search.",
+ "createSuccess": "Role «{{name}}» has been created successfully",
+ "editSuccess": "Role «{{name}}» has been updated successfully",
+ "deleteSuccess": "Role «{{name}}» has been deleted successfully",
+ "deleteFailed": "Failed to delete role «{{name}}»",
+ "deleteConfirmation": "Delete role",
+ "deleteConfirm": "Are you sure you want to delete role {{name}}?",
+ "bulkDeleteTitle": "Delete selected roles",
+ "bulkDeletePrompt": "Are you sure you want to delete {{count}} selected roles? This action cannot be undone.",
+ "bulkDeleteSuccess": "{{count}} roles deleted successfully.",
+ "bulkDeletePartial": "{{count}} roles could not be deleted.",
+ "bulkDeleteFailed": "Failed to delete selected roles.",
+ "requireTemplateTitle": "Require template",
+ "requireTemplateDescription": "Force admins with this role to create users only from a template.",
+ "allowedTemplates": "Allowed templates",
+ "allowedTemplatesDescription": "Restrict templates this role can use. Leave empty to allow all.",
+ "allowedGroups": "Allowed groups",
+ "allowedGroupsDescription": "Restrict user groups this role can manage. Leave empty to allow all.",
+ "noTemplates": "No templates available",
+ "noGroups": "No groups available",
+ "names": {
+ "administrator": "Administrator",
+ "operator": "Operator"
+ },
+ "groups": {
+ "users": "Users",
+ "admins": "Admins",
+ "roles": "Roles",
+ "nodes": "Nodes",
+ "coreHosts": "Core and hosts",
+ "groupsTemplates": "Groups and templates",
+ "settings": "Settings"
+ },
+ "actions": {
+ "common": {
+ "read": "View",
+ "read_simple": "View simple list",
+ "create": "Create",
+ "update": "Update",
+ "delete": "Delete",
+ "reset_usage": "Reset usage",
+ "revoke_sub": "Revoke subscription",
+ "set_owner": "Set owner",
+ "activate_next_plan": "Activate next plan",
+ "reconnect": "Reconnect",
+ "update_core": "Update core",
+ "stats": "View statistics",
+ "logs": "View logs",
+ "read_general": "View general"
+ },
+ "users": {
+ "reset_usage": "Reset user usage",
+ "revoke_sub": "Revoke subscription",
+ "set_owner": "Set user owner",
+ "activate_next_plan": "Activate next plan"
+ },
+ "admins": {
+ "reset_usage": "Reset admin usage"
+ },
+ "settings": {
+ "read": "View settings",
+ "read_general": "View general settings",
+ "update": "Update settings"
+ },
+ "system": {
+ "read": "View system",
+ "update": "Update system"
+ },
+ "hwids": {
+ "read": "View HWIDs",
+ "update": "Update HWIDs"
+ },
+ "nodes": {
+ "reconnect": "Reconnect nodes",
+ "update_core": "Update node core",
+ "stats": "View node statistics",
+ "logs": "View node logs"
+ }
+ },
+ "resources": {
+ "users": "Users",
+ "admins": "Admins",
+ "admin_roles": "Roles",
+ "nodes": "Nodes",
+ "cores": "Cores",
+ "hosts": "Hosts",
+ "groups": "Groups",
+ "templates": "User templates",
+ "client_templates": "Client templates",
+ "settings": "Settings",
+ "system": "System",
+ "hwids": "HWIDs"
+ },
+ "scopedBadge": "Scoped",
+ "scopes": {
+ "none": "None",
+ "own": "Own",
+ "all": "All"
+ },
+ "limitFields": {
+ "max_users": "Max users",
+ "data_limit_min": "Min data limit (bytes)",
+ "data_limit_max": "Max data limit (bytes)",
+ "expire_days_min": "Min expire days",
+ "expire_days_max": "Max expire days",
+ "min_hwid_per_user": "Min HWIDs per user",
+ "max_hwid_per_user": "Max HWIDs per user"
+ },
+ "limitedBehavior": {
+ "disabledWhenLimited": {
+ "title": "Block limited admins",
+ "description": "Deny all dashboard and API access after an admin reaches their data limit."
+ },
+ "disableUsersWhenLimited": {
+ "title": "Disable users when limited",
+ "description": "Remove this admin's users from nodes while the admin is usage-limited."
+ }
+ },
+ "featureFields": {
+ "can_use_reset_strategy": {
+ "title": "Use reset strategy",
+ "description": "Allow this role to set the data-limit reset strategy on users."
+ },
+ "can_use_next_plan": {
+ "title": "Use next plan",
+ "description": "Allow this role to configure auto-applied next plans."
+ }
+ }
+ },
+ "setup": {
+ "createOwner": "Create owner",
+ "resetOwner": "Reset owner password",
+ "deleteOwner": "Delete owner",
+ "createOwnerShort": "Create",
+ "resetOwnerShort": "Reset",
+ "deleteOwnerShort": "Delete",
+ "ownerAccess": "Owner access",
+ "ownerAccessDescription": "Use a temporary setup key to create, reset, or remove the owner account.",
+ "tempKey": "Temp key",
+ "deleteConfirm": "Type DELETE to confirm",
+ "keyRequired": "Temp key is required",
+ "usernameRequired": "Username is required",
+ "passwordRequired": "Password is required",
+ "passwordMismatch": "Passwords do not match",
+ "deleteConfirmRequired": "Type DELETE to confirm",
+ "ownerCreated": "Owner created successfully",
+ "ownerReset": "Owner password reset successfully",
+ "ownerDeleted": "Owner deleted successfully",
+ "deleteWarning": "This action cannot be undone. The owner account will be permanently removed."
+ },
"shortcuts": {
"title": "Keyboard Shortcuts",
"description": "Manage your dashboard shortcuts for quick access",
@@ -1303,6 +1497,7 @@
"login.fieldRequired": "This field is required",
"login.loginYourAccount": "Login to your account",
"login.welcomeBack": "Welcome back, please enter your details",
+ "login.backToLogin": "Back to login",
"memoryUsage": "memory usage",
"next": "Next",
"monitorServers": "Monitor your servers and users",
@@ -2760,6 +2955,7 @@
"createdAt": "Created at",
"toggle": "Toggle status",
"close": "Close",
+ "view": "View",
"copy": "Copy",
"copyAll": "Copy All",
"copied": "Copied!",
diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json
index ce413765c..42726e7cb 100644
--- a/dashboard/public/statics/locales/fa.json
+++ b/dashboard/public/statics/locales/fa.json
@@ -38,8 +38,10 @@
"cancelSuccess": "تغییرات لغو شد و تنظیمات اصلی بازیابی شد",
"filterTitle": "فیلتر اعلانها",
"filterDescription": "رویدادهایی را انتخاب کنید که باید اعلانهایی را برای اطلاعرسانی از فعالیتهای مهم سیستم ایجاد کنند",
+ "toggleAll": "تغییر وضعیت همه اعلانها",
"types": {
"admin": "مدیران",
+ "adminRole": "نقشهای مدیر",
"core": "هستهها",
"group": "گروهها",
"host": "هاستها",
@@ -56,9 +58,11 @@
"delete": "حذف",
"resetUsage": "بازنشانی مصرف",
"login": "ورود",
+ "usageLimitWarning": "هشدار محدودیت مصرف",
"modifyHosts": "ویرایش هاستها",
"connect": "اتصال",
"error": "خطا",
+ "limited": "محدود شده",
"statusChange": "تغییر وضعیت",
"resetDataUsage": "بازنشانی مصرف داده",
"dataResetByNext": "بازنشانی داده در بعدی",
@@ -90,6 +94,12 @@
"title": "مقصد اعلانها",
"description": "تنظیم مقصد پیشفرض برای رویدادهای اعلانهای خاص. هر فیلد را خالی بگذارید تا تنظیمات عمومی را دریافت کنید.",
"hint": "هر فیلد را خالی بگذارید تا تنظیمات عمومی را دریافت کنید."
+ },
+ "usageLimitWarnings": {
+ "thresholds": "آستانههای هشدار",
+ "description": "وقتی مصرف مدیر به این درصدها رسید، هشدار محدودیت داده ارسال میشود.",
+ "addThreshold": "افزودن آستانه",
+ "empty": "هیچ آستانهای تنظیم نشده است"
}
},
"subscriptions": {
@@ -504,9 +514,11 @@
"description": "مدیریت مدیران سیستم",
"createAdmin": "ایجاد مدیر",
"editAdmin": "ویرایش مدیر",
+ "profileSection": "پروفایل و ارتباط",
"deleteAdmin": "حذف مدیر",
"username": "نام کاربری",
"password": "رمز عبور",
+ "dataLimit": "محدودیت داده مدیر",
"isSudo": "sudo",
"createdAt": "تاریخ ایجاد",
"actions": "عملیات",
@@ -551,6 +563,8 @@
"create": "ایجاد مدیر",
"status": "وضعیت",
"role": "نقش",
+ "permissionOverrides": "بازنویسیهای دسترسی",
+ "permissionOverridesHint": "برای ارثبری محدودیتها از نقش انتخابشده، خالی بگذارید. برای غیرفعالسازی ۰ بگذارید.",
"total.users": "کل کاربران",
"used.traffic": "ترافیک مصرف شده",
"monitor.traffic": "نظارت بر ترافیک مصرف شده",
@@ -558,6 +572,7 @@
"total": "کل مدیران",
"active": "مدیران فعال",
"disable": "مدیران غیرفعال",
+ "limited": "مدیران محدودشده",
"telegramId": "شناسه تلگرام",
"discord": "وبهوک دیسکورد",
"supportUrl": "آدرس پشتیبانی",
@@ -592,6 +607,185 @@
"no_traffic": "دادهای برای ترافیک موجود نیست"
}
},
+ "adminRoles": {
+ "title": "نقشهای مدیر",
+ "description": "مدیریت نقشهای داشبورد و مجوزهای RBAC",
+ "createRole": "ایجاد نقش",
+ "editRole": "ویرایش نقش",
+ "viewRole": "مشاهده نقش",
+ "readOnlyHint": "این یک نقش داخلی است. میتوانید پیکربندی آن را مشاهده کنید اما قابل ویرایش نیست.",
+ "modalDescription": "مجوزها، محدودیتها، قابلیتها و دسترسیهای این نقش را پیکربندی کنید.",
+ "currentRoleUnavailable": "نقش فعلی در دسترس نیست",
+ "loadFallback": "استفاده از نقشهای داخلی",
+ "id": "شناسه",
+ "ownerRole": "مالک",
+ "builtInRole": "داخلی",
+ "protectedRole": "نقش محافظتشده",
+ "protectedRoleHint": "نقشهای داخلی و مالک قابل ویرایش نیستند.",
+ "permissions": "مجوزها",
+ "limits": "محدودیتها",
+ "limitsAndFeatures": "محدودیتها و قابلیتها",
+ "limitsHint": "برای استفاده از پیشفرض، خالی بگذارید. برای غیرفعالسازی ۰ بگذارید.",
+ "roleFormHint": "اعمال کاربر دارای محدوده، مقدار «هیچ»، «خودی» یا «همه» میگیرند. سایر اعمال سوئیچ بولی هستند.",
+ "scopedActionInfo": "محدوده مالکیت را انتخاب کنید: «هیچ» دسترسی را رد میکند، «خودی» فقط کاربران خود مدیر را شامل میشود و «همه» دسترسی کامل میدهد.",
+ "allowAll": "اجازه همه",
+ "features": "قابلیتها",
+ "access": "دسترسی",
+ "permissionCount": "{{count}} مجوز",
+ "limitFeatureCount": "{{limits}} محدودیت، {{features}} پرچم قابلیت",
+ "unlimited": "نامحدود",
+ "empty": "نقشی وجود ندارد",
+ "emptyDescription": "نقش جدیدی بسازید تا مجوزها، محدودیتها، قابلیتها و دسترسیهای دقیق به مدیران اختصاص دهید.",
+ "noSearchResults": "نقشی با جستجوی شما مطابقت ندارد.",
+ "createSuccess": "نقش «{{name}}» با موفقیت ایجاد شد",
+ "editSuccess": "نقش «{{name}}» با موفقیت بهروزرسانی شد",
+ "deleteSuccess": "نقش «{{name}}» با موفقیت حذف شد",
+ "deleteFailed": "حذف نقش «{{name}}» ناموفق بود",
+ "deleteConfirmation": "حذف نقش",
+ "deleteConfirm": "آیا از حذف نقش {{name}} اطمینان دارید؟",
+ "bulkDeleteTitle": "حذف نقشهای انتخابشده",
+ "bulkDeletePrompt": "آیا مطمئنید که میخواهید {{count}} نقش انتخابشده را حذف کنید؟ این عمل قابل بازگشت نیست.",
+ "bulkDeleteSuccess": "{{count}} نقش با موفقیت حذف شد.",
+ "bulkDeletePartial": "{{count}} نقش حذف نشد.",
+ "bulkDeleteFailed": "حذف نقشهای انتخابشده ناموفق بود.",
+ "requireTemplateTitle": "نیاز به قالب",
+ "requireTemplateDescription": "مدیران این نقش را مجبور کنید کاربران را فقط از روی یک قالب بسازند.",
+ "allowedTemplates": "قالبهای مجاز",
+ "allowedTemplatesDescription": "قالبهای قابل استفاده توسط این نقش را محدود کنید. خالی بگذارید تا همه مجاز باشند.",
+ "allowedGroups": "گروههای مجاز",
+ "allowedGroupsDescription": "گروههای کاربری قابل مدیریت توسط این نقش را محدود کنید. خالی بگذارید تا همه مجاز باشند.",
+ "noTemplates": "هیچ قالبی موجود نیست",
+ "noGroups": "هیچ گروهی موجود نیست",
+ "names": {
+ "administrator": "مدیر کل",
+ "operator": "اپراتور"
+ },
+ "groups": {
+ "users": "کاربران",
+ "admins": "مدیران",
+ "roles": "نقشها",
+ "nodes": "گرهها",
+ "coreHosts": "هسته و هاستها",
+ "groupsTemplates": "گروهها و قالبها",
+ "settings": "تنظیمات"
+ },
+ "actions": {
+ "common": {
+ "read": "مشاهده",
+ "read_simple": "مشاهده فهرست ساده",
+ "create": "ایجاد",
+ "update": "ویرایش",
+ "delete": "حذف",
+ "reset_usage": "بازنشانی مصرف",
+ "revoke_sub": "لغو اشتراک",
+ "set_owner": "تغییر مالک",
+ "activate_next_plan": "فعالسازی پلن بعدی",
+ "reconnect": "اتصال مجدد",
+ "update_core": "بهروزرسانی هسته",
+ "stats": "مشاهده آمار",
+ "logs": "مشاهده لاگها",
+ "read_general": "مشاهده عمومی"
+ },
+ "users": {
+ "reset_usage": "بازنشانی مصرف کاربر",
+ "revoke_sub": "لغو اشتراک",
+ "set_owner": "تغییر مالک کاربر",
+ "activate_next_plan": "فعالسازی پلن بعدی"
+ },
+ "admins": {
+ "reset_usage": "بازنشانی مصرف مدیر"
+ },
+ "settings": {
+ "read": "مشاهده تنظیمات",
+ "read_general": "مشاهده تنظیمات عمومی",
+ "update": "ویرایش تنظیمات"
+ },
+ "system": {
+ "read": "مشاهده سیستم",
+ "update": "ویرایش سیستم"
+ },
+ "hwids": {
+ "read": "مشاهده HWIDها",
+ "update": "ویرایش HWIDها"
+ },
+ "nodes": {
+ "reconnect": "اتصال مجدد گرهها",
+ "update_core": "بهروزرسانی هسته گره",
+ "stats": "مشاهده آمار گرهها",
+ "logs": "مشاهده لاگ گرهها"
+ }
+ },
+ "resources": {
+ "users": "کاربران",
+ "admins": "مدیران",
+ "admin_roles": "نقشها",
+ "nodes": "گرهها",
+ "cores": "هستهها",
+ "hosts": "هاستها",
+ "groups": "گروهها",
+ "templates": "قالبهای کاربری",
+ "client_templates": "قالبهای کلاینت",
+ "settings": "تنظیمات",
+ "system": "سیستم",
+ "hwids": "HWID"
+ },
+ "scopedBadge": "محدودهدار",
+ "scopes": {
+ "none": "هیچ",
+ "own": "خودی",
+ "all": "همه"
+ },
+ "limitFields": {
+ "max_users": "حداکثر کاربران",
+ "data_limit_min": "حداقل محدودیت داده (بایت)",
+ "data_limit_max": "حداکثر محدودیت داده (بایت)",
+ "expire_days_min": "حداقل روز انقضا",
+ "expire_days_max": "حداکثر روز انقضا",
+ "min_hwid_per_user": "حداقل HWID به ازای کاربر",
+ "max_hwid_per_user": "حداکثر HWID به ازای کاربر"
+ },
+ "limitedBehavior": {
+ "disabledWhenLimited": {
+ "title": "مسدود کردن مدیران محدودشده",
+ "description": "پس از رسیدن مدیر به محدودیت داده، همه دسترسیهای داشبورد و API را رد میکند."
+ },
+ "disableUsersWhenLimited": {
+ "title": "غیرفعال کردن کاربران هنگام محدودیت",
+ "description": "تا زمانی که مدیر از نظر مصرف محدود است، کاربران این مدیر را از گرهها حذف میکند."
+ }
+ },
+ "featureFields": {
+ "can_use_reset_strategy": {
+ "title": "استفاده از استراتژی بازنشانی",
+ "description": "اجازه تنظیم استراتژی بازنشانی محدودیت داده برای کاربران."
+ },
+ "can_use_next_plan": {
+ "title": "استفاده از پلن بعدی",
+ "description": "اجازه پیکربندی پلن بعدی برای اعمال خودکار."
+ }
+ }
+ },
+ "setup": {
+ "createOwner": "ایجاد مالک",
+ "resetOwner": "بازنشانی رمز مالک",
+ "deleteOwner": "حذف مالک",
+ "createOwnerShort": "ایجاد",
+ "resetOwnerShort": "بازنشانی",
+ "deleteOwnerShort": "حذف",
+ "ownerAccess": "دسترسی مالک",
+ "ownerAccessDescription": "با کلید موقت راهاندازی، حساب مالک را ایجاد، بازنشانی یا حذف کنید.",
+ "tempKey": "کلید موقت",
+ "deleteConfirm": "برای تایید DELETE را وارد کنید",
+ "keyRequired": "کلید موقت الزامی است",
+ "usernameRequired": "نام کاربری الزامی است",
+ "passwordRequired": "رمز عبور الزامی است",
+ "passwordMismatch": "رمزهای عبور یکسان نیستند",
+ "deleteConfirmRequired": "برای تایید DELETE را وارد کنید",
+ "ownerCreated": "مالک با موفقیت ایجاد شد",
+ "ownerReset": "رمز مالک با موفقیت بازنشانی شد",
+ "ownerDeleted": "مالک با موفقیت حذف شد",
+ "deleteWarning": "این عمل قابل بازگشت نیست. حساب مالک برای همیشه حذف خواهد شد."
+ },
"shortcuts": {
"title": "میانبرهای صفحهکلید",
"description": "میانبرهای داشبورد خود را برای دسترسی سریع مدیریت کنید",
@@ -1142,6 +1336,7 @@
"login.fieldRequired": "این فیلد باید پر شود!",
"login.loginYourAccount": "وارد حساب خود شوید",
"login.welcomeBack": "خوش آمدید, لطفا اطلاعات خود را وارد کنید",
+ "login.backToLogin": "بازگشت به ورود",
"memoryUsage": "مصرف حافظه",
"next": "بعدی",
"monitorServers": "نظارت بر سرورها و کاربران شما",
@@ -2681,6 +2876,7 @@
"core.bulkDeleteTitle": "حذف هستههای انتخابشده",
"core.bulkDeletePrompt": "آیا مطمئن هستید که میخواهید {{count}} هسته انتخابشده را حذف کنید؟ این عمل قابل بازگشت نیست.",
"close": "بستن",
+ "view": "مشاهده",
"copy": "کپی",
"copyAll": "کپی همه",
"copied": "کپی شد!",
diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json
index 4a6ffbaef..34c5c8afd 100644
--- a/dashboard/public/statics/locales/ru.json
+++ b/dashboard/public/statics/locales/ru.json
@@ -169,9 +169,11 @@
"cancelSuccess": "Изменения отменены и восстановлены исходные настройки",
"activeTypes": "Активные типы",
"filterTitle": "Фильтр уведомлений",
+ "toggleAll": "Включить все уведомления",
"filterDescription": "Выберите события, которые должны вызывать уведомления для информирования о важных системных действиях",
"types": {
"admin": "Админы",
+ "adminRole": "Роли админов",
"core": "Ядра",
"group": "Группы",
"host": "Хосты",
@@ -188,9 +190,11 @@
"delete": "Удалить",
"resetUsage": "Сбросить использование",
"login": "Вход",
+ "usageLimitWarning": "Предупреждение о лимите",
"modifyHosts": "Изменить хосты",
"connect": "Подключить",
"error": "Ошибка",
+ "limited": "Ограничен",
"statusChange": "Изменение статуса",
"resetDataUsage": "Сбросить использование данных",
"dataResetByNext": "Сброс данных при следующем",
@@ -222,6 +226,12 @@
"title": "Переопределение каналов",
"description": "Переопределите стандартные каналы для конкретных событий уведомлений. Оставьте поля пустыми, чтобы использовать глобальные настройки.",
"hint": "Оставьте поля пустыми, чтобы вернуться к основным настройкам уведомлений для этого события."
+ },
+ "usageLimitWarnings": {
+ "thresholds": "Пороги предупреждений",
+ "description": "Отправлять предупреждения о лимите данных админа при достижении этих процентов.",
+ "addThreshold": "Добавить порог",
+ "empty": "Пороги не настроены"
}
},
"subscriptions": {
@@ -625,9 +635,11 @@
"description": "Управление системными администраторами",
"createAdmin": "Создать администратора",
"editAdmin": "Редактировать администратора",
+ "profileSection": "Профиль и контакты",
"deleteAdmin": "Удалить администратора",
"username": "Имя пользователя",
"password": "Пароль",
+ "dataLimit": "Лимит данных администратора",
"isSudo": "Супер администратор",
"createdAt": "Дата создания",
"actions": "Действия",
@@ -672,11 +684,14 @@
"create": "Создать администратора",
"status": "Статус",
"role": "Роль",
+ "permissionOverrides": "Переопределения прав",
+ "permissionOverridesHint": "Оставьте пустым, чтобы унаследовать ограничения выбранной роли. Установите 0, чтобы отключить.",
"total.users": "Всего пользователей",
"used.traffic": "Использованный трафик",
"total": "Всего администраторов",
"active": "Активные администраторы",
"disable": "Отключённые администраторы",
+ "limited": "Ограниченные администраторы",
"telegramId": "Telegram ID",
"discord": "Вебхук Discord",
"supportUrl": "URL поддержки",
@@ -711,6 +726,185 @@
"no_traffic": "Данные о трафике отсутствуют"
}
},
+ "adminRoles": {
+ "title": "Роли администраторов",
+ "description": "Управление ролями панели и правами RBAC",
+ "createRole": "Создать роль",
+ "editRole": "Изменить роль",
+ "viewRole": "Просмотр роли",
+ "readOnlyHint": "Это встроенная роль. Вы можете просмотреть её настройки, но не можете изменить.",
+ "modalDescription": "Настройте права, лимиты, функции и доступ для этой роли.",
+ "currentRoleUnavailable": "Текущая роль недоступна",
+ "loadFallback": "Используются встроенные роли",
+ "id": "ID",
+ "ownerRole": "владелец",
+ "builtInRole": "встроенная",
+ "protectedRole": "Защищенная роль",
+ "protectedRoleHint": "Встроенные роли и роль владельца нельзя изменять.",
+ "permissions": "Права",
+ "limits": "Лимиты",
+ "limitsAndFeatures": "Лимиты и функции",
+ "limitsHint": "Оставьте пустым, чтобы использовать значения по умолчанию. Установите 0, чтобы отключить.",
+ "roleFormHint": "Действия с пользователями имеют область: нет, свои или все. Остальные действия — булевы переключатели.",
+ "scopedActionInfo": "Выберите область: нет — запрет доступа, свои — только пользователи этого админа, все — полный доступ.",
+ "allowAll": "Разрешить все",
+ "features": "Функции",
+ "access": "Доступ",
+ "permissionCount": "{{count}} прав",
+ "limitFeatureCount": "{{limits}} лимитов, {{features}} флагов функций",
+ "unlimited": "Без ограничений",
+ "empty": "Ролей нет",
+ "emptyDescription": "Создайте роль, чтобы назначать админам детальные права, лимиты, функции и ограничения доступа.",
+ "noSearchResults": "Нет ролей по вашему запросу.",
+ "createSuccess": "Роль «{{name}}» успешно создана",
+ "editSuccess": "Роль «{{name}}» успешно обновлена",
+ "deleteSuccess": "Роль «{{name}}» успешно удалена",
+ "deleteFailed": "Не удалось удалить роль «{{name}}»",
+ "deleteConfirmation": "Удалить роль",
+ "deleteConfirm": "Вы уверены, что хотите удалить роль {{name}}?",
+ "bulkDeleteTitle": "Удалить выбранные роли",
+ "bulkDeletePrompt": "Удалить {{count}} выбранных ролей? Это действие нельзя отменить.",
+ "bulkDeleteSuccess": "{{count}} ролей успешно удалено.",
+ "bulkDeletePartial": "{{count}} ролей не удалось удалить.",
+ "bulkDeleteFailed": "Не удалось удалить выбранные роли.",
+ "requireTemplateTitle": "Требовать шаблон",
+ "requireTemplateDescription": "Заставить админов с этой ролью создавать пользователей только из шаблона.",
+ "allowedTemplates": "Разрешённые шаблоны",
+ "allowedTemplatesDescription": "Ограничить шаблоны, доступные для роли. Оставьте пустым, чтобы разрешить все.",
+ "allowedGroups": "Разрешённые группы",
+ "allowedGroupsDescription": "Ограничить группы пользователей, доступные роли. Оставьте пустым, чтобы разрешить все.",
+ "noTemplates": "Шаблонов нет",
+ "noGroups": "Групп нет",
+ "names": {
+ "administrator": "Администратор",
+ "operator": "Оператор"
+ },
+ "groups": {
+ "users": "Пользователи",
+ "admins": "Админы",
+ "roles": "Роли",
+ "nodes": "Узлы",
+ "coreHosts": "Ядра и хосты",
+ "groupsTemplates": "Группы и шаблоны",
+ "settings": "Настройки"
+ },
+ "actions": {
+ "common": {
+ "read": "Просмотр",
+ "read_simple": "Просмотр простого списка",
+ "create": "Создание",
+ "update": "Изменение",
+ "delete": "Удаление",
+ "reset_usage": "Сброс трафика",
+ "revoke_sub": "Отозвать подписку",
+ "set_owner": "Назначить владельца",
+ "activate_next_plan": "Активировать следующий план",
+ "reconnect": "Переподключить",
+ "update_core": "Обновить ядро",
+ "stats": "Просмотр статистики",
+ "logs": "Просмотр логов",
+ "read_general": "Просмотр общих"
+ },
+ "users": {
+ "reset_usage": "Сброс трафика пользователя",
+ "revoke_sub": "Отозвать подписку",
+ "set_owner": "Назначить владельца пользователя",
+ "activate_next_plan": "Активировать следующий план"
+ },
+ "admins": {
+ "reset_usage": "Сброс трафика администратора"
+ },
+ "settings": {
+ "read": "Просмотр настроек",
+ "read_general": "Просмотр общих настроек",
+ "update": "Изменение настроек"
+ },
+ "system": {
+ "read": "Просмотр системы",
+ "update": "Изменение системы"
+ },
+ "hwids": {
+ "read": "Просмотр HWID",
+ "update": "Изменение HWID"
+ },
+ "nodes": {
+ "reconnect": "Переподключить узлы",
+ "update_core": "Обновить ядро узла",
+ "stats": "Просмотр статистики узлов",
+ "logs": "Просмотр логов узлов"
+ }
+ },
+ "resources": {
+ "users": "Пользователи",
+ "admins": "Админы",
+ "admin_roles": "Роли",
+ "nodes": "Узлы",
+ "cores": "Ядра",
+ "hosts": "Хосты",
+ "groups": "Группы",
+ "templates": "Шаблоны пользователей",
+ "client_templates": "Шаблоны клиентов",
+ "settings": "Настройки",
+ "system": "Система",
+ "hwids": "HWID"
+ },
+ "scopedBadge": "С областью",
+ "scopes": {
+ "none": "Нет",
+ "own": "Свои",
+ "all": "Все"
+ },
+ "limitFields": {
+ "max_users": "Макс. пользователей",
+ "data_limit_min": "Мин. лимит трафика (байт)",
+ "data_limit_max": "Макс. лимит трафика (байт)",
+ "expire_days_min": "Мин. дней до истечения",
+ "expire_days_max": "Макс. дней до истечения",
+ "min_hwid_per_user": "Мин. HWID на пользователя",
+ "max_hwid_per_user": "Макс. HWID на пользователя"
+ },
+ "limitedBehavior": {
+ "disabledWhenLimited": {
+ "title": "Блокировать ограниченных администраторов",
+ "description": "Запрещать весь доступ к панели и API после достижения администратором лимита трафика."
+ },
+ "disableUsersWhenLimited": {
+ "title": "Отключать пользователей при ограничении",
+ "description": "Удалять пользователей этого администратора с узлов, пока администратор ограничен по использованию."
+ }
+ },
+ "featureFields": {
+ "can_use_reset_strategy": {
+ "title": "Стратегия сброса",
+ "description": "Разрешить роли задавать стратегию сброса лимита трафика для пользователей."
+ },
+ "can_use_next_plan": {
+ "title": "Следующий план",
+ "description": "Разрешить роли настраивать автоматически применяемые следующие планы."
+ }
+ }
+ },
+ "setup": {
+ "createOwner": "Создать владельца",
+ "resetOwner": "Сбросить пароль владельца",
+ "deleteOwner": "Удалить владельца",
+ "createOwnerShort": "Создать",
+ "resetOwnerShort": "Сбросить",
+ "deleteOwnerShort": "Удалить",
+ "ownerAccess": "Доступ владельца",
+ "ownerAccessDescription": "Используйте временный ключ настройки, чтобы создать, сбросить или удалить учетную запись владельца.",
+ "tempKey": "Временный ключ",
+ "deleteConfirm": "Введите DELETE для подтверждения",
+ "keyRequired": "Требуется временный ключ",
+ "usernameRequired": "Требуется имя пользователя",
+ "passwordRequired": "Требуется пароль",
+ "passwordMismatch": "Пароли не совпадают",
+ "deleteConfirmRequired": "Введите DELETE для подтверждения",
+ "ownerCreated": "Владелец успешно создан",
+ "ownerReset": "Пароль владельца успешно сброшен",
+ "ownerDeleted": "Владелец успешно удален",
+ "deleteWarning": "Это действие нельзя отменить. Учетная запись владельца будет удалена безвозвратно."
+ },
"shortcuts": {
"title": "Горячие клавиши",
"description": "Управляйте горячими клавишами панели для быстрого доступа",
@@ -923,6 +1117,7 @@
"login.fieldRequired": "Это поле обязательно для заполнения",
"login.loginYourAccount": "Войдите в свой аккаунт",
"login.welcomeBack": "Пожалуйста, введите свои данные",
+ "login.backToLogin": "Вернуться ко входу",
"memoryUsage": "Память",
"next": "Вперед",
"monitorServers": "Следите за своими серверами и пользователями",
@@ -2647,6 +2842,7 @@
"createdAt": "Дата создания",
"toggle": "Переключить статус",
"close": "Закрыть",
+ "view": "Просмотр",
"copy": "Копировать",
"copyAll": "Копировать все",
"copied": "Скопировано!",
diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json
index abf43ef8f..8b251ddcc 100644
--- a/dashboard/public/statics/locales/zh.json
+++ b/dashboard/public/statics/locales/zh.json
@@ -138,9 +138,11 @@
"cancelSuccess": "已取消更改并恢复原始设置",
"activeTypes": "活跃类型",
"filterTitle": "通知过滤器",
+ "toggleAll": "切换所有通知",
"filterDescription": "选择应触发通知的事件,以便了解重要的系统活动",
"types": {
"admin": "管理员们",
+ "adminRole": "管理员角色",
"core": "核心们",
"group": "群组",
"host": "主机",
@@ -157,9 +159,11 @@
"delete": "删除",
"resetUsage": "重置使用量",
"login": "登录",
+ "usageLimitWarning": "用量限制预警",
"modifyHosts": "修改主机",
"connect": "连接",
"error": "错误",
+ "limited": "受限",
"statusChange": "状态变更",
"resetDataUsage": "重置数据使用量",
"dataResetByNext": "下次重置数据",
@@ -188,6 +192,12 @@
"title": "频道覆盖",
"description": "覆盖特定通知事件的默认目标。留空以继承全局配置。",
"hint": "留空以恢复此事件的主要通知设置。"
+ },
+ "usageLimitWarnings": {
+ "thresholds": "预警阈值",
+ "description": "当管理员数据用量达到这些百分比时发送限制预警。",
+ "addThreshold": "添加阈值",
+ "empty": "未配置阈值"
}
},
"subscriptions": {
@@ -639,9 +649,11 @@
"description": "管理系统管理员",
"createAdmin": "创建管理员",
"editAdmin": "编辑管理员",
+ "profileSection": "资料与联系方式",
"deleteAdmin": "删除管理员",
"username": "用户名",
"password": "密码",
+ "dataLimit": "管理员数据限制",
"isSudo": "超级管理员",
"createdAt": "创建时间",
"actions": "操作",
@@ -686,11 +698,14 @@
"create": "创建管理员",
"status": "状态",
"role": "角色",
+ "permissionOverrides": "权限覆盖",
+ "permissionOverridesHint": "留空以继承所选角色的限制。设置为 0 表示禁用。",
"total.users": "用户总数",
"used.traffic": "已用流量",
"total": "管理员总数",
"active": "活跃管理员",
"disable": "禁用的管理员",
+ "limited": "受限管理员",
"telegramId": "Telegram ID",
"discord": "Discord Webhook",
"supportUrl": "支持链接",
@@ -725,6 +740,185 @@
"no_traffic": "没有使用情况数据"
}
},
+ "adminRoles": {
+ "title": "管理员角色",
+ "description": "管理仪表板角色和 RBAC 权限",
+ "createRole": "创建角色",
+ "editRole": "编辑角色",
+ "viewRole": "查看角色",
+ "readOnlyHint": "这是内置角色。您可以查看其配置,但无法修改。",
+ "modalDescription": "为此角色配置权限、限制、功能和访问。",
+ "currentRoleUnavailable": "当前角色不可用",
+ "loadFallback": "使用内置角色",
+ "id": "ID",
+ "ownerRole": "所有者",
+ "builtInRole": "内置",
+ "protectedRole": "受保护角色",
+ "protectedRoleHint": "内置角色和所有者角色无法修改。",
+ "permissions": "权限",
+ "limits": "限制",
+ "limitsAndFeatures": "限制与功能",
+ "limitsHint": "留空以使用默认值。设置为 0 表示禁用。",
+ "roleFormHint": "带范围的用户操作使用「无」、「自己的」或「全部」。其他操作为布尔开关。",
+ "scopedActionInfo": "选择权限范围:无表示拒绝访问,自己的仅限管理员自己的用户,全部表示完全访问。",
+ "allowAll": "允许全部",
+ "features": "功能",
+ "access": "访问",
+ "permissionCount": "{{count}} 项权限",
+ "limitFeatureCount": "{{limits}} 个限制,{{features}} 个功能标记",
+ "unlimited": "无限制",
+ "empty": "暂无角色",
+ "emptyDescription": "创建角色以为管理员分配精细的权限、限制、功能和访问限制。",
+ "noSearchResults": "没有匹配的角色。",
+ "createSuccess": "角色 «{{name}}» 创建成功",
+ "editSuccess": "角色 «{{name}}» 更新成功",
+ "deleteSuccess": "角色 «{{name}}» 已成功删除",
+ "deleteFailed": "无法删除角色 «{{name}}»",
+ "deleteConfirmation": "删除角色",
+ "deleteConfirm": "确定要删除角色 {{name}} 吗?",
+ "bulkDeleteTitle": "删除所选角色",
+ "bulkDeletePrompt": "确定要删除选中的 {{count}} 个角色吗?此操作无法撤消。",
+ "bulkDeleteSuccess": "已成功删除 {{count}} 个角色。",
+ "bulkDeletePartial": "{{count}} 个角色无法删除。",
+ "bulkDeleteFailed": "删除所选角色失败。",
+ "requireTemplateTitle": "需要模板",
+ "requireTemplateDescription": "强制使用此角色的管理员只能从模板创建用户。",
+ "allowedTemplates": "允许的模板",
+ "allowedTemplatesDescription": "限制此角色可使用的模板。留空以允许全部。",
+ "allowedGroups": "允许的分组",
+ "allowedGroupsDescription": "限制此角色可管理的用户组。留空以允许全部。",
+ "noTemplates": "没有可用模板",
+ "noGroups": "没有可用分组",
+ "names": {
+ "administrator": "管理员",
+ "operator": "操作员"
+ },
+ "groups": {
+ "users": "用户",
+ "admins": "管理员",
+ "roles": "角色",
+ "nodes": "节点",
+ "coreHosts": "核心和主机",
+ "groupsTemplates": "组和模板",
+ "settings": "设置"
+ },
+ "actions": {
+ "common": {
+ "read": "查看",
+ "read_simple": "查看简单列表",
+ "create": "创建",
+ "update": "更新",
+ "delete": "删除",
+ "reset_usage": "重置用量",
+ "revoke_sub": "撤销订阅",
+ "set_owner": "设置所有者",
+ "activate_next_plan": "激活下一计划",
+ "reconnect": "重新连接",
+ "update_core": "更新核心",
+ "stats": "查看统计",
+ "logs": "查看日志",
+ "read_general": "查看常规"
+ },
+ "users": {
+ "reset_usage": "重置用户用量",
+ "revoke_sub": "撤销订阅",
+ "set_owner": "设置用户所有者",
+ "activate_next_plan": "激活下一计划"
+ },
+ "admins": {
+ "reset_usage": "重置管理员用量"
+ },
+ "settings": {
+ "read": "查看设置",
+ "read_general": "查看常规设置",
+ "update": "更新设置"
+ },
+ "system": {
+ "read": "查看系统",
+ "update": "更新系统"
+ },
+ "hwids": {
+ "read": "查看 HWID",
+ "update": "更新 HWID"
+ },
+ "nodes": {
+ "reconnect": "重新连接节点",
+ "update_core": "更新节点核心",
+ "stats": "查看节点统计",
+ "logs": "查看节点日志"
+ }
+ },
+ "resources": {
+ "users": "用户",
+ "admins": "管理员",
+ "admin_roles": "角色",
+ "nodes": "节点",
+ "cores": "核心",
+ "hosts": "主机",
+ "groups": "分组",
+ "templates": "用户模板",
+ "client_templates": "客户端模板",
+ "settings": "设置",
+ "system": "系统",
+ "hwids": "HWID"
+ },
+ "scopedBadge": "带范围",
+ "scopes": {
+ "none": "无",
+ "own": "自己的",
+ "all": "全部"
+ },
+ "limitFields": {
+ "max_users": "最大用户数",
+ "data_limit_min": "最小流量限制(字节)",
+ "data_limit_max": "最大流量限制(字节)",
+ "expire_days_min": "最小到期天数",
+ "expire_days_max": "最大到期天数",
+ "min_hwid_per_user": "每用户最少 HWID",
+ "max_hwid_per_user": "每用户最多 HWID"
+ },
+ "limitedBehavior": {
+ "disabledWhenLimited": {
+ "title": "阻止受限管理员",
+ "description": "当管理员达到流量限制后,拒绝其所有仪表板和 API 访问。"
+ },
+ "disableUsersWhenLimited": {
+ "title": "受限时禁用用户",
+ "description": "当该管理员处于用量受限状态时,从节点中移除其用户。"
+ }
+ },
+ "featureFields": {
+ "can_use_reset_strategy": {
+ "title": "使用重置策略",
+ "description": "允许此角色为用户设置流量重置策略。"
+ },
+ "can_use_next_plan": {
+ "title": "使用下一计划",
+ "description": "允许此角色配置自动应用的下一计划。"
+ }
+ }
+ },
+ "setup": {
+ "createOwner": "创建所有者",
+ "resetOwner": "重置所有者密码",
+ "deleteOwner": "删除所有者",
+ "createOwnerShort": "创建",
+ "resetOwnerShort": "重置",
+ "deleteOwnerShort": "删除",
+ "ownerAccess": "所有者访问",
+ "ownerAccessDescription": "使用临时设置密钥创建、重置或删除所有者账户。",
+ "tempKey": "临时密钥",
+ "deleteConfirm": "输入 DELETE 以确认",
+ "keyRequired": "临时密钥是必填项",
+ "usernameRequired": "用户名是必填项",
+ "passwordRequired": "密码是必填项",
+ "passwordMismatch": "密码不匹配",
+ "deleteConfirmRequired": "输入 DELETE 以确认",
+ "ownerCreated": "所有者创建成功",
+ "ownerReset": "所有者密码重置成功",
+ "ownerDeleted": "所有者删除成功",
+ "deleteWarning": "此操作无法撤销。所有者账户将被永久删除。"
+ },
"shortcuts": {
"title": "键盘快捷键",
"description": "管理您的仪表板快捷键以便快速访问",
@@ -1276,6 +1470,7 @@
"login.fieldRequired": "此项必填",
"login.loginYourAccount": "登录您的帐号",
"login.welcomeBack": "欢迎回来,请输入您的详细信息",
+ "login.backToLogin": "返回登录",
"memoryUsage": "内存状态",
"next": "下一页",
"monitorServers": "监控您的服务器和用户",
@@ -2717,6 +2912,7 @@
"settings.cores.coreNotFound": "未找到核心",
"toggle": "切换状态",
"close": "关闭",
+ "view": "查看",
"copy": "复制",
"copyAll": "复制全部",
"copied": "已复制!",
diff --git a/dashboard/src/app/router.tsx b/dashboard/src/app/router.tsx
index 0bef294ee..463479c82 100644
--- a/dashboard/src/app/router.tsx
+++ b/dashboard/src/app/router.tsx
@@ -1,6 +1,7 @@
import { Suspense } from 'react'
import { useAdmin } from '@/hooks/use-admin'
import { getCurrentAdmin } from '@/service/api'
+import { hasPermission } from '@/utils/rbac'
import { createHashRouter, Navigate, RouteObject } from 'react-router'
import { LoadingSpinner } from '@/components/common/loading-spinner'
import { TabbedRouteSuspenseFallback } from '@/components/layout/tabbed-route-suspense-fallback'
@@ -13,6 +14,7 @@ const ThemePage = lazyWithChunkRecovery(() => import('@/pages/_dashboard.setting
const DashboardLayout = lazyWithChunkRecovery(() => import('../pages/_dashboard'))
const Dashboard = lazyWithChunkRecovery(() => import('../pages/_dashboard._index'))
const AdminsPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.admins'))
+const AdminRolesPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.admin-roles'))
const BulkPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.bulk'))
const BulkCreatePage = lazyWithChunkRecovery(() => import('../pages/_dashboard.bulk.create'))
const BulkDataPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.bulk.data'))
@@ -44,10 +46,9 @@ const Login = lazyWithChunkRecovery(() => import('../pages/login'))
// Component to handle default settings routing based on user permissions
function SettingsIndex() {
const { admin } = useAdmin()
- const is_sudo = admin?.is_sudo || false
-
- // For sudo admins, default to notifications; for non-sudo admins, default to theme
- const defaultPath = is_sudo ? '/settings/general' : '/settings/theme'
+ const canUpdateSettings = hasPermission(admin, 'settings', 'update')
+ const canSeeGeneral = hasPermission(admin, 'settings', 'read_general') && canUpdateSettings
+ const defaultPath = canSeeGeneral ? '/settings/general' : '/settings/theme'
return {t('adminRoles.emptyDescription', { defaultValue: 'Create a role to assign granular permissions, limits, features, and access restrictions to admins.' })}
+{t('adminRoles.noSearchResults', { defaultValue: 'No roles match your search.' })}
++ {t('adminRoles.roleFormHint', { defaultValue: 'Scoped user actions use none, own, or all. Other actions are boolean toggles.' })} +
+ {PERMISSION_GROUPS.map(group => { + const enabledInGroup = group.actions.reduce((acc, item) => { + const value = permissions?.[item.resource]?.[item.action] + if (value === true) return acc + 1 + if (value && typeof value === 'object' && Number((value as any).scope) > 0) return acc + 1 + return acc + }, 0) + + const groupLabel = t(`adminRoles.groups.${group.labelKey}`) + const showResourcePrefix = group.actions.some((a, _, all) => all.some(b => b !== a && b.action === a.action && b.resource !== a.resource)) + + return ( +{t('adminRoles.limitsHint', { defaultValue: 'Leave empty to inherit defaults. Set to 0 to disable.' })}
+ ++ {formatBytes(numericValue)} +
+ )} ++ {t('adminRoles.limitedBehavior.disabledWhenLimited.description', { defaultValue: 'Deny all dashboard and API access after an admin reaches their data limit.' })} +
++ {t('adminRoles.limitedBehavior.disableUsersWhenLimited.description', { defaultValue: "Remove this admin's users from nodes while the admin is usage-limited." })} +
+{t(`adminRoles.featureFields.${key}.description`, { defaultValue: '' })}
+{t('adminRoles.requireTemplateDescription', { defaultValue: 'Force admins with this role to create users only from a template.' })}
+{description}
} +