{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/admin_role.py b/app/operation/admin_role.py
new file mode 100644
index 000000000..e01632168
--- /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 (
+ 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 (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)
+
+ # Guard: role cannot be deleted if any admin is assigned to it
+ from sqlalchemy import select, func
+ from app.db.models import Admin as DBAdmin
+
+ count = (await db.execute(select(func.count()).where(DBAdmin.role_id == role_id))).scalar() or 0
+ 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/routers/__init__.py b/app/routers/__init__.py
index 33e4bddd5..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,
@@ -22,6 +23,7 @@
routers = [
home.router,
admin.router,
+ admin_role.router,
setup.router,
system.router,
settings.router,
diff --git a/app/routers/admin_role.py b/app/routers/admin_role.py
new file mode 100644
index 000000000..8c077f294
--- /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
+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_owner),
+):
+ """List all roles. Owner only."""
+ 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_owner),
+):
+ """List all roles as lightweight id/name/is_owner tuples. Owner only."""
+ 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_owner),
+):
+ """Get a role by ID. Owner only."""
+ 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/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/tests/api/test_admin_role.py b/tests/api/test_admin_role.py
new file mode 100644
index 000000000..cd2b927da
--- /dev/null
+++ b/tests/api/test_admin_role.py
@@ -0,0 +1,230 @@
+"""Tests for /api/admin-role endpoints (owner-only role management)."""
+
+from fastapi import status
+
+from tests.api import client
+from tests.api.helpers import auth_headers, unique_name
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _role_payload(name: str | None = None) -> dict:
+ return {
+ "name": name or unique_name("role"),
+ "permissions": {},
+ "limits": {
+ "max_users": None,
+ "data_limit_min": None,
+ "data_limit_max": None,
+ "expire_days_min": None,
+ "expire_days_max": None,
+ "max_hwid_per_user": None,
+ },
+ "features": {"can_use_reset_strategy": True, "can_use_next_plan": True},
+ "access": {"require_template": False, "allowed_template_ids": None, "allowed_group_ids": None},
+ }
+
+
+def _create_role(access_token: str, name: str | None = None) -> dict:
+ response = client.post(
+ "/api/admin-role",
+ headers=auth_headers(access_token),
+ json=_role_payload(name),
+ )
+ assert response.status_code == status.HTTP_201_CREATED
+ return response.json()
+
+
+def _delete_role(access_token: str, role_id: int) -> None:
+ client.delete(f"/api/admin-role/{role_id}", headers=auth_headers(access_token))
+
+
+# ---------------------------------------------------------------------------
+# GET /api/admin-roles
+# ---------------------------------------------------------------------------
+
+
+def test_get_roles_returns_list(access_token):
+ """Owner can list all roles."""
+ response = client.get("/api/admin-roles", headers=auth_headers(access_token))
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()
+ assert "roles" in data
+ assert "total" in data
+ assert data["total"] >= 3 # owner, administrator, operator seeded by migration
+
+
+def test_get_roles_simple(access_token):
+ """Owner can get lightweight role list."""
+ response = client.get("/api/admin-roles/simple", headers=auth_headers(access_token))
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()
+ assert "roles" in data
+ for role in data["roles"]:
+ assert "id" in role
+ assert "name" in role
+ assert "is_owner" in role
+
+
+# ---------------------------------------------------------------------------
+# GET /api/admin-role/{id}
+# ---------------------------------------------------------------------------
+
+
+def test_get_role_by_id(access_token):
+ """Owner can fetch a role by ID."""
+ response = client.get("/api/admin-role/1", headers=auth_headers(access_token))
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()
+ assert data["id"] == 1
+ assert data["name"] == "owner"
+ assert data["is_owner"] is True
+
+
+def test_get_role_not_found(access_token):
+ """Non-existent role returns 404."""
+ response = client.get("/api/admin-role/99999", headers=auth_headers(access_token))
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+
+# ---------------------------------------------------------------------------
+# POST /api/admin-role
+# ---------------------------------------------------------------------------
+
+
+def test_create_role(access_token):
+ """Owner can create a new role."""
+ name = unique_name("role")
+ response = client.post(
+ "/api/admin-role",
+ headers=auth_headers(access_token),
+ json=_role_payload(name),
+ )
+ assert response.status_code == status.HTTP_201_CREATED
+ data = response.json()
+ assert data["name"] == name
+ assert data["is_owner"] is False
+ _delete_role(access_token, data["id"])
+
+
+def test_create_role_duplicate_name_returns_409(access_token):
+ """Creating a role with a duplicate name returns 409."""
+ role = _create_role(access_token)
+ try:
+ response = client.post(
+ "/api/admin-role",
+ headers=auth_headers(access_token),
+ json=_role_payload(role["name"]),
+ )
+ assert response.status_code == status.HTTP_409_CONFLICT
+ finally:
+ _delete_role(access_token, role["id"])
+
+
+# ---------------------------------------------------------------------------
+# PUT /api/admin-role/{id}
+# ---------------------------------------------------------------------------
+
+
+def test_modify_role(access_token):
+ """Owner can modify a custom role."""
+ role = _create_role(access_token)
+ try:
+ new_name = unique_name("modified")
+ response = client.put(
+ f"/api/admin-role/{role['id']}",
+ headers=auth_headers(access_token),
+ json={"name": new_name},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["name"] == new_name
+ finally:
+ _delete_role(access_token, role["id"])
+
+
+def test_modify_owner_role_returns_403(access_token):
+ """Owner role (id=1) cannot be modified."""
+ response = client.put(
+ "/api/admin-role/1",
+ headers=auth_headers(access_token),
+ json={"name": "hacked"},
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_modify_role_not_found(access_token):
+ """Modifying a non-existent role returns 404."""
+ response = client.put(
+ "/api/admin-role/99999",
+ headers=auth_headers(access_token),
+ json={"name": "ghost"},
+ )
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+
+# ---------------------------------------------------------------------------
+# DELETE /api/admin-role/{id}
+# ---------------------------------------------------------------------------
+
+
+def test_delete_role(access_token):
+ """Owner can delete a custom role."""
+ role = _create_role(access_token)
+ response = client.delete(f"/api/admin-role/{role['id']}", headers=auth_headers(access_token))
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+
+
+def test_delete_builtin_role_returns_403(access_token):
+ """Built-in roles (1, 2, 3) cannot be deleted."""
+ for role_id in (1, 2, 3):
+ response = client.delete(f"/api/admin-role/{role_id}", headers=auth_headers(access_token))
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_delete_role_in_use_returns_409(access_token):
+ """A role assigned to at least one admin cannot be deleted."""
+ # Role 3 (operator) is assigned to newly created admins — but we can't
+ # easily create an admin with a custom role via API without a DB admin.
+ # Instead verify the guard exists by checking role 2 (administrator) which
+ # has admins assigned in some test runs. We test the custom role path:
+ # create a role, assign it to an admin directly via DB, then try to delete.
+ import asyncio
+ from sqlalchemy import select
+ from app.db.models import Admin
+ from tests.api import TestSession
+
+ role = _create_role(access_token)
+ role_id = role["id"]
+
+ async def _assign_role():
+ async with TestSession() as session:
+ result = await session.execute(select(Admin).where(Admin.username == "testadmin"))
+ admin = result.scalar_one()
+ original_role_id = admin.role_id
+ admin.role_id = role_id
+ await session.commit()
+ return original_role_id
+
+ async def _restore_role(original_role_id: int):
+ async with TestSession() as session:
+ result = await session.execute(select(Admin).where(Admin.username == "testadmin"))
+ admin = result.scalar_one()
+ admin.role_id = original_role_id
+ await session.commit()
+
+ original_role_id = asyncio.run(_assign_role())
+ try:
+ response = client.delete(f"/api/admin-role/{role_id}", headers=auth_headers(access_token))
+ assert response.status_code == status.HTTP_409_CONFLICT
+ finally:
+ asyncio.run(_restore_role(original_role_id))
+ _delete_role(access_token, role_id)
+
+
+def test_delete_role_not_found(access_token):
+ """Deleting a non-existent role returns 404."""
+ response = client.delete("/api/admin-role/99999", headers=auth_headers(access_token))
+ assert response.status_code == status.HTTP_404_NOT_FOUND
From 371b56c539af383dbc6e549a887276675bb58991 Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Sun, 17 May 2026 20:43:17 +0330
Subject: [PATCH 16/75] fix
---
tests/api/test_admin_role.py | 43 +++++++++++++++++-------------------
1 file changed, 20 insertions(+), 23 deletions(-)
diff --git a/tests/api/test_admin_role.py b/tests/api/test_admin_role.py
index cd2b927da..d9d7579fd 100644
--- a/tests/api/test_admin_role.py
+++ b/tests/api/test_admin_role.py
@@ -1,8 +1,13 @@
"""Tests for /api/admin-role endpoints (owner-only role management)."""
+import asyncio
+
from fastapi import status
+from sqlalchemy import select
-from tests.api import client
+from app.db.models import Admin
+from app.models.admin import hash_password as _hash_password
+from tests.api import client, TestSession
from tests.api.helpers import auth_headers, unique_name
@@ -186,41 +191,33 @@ def test_delete_builtin_role_returns_403(access_token):
def test_delete_role_in_use_returns_409(access_token):
"""A role assigned to at least one admin cannot be deleted."""
- # Role 3 (operator) is assigned to newly created admins — but we can't
- # easily create an admin with a custom role via API without a DB admin.
- # Instead verify the guard exists by checking role 2 (administrator) which
- # has admins assigned in some test runs. We test the custom role path:
- # create a role, assign it to an admin directly via DB, then try to delete.
- import asyncio
- from sqlalchemy import select
- from app.db.models import Admin
- from tests.api import TestSession
role = _create_role(access_token)
role_id = role["id"]
- async def _assign_role():
+ # Create a real DB admin assigned to the new role so the in-use guard triggers
+ async def _create_test_admin() -> int:
+ hashed = await _hash_password("TestPass#99")
async with TestSession() as session:
- result = await session.execute(select(Admin).where(Admin.username == "testadmin"))
- admin = result.scalar_one()
- original_role_id = admin.role_id
- admin.role_id = role_id
+ admin = Admin(username=unique_name("roletest"), hashed_password=hashed, is_sudo=False, role_id=role_id)
+ session.add(admin)
await session.commit()
- return original_role_id
+ return admin.id
- async def _restore_role(original_role_id: int):
+ async def _delete_test_admin(admin_id: int) -> None:
async with TestSession() as session:
- result = await session.execute(select(Admin).where(Admin.username == "testadmin"))
- admin = result.scalar_one()
- admin.role_id = original_role_id
- await session.commit()
+ result = await session.execute(select(Admin).where(Admin.id == admin_id))
+ admin = result.scalar_one_or_none()
+ if admin:
+ await session.delete(admin)
+ await session.commit()
- original_role_id = asyncio.run(_assign_role())
+ admin_id = asyncio.run(_create_test_admin())
try:
response = client.delete(f"/api/admin-role/{role_id}", headers=auth_headers(access_token))
assert response.status_code == status.HTTP_409_CONFLICT
finally:
- asyncio.run(_restore_role(original_role_id))
+ asyncio.run(_delete_test_admin(admin_id))
_delete_role(access_token, role_id)
From 53edc73aefdc73de3b83b801a929c78b5481e43a Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Sun, 17 May 2026 23:18:39 +0330
Subject: [PATCH 17/75] feat: Enhance user management with role-based limits
and permissions
- Implemented per-user limits enforcement in bulk user creation and modification processes.
- Added new permission checks for user-related operations, ensuring proper access control.
- Introduced scope-based permission handling, allowing for more granular access management.
- Refactored user creation and modification methods to utilize new template access validation.
- Updated tests to cover new permission and scope functionalities, ensuring robust access control.
---
app/db/crud/user.py | 17 ++
.../versions/66c38b8a687a_admin_rbac_roles.py | 8 +-
app/models/admin.py | 6 +-
app/models/admin_role.py | 56 +++++-
app/operation/permissions.py | 161 +++++++++---------
app/operation/user.py | 141 +++++++++++++--
app/routers/authentication.py | 31 +++-
app/routers/user.py | 108 ++++++------
tests/api/test_permissions.py | 30 +++-
9 files changed, 385 insertions(+), 173 deletions(-)
diff --git a/app/db/crud/user.py b/app/db/crud/user.py
index 270380a1e..e58d1e7d7 100644
--- a/app/db/crud/user.py
+++ b/app/db/crud/user.py
@@ -721,6 +721,23 @@ async def get_user_usages(
return UserUsageStatsList(period=period, start=start, end=end, stats=stats)
+async def get_users_count_by_admin(db: AsyncSession, admin_id: int | None) -> int:
+ """
+ Gets the total count of users belonging to a specific admin.
+
+ Args:
+ db (AsyncSession): Database session.
+ admin_id (int | None): Admin ID to filter by. If None, counts all users.
+
+ Returns:
+ int: Total count of users for the given admin.
+ """
+ stmt = select(func.count(User.id))
+ if admin_id is not None:
+ stmt = stmt.where(User.admin_id == admin_id)
+ return (await db.execute(stmt)).scalar_one() or 0
+
+
async def get_users_count(db: AsyncSession, status: UserStatus = None, admin_id: int = None) -> int:
"""
Gets the total count of users with optional filters.
diff --git a/app/db/migrations/versions/66c38b8a687a_admin_rbac_roles.py b/app/db/migrations/versions/66c38b8a687a_admin_rbac_roles.py
index c5bbbc243..adbadff00 100644
--- a/app/db/migrations/versions/66c38b8a687a_admin_rbac_roles.py
+++ b/app/db/migrations/versions/66c38b8a687a_admin_rbac_roles.py
@@ -18,7 +18,7 @@
depends_on = None
OWNER_PERMISSIONS = {
- "users": {"create": True, "read": {"scope": "all"}, "read_simple": True, "update": {"scope": "all"}, "delete": {"scope": "all"}, "reset_usage": {"scope": "all"}, "revoke_sub": {"scope": "all"}, "set_owner": True, "activate_next_plan": {"scope": "all"}},
+ "users": {"create": True, "read": {"scope": 2}, "read_simple": True, "update": {"scope": 2}, "delete": {"scope": 2}, "reset_usage": {"scope": 2}, "revoke_sub": {"scope": 2}, "set_owner": True, "activate_next_plan": {"scope": 2}},
"admins": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True, "reset_usage": True},
"nodes": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True, "reconnect": True, "update_core": True, "logs": True, "stats": True},
"groups": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True},
@@ -32,7 +32,7 @@
"admin_roles": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True},
}
ADMINISTRATOR_PERMISSIONS = {
- "users": {"create": True, "read": {"scope": "all"}, "read_simple": True, "update": {"scope": "all"}, "delete": {"scope": "all"}, "reset_usage": {"scope": "all"}, "revoke_sub": {"scope": "all"}, "set_owner": True, "activate_next_plan": {"scope": "all"}},
+ "users": {"create": True, "read": {"scope": 2}, "read_simple": True, "update": {"scope": 2}, "delete": {"scope": 2}, "reset_usage": {"scope": 2}, "revoke_sub": {"scope": 2}, "set_owner": True, "activate_next_plan": {"scope": 2}},
"admins": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True, "reset_usage": True},
"nodes": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True, "reconnect": True, "update_core": True, "logs": True, "stats": True},
"groups": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True},
@@ -46,14 +46,14 @@
"admin_roles": {"read": True, "read_simple": True},
}
OPERATOR_PERMISSIONS = {
- "users": {"create": True, "read": {"scope": "own"}, "read_simple": True, "update": {"scope": "own"}, "delete": {"scope": "own"}, "reset_usage": {"scope": "own"}, "revoke_sub": {"scope": "own"}, "activate_next_plan": {"scope": "own"}},
+ "users": {"create": True, "read": {"scope": 1}, "read_simple": True, "update": {"scope": 1}, "delete": {"scope": 1}, "reset_usage": {"scope": 1}, "revoke_sub": {"scope": 1}, "activate_next_plan": {"scope": 1}},
"groups": {"read": True, "read_simple": True},
"templates": {"read": True, "read_simple": True},
"system": {"read": True},
"settings": {"read_general": True},
"hwids": {"read": True, "delete": True},
}
-DEFAULT_LIMITS = {"max_users": None, "data_limit_min": None, "data_limit_max": None, "expire_days_min": None, "expire_days_max": None, "max_hwid_per_user": None}
+DEFAULT_LIMITS = {"max_users": None, "data_limit_min": None, "data_limit_max": None, "expire_days_min": None, "expire_days_max": None, "min_hwid_per_user": None, "max_hwid_per_user": None}
DEFAULT_FEATURES = {"can_use_reset_strategy": True, "can_use_next_plan": True}
DEFAULT_ACCESS = {"require_template": False, "allowed_template_ids": None, "allowed_group_ids": None}
diff --git a/app/models/admin.py b/app/models/admin.py
index 267aea4d2..baaea1711 100644
--- a/app/models/admin.py
+++ b/app/models/admin.py
@@ -7,7 +7,7 @@
import bcrypt
from pydantic import BaseModel, ConfigDict, Field, field_validator
-from app.models.admin_role import RoleAccess, RoleFeatures, RoleLimits
+from app.models.admin_role import RoleAccess, RoleFeatures, RoleLimits, RolePermissions
from app.models.stats import Period
from app.utils.helpers import fix_datetime_timezone
@@ -48,7 +48,7 @@ class AdminRoleData(BaseModel):
"""Runtime role data carried on AdminDetails — only the fields needed for permission checks."""
is_owner: bool = False
- permissions: dict = Field(default_factory=dict)
+ permissions: RolePermissions = Field(default_factory=RolePermissions)
limits: RoleLimits = Field(default_factory=RoleLimits)
features: RoleFeatures = Field(default_factory=RoleFeatures)
access: RoleAccess = Field(default_factory=RoleAccess)
@@ -107,7 +107,7 @@ class AdminDetails(AdminContactInfo):
lifetime_used_traffic: int | None = None
note: str | None = None
role: AdminRoleData | None = None
- permission_overrides: dict | None = None
+ permission_overrides: RoleLimits | None = None
@property
def is_owner(self) -> bool:
diff --git a/app/models/admin_role.py b/app/models/admin_role.py
index c048220c7..bf6a0293b 100644
--- a/app/models/admin_role.py
+++ b/app/models/admin_role.py
@@ -1,38 +1,87 @@
from datetime import datetime as dt
-from enum import Enum
+from enum import Enum, IntEnum
+from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.models.validators import ListValidator
+class PermissionScope(IntEnum):
+ """Scope for user-resource permissions. Stored as int in JSON for efficiency."""
+ NONE = 0 # explicitly denied
+ OWN = 1 # only own users (user.admin_id == admin.id)
+ ALL = 2 # all users regardless of owner
+
+
class RoleLimits(BaseModel):
max_users: int | None = None
data_limit_min: int | None = None
data_limit_max: int | None = None
expire_days_min: int | None = None
expire_days_max: int | None = None
+ min_hwid_per_user: int | None = None
max_hwid_per_user: int | None = None
+ model_config = ConfigDict(from_attributes=True)
+
class RoleFeatures(BaseModel):
can_use_reset_strategy: bool = True
can_use_next_plan: bool = True
+ model_config = ConfigDict(from_attributes=True)
+
class RoleAccess(BaseModel):
require_template: bool = False
allowed_template_ids: list[int] | None = None
allowed_group_ids: list[int] | None = None
+ model_config = ConfigDict(from_attributes=True)
+
+
+# Each action value is either True (allowed, no scope) or {"scope": PermissionScope}
+RoleActionValue = bool | dict[str, PermissionScope | int]
+# Each resource maps action names to their permission value
+RoleResourcePermissions = dict[str, RoleActionValue]
+
+
+class RolePermissions(BaseModel):
+ """
+ Sparse permission map. Missing resource or action = denied.
+ Each action value is True (allowed) or {"scope": "own"|"all"}.
+ """
+
+ users: RoleResourcePermissions | None = None
+ admins: RoleResourcePermissions | None = None
+ nodes: RoleResourcePermissions | None = None
+ groups: RoleResourcePermissions | None = None
+ hosts: RoleResourcePermissions | None = None
+ templates: RoleResourcePermissions | None = None
+ client_templates: RoleResourcePermissions | None = None
+ cores: RoleResourcePermissions | None = None
+ settings: RoleResourcePermissions | None = None
+ system: RoleResourcePermissions | None = None
+ hwids: RoleResourcePermissions | None = None
+ admin_roles: RoleResourcePermissions | None = None
+
+ model_config = ConfigDict(from_attributes=True, extra="allow")
+
+ def get(self, resource: str, default: Any = None) -> RoleResourcePermissions | None:
+ """Dict-like access so permissions.py can call permissions.get('users', {})."""
+ return getattr(self, resource, None) if hasattr(self, resource) else default
+
class AdminRoleBase(BaseModel):
name: str = Field(max_length=64)
- permissions: dict = Field(default_factory=dict)
+ permissions: RolePermissions = Field(default_factory=RolePermissions)
limits: RoleLimits = Field(default_factory=RoleLimits)
features: RoleFeatures = Field(default_factory=RoleFeatures)
access: RoleAccess = Field(default_factory=RoleAccess)
+ model_config = ConfigDict(from_attributes=True)
+
class AdminRoleCreate(AdminRoleBase):
pass
@@ -40,7 +89,7 @@ class AdminRoleCreate(AdminRoleBase):
class AdminRoleModify(BaseModel):
name: str | None = Field(default=None, max_length=64)
- permissions: dict | None = None
+ permissions: RolePermissions | None = None
limits: RoleLimits | None = None
features: RoleFeatures | None = None
access: RoleAccess | None = None
@@ -58,6 +107,7 @@ class AdminRoleSimple(BaseModel):
id: int
name: str
is_owner: bool
+
model_config = ConfigDict(from_attributes=True)
diff --git a/app/operation/permissions.py b/app/operation/permissions.py
index 9aa5823c2..1d0a7fe79 100644
--- a/app/operation/permissions.py
+++ b/app/operation/permissions.py
@@ -1,6 +1,7 @@
from functools import wraps
from app.models.admin import AdminDetails
+from app.models.admin_role import PermissionScope, RoleLimits
class PermissionDenied(Exception):
@@ -15,99 +16,126 @@ def __init__(self, detail: str):
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
+
+
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 (from plan):
- 1. If role.is_owner → ALLOW unconditionally
- 2. Look up permissions[resource][action]:
- - missing → DENY
- - True → ALLOW
- - {"scope": "own"} → ALLOW (scope check done separately via enforce_scope)
- - {"scope": "all"} → ALLOW
+ Resolution order:
+ 1. role.is_owner → ALLOW unconditionally
+ 2. 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
- permissions = admin.role.permissions if admin.role else {}
- resource_perms = permissions.get(resource)
- if resource_perms is None:
- raise PermissionDenied(f"Permission denied: {resource}.{action}")
-
- action_perm = resource_perms.get(action)
+ action_perm = _get_resource_action(admin, resource, action)
if action_perm is None:
raise PermissionDenied(f"Permission denied: {resource}.{action}")
- # True or {"scope": ...} both mean allowed at the permission level
- # (scope enforcement is done separately via enforce_scope)
+ 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 for actions that support it (users resource only).
- Call AFTER enforce_permission.
- Raises PermissionDenied if scope is "own" and target doesn't belong to this admin.
+ 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
- permissions = admin.role.permissions if admin.role else {}
- action_perm = permissions.get(resource, {}).get(action)
-
- if isinstance(action_perm, dict) and action_perm.get("scope") == "own":
- if target_admin_id != admin.id:
- raise PermissionDenied(f"Permission denied: {resource}.{action} (scope: own)")
+ 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 get_effective_limits(admin: AdminDetails) -> dict:
+def is_scope_all(admin: AdminDetails, resource: str, action: str) -> bool:
"""
- Merge role limits with per-admin permission_overrides.
- Non-null override values win over role limits.
- Returns a dict with the same keys as RoleLimits.
+ Return True if the action has scope=ALL or True (no scope restriction).
+ Used to gate operations that require all-user access.
"""
- role_limits = admin.role.limits.model_dump() if admin.role else {}
- overrides = admin.permission_overrides or {}
-
- merged = dict(role_limits)
- for key, value in overrides.items():
- if value is not None:
- merged[key] = value
-
- return merged
+ 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_allowed_group_ids(admin: AdminDetails) -> list[int] | None:
+def get_scope_admin_id(admin: AdminDetails, resource: str, action: str) -> int | None:
"""
- Return the list of group IDs this admin is allowed to see/use.
- None means all groups are allowed (owner or no restriction set).
+ 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
- return admin.role.access.allowed_group_ids
+ action_perm = _get_resource_action(admin, resource, action)
+ if _resolve_scope(action_perm) is PermissionScope.OWN:
+ return admin.id
+ return None
-def get_allowed_template_ids(admin: AdminDetails) -> list[int] | None:
+def get_effective_limits(admin: AdminDetails) -> RoleLimits:
"""
- Return the list of user-template IDs this admin is allowed to see/use.
- None means all templates are allowed (owner or no restriction set).
+ Merge role limits with per-admin permission_overrides.
+ Non-null override values win over role limits.
"""
- if admin.is_owner:
+ 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
- if admin.role is 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:
- """
- Intersect a requested id list with an allowed id list.
- - allowed=None means no restriction → return requested as-is
- - requested=None means no filter → return allowed as-is (or None if allowed is also None)
- """
if allowed is None:
return requested
if requested is None:
@@ -116,44 +144,19 @@ def _intersect_ids(requested: list[int] | None, allowed: list[int] | None) -> li
def apply_group_access(admin: AdminDetails, ids: list[int] | None) -> list[int] | None:
- """
- Apply the admin's allowed_group_ids restriction to a requested id list.
- Returns the filtered id list to pass to the CRUD query.
- """
+ """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:
- """
- Apply the admin's allowed_template_ids restriction to a requested id list.
- Returns the filtered id list to pass to the CRUD query.
- """
+ """Intersect requested ids with admin's allowed_template_ids."""
return _intersect_ids(ids, get_allowed_template_ids(admin))
-def get_scope_admin_id(admin: AdminDetails, resource: str, action: str) -> int | None:
- """
- Return admin.id if the given resource+action has scope='own', else None.
-
- Usage: pass the returned value as admin_id to CRUD queries.
- - None → no admin_id filter applied (scope=all, True, or owner)
- - int → WHERE admin_id = ? added by the CRUD (scope=own)
- """
- if admin.is_owner:
- return None
- if admin.role is None:
- return None
- perm = admin.role.permissions.get(resource, {}).get(action)
- if isinstance(perm, dict) and perm.get("scope") == "own":
- return admin.id
- return None
-
-
def check_permission(resource: str, action: str):
"""
Decorator for operation-layer methods.
- Expects the decorated method to have signature:
- async def method(self, db, *args, admin: AdminDetails, **kwargs)
+ Signature: async def method(self, db, *args, admin: AdminDetails, **kwargs)
"""
def decorator(func):
diff --git a/app/operation/user.py b/app/operation/user.py
index 63a406de5..21c69b72f 100644
--- a/app/operation/user.py
+++ b/app/operation/user.py
@@ -3,6 +3,7 @@
import secrets
import warnings
from collections import Counter
+from datetime import datetime, timezone
from datetime import datetime as dt, timedelta as td, timezone as tz
from fastapi import HTTPException
@@ -12,6 +13,7 @@
from app import notification
from app.db import AsyncSession
from app.db.crud.admin import get_admin
+from app.db.crud.hwid import get_user_hwid_count
from app.db.crud.bulk import (
count_bulk_datalimit_targets,
count_bulk_expire_targets,
@@ -34,6 +36,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,
@@ -90,6 +93,7 @@
)
from app.node.sync import remove_user as sync_remove_user, sync_user, sync_users
from app.operation import BaseOperation, OperatorType
+from app.operation.permissions import get_effective_limits, apply_template_access
from app.settings import hwid_settings, subscription_settings
from app.utils.jwt import create_subscription_token
from app.utils.logger import get_logger
@@ -236,10 +240,33 @@ 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,
+ 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,7 +338,67 @@ 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 | 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 {limits.data_limit_min} bytes", 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 {limits.data_limit_max} bytes", code=400, db=db)
+
+ if expire is not None:
+ days = (expire - datetime.now(timezone.utc)).days
+ if limits.expire_days_min is not None and days < limits.expire_days_min:
+ await self.raise_error(message=f"Expire must be at least {limits.expire_days_min} days from now", code=400, db=db)
+ if limits.expire_days_max is not None and days > limits.expire_days_max:
+ await self.raise_error(message=f"Expire cannot exceed {limits.expire_days_max} days from now", 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:
@@ -323,6 +410,17 @@ async def create_user(self, db: AsyncSession, new_user: UserCreate, admin: Admin
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,
+ 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 +442,9 @@ 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(
@@ -366,6 +462,16 @@ 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,
+ 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 +532,9 @@ 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(
@@ -647,11 +753,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,
)
@@ -1047,7 +1152,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 +1162,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 +1185,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 +1222,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 +1266,7 @@ 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 +1277,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 +1307,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)
diff --git a/app/routers/authentication.py b/app/routers/authentication.py
index 228a937a3..acef70d1d 100644
--- a/app/routers/authentication.py
+++ b/app/routers/authentication.py
@@ -14,9 +14,9 @@
)
from app.db.models import Admin, AdminUsageLogs, User
from app.models.admin import AdminDetails, AdminRoleData, AdminValidationResult, verify_password
-from app.models.admin_role import RoleAccess, RoleFeatures, RoleLimits
+from app.models.admin_role import RoleAccess, RoleFeatures, RoleLimits, RolePermissions
from app.models.settings import Telegram
-from app.operation.permissions import PermissionDenied, enforce_permission
+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
@@ -26,7 +26,7 @@
# Owner-level role data given to env admins — full permissions, bypasses all checks
_ENV_ADMIN_ROLE = AdminRoleData(
is_owner=True,
- permissions={}, # is_owner=True bypasses permission checks entirely
+ permissions=RolePermissions(), # is_owner=True bypasses permission checks entirely
limits=RoleLimits(),
features=RoleFeatures(),
access=RoleAccess(),
@@ -59,7 +59,7 @@ def _build_admin_details(
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=db_admin.permission_overrides,
+ permission_overrides=RoleLimits.model_validate(db_admin.permission_overrides) if db_admin.permission_overrides else None,
)
@@ -179,6 +179,29 @@ async def _check(admin: AdminDetails = Depends(get_current)):
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:
diff --git a/app/routers/user.py b/app/routers/user.py
index b99935191..381a661ae 100644
--- a/app/routers/user.py
+++ b/app/routers/user.py
@@ -51,7 +51,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 +66,7 @@
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 +96,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 +127,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 +141,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 +149,7 @@ 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 +160,7 @@ 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 +171,14 @@ 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 +190,7 @@ 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 +199,7 @@ 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 +208,7 @@ 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 +220,7 @@ 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 +231,13 @@ 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 +254,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 +271,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 +282,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 +292,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 +301,7 @@ 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 +313,7 @@ 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 +322,13 @@ 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 +337,13 @@ 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 +356,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 +378,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 +394,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 +409,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 +426,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 +442,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 +455,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 +470,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 +484,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 +493,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,7 +504,7 @@ 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:
@@ -523,7 +523,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 +541,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 +562,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 +576,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 +590,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 +604,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 +618,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 +632,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 +642,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 +656,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 +679,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 +690,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 +700,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 +710,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 +719,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 +741,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 +763,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)
@@ -777,7 +777,7 @@ async def bulk_modify_users_proxy_settings(
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/tests/api/test_permissions.py b/tests/api/test_permissions.py
index 5fc6b2d1e..25ca474c9 100644
--- a/tests/api/test_permissions.py
+++ b/tests/api/test_permissions.py
@@ -27,6 +27,12 @@ def _make_admin(*, is_owner=False, permissions=None, limits=None, overrides=None
)
+# Scope constants for tests
+SCOPE_OWN = {"scope": 1}
+SCOPE_ALL = {"scope": 2}
+SCOPE_NONE = {"scope": 0}
+
+
# --- enforce_permission ---
@@ -53,15 +59,21 @@ def test_missing_action_raises():
def test_scope_own_is_allowed_at_permission_level():
- admin = _make_admin(permissions={"users": {"read": {"scope": "own"}}})
+ admin = _make_admin(permissions={"users": {"read": SCOPE_OWN}})
enforce_permission(admin, "users", "read") # should not raise (scope checked separately)
def test_scope_all_is_allowed():
- admin = _make_admin(permissions={"users": {"read": {"scope": "all"}}})
+ admin = _make_admin(permissions={"users": {"read": SCOPE_ALL}})
enforce_permission(admin, "users", "read") # should not raise
+def test_scope_none_raises():
+ admin = _make_admin(permissions={"users": {"read": SCOPE_NONE}})
+ with pytest.raises(PermissionDenied):
+ enforce_permission(admin, "users", "read") # explicitly disabled
+
+
# --- enforce_scope ---
@@ -71,18 +83,18 @@ def test_owner_bypasses_scope():
def test_scope_own_allows_own_users():
- admin = _make_admin(permissions={"users": {"read": {"scope": "own"}}}, admin_id=10)
+ admin = _make_admin(permissions={"users": {"read": SCOPE_OWN}}, admin_id=10)
enforce_scope(admin, "users", "read", target_admin_id=10) # should not raise
def test_scope_own_denies_other_users():
- admin = _make_admin(permissions={"users": {"read": {"scope": "own"}}}, admin_id=10)
+ admin = _make_admin(permissions={"users": {"read": SCOPE_OWN}}, admin_id=10)
with pytest.raises(PermissionDenied):
enforce_scope(admin, "users", "read", target_admin_id=99)
def test_scope_all_allows_any_user():
- admin = _make_admin(permissions={"users": {"read": {"scope": "all"}}}, admin_id=10)
+ admin = _make_admin(permissions={"users": {"read": SCOPE_ALL}}, admin_id=10)
enforce_scope(admin, "users", "read", target_admin_id=99) # should not raise
@@ -97,7 +109,7 @@ def test_true_permission_no_scope_check():
def test_role_limits_returned_when_no_overrides():
admin = _make_admin(limits={"max_users": 100, "data_limit_max": None})
limits = get_effective_limits(admin)
- assert limits["max_users"] == 100
+ assert limits.max_users == 100
def test_non_null_override_wins():
@@ -106,7 +118,7 @@ def test_non_null_override_wins():
overrides={"max_users": 50},
)
limits = get_effective_limits(admin)
- assert limits["max_users"] == 50
+ assert limits.max_users == 50
def test_null_override_does_not_override():
@@ -115,10 +127,10 @@ def test_null_override_does_not_override():
overrides={"max_users": None},
)
limits = get_effective_limits(admin)
- assert limits["max_users"] == 100
+ assert limits.max_users == 100
def test_no_role_returns_empty():
admin = AdminDetails(username="x", is_sudo=False, role=None)
limits = get_effective_limits(admin)
- assert limits == {}
+ assert limits.max_users is None # RoleLimits with all None fields
From 66c3ca50971d16394c7c1eb60ba581cccc3cba8c Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Sun, 17 May 2026 23:24:46 +0330
Subject: [PATCH 18/75] fix
---
app/db/crud/admin_role.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/db/crud/admin_role.py b/app/db/crud/admin_role.py
index 9a56f292d..da6fcd097 100644
--- a/app/db/crud/admin_role.py
+++ b/app/db/crud/admin_role.py
@@ -48,7 +48,7 @@ async def get_roles_simple(db: AsyncSession) -> list[AdminRole]:
async def create_role(db: AsyncSession, data: AdminRoleCreate) -> AdminRole:
role = AdminRole(
name=data.name,
- permissions=data.permissions,
+ permissions=data.permissions.model_dump(exclude_none=True),
limits=data.limits.model_dump(),
features=data.features.model_dump(),
access=data.access.model_dump(),
@@ -65,7 +65,7 @@ async def modify_role(db: AsyncSession, role: AdminRole, data: AdminRoleModify)
if data.name is not None:
role.name = data.name
if data.permissions is not None:
- role.permissions = data.permissions
+ role.permissions = data.permissions.model_dump(exclude_none=True)
if data.limits is not None:
role.limits = data.limits.model_dump()
if data.features is not None:
From fd0665086723cd8e4e9ef679fea1c9ff9e324445 Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Sun, 17 May 2026 23:36:19 +0330
Subject: [PATCH 19/75] refactor(admin): replace get_current with
permission-based dependency injection
- Remove unused get_current import from authentication module
- Replace get_current dependency with require_permission("admins", "read") in get_admin_usage endpoint
- Replace get_current dependency with require_permission("admins", "read") in get_admin_usage_by_username endpoint
- Replace get_current dependency with require_permission("admins", "read") in get_admin_usage_by_id endpoint
- Enforce explicit permission checks for all admin usage retrieval operations
---
app/routers/admin.py | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/app/routers/admin.py b/app/routers/admin.py
index a2a882632..5a5e81300 100644
--- a/app/routers/admin.py
+++ b/app/routers/admin.py
@@ -28,7 +28,6 @@
from app.utils.request import get_client_ip
from .authentication import (
- get_current,
get_current_with_metrics,
require_permission,
validate_admin,
@@ -213,7 +212,7 @@ async def get_admin_usage(
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")),
):
"""Get admin usage aggregated from user traffic."""
return await admin_operator.get_admin_usage(db, username=username, admin=admin, query=query)
@@ -228,7 +227,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)
@@ -242,7 +241,7 @@ 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)
From 116b2d8ec86c0952c56e996281cf01961140afcd Mon Sep 17 00:00:00 2001
From: x0sina {username}
-Is Sudo: {is_sudo}
+Role: {role}
Is Disabled: {is_disabled}
Used Traffic: {used_traffic}
➖➖➖➖➖➖➖➖➖
@@ -92,7 +92,7 @@
#Modify_Admin
➖➖➖➖➖➖➖➖➖
Username: {username}
-Is Sudo: {is_sudo}
+Role: {role}
Is Disabled: {is_disabled}
Used Traffic: {used_traffic}
➖➖➖➖➖➖➖➖➖
diff --git a/app/operation/system.py b/app/operation/system.py
index 8f3223a85..96d65ff72 100644
--- a/app/operation/system.py
+++ b/app/operation/system.py
@@ -26,9 +26,9 @@ async def get_system_stats(db: AsyncSession, admin: AdminDetails, admin_username
uptime_task = asyncio.create_task(asyncio.to_thread(get_uptime))
admin_param = None
- if admin.is_sudo and admin_username:
+ if admin.is_owner and admin_username:
admin_param = await get_admin(db, admin_username, load_users=False, load_usage_logs=False)
- elif not admin.is_sudo:
+ elif not admin.is_owner:
admin_param = admin
system_task = None
diff --git a/app/operation/user.py b/app/operation/user.py
index 18d0578a9..6527325f2 100644
--- a/app/operation/user.py
+++ b/app/operation/user.py
@@ -417,7 +417,7 @@ async def create_user(
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):
@@ -473,7 +473,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)
@@ -879,7 +879,7 @@ async def _get_user_usage(
) -> UserUsageStatsList:
start, end = await self.validate_dates(start, end, True)
- if not admin.is_sudo:
+ if not admin.is_owner:
node_id = None
group_by_node = False
@@ -949,7 +949,7 @@ async def get_users(
query: UserListQuery,
) -> UsersResponse:
"""Get all users"""
- if not admin.is_sudo:
+ if not admin.is_owner:
query = query.model_copy(update={"owner": [admin.username], "admin_ids": None})
users, count = await get_users(
@@ -978,7 +978,7 @@ async def get_users_simple(
"""Get lightweight user list with only id and username"""
# Authorization: non-sudo admins see only their users
admin_filter = (
- None if admin.is_sudo else await get_admin(db, admin.username, load_users=False, load_usage_logs=False)
+ None if admin.is_owner else await get_admin(db, admin.username, load_users=False, load_usage_logs=False)
)
# Call CRUD function
@@ -1004,7 +1004,7 @@ async def get_users_usage(
node_id = query.node_id
group_by_node = query.group_by_node
- if not admin.is_sudo:
+ if not admin.is_owner:
node_id = None
group_by_node = False
@@ -1014,7 +1014,7 @@ async def get_users_usage(
end=end,
period=query.period,
node_id=node_id,
- admins=query.owner if admin.is_sudo else [admin.username],
+ admins=query.owner if admin.is_owner else [admin.username],
group_by_node=group_by_node,
)
@@ -1030,7 +1030,7 @@ async def get_users_count_metric(
node_id = query.node_id
group_by_node = query.group_by_node
- if not admin.is_sudo:
+ if not admin.is_owner:
node_id = None
group_by_node = False
@@ -1041,7 +1041,7 @@ async def get_users_count_metric(
return await get_user_count_metric_stats(
db=db,
- admins=query.owner if admin.is_sudo else [admin.username],
+ admins=query.owner if admin.is_owner else [admin.username],
start=start,
end=end,
period=query.period,
@@ -1383,7 +1383,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=None if admin.is_owner else admin.id,
)
out = await run_bulk_reallocate_wireguard_peer_ips(
@@ -1443,12 +1443,12 @@ 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:
+ if not admin.is_owner 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 admin.is_owner 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 = None if admin.is_owner else admin.id
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/routers/admin.py b/app/routers/admin.py
index 94a15a6eb..c5cfaa4e7 100644
--- a/app/routers/admin.py
+++ b/app/routers/admin.py
@@ -58,7 +58,7 @@ async def admin_token(
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})
@@ -75,7 +75,7 @@ async def admin_mini_app_token(
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(
diff --git a/app/routers/authentication.py b/app/routers/authentication.py
index 31f4741d6..e0f5b0fcf 100644
--- a/app/routers/authentication.py
+++ b/app/routers/authentication.py
@@ -44,7 +44,6 @@ def _build_admin_details(
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,
@@ -92,7 +91,7 @@ async def get_admin(db: AsyncSession, token: str) -> AdminDetails | None:
# Env admin fallback — gets owner-level role so it bypasses all permission checks
if payload["username"] in auth_settings.sudoers and payload.get("is_sudo") is True:
- return AdminDetails(username=payload["username"], is_sudo=True, role=_ENV_ADMIN_ROLE)
+ return AdminDetails(username=payload["username"], role=_ENV_ADMIN_ROLE)
return None
@@ -129,7 +128,7 @@ async def get_admin_with_metrics(db: AsyncSession, token: str) -> AdminDetails |
# Env admin fallback
if payload["username"] in auth_settings.sudoers and payload.get("is_sudo") is True:
- return AdminDetails(username=payload["username"], is_sudo=True, role=_ENV_ADMIN_ROLE)
+ return AdminDetails(username=payload["username"], role=_ENV_ADMIN_ROLE)
return None
@@ -211,12 +210,6 @@ async def require_owner(admin: AdminDetails = Depends(get_current)):
return admin
-# Kept for backward compatibility — other routers still import this until Stage 8 cleanup
-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")
- return admin
-
async def validate_admin(db: AsyncSession, username: str, password: str) -> AdminValidationResult | None:
"""Validate admin credentials against the database, with env admin fallback."""
@@ -225,7 +218,6 @@ async def validate_admin(db: AsyncSession, username: str, password: str) -> Admi
return AdminValidationResult(
id=db_admin.id,
username=db_admin.username,
- is_sudo=db_admin.is_sudo,
is_disabled=db_admin.is_disabled,
)
@@ -233,7 +225,7 @@ async def validate_admin(db: AsyncSession, username: str, password: str) -> Admi
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, is_sudo=True, is_disabled=False)
+ return AdminValidationResult(username=username, is_disabled=False)
return None
@@ -269,7 +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,
)
return None
diff --git a/app/routers/setup.py b/app/routers/setup.py
index d269ce73f..30786b50c 100644
--- a/app/routers/setup.py
+++ b/app/routers/setup.py
@@ -49,7 +49,7 @@ async def create_owner(
db_admin = await create_admin(
db,
- AdminCreate(username=body.username, password=body.password, role_id=1, is_sudo=True),
+ AdminCreate(username=body.username, password=body.password, role_id=1),
)
await consume_temp_key(db, temp_key, action="create_owner", ip=get_client_ip(request))
return AdminDetails.model_validate(db_admin)
diff --git a/app/routers/user.py b/app/routers/user.py
index dc309f35f..edb254e53 100644
--- a/app/routers/user.py
+++ b/app/routers/user.py
@@ -548,8 +548,8 @@ async def get_users_count_metric(
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,
+ node_id=query.node_id if admin.is_owner else None,
+ group_by_node=query.group_by_node if admin.is_owner else False,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
diff --git a/app/utils/jwt.py b/app/utils/jwt.py
index f00f30b43..26befd69d 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:
@@ -38,7 +38,7 @@ async def get_admin_payload(token: str) -> dict | None:
if admin_id is not None:
try:
admin_id = int(admin_id)
- except TypeError, ValueError:
+ except (TypeError, ValueError):
return
if not username or access not in ("admin", "sudo"):
return
@@ -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/tests/api/helpers.py b/tests/api/helpers.py
index e8da65e0b..0286c4c11 100644
--- a/tests/api/helpers.py
+++ b/tests/api/helpers.py
@@ -42,7 +42,7 @@ def create_admin(
response = client.post(
"/api/admin",
headers=auth_headers(access_token),
- json={"username": username, "password": password, "is_sudo": is_sudo, "role_id": 2 if is_sudo else 3},
+ json={"username": username, "password": password, "role_id": 2 if is_sudo else 3},
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
diff --git a/tests/api/test_admin.py b/tests/api/test_admin.py
index c87df20c0..fe856e1f5 100644
--- a/tests/api/test_admin.py
+++ b/tests/api/test_admin.py
@@ -48,11 +48,12 @@ def create_admin(
def set_admin_sudo(username: str, is_sudo: bool) -> None:
+ """Set admin role: is_sudo=True -> administrator (role_id=2), False -> operator (role_id=3)."""
async def _set_flag():
async with TestSession() as session:
result = await session.execute(select(Admin).where(Admin.username == username))
db_admin = result.scalar_one()
- db_admin.is_sudo = is_sudo
+ db_admin.role_id = 2 if is_sudo else 3
await session.commit()
asyncio.run(_set_flag())
@@ -181,8 +182,7 @@ def test_admin_create(access_token):
password = strong_password("TestAdmincreate")
admin = create_admin(access_token, username=username, password=password)
assert admin["username"] == username
- assert admin["is_sudo"] is False
- delete_admin(access_token, username)
+ delete_admin(access_token, username)
def test_admin_create_sudo_forbidden_via_api(access_token):
@@ -192,7 +192,7 @@ def test_admin_create_sudo_forbidden_via_api(access_token):
response = client.post(
url="/api/admin",
- json={"username": username, "password": password, "is_sudo": True, "role_id": 1},
+ json={"username": username, "password": password, "role_id": 1},
headers={"Authorization": f"Bearer {access_token}"},
)
@@ -208,7 +208,7 @@ def test_admin_create_with_note(access_token):
response = client.post(
url="/api/admin",
- json={"username": username, "password": password, "is_sudo": False, "note": note, "role_id": 3},
+ json={"username": username, "password": password, "note": note, "role_id": 3},
headers={"Authorization": f"Bearer {access_token}"},
)
@@ -227,7 +227,7 @@ def test_admin_create_duplicate_telegram_id_conflict(access_token):
try:
response_a = client.put(
url=f"/api/admin/{admin_a['username']}",
- json={"is_sudo": False, "telegram_id": telegram_id},
+ json={"telegram_id": telegram_id},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response_a.status_code == status.HTTP_200_OK
@@ -278,8 +278,7 @@ def test_update_admin(access_token):
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["username"] == admin["username"]
- assert response.json()["is_sudo"] is False
- assert response.json()["is_disabled"] is True
+ assert response.json()["is_disabled"] is True
delete_admin(access_token, admin["username"])
@@ -288,7 +287,7 @@ def test_admin_routes_by_id_and_by_username(access_token):
try:
by_username_update = client.put(
url=f"/api/admin/by-username/{admin['username']}",
- json={"is_sudo": False, "note": "by-username note"},
+ json={"note": "by-username note"},
headers=auth_headers(access_token),
)
assert by_username_update.status_code == status.HTTP_200_OK
@@ -296,7 +295,7 @@ def test_admin_routes_by_id_and_by_username(access_token):
by_id_update = client.put(
url=f"/api/admin/by-id/{admin['id']}",
- json={"is_sudo": False, "note": "by-id note"},
+ json={"note": "by-id note"},
headers=auth_headers(access_token),
)
assert by_id_update.status_code == status.HTTP_200_OK
@@ -326,7 +325,7 @@ def test_update_admin_note(access_token):
response = client.put(
url=f"/api/admin/{admin['username']}",
- json={"is_sudo": False, "note": note},
+ json={"note": note},
headers={"Authorization": f"Bearer {access_token}"},
)
@@ -344,14 +343,14 @@ def test_update_admin_duplicate_telegram_id_conflict(access_token):
try:
first_update = client.put(
url=f"/api/admin/{admin_a['username']}",
- json={"is_sudo": False, "telegram_id": telegram_id},
+ json={"telegram_id": telegram_id},
headers={"Authorization": f"Bearer {access_token}"},
)
assert first_update.status_code == status.HTTP_200_OK
second_update = client.put(
url=f"/api/admin/{admin_b['username']}",
- json={"is_sudo": False, "telegram_id": telegram_id},
+ json={"telegram_id": telegram_id},
headers={"Authorization": f"Bearer {access_token}"},
)
assert second_update.status_code == status.HTTP_409_CONFLICT
@@ -387,7 +386,7 @@ def test_sudo_admin_can_modify_self(access_token):
sudo_admin_password = strong_password("TestAdminSudo")
create_response = client.post(
url="/api/admin",
- json={"username": sudo_admin_username, "password": sudo_admin_password, "is_sudo": True, "role_id": 2},
+ json={"username": sudo_admin_username, "password": sudo_admin_password, "role_id": 2},
headers={"Authorization": f"Bearer {access_token}"},
)
assert create_response.status_code == status.HTTP_201_CREATED
@@ -428,7 +427,7 @@ def test_sudo_admin_cannot_disable_self(access_token):
sudo_admin_password = strong_password("TestAdminSudo")
create_response = client.post(
url="/api/admin",
- json={"username": sudo_admin_username, "password": sudo_admin_password, "is_sudo": True, "role_id": 2},
+ json={"username": sudo_admin_username, "password": sudo_admin_password, "role_id": 2},
headers={"Authorization": f"Bearer {access_token}"},
)
assert create_response.status_code == status.HTTP_201_CREATED
@@ -525,7 +524,7 @@ def test_get_admins_returns_admin_note(access_token):
create_response = client.post(
url="/api/admin",
- json={"username": username, "password": password, "is_sudo": False, "note": note, "role_id": 3},
+ json={"username": username, "password": password, "note": note, "role_id": 3},
headers={"Authorization": f"Bearer {access_token}"},
)
assert create_response.status_code == status.HTTP_201_CREATED
@@ -552,7 +551,7 @@ def test_disable_admin(access_token):
password = admin["password"]
disable_response = client.put(
url=f"/api/admin/{admin['username']}",
- json={"password": password, "is_sudo": False, "is_disabled": True},
+ json={"password": password, "is_disabled": True},
headers={"Authorization": f"Bearer {access_token}"},
)
assert disable_response.status_code == status.HTTP_200_OK
diff --git a/tests/api/test_admin_role.py b/tests/api/test_admin_role.py
index d9d7579fd..a2fc1f4c1 100644
--- a/tests/api/test_admin_role.py
+++ b/tests/api/test_admin_role.py
@@ -199,7 +199,7 @@ def test_delete_role_in_use_returns_409(access_token):
async def _create_test_admin() -> int:
hashed = await _hash_password("TestPass#99")
async with TestSession() as session:
- admin = Admin(username=unique_name("roletest"), hashed_password=hashed, is_sudo=False, role_id=role_id)
+ admin = Admin(username=unique_name("roletest"), hashed_password=hashed, role_id=role_id)
session.add(admin)
await session.commit()
return admin.id
diff --git a/tests/api/test_bulk_delete_entities.py b/tests/api/test_bulk_delete_entities.py
index fe0697502..892ed0e0a 100644
--- a/tests/api/test_bulk_delete_entities.py
+++ b/tests/api/test_bulk_delete_entities.py
@@ -53,9 +53,10 @@
def set_admin_sudo(username: str, is_sudo: bool) -> None:
+ """Set admin role: is_sudo=True -> administrator (role_id=2), False -> operator (role_id=3)."""
async def _set_flag():
async with TestSession() as session:
- await session.execute(update(Admin).where(Admin.username == username).values(is_sudo=is_sudo))
+ await session.execute(update(Admin).where(Admin.username == username).values(role_id=2 if is_sudo else 3))
await session.commit()
asyncio.run(_set_flag())
diff --git a/tests/api/test_node.py b/tests/api/test_node.py
index 41d05be92..87f13a233 100644
--- a/tests/api/test_node.py
+++ b/tests/api/test_node.py
@@ -24,7 +24,7 @@
User,
)
from app.models.core import CoreCreate
-from app.models.admin import AdminDetails
+from app.models.admin import AdminDetails, AdminRoleData
from app.models.node import NodeCreate, NodeModify, NodeResponse, NodeSettings, NodesResponse
from app.models.stats import (
NodeRealtimeStats,
@@ -578,7 +578,7 @@ async def record_background_connect(node_id: int) -> None:
monkeypatch.setattr("app.operation.node.notification.create_node", AsyncMock())
monkeypatch.setattr("app.operation.node.notification.modify_node", AsyncMock())
- admin = AdminDetails(username="admin", is_sudo=True)
+ admin = AdminDetails(username="admin", role=AdminRoleData(is_owner=True))
async with TestSession() as session:
core = await create_core_config(session, core_create_model(unique_name("core_node_background")))
core_id = inspect(core).identity[0]
diff --git a/tests/api/test_permissions.py b/tests/api/test_permissions.py
index 25ca474c9..ce016cbd4 100644
--- a/tests/api/test_permissions.py
+++ b/tests/api/test_permissions.py
@@ -21,8 +21,7 @@ def _make_admin(*, is_owner=False, permissions=None, limits=None, overrides=None
return AdminDetails(
id=admin_id,
username="testadmin",
- is_sudo=False,
- role=role,
+ role=role,
permission_overrides=overrides,
)
@@ -131,6 +130,6 @@ def test_null_override_does_not_override():
def test_no_role_returns_empty():
- admin = AdminDetails(username="x", is_sudo=False, role=None)
+ admin = AdminDetails(username="x", role=None)
limits = get_effective_limits(admin)
assert limits.max_users is None # RoleLimits with all None fields
diff --git a/tests/api/test_setup.py b/tests/api/test_setup.py
index 4b414800f..ba2142100 100644
--- a/tests/api/test_setup.py
+++ b/tests/api/test_setup.py
@@ -80,7 +80,8 @@ def test_create_owner_success():
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["username"] == "owner_user"
- assert data["is_sudo"] is True
+ # Owner role has is_owner=True in the role object
+ assert data["role"]["is_owner"] is True
finally:
_delete_owner()
From 89ac5b6483fb48251bc10ef0b6e1ec1719dce002 Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Mon, 18 May 2026 01:12:59 +0330
Subject: [PATCH 24/75] fix
---
app/routers/authentication.py | 1 -
app/utils/jwt.py | 2 +-
tests/api/test_admin.py | 5 +++--
tests/api/test_bulk_delete_entities.py | 1 +
tests/api/test_permissions.py | 2 +-
5 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/app/routers/authentication.py b/app/routers/authentication.py
index e0f5b0fcf..c5199af3d 100644
--- a/app/routers/authentication.py
+++ b/app/routers/authentication.py
@@ -210,7 +210,6 @@ async def require_owner(admin: AdminDetails = Depends(get_current)):
return admin
-
async def validate_admin(db: AsyncSession, username: str, password: str) -> AdminValidationResult | None:
"""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)
diff --git a/app/utils/jwt.py b/app/utils/jwt.py
index 26befd69d..2efa14451 100644
--- a/app/utils/jwt.py
+++ b/app/utils/jwt.py
@@ -38,7 +38,7 @@ async def get_admin_payload(token: str) -> dict | None:
if admin_id is not None:
try:
admin_id = int(admin_id)
- except (TypeError, ValueError):
+ except TypeError, ValueError:
return
if not username or access not in ("admin", "sudo"):
return
diff --git a/tests/api/test_admin.py b/tests/api/test_admin.py
index fe856e1f5..fa949c766 100644
--- a/tests/api/test_admin.py
+++ b/tests/api/test_admin.py
@@ -49,6 +49,7 @@ def create_admin(
def set_admin_sudo(username: str, is_sudo: bool) -> None:
"""Set admin role: is_sudo=True -> administrator (role_id=2), False -> operator (role_id=3)."""
+
async def _set_flag():
async with TestSession() as session:
result = await session.execute(select(Admin).where(Admin.username == username))
@@ -182,7 +183,7 @@ def test_admin_create(access_token):
password = strong_password("TestAdmincreate")
admin = create_admin(access_token, username=username, password=password)
assert admin["username"] == username
- delete_admin(access_token, username)
+ delete_admin(access_token, username)
def test_admin_create_sudo_forbidden_via_api(access_token):
@@ -278,7 +279,7 @@ def test_update_admin(access_token):
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["username"] == admin["username"]
- assert response.json()["is_disabled"] is True
+ assert response.json()["is_disabled"] is True
delete_admin(access_token, admin["username"])
diff --git a/tests/api/test_bulk_delete_entities.py b/tests/api/test_bulk_delete_entities.py
index 892ed0e0a..ebac905cd 100644
--- a/tests/api/test_bulk_delete_entities.py
+++ b/tests/api/test_bulk_delete_entities.py
@@ -54,6 +54,7 @@
def set_admin_sudo(username: str, is_sudo: bool) -> None:
"""Set admin role: is_sudo=True -> administrator (role_id=2), False -> operator (role_id=3)."""
+
async def _set_flag():
async with TestSession() as session:
await session.execute(update(Admin).where(Admin.username == username).values(role_id=2 if is_sudo else 3))
diff --git a/tests/api/test_permissions.py b/tests/api/test_permissions.py
index ce016cbd4..c84e4be3f 100644
--- a/tests/api/test_permissions.py
+++ b/tests/api/test_permissions.py
@@ -21,7 +21,7 @@ def _make_admin(*, is_owner=False, permissions=None, limits=None, overrides=None
return AdminDetails(
id=admin_id,
username="testadmin",
- role=role,
+ role=role,
permission_overrides=overrides,
)
From 229bf16ee70f12ab914376eb0858136f428285fd Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Mon, 18 May 2026 01:36:30 +0330
Subject: [PATCH 25/75] refactor: replace sudo terminology with role-based
authorization language
- Remove is_sudo field checks from JWT token validation in authentication
- Update env admin fallback logic to rely on sudoers list without is_sudo flag
- Replace "sudo admin" with "authorized admin" in API documentation and docstrings
- Update authorization comments from "non-sudo" to "non-owner" for clarity
- Simplify admin permission checks to use role-based access control
- Update test helpers and fixtures to reflect new authorization model
- Remove redundant safety limit comment in admin query
- Align terminology across routers (group, node, user) and CRUD operations
- Consolidate authorization language to use consistent role-based terminology throughout codebase
---
app/db/crud/admin.py | 2 +-
app/db/crud/user.py | 2 +-
app/operation/user.py | 2 +-
app/routers/authentication.py | 8 +-
app/routers/group.py | 12 +--
app/routers/node.py | 8 +-
app/routers/user.py | 2 +-
cli/__init__.py | 6 --
tests/api/helpers.py | 4 +-
tests/api/test_admin.py | 132 ++++++++++++-------------
tests/api/test_bulk.py | 2 +-
tests/api/test_bulk_delete_entities.py | 10 +-
tests/api/test_bulk_entity_actions.py | 4 +-
tests/api/test_user.py | 4 +-
14 files changed, 93 insertions(+), 105 deletions(-)
diff --git a/app/db/crud/admin.py b/app/db/crud/admin.py
index 99550ac62..6f0706a6a 100644
--- a/app/db/crud/admin.py
+++ b/app/db/crud/admin.py
@@ -362,7 +362,7 @@ async def get_admins_simple(
if query.limit is not None:
stmt = stmt.limit(query.limit)
else:
- stmt = stmt.limit(10000) # Safety limit when all=true
+ stmt = stmt.limit(10000)
# Execute and return
result = await db.execute(stmt)
diff --git a/app/db/crud/user.py b/app/db/crud/user.py
index e58d1e7d7..3622de666 100644
--- a/app/db/crud/user.py
+++ b/app/db/crud/user.py
@@ -420,7 +420,7 @@ async def get_users_simple(
Args:
db: Database session.
query: Structured lightweight user list filters.
- admin: Admin filter (for non-sudo authorization).
+ admin: Admin filter (for scope-based authorization).
Returns:
Tuple of (list of (id, username) tuples, total_count).
diff --git a/app/operation/user.py b/app/operation/user.py
index 6527325f2..e1df21cec 100644
--- a/app/operation/user.py
+++ b/app/operation/user.py
@@ -976,7 +976,7 @@ 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
+ # Authorization: non-owner admins see only their users
admin_filter = (
None if admin.is_owner else await get_admin(db, admin.username, load_users=False, load_usage_logs=False)
)
diff --git a/app/routers/authentication.py b/app/routers/authentication.py
index c5199af3d..41866341c 100644
--- a/app/routers/authentication.py
+++ b/app/routers/authentication.py
@@ -89,8 +89,8 @@ async def get_admin(db: AsyncSession, token: str) -> AdminDetails | None:
return None
return _build_admin_details(db_admin)
- # Env admin fallback — gets owner-level role so it bypasses all permission checks
- if payload["username"] in auth_settings.sudoers and payload.get("is_sudo") is 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
@@ -126,8 +126,8 @@ async def get_admin_with_metrics(db: AsyncSession, token: str) -> AdminDetails |
return None
return _build_admin_details(db_admin, total_users=total_users, reseted_usage=reseted_usage)
- # Env admin fallback
- if payload["username"] in auth_settings.sudoers and payload.get("is_sudo") is 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
diff --git a/app/routers/group.py b/app/routers/group.py
index b9a1ecffc..a6ced4efb 100644
--- a/app/routers/group.py
+++ b/app/routers/group.py
@@ -33,7 +33,7 @@
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,
@@ -55,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)
@@ -142,7 +142,7 @@ async def get_group(
"/{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(
@@ -166,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)
@@ -176,7 +176,7 @@ 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(
@@ -192,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)
diff --git a/app/routers/node.py b/app/routers/node.py
index edfc69a20..68b39212f 100644
--- a/app/routers/node.py
+++ b/app/routers/node.py
@@ -170,7 +170,7 @@ async def get_nodes(
db: AsyncSession = Depends(get_db),
_: 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)
@@ -262,7 +262,7 @@ async def modify_node(
db: AsyncSession = Depends(get_db),
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)
@@ -275,7 +275,7 @@ async def reset_node_usage(
"""
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)
@@ -286,7 +286,7 @@ async def reconnect_node(
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 {}
diff --git a/app/routers/user.py b/app/routers/user.py
index edb254e53..18cc401c3 100644
--- a/app/routers/user.py
+++ b/app/routers/user.py
@@ -810,7 +810,7 @@ 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,
diff --git a/cli/__init__.py b/cli/__init__.py
index 476480ac7..89ff812bd 100644
--- a/cli/__init__.py
+++ b/cli/__init__.py
@@ -8,18 +8,12 @@
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
# 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."""
diff --git a/tests/api/helpers.py b/tests/api/helpers.py
index 0286c4c11..e3039937c 100644
--- a/tests/api/helpers.py
+++ b/tests/api/helpers.py
@@ -34,7 +34,7 @@ def strong_password(prefix: str) -> str:
def create_admin(
- access_token: str, *, username: str | None = None, password: str | None = None, is_sudo: bool = False
+ access_token: str, *, username: str | None = None, password: str | None = None, role_id: int = 3
) -> dict:
username = username or unique_name("admin")
# Ensure password always meets complexity rules (>=2 digits, 2 uppercase, 2 lowercase, special char)
@@ -42,7 +42,7 @@ def create_admin(
response = client.post(
"/api/admin",
headers=auth_headers(access_token),
- json={"username": username, "password": password, "role_id": 2 if is_sudo else 3},
+ json={"username": username, "password": password, "role_id": role_id},
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
diff --git a/tests/api/test_admin.py b/tests/api/test_admin.py
index fa949c766..1cbb45cc2 100644
--- a/tests/api/test_admin.py
+++ b/tests/api/test_admin.py
@@ -37,24 +37,24 @@ def admin_username(label: str = "admin") -> str:
def create_admin(
- access_token: str, *, username: str | None = None, password: str | None = None, is_sudo: bool = False
+ access_token: str, *, username: str | None = None, password: str | None = None, role_id: int = 3
) -> dict:
return _create_admin(
access_token,
username=username or admin_username("admin"),
password=password,
- is_sudo=is_sudo,
+ role_id=role_id,
)
-def set_admin_sudo(username: str, is_sudo: bool) -> None:
- """Set admin role: is_sudo=True -> administrator (role_id=2), False -> operator (role_id=3)."""
+def set_admin_role(username: str, role_id: int) -> None:
+ """Set admin role by role_id (2=administrator, 3=operator)."""
async def _set_flag():
async with TestSession() as session:
result = await session.execute(select(Admin).where(Admin.username == username))
db_admin = result.scalar_one()
- db_admin.role_id = 2 if is_sudo else 3
+ db_admin.role_id = role_id
await session.commit()
asyncio.run(_set_flag())
@@ -186,7 +186,7 @@ def test_admin_create(access_token):
delete_admin(access_token, username)
-def test_admin_create_sudo_forbidden_via_api(access_token):
+def test_admin_create_owner_forbidden_via_api(access_token):
"""Creating an admin with owner role (role_id=1) via API should be forbidden."""
username = admin_username("forbidden")
password = strong_password("ForbiddenOwner")
@@ -238,8 +238,7 @@ def test_admin_create_duplicate_telegram_id_conflict(access_token):
json={
"username": admin_b_username,
"password": admin_b_password,
- "is_sudo": False,
- "telegram_id": telegram_id,
+ "telegram_id": telegram_id,
"role_id": 3,
},
headers={"Authorization": f"Bearer {access_token}"},
@@ -272,8 +271,7 @@ def test_update_admin(access_token):
url=f"/api/admin/{admin['username']}",
json={
"password": password,
- "is_sudo": False,
- "is_disabled": True,
+ "is_disabled": True,
},
headers={"Authorization": f"Bearer {access_token}"},
)
@@ -361,15 +359,14 @@ def test_update_admin_duplicate_telegram_id_conflict(access_token):
delete_admin(access_token, admin_b["username"])
-def test_promote_admin_to_sudo_forbidden_via_api(access_token):
+def test_promote_admin_to_owner_forbidden_via_api(access_token):
"""Assigning owner role (role_id=1) to an admin via API should be forbidden."""
- admin = create_admin(access_token, is_sudo=False)
+ admin = create_admin(access_token)
try:
response = client.put(
url=f"/api/admin/{admin['username']}",
json={
- "is_sudo": False,
- "is_disabled": False,
+ "is_disabled": False,
"role_id": 1,
},
headers={"Authorization": f"Bearer {access_token}"},
@@ -380,121 +377,118 @@ def test_promote_admin_to_sudo_forbidden_via_api(access_token):
delete_admin(access_token, admin["username"])
-def test_sudo_admin_can_modify_self(access_token):
+def test_administrator_can_modify_self(access_token):
"""An administrator (role_id=2) can edit their own account."""
# Create admin with administrator role so they have admins.update permission
- sudo_admin_username = admin_username("admin")
- sudo_admin_password = strong_password("TestAdminSudo")
+ administrator_username = admin_username("admin")
+ administrator_password = strong_password("TestAdminPass")
create_response = client.post(
url="/api/admin",
- json={"username": sudo_admin_username, "password": sudo_admin_password, "role_id": 2},
+ json={"username": administrator_username, "password": administrator_password, "role_id": 2},
headers={"Authorization": f"Bearer {access_token}"},
)
assert create_response.status_code == status.HTTP_201_CREATED
- sudo_admin = create_response.json()
- sudo_admin["password"] = sudo_admin_password
+ administrator_admin = create_response.json()
+ administrator_admin["password"] = administrator_password
try:
login_response = client.post(
url="/api/admin/token",
data={
- "username": sudo_admin["username"],
- "password": sudo_admin["password"],
+ "username": administrator_admin["username"],
+ "password": administrator_admin["password"],
"grant_type": "password",
},
)
assert login_response.status_code == status.HTTP_200_OK
- sudo_token = login_response.json()["access_token"]
+ administrator_token = login_response.json()["access_token"]
response = client.put(
- url=f"/api/admin/{sudo_admin['username']}",
+ url=f"/api/admin/{administrator_admin['username']}",
json={
- "is_sudo": True,
- "is_disabled": False,
+ "is_disabled": False,
"note": "self-updated",
},
- headers={"Authorization": f"Bearer {sudo_token}"},
+ headers={"Authorization": f"Bearer {administrator_token}"},
)
assert response.status_code == status.HTTP_200_OK
- assert response.json()["username"] == sudo_admin["username"]
+ assert response.json()["username"] == administrator_admin["username"]
assert response.json()["note"] == "self-updated"
finally:
- delete_admin(access_token, sudo_admin["username"])
+ delete_admin(access_token, administrator_admin["username"])
-def test_sudo_admin_cannot_disable_self(access_token):
+def test_administrator_cannot_disable_self(access_token):
"""An administrator (role_id=2) cannot disable their own account."""
- sudo_admin_username = admin_username("admin")
- sudo_admin_password = strong_password("TestAdminSudo")
+ administrator_username = admin_username("admin")
+ administrator_password = strong_password("TestAdminPass")
create_response = client.post(
url="/api/admin",
- json={"username": sudo_admin_username, "password": sudo_admin_password, "role_id": 2},
+ json={"username": administrator_username, "password": administrator_password, "role_id": 2},
headers={"Authorization": f"Bearer {access_token}"},
)
assert create_response.status_code == status.HTTP_201_CREATED
- sudo_admin = create_response.json()
- sudo_admin["password"] = sudo_admin_password
+ administrator_admin = create_response.json()
+ administrator_admin["password"] = administrator_password
try:
login_response = client.post(
url="/api/admin/token",
data={
- "username": sudo_admin["username"],
- "password": sudo_admin["password"],
+ "username": administrator_admin["username"],
+ "password": administrator_admin["password"],
"grant_type": "password",
},
)
assert login_response.status_code == status.HTTP_200_OK
- sudo_token = login_response.json()["access_token"]
+ administrator_token = login_response.json()["access_token"]
response = client.put(
- url=f"/api/admin/{sudo_admin['username']}",
+ url=f"/api/admin/{administrator_admin['username']}",
json={
- "is_sudo": True,
- "is_disabled": True,
+ "is_disabled": True,
},
- headers={"Authorization": f"Bearer {sudo_token}"},
+ headers={"Authorization": f"Bearer {administrator_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["detail"] == "You're not allowed to disable your own account."
finally:
- delete_admin(access_token, sudo_admin["username"])
+ delete_admin(access_token, administrator_admin["username"])
-def test_sudo_admin_cannot_modify_other_sudo_admin(access_token):
- """A sudo admin cannot edit another sudo admin account."""
- sudo_admin_a = create_admin(access_token)
- sudo_admin_b = create_admin(access_token)
- set_admin_sudo(sudo_admin_a["username"], True)
- set_admin_sudo(sudo_admin_b["username"], True)
+def test_administrator_cannot_modify_other_administrator(access_token):
+ """An administrator cannot edit another administrator account."""
+ admin_a = create_admin(access_token)
+ admin_b = create_admin(access_token)
+ set_admin_role(admin_a["username"], 2)
+ set_admin_role(admin_b["username"], 2)
try:
login_response = client.post(
url="/api/admin/token",
data={
- "username": sudo_admin_a["username"],
- "password": sudo_admin_a["password"],
+ "username": admin_a["username"],
+ "password": admin_a["password"],
"grant_type": "password",
},
)
assert login_response.status_code == status.HTTP_200_OK
- sudo_a_token = login_response.json()["access_token"]
+ admin_a_token = login_response.json()["access_token"]
response = client.put(
- url=f"/api/admin/{sudo_admin_b['username']}",
+ url=f"/api/admin/{admin_b['username']}",
json={
- "is_sudo": True,
- "is_disabled": False,
+ "is_disabled": False,
"note": "should-fail",
},
- headers={"Authorization": f"Bearer {sudo_a_token}"},
+ headers={"Authorization": f"Bearer {admin_a_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
finally:
- set_admin_sudo(sudo_admin_a["username"], False)
- set_admin_sudo(sudo_admin_b["username"], False)
- delete_admin(access_token, sudo_admin_a["username"])
- delete_admin(access_token, sudo_admin_b["username"])
+ set_admin_role(admin_a["username"], 3)
+ set_admin_role(admin_b["username"], 3)
+ delete_admin(access_token, admin_a["username"])
+ delete_admin(access_token, admin_b["username"])
def test_get_admins(access_token):
@@ -969,32 +963,32 @@ def test_get_admins_simple_skip_pagination(access_token):
delete_admin(access_token, username)
-def test_get_admins_simple_requires_sudo(access_token):
- """Test that non-sudo admin cannot access admins/simple."""
- non_sudo_admin = create_admin(access_token, is_sudo=False)
+def test_get_admins_simple_requires_permission(access_token):
+ """Test that operator admin cannot access admins/simple."""
+ non_administrator_admin = create_admin(access_token)
try:
- # Login as non-sudo admin
+ # Login as operator admin
login_response = client.post(
url="/api/admin/token",
data={
- "username": non_sudo_admin["username"],
- "password": non_sudo_admin["password"],
+ "username": non_administrator_admin["username"],
+ "password": non_administrator_admin["password"],
"grant_type": "password",
},
)
assert login_response.status_code == status.HTTP_200_OK
- non_sudo_token = login_response.json()["access_token"]
+ non_administrator_token = login_response.json()["access_token"]
# Try to access admins/simple
response = client.get(
"/api/admins/simple",
- headers={"Authorization": f"Bearer {non_sudo_token}"},
+ headers={"Authorization": f"Bearer {non_administrator_token}"},
)
# Assert 403 Forbidden
assert response.status_code == status.HTTP_403_FORBIDDEN
finally:
- delete_admin(access_token, non_sudo_admin["username"])
+ delete_admin(access_token, non_administrator_admin["username"])
def test_get_admins_simple_empty_search(access_token):
diff --git a/tests/api/test_bulk.py b/tests/api/test_bulk.py
index 4f4a1d71c..5a473be81 100644
--- a/tests/api/test_bulk.py
+++ b/tests/api/test_bulk.py
@@ -546,7 +546,7 @@ def test_bulk_set_owner_by_ids(access_token):
create_user(access_token, group_ids=[groups[0]["id"]], payload={"username": unique_name("bulk_owner")})
for _ in range(2)
]
- new_owner = create_admin(access_token, is_sudo=False)
+ new_owner = create_admin(access_token)
try:
response = client.put(
"/api/users/bulk/set_owner",
diff --git a/tests/api/test_bulk_delete_entities.py b/tests/api/test_bulk_delete_entities.py
index ebac905cd..1458486fa 100644
--- a/tests/api/test_bulk_delete_entities.py
+++ b/tests/api/test_bulk_delete_entities.py
@@ -52,12 +52,12 @@
-----END CERTIFICATE-----"""
-def set_admin_sudo(username: str, is_sudo: bool) -> None:
- """Set admin role: is_sudo=True -> administrator (role_id=2), False -> operator (role_id=3)."""
+def set_admin_role(username: str, role_id: int) -> None:
+ """Set admin role by role_id (2=administrator, 3=operator)."""
async def _set_flag():
async with TestSession() as session:
- await session.execute(update(Admin).where(Admin.username == username).values(role_id=2 if is_sudo else 3))
+ await session.execute(update(Admin).where(Admin.username == username).values(role_id=role_id))
await session.commit()
asyncio.run(_set_flag())
@@ -293,7 +293,7 @@ async def _count():
def test_bulk_delete_admins_clears_owned_users_and_usage_logs(access_token):
- admin = create_admin(access_token, is_sudo=False)
+ admin = create_admin(access_token)
user = create_user(access_token, payload={"username": unique_name("bulk_admin_user")})
try:
owner_response = client.put(
@@ -326,7 +326,7 @@ def test_bulk_delete_admins_rejects_owner_account(access_token):
# by attempting to bulk-delete a non-existent owner username — the
# operation layer blocks role_id=1 deletions before hitting the DB.
# We test this indirectly: a normal admin (role_id=3) can be bulk-deleted.
- admin = create_admin(access_token, is_sudo=False)
+ admin = create_admin(access_token)
try:
response = client.post(
"/api/admins/bulk/delete",
diff --git a/tests/api/test_bulk_entity_actions.py b/tests/api/test_bulk_entity_actions.py
index a391547c2..e9b722d43 100644
--- a/tests/api/test_bulk_entity_actions.py
+++ b/tests/api/test_bulk_entity_actions.py
@@ -321,7 +321,7 @@ async def _seed_usage():
def test_bulk_disable_enable_and_reset_admins(access_token):
- admin = create_admin(access_token, is_sudo=False)
+ admin = create_admin(access_token)
try:
set_admin_used_traffic(admin["username"], 8192)
@@ -357,7 +357,7 @@ def test_bulk_disable_enable_and_reset_admins(access_token):
def test_bulk_admin_user_actions(access_token):
- admin = create_admin(access_token, is_sudo=False)
+ admin = create_admin(access_token)
active_user = create_user(access_token, payload={"username": unique_name("bulk_admin_active")})
disabled_user = create_user(access_token, payload={"username": unique_name("bulk_admin_disabled")})
diff --git a/tests/api/test_user.py b/tests/api/test_user.py
index 6ac61fb00..689d1f4ee 100644
--- a/tests/api/test_user.py
+++ b/tests/api/test_user.py
@@ -521,8 +521,8 @@ def test_users_get_filters_by_no_expire(access_token):
def test_users_get_filters_by_admin_ids(access_token):
core, groups = setup_groups(access_token, 1)
- admin_a = create_admin(access_token, is_sudo=False)
- admin_b = create_admin(access_token, is_sudo=False)
+ admin_a = create_admin(access_token)
+ admin_b = create_admin(access_token)
user_a = create_user(
access_token,
group_ids=[groups[0]["id"]],
From 4735e621cc32365299e175bc73d6e8ff508a6bb0 Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Mon, 18 May 2026 01:53:10 +0330
Subject: [PATCH 26/75] refactor(admin): add role-admin relationship and
builtin role detection
---
app/db/crud/admin_role.py | 7 ++++++-
app/db/models.py | 12 +++++++++++-
app/operation/admin.py | 5 +++++
app/operation/admin_role.py | 10 +++++-----
4 files changed, 27 insertions(+), 7 deletions(-)
diff --git a/app/db/crud/admin_role.py b/app/db/crud/admin_role.py
index da6fcd097..99987b585 100644
--- a/app/db/crud/admin_role.py
+++ b/app/db/crud/admin_role.py
@@ -1,7 +1,7 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from app.db.models import AdminRole
+from app.db.models import Admin, AdminRole
from app.models.admin_role import AdminRoleCreate, AdminRoleListQuery, AdminRoleModify, AdminRoleSortField
@@ -77,6 +77,11 @@ async def modify_role(db: AsyncSession, role: AdminRole, data: AdminRoleModify)
return role
+async def count_admins_by_role(db: AsyncSession, role_id: int) -> int:
+ """Return the number of admins assigned to the given role."""
+ return (await db.execute(select(func.count()).where(Admin.role_id == role_id))).scalar() or 0
+
+
async def delete_role(db: AsyncSession, role: AdminRole) -> None:
if role.id in (1, 2, 3):
raise ValueError(f"Cannot delete built-in role '{role.name}'")
diff --git a/app/db/models.py b/app/db/models.py
index 7ace8c918..1affcb26a 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -87,7 +87,7 @@ class Admin(Base):
notification_enable: Mapped[Optional[Dict]] = mapped_column(PostgresJSONB, default=None)
note: Mapped[Optional[str]] = mapped_column(String(500), default=None)
role_id: Mapped[int] = fk_id_column("admin_roles.id", default=0)
- role: Mapped[Optional["AdminRole"]] = relationship(init=False, lazy="selectin")
+ role: Mapped[Optional["AdminRole"]] = relationship(back_populates="admins", init=False, lazy="selectin")
permission_overrides: Mapped[Optional[Dict]] = mapped_column(PostgresJSONB, default=None)
@hybrid_property
@@ -836,6 +836,16 @@ class AdminRole(Base):
features: Mapped[Dict] = mapped_column(PostgresJSONB, default_factory=dict)
access: Mapped[Dict] = mapped_column(PostgresJSONB, default_factory=dict)
created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False)
+ admins: Mapped[List["Admin"]] = relationship(back_populates="role", init=False, viewonly=True, lazy="noload")
+
+ @hybrid_property
+ def is_builtin(self) -> bool:
+ """True for the 3 default roles (owner, administrator, operator) that cannot be deleted."""
+ return self.id <= 3
+
+ @is_builtin.expression
+ def is_builtin(cls):
+ return cls.id <= 3
class TempKey(Base):
diff --git a/app/operation/admin.py b/app/operation/admin.py
index 6f537a420..49d7864dc 100644
--- a/app/operation/admin.py
+++ b/app/operation/admin.py
@@ -93,6 +93,11 @@ async def _modify_admin(
message="Owner role cannot be assigned via this endpoint. Use the setup flow.", code=403
)
+ # 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.is_disabled is True:
await self.raise_error(message="You're not allowed to disable your own account.", code=403)
diff --git a/app/operation/admin_role.py b/app/operation/admin_role.py
index e01632168..853167349 100644
--- a/app/operation/admin_role.py
+++ b/app/operation/admin_role.py
@@ -5,6 +5,7 @@
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,
@@ -87,16 +88,15 @@ async def modify_role(
return response
async def delete_role(self, db: AsyncSession, role_id: int, admin: AdminDetails) -> None:
- """Delete a role. Built-in roles (1, 2, 3) cannot be deleted."""
+ """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)
- # Guard: role cannot be deleted if any admin is assigned to it
- from sqlalchemy import select, func
- from app.db.models import Admin as DBAdmin
+ if role.is_builtin:
+ await self.raise_error(message=f"Cannot delete built-in role '{role.name}'", code=403)
- count = (await db.execute(select(func.count()).where(DBAdmin.role_id == role_id))).scalar() or 0
+ 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",
From f07cb48f32b14373a8979e10af56d240e1cd50ec Mon Sep 17 00:00:00 2001
From: x0sina {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.featureFields.${key}.description`, { defaultValue: '' })}
+{t('adminRoles.requireTemplateDescription', { defaultValue: 'Force admins with this role to create users only from a template.' })}
+{description}
} ++ {t('admins.permissionOverridesHint', { defaultValue: 'Leave empty to inherit limits from the selected role. Set to 0 to disable.' })} +
++ {formatBytes(numericValue)} +
+ )} +{username}
Role: {role}
-Is Disabled: {is_disabled}
+Status: {status}
Used Traffic: {used_traffic}
➖➖➖➖➖➖➖➖➖
By: #{by}
@@ -93,7 +93,7 @@
➖➖➖➖➖➖➖➖➖
Username: {username}
Role: {role}
-Is Disabled: {is_disabled}
+Status: {status}
Used Traffic: {used_traffic}
➖➖➖➖➖➖➖➖➖
By: #{by}
@@ -298,7 +298,7 @@
➖➖➖➖➖➖➖➖➖
Name: {name}
Inbound Tags: {inbound_tags}
-Is Disabled: {is_disabled}
+Status: {status}
➖➖➖➖➖➖➖➖➖
ID: {id}
By: #{by}
@@ -309,7 +309,7 @@
➖➖➖➖➖➖➖➖➖
Name: {name}
Inbound Tags: {inbound_tags}
-Is Disabled: {is_disabled}
+Status: {status}
➖➖➖➖➖➖➖➖➖
ID: {id}
By: #{by}
diff --git a/app/operation/admin.py b/app/operation/admin.py
index 1d1b6713b..df27196cf 100644
--- a/app/operation/admin.py
+++ b/app/operation/admin.py
@@ -20,7 +20,8 @@
)
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,
AdminDetails,
@@ -36,8 +37,7 @@
RemoveAdminsResponse,
)
from app.models.stats import Period, UserUsageStatsList
-from app.models.user import UserListQuery
-from app.node.sync import remove_user as sync_remove_user, sync_users
+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
@@ -98,7 +98,7 @@ async def _modify_admin(
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.is_disabled is True:
+ 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:
@@ -108,7 +108,23 @@ 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)
+
+ # 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, db)
+
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)
@@ -148,8 +164,8 @@ async def remove_admin_by_id(self, db: AsyncSession, admin_id: int, current_admi
async def get_admins(self, db: AsyncSession, query: AdminListQuery) -> AdminsResponse:
"""Retrieve a list of admins with optional filters and pagination."""
- admins, total, active, disabled = await get_admins(db, query, return_with_count=True, compact=True)
- return AdminsResponse(admins=admins, total=total, active=active, disabled=disabled)
+ admins, total, active, disabled, limited = await get_admins(db, query, return_with_count=True, compact=True)
+ return AdminsResponse(admins=admins, total=total, active=active, disabled=disabled, limited=limited)
async def get_admins_simple(self, db: AsyncSession, query: AdminSimpleListQuery) -> AdminsSimpleResponse:
"""Get lightweight admin list with only id and username."""
@@ -173,7 +189,7 @@ async def _disable_all_active_users_for_admin(self, db: AsyncSession, db_admin:
"""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)
+ await sync_users(users, db)
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):
@@ -193,7 +209,7 @@ async def _activate_all_disabled_users_for_admin(self, db: AsyncSession, db_admi
"""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)
+ await sync_users(users, db)
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):
@@ -244,7 +260,14 @@ async def reset_admin_usage(self, db: AsyncSession, username: str, admin: AdminD
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 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, db)
+
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))
@@ -367,14 +390,15 @@ async def bulk_set_admins_disabled(
) -> BulkAdminsActionResponse:
"""Enable or disable selected admins in bulk."""
db_admins = await self._get_validated_bulk_admins(db, bulk_admins.ids)
+ target_status = AdminStatus.disabled if is_disabled else AdminStatus.active
for db_admin in db_admins:
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.is_disabled != is_disabled]
+ 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:
diff --git a/app/operation/group.py b/app/operation/group.py
index c6dedc93c..e0528dc27 100644
--- a/app/operation/group.py
+++ b/app/operation/group.py
@@ -87,7 +87,7 @@ async def modify_group(self, db: AsyncSession, group_id: int, modified_group: Gr
db,
query=UserListQuery(group_ids=[db_group.id], status=[UserStatus.active, UserStatus.on_hold]),
)
- await sync_users(users)
+ await sync_users(users, db)
group = GroupResponse.model_validate(db_group)
@@ -105,7 +105,7 @@ async def remove_group(self, db: AsyncSession, group_id: int, admin: Admin) -> N
await remove_group(db, db_group)
users = await get_users(db, query=UserListQuery(username=username_list))
- await sync_users(users)
+ await sync_users(users, db)
logger.info(f'Group "{db_group.name}" deleted by admin "{admin.username}"')
@@ -118,7 +118,7 @@ async def bulk_add_groups(self, db: AsyncSession, bulk_model: BulkGroup):
return BulkOperationDryRunResponse(affected_users=n)
users, users_count = await add_groups_to_users(db, bulk_model)
- await sync_users(users)
+ await sync_users(users, db)
if self.operator_type in (OperatorType.API, OperatorType.WEB):
return {"detail": f"operation has been successfuly done on {users_count} users"}
@@ -131,7 +131,7 @@ async def bulk_remove_groups(self, db: AsyncSession, bulk_model: BulkGroup):
return BulkOperationDryRunResponse(affected_users=n)
users, users_count = await remove_groups_from_users(db, bulk_model)
- await sync_users(users)
+ await sync_users(users, db)
if self.operator_type in (OperatorType.API, OperatorType.WEB):
return {"detail": f"operation has been successfuly done on {users_count} users"}
@@ -163,7 +163,7 @@ async def bulk_remove_groups_by_id(
if all_affected_usernames:
users = await get_users(db, query=UserListQuery(username=list(all_affected_usernames)))
- await sync_users(users)
+ await sync_users(users, db)
for name, group_id in zip(group_names, group_ids):
logger.info(f'Group "{name}" deleted by admin "{admin.username}"')
@@ -211,7 +211,7 @@ async def bulk_set_groups_disabled(
status=[UserStatus.active, UserStatus.on_hold],
),
)
- await sync_users(users)
+ await sync_users(users, db)
for db_group in groups_to_update:
group = GroupResponse.model_validate(db_group)
diff --git a/app/operation/permissions.py b/app/operation/permissions.py
index 8b4e2b9c2..c1bff7d37 100644
--- a/app/operation/permissions.py
+++ b/app/operation/permissions.py
@@ -32,6 +32,9 @@ def _get_resource_action(admin: AdminDetails, resource: str, action: str):
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.
@@ -39,7 +42,10 @@ def enforce_permission(admin: AdminDetails, resource: str, action: str) -> None:
Resolution order:
1. role.is_owner → ALLOW unconditionally
- 2. permissions[resource][action]:
+ 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)
@@ -49,6 +55,13 @@ def enforce_permission(admin: AdminDetails, resource: str, action: str) -> None:
if admin.is_owner:
return
+ if admin.is_limited:
+ features = admin.role.features if admin.role else None
+ if features and features.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 action_perm is None:
raise PermissionDenied(f"Permission denied: {resource}.{action}")
diff --git a/app/operation/user.py b/app/operation/user.py
index db786e765..0e349595e 100644
--- a/app/operation/user.py
+++ b/app/operation/user.py
@@ -3,8 +3,7 @@
import secrets
import warnings
from collections import Counter
-from datetime import datetime, timezone
-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
@@ -13,7 +12,6 @@
from app import notification
from app.db import AsyncSession
from app.db.crud.admin import get_admin
-from app.db.crud.hwid import get_user_hwid_count
from app.db.crud.bulk import (
count_bulk_datalimit_targets,
count_bulk_expire_targets,
@@ -24,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,
@@ -91,17 +90,16 @@
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,
- apply_template_access,
get_scope_admin_id,
is_scope_all,
- PermissionDenied,
)
-
from app.settings import hwid_settings, subscription_settings
from app.utils.jwt import create_subscription_token
from app.utils.logger import get_logger
@@ -308,7 +306,7 @@ async def _persist_bulk_users(
)
db_users = await create_users_bulk(db, users_to_create, groups, db_admin)
- await sync_users(db_users)
+ await sync_users(db_users, db)
users_list = []
for db_user in db_users:
@@ -732,7 +730,7 @@ async def bulk_reset_user_data_usage(
db_users,
clean_chart_data=usage_settings.reset_user_usage_clean_chart_data,
)
- await sync_users(db_users)
+ await sync_users(db_users, db)
users = [await self.validate_user(db_user) for db_user in db_users]
for user in users:
@@ -772,7 +770,7 @@ async def bulk_revoke_user_sub(
db_users = await self._get_validated_users_by_ids(db, bulk_users.ids, admin, load_usage_logs=False)
db_users = await bulk_revoke_user_sub(db, db_users)
- await sync_users(db_users)
+ await sync_users(db_users, db)
users = [await self.validate_user(db_user) for db_user in db_users]
for user in users:
@@ -1382,7 +1380,7 @@ async def bulk_modify_expire(self, db: AsyncSession, bulk_model: BulkUser):
n = await count_bulk_expire_targets(db, bulk_model)
return BulkOperationDryRunResponse(affected_users=n)
users, users_count = await update_users_expire(db, bulk_model)
- await sync_users(users)
+ await sync_users(users, db)
if self.operator_type in (OperatorType.API, OperatorType.WEB):
return {"detail": f"operation has been successfuly done on {users_count} users"}
@@ -1393,7 +1391,7 @@ async def bulk_modify_datalimit(self, db: AsyncSession, bulk_model: BulkUser):
n = await count_bulk_datalimit_targets(db, bulk_model)
return BulkOperationDryRunResponse(affected_users=n)
users, users_count = await update_users_datalimit(db, bulk_model)
- await sync_users(users)
+ await sync_users(users, db)
if self.operator_type in (OperatorType.API, OperatorType.WEB):
return {"detail": f"operation has been successfuly done on {users_count} users"}
@@ -1406,7 +1404,7 @@ async def bulk_modify_proxy_settings(self, db: AsyncSession, bulk_model: BulkUse
n = await count_bulk_proxy_targets(db, bulk_model)
return BulkOperationDryRunResponse(affected_users=n)
users, users_count = await update_users_proxy_settings(db, bulk_model)
- await sync_users(users)
+ await sync_users(users, db)
if self.operator_type in (OperatorType.API, OperatorType.WEB):
return {"detail": f"operation has been successfuly done on {users_count} users"}
diff --git a/app/routers/admin.py b/app/routers/admin.py
index b8045915e..c999fbbd9 100644
--- a/app/routers/admin.py
+++ b/app/routers/admin.py
@@ -12,6 +12,7 @@
AdminListQuery,
AdminModify,
AdminSimpleListQuery,
+ AdminStatus,
AdminsResponse,
AdminsSimpleResponse,
AdminUsageQuery,
@@ -52,7 +53,7 @@ async def admin_token(
raise HTTPException(
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"}
@@ -70,7 +71,7 @@ async def admin_mini_app_token(
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.is_disabled:
+ if db_admin.status == AdminStatus.disabled:
raise HTTPException(
status_code=403, detail="your account has been disabled", headers={"WWW-Authenticate": "Bearer"}
)
diff --git a/app/routers/authentication.py b/app/routers/authentication.py
index 41866341c..b7bdb2150 100644
--- a/app/routers/authentication.py
+++ b/app/routers/authentication.py
@@ -13,7 +13,7 @@
get_admin_by_telegram_id,
)
from app.db.models import Admin, AdminUsageLogs, User
-from app.models.admin import AdminDetails, AdminRoleData, 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
@@ -46,7 +46,8 @@ def _build_admin_details(
username=db_admin.username,
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,
@@ -141,7 +142,7 @@ 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",
@@ -158,7 +159,7 @@ 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",
@@ -217,14 +218,14 @@ async def validate_admin(db: AsyncSession, username: str, password: str) -> Admi
return AdminValidationResult(
id=db_admin.id,
username=db_admin.username,
- 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, is_disabled=False)
+ return AdminValidationResult(username=username, status=AdminStatus.active)
return None
@@ -260,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_disabled=db_admin.is_disabled,
+ status=db_admin.status,
)
return None
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/utils/wireguard.py b/app/utils/wireguard.py
index c71cb4a55..976666f3f 100644
--- a/app/utils/wireguard.py
+++ b/app/utils/wireguard.py
@@ -355,7 +355,7 @@ async def bulk_reallocate_wireguard_peer_ips(
if updated_users:
await db.commit()
- await sync_users(updated_users)
+ await sync_users(updated_users, db)
return {
"wireguard_inbound_tags": len(wg_tags),
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/tests/api/test_admin.py b/tests/api/test_admin.py
index 69f210f0b..252eee5c7 100644
--- a/tests/api/test_admin.py
+++ b/tests/api/test_admin.py
@@ -9,7 +9,7 @@
from sqlalchemy import select
from app.db.crud.admin import get_admin_by_telegram_id
-from app.db.models import Admin, AdminUsageLogs, NodeUserUsage
+from app.db.models import Admin, AdminRole, AdminUsageLogs, NodeUserUsage
from app.models.settings import RunMethod, Telegram
from app.routers.authentication import validate_mini_app_admin
from app.utils.jwt import get_admin_payload
@@ -276,13 +276,13 @@ def test_update_admin(access_token):
url=f"/api/admin/{admin['username']}",
json={
"password": password,
- "is_disabled": True,
+ "status": "disabled",
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["username"] == admin["username"]
- assert response.json()["is_disabled"] is True
+ assert response.json()["status"] == "disabled"
# Verify role_id change is applied
role_change_response = client.put(
@@ -383,7 +383,7 @@ def test_promote_admin_to_owner_forbidden_via_api(access_token):
response = client.put(
url=f"/api/admin/{admin['username']}",
json={
- "is_disabled": False,
+ "status": "active",
"role_id": 1,
},
headers={"Authorization": f"Bearer {access_token}"},
@@ -422,7 +422,7 @@ def test_administrator_can_modify_self(access_token):
response = client.put(
url=f"/api/admin/{administrator_admin['username']}",
json={
- "is_disabled": False,
+ "status": "active",
"note": "self-updated",
},
headers={"Authorization": f"Bearer {administrator_token}"},
@@ -462,7 +462,7 @@ def test_administrator_cannot_disable_self(access_token):
response = client.put(
url=f"/api/admin/{administrator_admin['username']}",
json={
- "is_disabled": True,
+ "status": "disabled",
},
headers={"Authorization": f"Bearer {administrator_token}"},
)
@@ -494,7 +494,7 @@ def test_administrator_cannot_modify_other_administrator(access_token):
response = client.put(
url=f"/api/admin/{admin_b['username']}",
json={
- "is_disabled": False,
+ "status": "active",
"note": "should-fail",
},
headers={"Authorization": f"Bearer {admin_a_token}"},
@@ -573,7 +573,7 @@ def test_disable_admin(access_token):
password = admin["password"]
disable_response = client.put(
url=f"/api/admin/{admin['username']}",
- json={"password": password, "is_disabled": True},
+ json={"password": password, "status": "disabled"},
headers={"Authorization": f"Bearer {access_token}"},
)
assert disable_response.status_code == status.HTTP_200_OK
@@ -1107,3 +1107,257 @@ def test_create_admin_with_custom_role(access_token):
finally:
delete_admin(access_token, username)
client.delete(f"/api/admin-role/{role['id']}", headers={"Authorization": f"Bearer {access_token}"})
+
+
+# ---------------------------------------------------------------------------
+# Admin data_limit, status, and limited-access tests
+# ---------------------------------------------------------------------------
+
+
+def _set_admin_traffic(username: str, used_traffic: int, data_limit: int | None = None) -> None:
+ """Directly set used_traffic and optionally data_limit on an admin."""
+ async def _set():
+ async with TestSession() as session:
+ result = await session.execute(select(Admin).where(Admin.username == username))
+ db_admin = result.scalar_one()
+ db_admin.used_traffic = used_traffic
+ if data_limit is not None:
+ db_admin.data_limit = data_limit
+ await session.commit()
+ asyncio.run(_set())
+
+
+def _set_admin_status(username: str, status_value: str) -> None:
+ """Directly set admin status in DB."""
+ async def _set():
+ async with TestSession() as session:
+ result = await session.execute(select(Admin).where(Admin.username == username))
+ db_admin = result.scalar_one()
+ db_admin.status = status_value
+ await session.commit()
+ asyncio.run(_set())
+
+
+def _login(username: str, password: str) -> str:
+ response = client.post(
+ "/api/admin/token",
+ data={"username": username, "password": password, "grant_type": "password"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ return response.json()["access_token"]
+
+
+def test_admin_data_limit_set_and_returned(access_token):
+ """Setting data_limit on an admin is persisted and returned in the response."""
+ admin = create_admin(access_token)
+ try:
+ response = client.put(
+ f"/api/admin/{admin['username']}",
+ json={"data_limit": 1073741824}, # 1 GiB
+ headers=auth_headers(access_token),
+ )
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()
+ assert data["data_limit"] == 1073741824
+ assert data["status"] == "active"
+ finally:
+ delete_admin(access_token, admin["username"])
+
+
+def test_admin_data_limit_zero_means_unlimited(access_token):
+ """Setting data_limit=0 clears the limit (treated as unlimited)."""
+ admin = create_admin(access_token)
+ try:
+ # Set a limit first
+ client.put(
+ f"/api/admin/{admin['username']}",
+ json={"data_limit": 1073741824},
+ headers=auth_headers(access_token),
+ )
+ # Clear it with 0
+ response = client.put(
+ f"/api/admin/{admin['username']}",
+ json={"data_limit": 0},
+ headers=auth_headers(access_token),
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["data_limit"] is None
+ finally:
+ delete_admin(access_token, admin["username"])
+
+
+def test_admin_status_defaults_to_active(access_token):
+ """Newly created admin has status=active."""
+ admin = create_admin(access_token)
+ try:
+ response = client.get("/api/admins", headers=auth_headers(access_token), params={"username": admin["username"]})
+ assert response.status_code == status.HTTP_200_OK
+ rows = response.json()["admins"]
+ target = next(r for r in rows if r["username"] == admin["username"])
+ assert target["status"] == "active"
+ finally:
+ delete_admin(access_token, admin["username"])
+
+
+def test_admin_status_becomes_limited_when_traffic_exceeds_limit(access_token):
+ """When used_traffic >= data_limit, status flips to limited via update_admin."""
+ admin = create_admin(access_token)
+ try:
+ # Set data_limit=100 bytes, then simulate traffic=100 bytes
+ client.put(
+ f"/api/admin/{admin['username']}",
+ json={"data_limit": 100},
+ headers=auth_headers(access_token),
+ )
+ _set_admin_traffic(admin["username"], used_traffic=100)
+ # Trigger status recompute by calling update_admin with any field
+ response = client.put(
+ f"/api/admin/{admin['username']}",
+ json={"data_limit": 100}, # same value, triggers recompute in CRUD
+ headers=auth_headers(access_token),
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["status"] == "limited"
+ finally:
+ delete_admin(access_token, admin["username"])
+
+
+def test_admin_status_returns_to_active_after_limit_raised(access_token):
+ """Raising data_limit above used_traffic flips status back to active."""
+ admin = create_admin(access_token)
+ try:
+ # Set limit=100, traffic=100 → limited
+ client.put(f"/api/admin/{admin['username']}", json={"data_limit": 100}, headers=auth_headers(access_token))
+ _set_admin_traffic(admin["username"], used_traffic=100)
+ client.put(f"/api/admin/{admin['username']}", json={"data_limit": 100}, headers=auth_headers(access_token))
+
+ # Raise limit → active
+ response = client.put(
+ f"/api/admin/{admin['username']}",
+ json={"data_limit": 1073741824},
+ headers=auth_headers(access_token),
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["status"] == "active"
+ finally:
+ delete_admin(access_token, admin["username"])
+
+
+def test_admin_status_returns_to_active_after_reset_usage(access_token):
+ """Resetting usage on a limited admin flips status back to active."""
+ admin = create_admin(access_token)
+ try:
+ # Set limit=100, traffic=100 → limited
+ client.put(f"/api/admin/{admin['username']}", json={"data_limit": 100}, headers=auth_headers(access_token))
+ _set_admin_traffic(admin["username"], used_traffic=100)
+ client.put(f"/api/admin/{admin['username']}", json={"data_limit": 100}, headers=auth_headers(access_token))
+
+ # Reset usage → active
+ response = client.post(f"/api/admin/{admin['username']}/reset", headers=auth_headers(access_token))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["status"] == "active"
+ assert response.json()["used_traffic"] == 0
+ finally:
+ delete_admin(access_token, admin["username"])
+
+
+def test_limited_admin_write_blocked_by_default(access_token):
+ """A limited admin cannot perform write operations (default: disabled_when_limited=False blocks writes)."""
+ admin = create_admin(access_token)
+ try:
+ # Set limit and exceed it
+ client.put(f"/api/admin/{admin['username']}", json={"data_limit": 100}, headers=auth_headers(access_token))
+ _set_admin_traffic(admin["username"], used_traffic=100)
+ _set_admin_status(admin["username"], "limited")
+
+ token = _login(admin["username"], admin["password"])
+
+ # Write operation should be blocked
+ response = client.post(
+ "/api/user",
+ headers=auth_headers(token),
+ json={
+ "username": unique_name("limited_user"),
+ "proxy_settings": {},
+ "data_limit": 0,
+ "data_limit_reset_strategy": "no_reset",
+ "status": "active",
+ },
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+ finally:
+ delete_admin(access_token, admin["username"])
+
+
+def test_limited_admin_read_allowed_when_disabled_when_limited_false(access_token):
+ """A limited admin can still read when disabled_when_limited=False (default)."""
+ admin = create_admin(access_token)
+ try:
+ client.put(f"/api/admin/{admin['username']}", json={"data_limit": 100}, headers=auth_headers(access_token))
+ _set_admin_traffic(admin["username"], used_traffic=100)
+ _set_admin_status(admin["username"], "limited")
+
+ token = _login(admin["username"], admin["password"])
+
+ # Read operation should be allowed
+ response = client.get("/api/users", headers=auth_headers(token))
+ assert response.status_code == status.HTTP_200_OK
+ finally:
+ delete_admin(access_token, admin["username"])
+
+
+def test_limited_admin_all_blocked_when_disabled_when_limited_true(access_token):
+ """A limited admin is fully blocked when disabled_when_limited=True on their role."""
+ # Create a custom role with disabled_when_limited=True
+ role_response = client.post(
+ "/api/admin-role",
+ headers=auth_headers(access_token),
+ json={
+ "name": unique_name("limited_role"),
+ "permissions": {"users": {"read": True, "create": True}},
+ "limits": {},
+ "features": {"can_use_reset_strategy": True, "can_use_next_plan": True},
+ "access": {},
+ },
+ )
+ assert role_response.status_code == status.HTTP_201_CREATED
+ role = role_response.json()
+
+ # Set disabled_when_limited=True directly in DB
+ async def _set_role_flag():
+ async with TestSession() as session:
+ from app.db.models import AdminRole
+ result = await session.execute(select(AdminRole).where(AdminRole.id == role["id"]))
+ db_role = result.scalar_one()
+ db_role.disabled_when_limited = True
+ await session.commit()
+ asyncio.run(_set_role_flag())
+
+ admin = create_admin(access_token, role_id=role["id"])
+ try:
+ client.put(f"/api/admin/{admin['username']}", json={"data_limit": 100}, headers=auth_headers(access_token))
+ _set_admin_traffic(admin["username"], used_traffic=100)
+ _set_admin_status(admin["username"], "limited")
+
+ token = _login(admin["username"], admin["password"])
+
+ # Even read should be blocked
+ response = client.get("/api/users", headers=auth_headers(token))
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+ finally:
+ delete_admin(access_token, admin["username"])
+ client.delete(f"/api/admin-role/{role['id']}", headers=auth_headers(access_token))
+
+
+def test_admins_list_includes_limited_count(access_token):
+ """GET /api/admins response includes a 'limited' count field."""
+ admin = create_admin(access_token)
+ try:
+ _set_admin_status(admin["username"], "limited")
+ response = client.get("/api/admins", headers=auth_headers(access_token))
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()
+ assert "limited" in data
+ assert data["limited"] >= 1
+ finally:
+ delete_admin(access_token, admin["username"])
diff --git a/tests/api/test_bulk_entity_actions.py b/tests/api/test_bulk_entity_actions.py
index 3379a0e29..a9c422c5c 100644
--- a/tests/api/test_bulk_entity_actions.py
+++ b/tests/api/test_bulk_entity_actions.py
@@ -342,7 +342,7 @@ def test_bulk_disable_enable_and_reset_admins(access_token):
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 1
- assert get_admin_details(access_token, admin["username"])["is_disabled"] is True
+ assert get_admin_details(access_token, admin["username"])["status"] == "disabled"
response = client.post(
"/api/admins/bulk/enable",
@@ -351,7 +351,7 @@ def test_bulk_disable_enable_and_reset_admins(access_token):
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 1
- assert get_admin_details(access_token, admin["username"])["is_disabled"] is False
+ assert get_admin_details(access_token, admin["username"])["status"] == "active"
finally:
delete_admin(access_token, admin["username"])
From 3783d6239941b5715a8b24894d02aad285f51874 Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Mon, 18 May 2026 23:10:36 +0330
Subject: [PATCH 43/75] fix: import
---
app/db/crud/admin.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/db/crud/admin.py b/app/db/crud/admin.py
index 01ce251c9..75fadcfb8 100644
--- a/app/db/crud/admin.py
+++ b/app/db/crud/admin.py
@@ -9,7 +9,7 @@
get_complete_period_start_for_filter,
to_utc_for_filter,
)
-from app.db.models import Admin, AdminUsageLogs, NodeUserUsage, User
+from app.db.models import Admin, AdminRole, AdminUsageLogs, NodeUserUsage, User
from app.models.admin import (
AdminCreate,
AdminDetails,
@@ -24,7 +24,7 @@
AdminStatus,
hash_password,
)
-from app.models.admin_role import RoleLimits, AdminRole
+from app.models.admin_role import RoleLimits
from app.models.stats import Period, UserUsageStat, UserUsageStatsList
from app.utils.logger import get_logger
From a3c9608c4a41ac5ea952090fdd4e407aec2e1469 Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Mon, 18 May 2026 23:19:04 +0330
Subject: [PATCH 44/75] feat(admin-role): add disabled_when_limited flags and
update permission checks
---
.../versions/a1d3f5b7c9e2_admin_status_and_data_limit.py | 6 ++++--
app/models/admin.py | 2 ++
app/models/admin_role.py | 2 --
app/operation/permissions.py | 3 +--
4 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/app/db/migrations/versions/a1d3f5b7c9e2_admin_status_and_data_limit.py b/app/db/migrations/versions/a1d3f5b7c9e2_admin_status_and_data_limit.py
index ef4ad6bf0..e1c36aab6 100644
--- a/app/db/migrations/versions/a1d3f5b7c9e2_admin_status_and_data_limit.py
+++ b/app/db/migrations/versions/a1d3f5b7c9e2_admin_status_and_data_limit.py
@@ -37,7 +37,9 @@ def upgrade() -> None:
# Backfill status from is_disabled
if dialect == "postgresql":
conn.execute(sa.text(
- "UPDATE admins SET status = CASE WHEN is_disabled = true THEN 'disabled' ELSE 'active' END"
+ "UPDATE admins SET status = CASE "
+ "WHEN is_disabled = true THEN 'disabled'::adminstatus "
+ "ELSE 'active'::adminstatus END"
))
else:
conn.execute(sa.text(
@@ -80,7 +82,7 @@ def downgrade() -> None:
if dialect == "postgresql":
conn.execute(sa.text(
- "UPDATE admins SET is_disabled = (status = 'disabled')"
+ "UPDATE admins SET is_disabled = (status = 'disabled'::adminstatus)"
))
else:
conn.execute(sa.text(
diff --git a/app/models/admin.py b/app/models/admin.py
index 68b47c7c0..e0d1ea9a4 100644
--- a/app/models/admin.py
+++ b/app/models/admin.py
@@ -55,6 +55,8 @@ class AdminRoleData(BaseModel):
limits: RoleLimits = Field(default_factory=RoleLimits)
features: RoleFeatures = Field(default_factory=RoleFeatures)
access: RoleAccess = Field(default_factory=RoleAccess)
+ disabled_when_limited: bool = False
+ disable_users_when_limited: bool = False
model_config = ConfigDict(from_attributes=True)
diff --git a/app/models/admin_role.py b/app/models/admin_role.py
index 092d66a53..b561d9fb5 100644
--- a/app/models/admin_role.py
+++ b/app/models/admin_role.py
@@ -95,8 +95,6 @@ class RoleLimits(BaseModel):
class RoleFeatures(BaseModel):
can_use_reset_strategy: bool = True
can_use_next_plan: bool = True
- disabled_when_limited: bool = False
- disable_users_when_limited: bool = False
model_config = ConfigDict(from_attributes=True)
diff --git a/app/operation/permissions.py b/app/operation/permissions.py
index c1bff7d37..d007a33ed 100644
--- a/app/operation/permissions.py
+++ b/app/operation/permissions.py
@@ -56,8 +56,7 @@ def enforce_permission(admin: AdminDetails, resource: str, action: str) -> None:
return
if admin.is_limited:
- features = admin.role.features if admin.role else None
- if features and features.disabled_when_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")
From 117acd71efc15acce2a0b0a862716ce3aec19a18 Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Mon, 18 May 2026 23:41:21 +0330
Subject: [PATCH 45/75] fix
---
app/db/models.py | 7 ++++++-
app/node/sync.py | 3 +++
tests/api/test_admin.py | 8 +++++++-
3 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/app/db/models.py b/app/db/models.py
index bb835ea77..e54845263 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -99,7 +99,7 @@ class Admin(Base):
notification_enable: Mapped[Optional[Dict]] = mapped_column(PostgresJSONB, default=None)
note: Mapped[Optional[str]] = mapped_column(String(500), default=None)
role_id: Mapped[int] = fk_id_column("admin_roles.id", default=0)
- role: Mapped[Optional["AdminRole"]] = relationship(back_populates="admins", init=False, lazy="selectin")
+ role: Mapped[Optional[AdminRole]] = relationship(back_populates="admins", init=False, lazy="selectin")
permission_overrides: Mapped[Optional[Dict]] = mapped_column(PostgresJSONB, default=None)
@hybrid_property
@@ -136,6 +136,11 @@ def reseted_usage(cls):
def lifetime_used_traffic(self) -> int:
return self.reseted_usage + self.used_traffic
+ @property
+ def users_sync_blocked(self) -> bool:
+ """True when this admin's users should NOT be synced to nodes."""
+ return self.status == AdminStatus.limited and self.role.disable_users_when_limited
+
@property
def total_users(self) -> int:
return len(self.users)
diff --git a/app/node/sync.py b/app/node/sync.py
index 76500fae7..20d8c17d6 100644
--- a/app/node/sync.py
+++ b/app/node/sync.py
@@ -34,6 +34,9 @@ async def _dispatch_users_update(proto_users):
async def sync_user(db_user: User) -> None:
+ if db_user.admin_id and db_user.admin.users_sync_blocked:
+ return
+
proto_user = await serialize_user(db_user)
asyncio.create_task(_dispatch_user_update(proto_user))
diff --git a/tests/api/test_admin.py b/tests/api/test_admin.py
index 252eee5c7..0060f33f8 100644
--- a/tests/api/test_admin.py
+++ b/tests/api/test_admin.py
@@ -9,7 +9,7 @@
from sqlalchemy import select
from app.db.crud.admin import get_admin_by_telegram_id
-from app.db.models import Admin, AdminRole, AdminUsageLogs, NodeUserUsage
+from app.db.models import Admin, AdminUsageLogs, NodeUserUsage
from app.models.settings import RunMethod, Telegram
from app.routers.authentication import validate_mini_app_admin
from app.utils.jwt import get_admin_payload
@@ -1116,6 +1116,7 @@ def test_create_admin_with_custom_role(access_token):
def _set_admin_traffic(username: str, used_traffic: int, data_limit: int | None = None) -> None:
"""Directly set used_traffic and optionally data_limit on an admin."""
+
async def _set():
async with TestSession() as session:
result = await session.execute(select(Admin).where(Admin.username == username))
@@ -1124,17 +1125,20 @@ async def _set():
if data_limit is not None:
db_admin.data_limit = data_limit
await session.commit()
+
asyncio.run(_set())
def _set_admin_status(username: str, status_value: str) -> None:
"""Directly set admin status in DB."""
+
async def _set():
async with TestSession() as session:
result = await session.execute(select(Admin).where(Admin.username == username))
db_admin = result.scalar_one()
db_admin.status = status_value
await session.commit()
+
asyncio.run(_set())
@@ -1327,10 +1331,12 @@ def test_limited_admin_all_blocked_when_disabled_when_limited_true(access_token)
async def _set_role_flag():
async with TestSession() as session:
from app.db.models import AdminRole
+
result = await session.execute(select(AdminRole).where(AdminRole.id == role["id"]))
db_role = result.scalar_one()
db_role.disabled_when_limited = True
await session.commit()
+
asyncio.run(_set_role_flag())
admin = create_admin(access_token, role_id=role["id"])
From cfc1b89f22a549e5383c32cffcfb1f6fcbd362b2 Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Mon, 18 May 2026 23:54:41 +0330
Subject: [PATCH 46/75] refactor: simplify sync_users function calls by
removing unnecessary db parameter
---
app/node/sync.py | 12 ++++--------
app/node/user.py | 13 +------------
app/operation/admin.py | 8 ++++----
app/operation/group.py | 12 ++++++------
app/operation/user.py | 12 ++++++------
app/utils/wireguard.py | 2 +-
6 files changed, 22 insertions(+), 37 deletions(-)
diff --git a/app/node/sync.py b/app/node/sync.py
index 20d8c17d6..47a272044 100644
--- a/app/node/sync.py
+++ b/app/node/sync.py
@@ -1,7 +1,5 @@
import asyncio
-from sqlalchemy.ext.asyncio import AsyncSession
-
from app.db.models import User
from app.models.user import UserNotificationResponse
from app.nats.node_rpc import node_nats_client
@@ -54,10 +52,8 @@ async def remove_users(users: list[User]) -> None:
asyncio.create_task(_dispatch_users_update(proto_users))
-async def sync_users(users: list[User], db: AsyncSession) -> None:
- """Sync users to nodes, excluding users of limited admins with disable_users_when_limited=True."""
- from app.db.crud.admin import get_limited_admin_ids_with_user_sync
-
- excluded_admin_ids = await get_limited_admin_ids_with_user_sync(db)
- proto_users = await serialize_users_for_node(users, excluded_admin_ids=excluded_admin_ids)
+async def sync_users(users: list[User]) -> None:
+ """Sync users to nodes, excluding users whose admin has users_sync_blocked."""
+ filtered = [u for u in users if not (u.admin_id and u.admin.users_sync_blocked)]
+ proto_users = await serialize_users_for_node(filtered)
asyncio.create_task(_dispatch_users_update(proto_users))
diff --git a/app/node/user.py b/app/node/user.py
index 5358f8c29..5b6d17d95 100644
--- a/app/node/user.py
+++ b/app/node/user.py
@@ -155,22 +155,11 @@ async def core_users(
async def serialize_users_for_node(
users: list[User],
allowed_protocols: frozenset[ProxyProtocol] | None = None,
- excluded_admin_ids: set[int] | None = None,
) -> list[ProtoUser]:
- """Serialize users for node dispatch.
-
- Args:
- users: Users to serialize.
- allowed_protocols: Optional protocol filter.
- excluded_admin_ids: Admin IDs whose users should be excluded
- (e.g. limited admins with disable_users_when_limited=True).
- """
+ """Serialize users for node dispatch."""
bridge_users: list = []
for user in users:
- if excluded_admin_ids and user.admin_id in excluded_admin_ids:
- continue
-
inbounds_list = []
if user.status in [UserStatus.active, UserStatus.on_hold]:
loaded_inbounds = _inbounds_from_loaded_groups(user)
diff --git a/app/operation/admin.py b/app/operation/admin.py
index df27196cf..d0130760c 100644
--- a/app/operation/admin.py
+++ b/app/operation/admin.py
@@ -123,7 +123,7 @@ async def _modify_admin(
# 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, db)
+ await sync_users(users)
logger.info(f'Admin "{db_admin.username}" with id "{db_admin.id}" modified by admin "{current_admin.username}"')
@@ -189,7 +189,7 @@ async def _disable_all_active_users_for_admin(self, db: AsyncSession, db_admin:
"""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, db)
+ 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):
@@ -209,7 +209,7 @@ async def _activate_all_disabled_users_for_admin(self, db: AsyncSession, db_admi
"""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, db)
+ 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):
@@ -266,7 +266,7 @@ async def _reset_admin_usage(self, db: AsyncSession, db_admin: Admin, admin: Adm
# 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, db)
+ 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)
diff --git a/app/operation/group.py b/app/operation/group.py
index e0528dc27..c6dedc93c 100644
--- a/app/operation/group.py
+++ b/app/operation/group.py
@@ -87,7 +87,7 @@ async def modify_group(self, db: AsyncSession, group_id: int, modified_group: Gr
db,
query=UserListQuery(group_ids=[db_group.id], status=[UserStatus.active, UserStatus.on_hold]),
)
- await sync_users(users, db)
+ await sync_users(users)
group = GroupResponse.model_validate(db_group)
@@ -105,7 +105,7 @@ async def remove_group(self, db: AsyncSession, group_id: int, admin: Admin) -> N
await remove_group(db, db_group)
users = await get_users(db, query=UserListQuery(username=username_list))
- await sync_users(users, db)
+ await sync_users(users)
logger.info(f'Group "{db_group.name}" deleted by admin "{admin.username}"')
@@ -118,7 +118,7 @@ async def bulk_add_groups(self, db: AsyncSession, bulk_model: BulkGroup):
return BulkOperationDryRunResponse(affected_users=n)
users, users_count = await add_groups_to_users(db, bulk_model)
- await sync_users(users, db)
+ await sync_users(users)
if self.operator_type in (OperatorType.API, OperatorType.WEB):
return {"detail": f"operation has been successfuly done on {users_count} users"}
@@ -131,7 +131,7 @@ async def bulk_remove_groups(self, db: AsyncSession, bulk_model: BulkGroup):
return BulkOperationDryRunResponse(affected_users=n)
users, users_count = await remove_groups_from_users(db, bulk_model)
- await sync_users(users, db)
+ await sync_users(users)
if self.operator_type in (OperatorType.API, OperatorType.WEB):
return {"detail": f"operation has been successfuly done on {users_count} users"}
@@ -163,7 +163,7 @@ async def bulk_remove_groups_by_id(
if all_affected_usernames:
users = await get_users(db, query=UserListQuery(username=list(all_affected_usernames)))
- await sync_users(users, db)
+ await sync_users(users)
for name, group_id in zip(group_names, group_ids):
logger.info(f'Group "{name}" deleted by admin "{admin.username}"')
@@ -211,7 +211,7 @@ async def bulk_set_groups_disabled(
status=[UserStatus.active, UserStatus.on_hold],
),
)
- await sync_users(users, db)
+ await sync_users(users)
for db_group in groups_to_update:
group = GroupResponse.model_validate(db_group)
diff --git a/app/operation/user.py b/app/operation/user.py
index 0e349595e..80187578d 100644
--- a/app/operation/user.py
+++ b/app/operation/user.py
@@ -306,7 +306,7 @@ async def _persist_bulk_users(
)
db_users = await create_users_bulk(db, users_to_create, groups, db_admin)
- await sync_users(db_users, db)
+ await sync_users(db_users)
users_list = []
for db_user in db_users:
@@ -730,7 +730,7 @@ async def bulk_reset_user_data_usage(
db_users,
clean_chart_data=usage_settings.reset_user_usage_clean_chart_data,
)
- await sync_users(db_users, db)
+ await sync_users(db_users)
users = [await self.validate_user(db_user) for db_user in db_users]
for user in users:
@@ -770,7 +770,7 @@ async def bulk_revoke_user_sub(
db_users = await self._get_validated_users_by_ids(db, bulk_users.ids, admin, load_usage_logs=False)
db_users = await bulk_revoke_user_sub(db, db_users)
- await sync_users(db_users, db)
+ await sync_users(db_users)
users = [await self.validate_user(db_user) for db_user in db_users]
for user in users:
@@ -1380,7 +1380,7 @@ async def bulk_modify_expire(self, db: AsyncSession, bulk_model: BulkUser):
n = await count_bulk_expire_targets(db, bulk_model)
return BulkOperationDryRunResponse(affected_users=n)
users, users_count = await update_users_expire(db, bulk_model)
- await sync_users(users, db)
+ await sync_users(users)
if self.operator_type in (OperatorType.API, OperatorType.WEB):
return {"detail": f"operation has been successfuly done on {users_count} users"}
@@ -1391,7 +1391,7 @@ async def bulk_modify_datalimit(self, db: AsyncSession, bulk_model: BulkUser):
n = await count_bulk_datalimit_targets(db, bulk_model)
return BulkOperationDryRunResponse(affected_users=n)
users, users_count = await update_users_datalimit(db, bulk_model)
- await sync_users(users, db)
+ await sync_users(users)
if self.operator_type in (OperatorType.API, OperatorType.WEB):
return {"detail": f"operation has been successfuly done on {users_count} users"}
@@ -1404,7 +1404,7 @@ async def bulk_modify_proxy_settings(self, db: AsyncSession, bulk_model: BulkUse
n = await count_bulk_proxy_targets(db, bulk_model)
return BulkOperationDryRunResponse(affected_users=n)
users, users_count = await update_users_proxy_settings(db, bulk_model)
- await sync_users(users, db)
+ await sync_users(users)
if self.operator_type in (OperatorType.API, OperatorType.WEB):
return {"detail": f"operation has been successfuly done on {users_count} users"}
diff --git a/app/utils/wireguard.py b/app/utils/wireguard.py
index 976666f3f..c71cb4a55 100644
--- a/app/utils/wireguard.py
+++ b/app/utils/wireguard.py
@@ -355,7 +355,7 @@ async def bulk_reallocate_wireguard_peer_ips(
if updated_users:
await db.commit()
- await sync_users(updated_users, db)
+ await sync_users(updated_users)
return {
"wireguard_inbound_tags": len(wg_tags),
From 0bd396f35269ed9dc7e6ac6a4d9c07bafc928d14 Mon Sep 17 00:00:00 2001
From: x0sina {formatBytes(numericValue)}
@@ -512,6 +514,52 @@ function FeaturesSection({ form }: { form: AdminRoleForm }) { const { t } = useTranslation() return (+ {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 users from nodes while the admin is usage-limited.' })} +
++ {formatBytes(numericValue)} +
+ )} +{formatBytes(numericValue)}
diff --git a/dashboard/src/features/admins/forms/admin-form.ts b/dashboard/src/features/admins/forms/admin-form.ts index 28df2c8df..026f95f6e 100644 --- a/dashboard/src/features/admins/forms/admin-form.ts +++ b/dashboard/src/features/admins/forms/admin-form.ts @@ -1,6 +1,8 @@ import { z } from 'zod' import type { RoleLimits } from '@/service/api' +export const adminStatusEditEnum = z.enum(['active', 'disabled']) + const passwordValidation = z.string().refine( value => { if (!value) return false // Don't allow empty passwords @@ -50,6 +52,8 @@ export const adminFormSchema = z password: z.string().optional(), passwordConfirm: z.string().optional(), role_id: z.number().min(2, 'Role is required'), + status: adminStatusEditEnum.optional(), + data_limit: z.union([z.literal('').transform(() => null), z.null(), z.coerce.number().min(0)]).optional(), is_disabled: z.boolean().optional(), discord_webhook: z.string().optional(), sub_domain: z.string().optional(), @@ -134,6 +138,8 @@ export const adminFormDefaultValues: Partial{username}
+Used Traffic: {used_traffic}
+Data Limit: {data_limit}
+Usage: {usage_percentage}%
+Reached Threshold: {threshold}%
+"""
+
ADMIN_LOGIN = """
#Login_Attempt
Status: {status}
From 53d27eb76efde83edb21cd5dfb56ca93a77d9b02 Mon Sep 17 00:00:00 2001
From: Mohammad