实现用户对模型权限的管理,优化UI

This commit is contained in:
Von committed 2026-04-03 21:10:33 +08:00
1 parent d03b5466da
commit 1edc97072c
13 files changed
+2570 -373

No files matched your search

+18 -1
View File
@@ -9,13 +9,30 @@ admin:
providers:
- name: volcengine
enabled: true
api: https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions
api: http://127.0.0.1:22026
transport: http
source_format: openai_chat
target_format: openai_chat
verify_ssl: false
model_list:
- kimi-k2.5
- deepseek/deepseek-chat
- deepseek/deepseek-reasoner
- glm/glm-4.5
- glm/glm-4.5-air
- glm/glm-4.6
- glm/glm-4.7
- glm/glm-5
- volc_chat/deepseek-v3-2-251201
- volc_chat/doubao-seed-2-0-code-preview-260215
- volc_chat/doubao-seed-2-0-pro-260215
- volc_chat/doubao-seedance-2-0-260128
- volc_code/DeepSeek-V3.2
- volc_code/Doubao-Seed-2.0-Code
- volc_code/Doubao-Seed-2.0-pro
- volc_code/GLM-4.7
- volc_code/MiniMax-M2.5
- volc_code/kimi-k2.5
auth_groups:
- name: a
strategy: least_inflight
+3
View File
@@ -168,6 +168,7 @@ class Application:
auth_service = AuthenticationService(self._ctx)
user_service = UserService(self._ctx, self._user_repository)
self._user_service = user_service
self._user_service.sync_model_permissions()
proxy_service = ProxyService(self._ctx, self._auth_group_manager)
log_service = LogService(self._ctx, self._log_repository)
provider_service = ProviderService(self._ctx, self.reload_providers)
@@ -216,6 +217,8 @@ class Application:
)
self._auth_group_manager.load_auth_groups(auth_group_schemas)
self._provider_manager.load_providers(provider_schemas)
if hasattr(self, "_user_service"):
self._user_service.sync_model_permissions()
def run(self) -> None:
"""启动 WSGI 服务。"""
+67 -12
View File
@@ -50,6 +50,29 @@ class ProxyController:
def _is_whitelist_required(self) -> bool:
return self._config_manager.is_chat_whitelist_enabled()
def _get_authorized_user_for_request(
self,
client_ip: str,
*,
error_format: str,
) -> tuple[Optional[Dict[str, Any]], Optional[tuple[Response, int]]]:
"""在开启白名单时解析当前请求对应用户。"""
if not self._is_whitelist_required():
return None, None
user = self._get_user_by_ip(client_ip)
if user:
return user, None
self._logger.warning("Proxy denied: ip=%s is not in whitelist", client_ip)
return None, self._error_response(
f"IP address {client_ip} is not in whitelist",
403,
error_type="permission_error",
code="ip_not_whitelisted",
error_format=error_format,
)
@staticmethod
def _build_error_payload(
message: str,
@@ -137,17 +160,12 @@ class ProxyController:
try:
client_ip = normalize_ip(request.remote_addr)
self._logger.info("Proxy request received: route=%s ip=%s", route_name, client_ip)
whitelist_required = self._is_whitelist_required()
user = self._get_user_by_ip(client_ip) if whitelist_required else None
if whitelist_required and not user:
self._logger.warning("Proxy denied: ip=%s is not in whitelist", client_ip)
return self._error_response(
f"IP address {client_ip} is not in whitelist",
403,
error_type="permission_error",
code="ip_not_whitelisted",
error_format=resolved_error_format,
)
user, denial_response = self._get_authorized_user_for_request(
client_ip,
error_format=resolved_error_format,
)
if denial_response is not None:
return denial_response
request_data = request.get_json(silent=True)
if request_data is None:
@@ -185,6 +203,25 @@ class ProxyController:
error_format=resolved_error_format,
)
if self._is_whitelist_required() and not self._user_service.can_user_access_model(
user,
model_name,
available_models=self._provider_manager.list_model_names(),
):
self._logger.warning(
"Proxy denied: ip=%s is not allowed to access model=%s route=%s",
client_ip,
model_name,
route_name,
)
return self._error_response(
f"IP address {client_ip} is not allowed to access model {model_name}",
403,
error_type="permission_error",
code="model_not_allowed",
error_format=resolved_error_format,
)
provider_target_format = str(getattr(provider, "target_format", "") or "").strip().lower()
if provider_target_format not in normalized_expected_target_formats:
self._logger.warning(
@@ -302,8 +339,26 @@ class ProxyController:
def list_models(self) -> Response:
try:
client_ip = normalize_ip(request.remote_addr)
user, denial_response = self._get_authorized_user_for_request(
client_ip,
error_format="openai_chat",
)
if denial_response is not None:
return denial_response
model_names = list(self._provider_manager.list_model_names())
if self._is_whitelist_required():
allowed_models = set(
self._user_service.get_accessible_models_for_user(
user,
available_models=model_names,
)
)
model_names = [model_name for model_name in model_names if model_name in allowed_models]
data = []
for model_key in self._provider_manager.list_model_names():
for model_key in model_names:
provider_name, _, _ = str(model_key).partition("/")
provider_view = self._provider_manager.get_provider_view(provider_name)
source_format = getattr(provider_view, "source_format", None)
+78 -16
View File
@@ -181,6 +181,16 @@
margin-bottom: 14px;
}
.providers-page .provider-batch-summary .toolbar-pill {
border-color: rgba(58, 127, 237, 0.14);
background: rgba(58, 127, 237, 0.06);
color: var(--text-secondary);
}
.providers-page .provider-batch-summary .toolbar-pill strong {
color: var(--primary-active);
}
.providers-page .providers-table-shell {
overflow: auto;
border: 1px solid var(--line);
@@ -267,6 +277,16 @@
color: #e6edf3;
}
:root[data-theme="dark"] .providers-page .provider-batch-summary .toolbar-pill {
border-color: rgba(88, 166, 255, 0.2);
background: rgba(88, 166, 255, 0.1);
color: #a8b3c2;
}
:root[data-theme="dark"] .providers-page .provider-batch-summary .toolbar-pill strong {
color: #cfe0ff;
}
:root[data-theme="dark"] .providers-page .toolbar-pill-primary {
border-color: #2f5f97;
background: rgba(47, 129, 247, 0.12);
@@ -576,6 +596,24 @@
.providers-page .model-tag-detail {
min-height: 30px;
border-color: rgba(100, 116, 139, 0.18);
background: #f6f8fb;
color: #334155;
font-weight: 600;
box-shadow: none;
}
.providers-page .providers-table .model-tag {
min-height: 26px;
border-color: rgba(100, 116, 139, 0.18);
background: #f6f8fb;
color: #334155;
font-weight: 600;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-shadow: none;
}
:root[data-theme="dark"] .providers-page .model-tag {
@@ -585,6 +623,20 @@
box-shadow: inset 0 1px 0 rgba(125, 180, 255, 0.08);
}
:root[data-theme="dark"] .providers-page .providers-table .model-tag {
border-color: rgba(148, 163, 184, 0.22);
background: rgba(148, 163, 184, 0.08);
color: #d5dde7;
box-shadow: none;
}
:root[data-theme="dark"] .providers-page .model-tag-detail {
border-color: rgba(148, 163, 184, 0.22);
background: rgba(148, 163, 184, 0.08);
color: #d5dde7;
box-shadow: none;
}
:root[data-theme="dark"] .providers-page .provider-chip {
border-color: rgba(86, 150, 232, 0.34);
background: rgba(56, 104, 168, 0.18);
@@ -748,15 +800,15 @@
}
.providers-page .model-count {
border: 1px solid #d8e5f3;
background: rgba(17, 24, 39, 0.04);
color: var(--text-secondary);
border: 1px solid rgba(100, 116, 139, 0.18);
background: #f6f8fb;
color: #334155;
}
.providers-page .model-more {
border: 1px solid #bfd6ee;
background: linear-gradient(180deg, #f4f9ff 0%, #eaf3ff 100%);
color: #29507c;
border: 1px solid rgba(100, 116, 139, 0.18);
background: #f6f8fb;
color: #334155;
}
.providers-page .btn-inline {
@@ -1001,6 +1053,15 @@
margin-top: 6px;
}
.providers-page .provider-help-popover-body code {
padding: 1px 6px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.1);
color: #c2185b;
font-size: 12px;
font-weight: 700;
}
:root[data-theme="dark"] .providers-page .btn-action {
background: #0d1117;
}
@@ -1054,10 +1115,6 @@
max-width: min(1120px, calc(100vw - 32px));
}
.providers-page .provider-batch-delete-modal-dialog {
max-width: min(420px, calc(100vw - 32px));
}
.providers-page .auth-entry-yaml-modal-dialog {
max-width: min(1180px, calc(100vw - 32px));
}
@@ -1620,6 +1677,11 @@
color: #ffffff;
}
:root[data-theme="dark"] .providers-page .provider-help-popover-body code {
background: rgba(108, 167, 255, 0.14);
color: #ff8ac2;
}
:root[data-theme="dark"] .providers-page .inline-confirm-action:disabled {
opacity: 0.78;
}
@@ -1701,9 +1763,9 @@
}
:root[data-theme="dark"] .providers-page .model-count {
border-color: #313b47;
background: rgba(255, 255, 255, 0.04);
color: #a8b3c2;
border-color: rgba(148, 163, 184, 0.22);
background: rgba(148, 163, 184, 0.08);
color: #d5dde7;
}
:root[data-theme="dark"] .providers-page .fetch-model-list {
@@ -1741,9 +1803,9 @@
}
:root[data-theme="dark"] .providers-page .model-more {
border-color: #2d5a8a;
background: linear-gradient(180deg, rgba(19, 35, 56, 0.96) 0%, rgba(14, 28, 45, 0.96) 100%);
color: #cfe5ff;
border-color: rgba(148, 163, 184, 0.22);
background: rgba(148, 163, 184, 0.08);
color: #d5dde7;
}
.providers-page .provider-models-textarea {
File diff suppressed because it is too large. Load diff
+6 -99
View File
@@ -1,7 +1,7 @@
{% extends "base_admin.html" %}
{% block page_title %}Provider 管理{% endblock %}
{% block page_css %}<link rel="stylesheet" href="/static/css/providers.css?v=20260402-6">{% endblock %}
{% block page_css %}<link rel="stylesheet" href="/static/css/providers.css?v=20260403-11">{% endblock %}
{% block body_class %}providers-page{% endblock %}
{% block header_title %}Provider 管理{% endblock %}
{% block header_subtitle %}维护 providers 配置,修改后自动重载生效。{% endblock %}
@@ -49,10 +49,8 @@
</div>
<div class="toolbar-summary provider-batch-summary">
<span class="toolbar-pill">已选 <strong id="selectedProviderCount">0</strong> 个</span>
<button type="button" class="btn btn-toolbar-secondary" id="enableSelectedProvidersBtn" onclick="runProviderBatchAction('enable')" disabled>批量启用</button>
<button type="button" class="btn btn-toolbar-secondary" id="disableSelectedProvidersBtn" onclick="runProviderBatchAction('disable')" disabled>批量禁用</button>
<button type="button" class="btn btn-toolbar-secondary" id="clearSelectedProvidersBtn" onclick="clearSelectedProviders()" disabled>清空选择</button>
<button type="button" class="btn btn-toolbar-secondary" id="deleteSelectedProvidersBtn" onclick="runProviderBatchAction('delete')" disabled>批量删除</button>
<button type="button" class="btn btn-toolbar-secondary" id="enableSelectedProvidersBtn" onclick="runProviderBatchAction('enable')" disabled>启用</button>
<button type="button" class="btn btn-toolbar-secondary" id="disableSelectedProvidersBtn" onclick="runProviderBatchAction('disable')" disabled>禁用</button>
</div>
<div class="providers-table-shell" id="providersContainer">
<div class="loading">正在加载 provider 配置...</div>
@@ -444,24 +442,6 @@
</div>
</div>
</div>
<div class="modal fade" id="providerBatchDeleteModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered provider-batch-delete-modal-dialog">
<div class="modal-content border-0 shadow">
<div class="modal-header border-bottom-0">
<div>
<h5 class="modal-title fw-semibold">确认批量删除</h5>
</div>
</div>
<div class="modal-body pt-0">
<p class="mb-0 fw-semibold" id="providerBatchDeleteMessage">是否批量删除 0 个 Provider?</p>
</div>
<div class="modal-footer border-top-0">
<button type="button" class="btn btn-secondary" id="cancelProviderBatchDeleteBtn" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-danger" id="confirmProviderBatchDeleteBtn" onclick="confirmProviderBatchDelete()">确认删除</button>
</div>
</div>
</div>
</div>
<div class="inline-confirm-popover floating-delete-popover" id="providerDeletePopover" data-placement="bottom" hidden>
<div class="inline-confirm-text">确认删除?</div>
@@ -498,7 +478,6 @@
let editingProviderName = null;
let editingAuthGroupName = null;
let providerModalInstance = null;
let providerBatchDeleteModalInstance = null;
let authGroupModalInstance = null;
let authEntryImportModalInstance = null;
let authGroupRuntimeModalInstance = null;
@@ -510,7 +489,6 @@
let activeAuthGroupRuntime = null;
let pendingDeleteProviderName = null;
let deletingProviderName = null;
let pendingProviderBatchDeleteNames = [];
let selectedProviderNames = new Set();
let togglingProviderNames = new Set();
let providerBatchActionInFlight = false;
@@ -901,8 +879,6 @@
[
'enableSelectedProvidersBtn',
'disableSelectedProvidersBtn',
'deleteSelectedProvidersBtn',
'clearSelectedProvidersBtn',
].forEach(id => {
const button = document.getElementById(id);
if (!button) return;
@@ -939,52 +915,16 @@
renderProviders();
}
function syncProviderBatchDeleteModal() {
const count = pendingProviderBatchDeleteNames.length;
const messageElement = document.getElementById('providerBatchDeleteMessage');
const cancelButton = document.getElementById('cancelProviderBatchDeleteBtn');
const confirmButton = document.getElementById('confirmProviderBatchDeleteBtn');
if (messageElement) {
messageElement.textContent = `是否批量删除 ${count} 个 Provider?`;
}
if (cancelButton) {
cancelButton.disabled = providerBatchActionInFlight;
}
if (confirmButton) {
confirmButton.disabled = providerBatchActionInFlight || count === 0;
confirmButton.textContent = providerBatchActionInFlight ? '删除中...' : '确认删除';
}
}
function openProviderBatchDeleteModal(names) {
const normalizedNames = Array.isArray(names)
? names.map(name => String(name || '').trim()).filter(name => name !== '')
: [];
if (!normalizedNames.length) {
showMessage('请先选择至少一个 Provider', 'error');
return;
}
pendingProviderBatchDeleteNames = normalizedNames;
syncProviderBatchDeleteModal();
providerBatchDeleteModalInstance?.show();
}
function getProviderBatchActionMeta(action) {
return {
enable: { label: '启用', past: '启用', fallback: '批量启用 Provider 失败' },
disable: { label: '禁用', past: '禁用', fallback: '批量禁用 Provider 失败' },
delete: { label: '删除', past: '删除', fallback: '批量删除 Provider 失败' },
}[action] || null;
}
async function executeProviderBatchAction(action, selectedNames, meta, options = {}) {
const { closeDeleteModal = false } = options || {};
async function executeProviderBatchAction(action, selectedNames, meta) {
closeDeleteProviderConfirm(false);
providerBatchActionInFlight = true;
syncProviderBatchDeleteModal();
renderProviders();
try {
@@ -1001,13 +941,7 @@
throw new Error(result.error || meta.fallback);
}
selectedProviderNames = action === 'delete' ? new Set() : new Set(selectedNames);
if (action === 'delete') {
pendingProviderBatchDeleteNames = [];
}
if (closeDeleteModal) {
providerBatchDeleteModalInstance?.hide();
}
selectedProviderNames = new Set(selectedNames);
showMessage(`已批量${meta.past} ${result.count || selectedNames.length} 个 Provider,配置已重载`, 'success');
await loadProviders();
} catch (error) {
@@ -1015,24 +949,10 @@
showActionError(`批量${meta.label} Provider`, error, { fallback: meta.fallback });
} finally {
providerBatchActionInFlight = false;
syncProviderBatchDeleteModal();
renderProviders();
}
}
async function confirmProviderBatchDelete() {
const selectedNames = pendingProviderBatchDeleteNames.slice();
if (!selectedNames.length || providerBatchActionInFlight) {
return;
}
const meta = getProviderBatchActionMeta('delete');
if (!meta) {
return;
}
await executeProviderBatchAction('delete', selectedNames, meta, { closeDeleteModal: true });
}
function getAuthEntryDeletePopoverElement() {
return document.getElementById('authEntryDeletePopover');
}
@@ -1184,7 +1104,7 @@
switchProviderTab('providers');
activeModelProvider = provider;
document.getElementById('modelDetailTitle').textContent = `${provider.name} - 模型列表`;
document.getElementById('modelDetailSubtitle').textContent = provider.api || '未配置 API 地址';
document.getElementById('modelDetailSubtitle').textContent = `API 地址: ${provider.api}` || '未配置 API 地址';
document.getElementById('modelSearchInput').value = '';
renderModelDetailList();
modelDetailModalInstance.show();
@@ -3180,11 +3100,6 @@
return;
}
if (action === 'delete') {
openProviderBatchDeleteModal(selectedNames);
return;
}
await executeProviderBatchAction(action, selectedNames, meta);
}
@@ -3311,14 +3226,6 @@
editingProviderName = null;
fillForm(null);
});
const providerBatchDeleteModalElement = document.getElementById('providerBatchDeleteModal');
providerBatchDeleteModalInstance = new bootstrap.Modal(providerBatchDeleteModalElement);
providerBatchDeleteModalElement.addEventListener('hidden.bs.modal', function() {
if (!providerBatchActionInFlight) {
pendingProviderBatchDeleteNames = [];
syncProviderBatchDeleteModal();
}
});
const authGroupModalElement = document.getElementById('authGroupModal');
authGroupModalInstance = new bootstrap.Modal(authGroupModalElement);
authGroupModalElement.addEventListener('hidden.bs.modal', function() {
File diff suppressed because it is too large. Load diff
+34
View File
@@ -28,6 +28,7 @@ class UserController:
self._app.route('/api/users', methods=['GET'])(auth(self.get_users))
self._app.route('/api/users', methods=['POST'])(auth(self.create_user))
self._app.route('/api/users/batch', methods=['POST'])(auth(self.batch_users))
self._app.route('/api/users/<int:user_id>', methods=['GET'])(auth(self.get_user))
self._app.route('/api/users/<int:user_id>', methods=['PUT'])(auth(self.update_user))
self._app.route('/api/users/<int:user_id>', methods=['DELETE'])(auth(self.delete_user))
@@ -56,6 +57,7 @@ class UserController:
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
'keyword': keyword,
'available_models': self._user_service.get_available_models(),
})
except Exception as exc:
self._logger.error(f'Error getting users: {exc}')
@@ -114,6 +116,8 @@ class UserController:
username = data.get('username')
ip_address = data.get('ip_address')
whitelist_access_enabled = data.get('whitelist_access_enabled')
model_permissions_provided = 'model_permissions' in data
model_permissions = data.get('model_permissions')
normalized_ip = None
if ip_address is not None:
@@ -127,6 +131,8 @@ class UserController:
username,
normalized_ip,
whitelist_access_enabled,
model_permissions_provided=model_permissions_provided,
model_permissions=model_permissions,
)
if not success:
self._logger.warning(f"Update user failed: user_id={user_id}")
@@ -134,10 +140,38 @@ class UserController:
self._logger.info(f"Update user succeeded: user_id={user_id}")
return jsonify({'message': 'User updated successfully'})
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except Exception as exc:
self._logger.error(f'Error updating user: {exc}')
return jsonify({'error': str(exc)}), 500
def batch_users(self) -> Response:
"""执行用户批量操作。"""
try:
payload = request.get_json(silent=True) or {}
action = str(payload.get('action') or '').strip().lower()
if action != 'set_model_permissions':
raise ValueError(f'Unsupported user batch action: {action or "<empty>"}')
result = self._user_service.batch_update_model_permissions(
payload.get('user_ids'),
payload.get('model_permissions'),
)
self._logger.info(
'User batch action completed: action=%s count=%s',
action,
result.get('count', 0),
)
return jsonify(result)
except ValueError as exc:
message = str(exc)
status_code = 404 if 'not found' in message.lower() else 400
return jsonify({'error': message}), status_code
except Exception as exc:
self._logger.error(f'Error applying user batch action: {exc}')
return jsonify({'error': str(exc)}), 500
def delete_user(self, user_id: int) -> Response:
"""删除用户。"""
try:
+78 -6
View File
@@ -11,6 +11,8 @@ from ..utils.local_time import now_local_datetime_text
class UserRepository:
"""负责 users 表的数据访问。"""
MODEL_PERMISSIONS_ALL = "*"
def __init__(self, get_connection: ConnectionFactory):
self._get_connection = get_connection
self._ensure_table()
@@ -26,15 +28,29 @@ class UserRepository:
username TEXT NOT NULL,
ip_address TEXT NOT NULL UNIQUE,
whitelist_access_enabled INTEGER DEFAULT 1,
model_permissions TEXT NOT NULL DEFAULT '*',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
columns = {
str(row["name"]).strip()
for row in cursor.execute("PRAGMA table_info(users)").fetchall()
}
if "model_permissions" not in columns:
cursor.execute(
"ALTER TABLE users ADD COLUMN model_permissions TEXT NOT NULL DEFAULT '*'"
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_ip ON users(ip_address)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)")
def create(self, username: str, ip_address: str) -> Optional[int]:
def create(
self,
username: str,
ip_address: str,
model_permissions: str = MODEL_PERMISSIONS_ALL,
) -> Optional[int]:
"""创建用户记录。"""
now_text = now_local_datetime_text()
with self._get_connection() as conn:
@@ -42,11 +58,11 @@ class UserRepository:
cursor.execute(
"""
INSERT INTO users (
username, ip_address, whitelist_access_enabled, created_at, updated_at
username, ip_address, whitelist_access_enabled, model_permissions, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?)
""",
(username, ip_address, 1, now_text, now_text),
(username, ip_address, 1, model_permissions, now_text, now_text),
)
return cursor.lastrowid
@@ -56,7 +72,7 @@ class UserRepository:
cursor = conn.cursor()
cursor.execute(
"""
SELECT id, username, ip_address, whitelist_access_enabled, created_at, updated_at
SELECT id, username, ip_address, whitelist_access_enabled, model_permissions, created_at, updated_at
FROM users
WHERE id = ?
""",
@@ -90,6 +106,7 @@ class UserRepository:
u.username,
u.ip_address,
u.whitelist_access_enabled,
u.model_permissions,
u.created_at,
u.updated_at,
COALESCE(s.total_request_count, 0) AS total_request_count,
@@ -136,6 +153,7 @@ class UserRepository:
username: Optional[str] = None,
ip_address: Optional[str] = None,
whitelist_access_enabled: Optional[bool] = None,
model_permissions: Optional[str] = None,
) -> bool:
"""更新用户记录。"""
updates = []
@@ -150,6 +168,9 @@ class UserRepository:
if whitelist_access_enabled is not None:
updates.append("whitelist_access_enabled = ?")
params.append(1 if whitelist_access_enabled else 0)
if model_permissions is not None:
updates.append("model_permissions = ?")
params.append(model_permissions)
if not updates:
return False
@@ -176,7 +197,7 @@ class UserRepository:
cursor = conn.cursor()
cursor.execute(
"""
SELECT id, username, ip_address, whitelist_access_enabled, created_at, updated_at
SELECT id, username, ip_address, whitelist_access_enabled, model_permissions, created_at, updated_at
FROM users
WHERE ip_address = ?
""",
@@ -184,3 +205,54 @@ class UserRepository:
)
row = cursor.fetchone()
return dict(row) if row else None
def get_by_ids(self, user_ids: List[int]) -> List[Dict[str, Any]]:
"""按 ID 列表查询用户。"""
normalized_user_ids = [int(user_id) for user_id in user_ids]
if not normalized_user_ids:
return []
placeholders = ", ".join("?" for _ in normalized_user_ids)
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
f"""
SELECT id, username, ip_address, whitelist_access_enabled, model_permissions, created_at, updated_at
FROM users
WHERE id IN ({placeholders})
""",
normalized_user_ids,
)
return [dict(row) for row in cursor.fetchall()]
def list_all(self) -> List[Dict[str, Any]]:
"""查询全部用户,用于权限同步与批量设置。"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT id, username, ip_address, whitelist_access_enabled, model_permissions, created_at, updated_at
FROM users
ORDER BY id ASC
"""
)
return [dict(row) for row in cursor.fetchall()]
def batch_update_model_permissions(self, user_ids: List[int], model_permissions: str) -> int:
"""批量更新用户模型权限。"""
normalized_user_ids = [int(user_id) for user_id in user_ids]
if not normalized_user_ids:
return 0
placeholders = ", ".join("?" for _ in normalized_user_ids)
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
f"""
UPDATE users
SET model_permissions = ?, updated_at = ?
WHERE id IN ({placeholders})
""",
[model_permissions, now_local_datetime_text(), *normalized_user_ids],
)
return cursor.rowcount
+327 -30
View File
@@ -2,10 +2,12 @@
# -*- coding: utf-8 -*-
"""用户服务。"""
import json
import threading
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Iterable, List, Optional, Sequence
from ..application.app_context import AppContext
from ..config.provider_config import normalize_model_list
from ..repositories import UserRepository
from ..utils import is_valid_ip
from ..utils.local_time import normalize_local_datetime_text
@@ -14,9 +16,12 @@ from ..utils.local_time import normalize_local_datetime_text
class UserService:
"""封装用户管理业务逻辑。"""
MODEL_PERMISSIONS_ALL = UserRepository.MODEL_PERMISSIONS_ALL
def __init__(self, ctx: AppContext, repository: UserRepository):
self._ctx = ctx
self._logger = ctx.logger
self._config_manager = getattr(ctx, "config_manager", None)
self._repository = repository
self._cache_lock = threading.RLock()
self._user_by_ip_cache: Dict[str, Optional[Dict[str, Any]]] = {}
@@ -51,49 +56,193 @@ class UserService:
normalized["updated_at"] = normalize_local_datetime_text(normalized.get("updated_at"))
return normalized
@staticmethod
def _dedupe_models(model_names: Iterable[Any]) -> List[str]:
seen_models: set[str] = set()
normalized_models: List[str] = []
for item in model_names:
model_name = str(item or "").strip()
if not model_name or model_name in seen_models:
continue
seen_models.add(model_name)
normalized_models.append(model_name)
return normalized_models
def _get_available_model_names(self) -> tuple[str, ...]:
"""读取配置中声明的模型列表,包含已禁用 Provider 的模型。"""
if self._config_manager is None:
return ()
try:
config = self._config_manager.get_raw_config()
except Exception as exc:
self._logger.error("Failed to load config model catalog: %s", exc)
return ()
raw_providers = config.get("providers", [])
if raw_providers is None or not isinstance(raw_providers, list):
return ()
model_names: List[str] = []
seen_models: set[str] = set()
for raw_provider in raw_providers:
if not isinstance(raw_provider, dict):
continue
provider_name = str(raw_provider.get("name") or "").strip()
if not provider_name:
continue
try:
provider_models = normalize_model_list(raw_provider.get("model_list"))
except ValueError:
continue
for provider_model in provider_models:
model_key = f"{provider_name}/{provider_model}"
if model_key in seen_models:
continue
seen_models.add(model_key)
model_names.append(model_key)
return tuple(sorted(model_names))
@classmethod
def _deserialize_model_permissions(cls, raw_value: Any) -> Optional[tuple[str, ...]]:
"""反序列化模型权限;返回 None 表示通配全模型。"""
normalized_text = str(raw_value or "").strip()
if not normalized_text or normalized_text == cls.MODEL_PERMISSIONS_ALL:
return None
try:
payload = json.loads(normalized_text)
except (TypeError, ValueError, json.JSONDecodeError):
payload = normalized_text.replace(",", "\n").splitlines()
if isinstance(payload, str):
payload = [payload]
if not isinstance(payload, list):
return ()
normalized_models = cls._dedupe_models(payload)
if cls.MODEL_PERMISSIONS_ALL in normalized_models:
return None
return tuple(normalized_models)
def _serialize_model_permissions(self, value: Any) -> str:
"""标准化模型权限存储格式。"""
if isinstance(value, str):
normalized_text = value.strip()
if normalized_text == self.MODEL_PERMISSIONS_ALL:
return self.MODEL_PERMISSIONS_ALL
raw_items: Sequence[Any] = normalized_text.replace(",", "\n").splitlines()
elif isinstance(value, (list, tuple, set)):
raw_items = list(value)
else:
raise ValueError('model_permissions must be "*" or a list of model names')
normalized_models = self._dedupe_models(raw_items)
if self.MODEL_PERMISSIONS_ALL in normalized_models:
return self.MODEL_PERMISSIONS_ALL
available_models = set(self._get_available_model_names())
unknown_models = [model for model in normalized_models if model not in available_models]
if unknown_models:
raise ValueError(f"Unknown model permission(s): {', '.join(unknown_models)}")
return json.dumps(normalized_models, ensure_ascii=True)
def _decorate_user(
self,
user: Optional[Dict[str, Any]],
*,
available_models: Optional[Sequence[str]] = None,
) -> Optional[Dict[str, Any]]:
"""补充模型权限字段并标准化时间格式。"""
normalized = self._normalize_user_timestamps(user)
if not normalized:
return normalized
resolved_available_models = tuple(
self._get_available_model_names()
if available_models is None
else available_models
)
available_model_set = set(resolved_available_models)
parsed_permissions = self._deserialize_model_permissions(normalized.get("model_permissions"))
if parsed_permissions is None:
normalized["model_permissions"] = self.MODEL_PERMISSIONS_ALL
normalized["model_permissions_mode"] = "all"
normalized["allowed_models_count"] = len(resolved_available_models)
else:
filtered_permissions = [
model_name
for model_name in parsed_permissions
if not available_model_set or model_name in available_model_set
]
normalized["model_permissions"] = filtered_permissions
normalized["model_permissions_mode"] = "selected"
normalized["allowed_models_count"] = len(filtered_permissions)
normalized["available_models_count"] = len(resolved_available_models)
return normalized
def get_available_models(self) -> List[str]:
"""返回当前配置中可选的模型列表。"""
return list(self._get_available_model_names())
def create_user(self, username: str, ip_address: str) -> Optional[int]:
"""创建用户。"""
try:
if not is_valid_ip(ip_address):
self._logger.error(f'Invalid IP address: {ip_address}')
self._logger.error(f"Invalid IP address: {ip_address}")
return None
existing_ip = self._repository.get_by_ip(ip_address)
if existing_ip:
self._logger.error(f'IP address already in use: {ip_address}')
self._logger.error(f"IP address already in use: {ip_address}")
return None
user_id = self._repository.create(username, ip_address)
user_id = self._repository.create(
username,
ip_address,
model_permissions=self.MODEL_PERMISSIONS_ALL,
)
self._invalidate_ip_cache(ip_address)
self._logger.info(f"User created: user_id={user_id}, username={username!r}, ip={ip_address}")
return user_id
except Exception as e:
self._logger.error(f'Failed to create user: {e}')
except Exception as exc:
self._logger.error(f"Failed to create user: {exc}")
return None
def get_user_by_id(self, user_id: int) -> Optional[Dict[str, Any]]:
"""按 ID 查询用户。"""
try:
return self._normalize_user_timestamps(self._repository.get_by_id(user_id))
except Exception as e:
self._logger.error(f'Failed to get user: {e}')
return self._decorate_user(self._repository.get_by_id(user_id))
except Exception as exc:
self._logger.error(f"Failed to get user: {exc}")
return None
def get_users(self, page: int = 1, page_size: int = 50, keyword: Optional[str] = None) -> List[Dict[str, Any]]:
"""分页查询用户列表。"""
try:
available_models = self._get_available_model_names()
users = self._repository.get(page=page, page_size=page_size, keyword=keyword)
return [self._normalize_user_timestamps(user) for user in users]
except Exception as e:
self._logger.error(f'Failed to get users: {e}')
return [
self._decorate_user(user, available_models=available_models)
for user in users
]
except Exception as exc:
self._logger.error(f"Failed to get users: {exc}")
return []
def get_total_users_count(self, keyword: Optional[str] = None) -> int:
"""查询用户总数。"""
try:
return self._repository.get_count(keyword=keyword)
except Exception as e:
self._logger.error(f'Failed to get users count: {e}')
except Exception as exc:
self._logger.error(f"Failed to get users count: {exc}")
return 0
def update_user(
@@ -102,6 +251,9 @@ class UserService:
username: Optional[str] = None,
ip_address: Optional[str] = None,
whitelist_access_enabled: Optional[bool] = None,
*,
model_permissions_provided: bool = False,
model_permissions: Any = None,
) -> bool:
"""更新用户信息。"""
try:
@@ -111,35 +263,72 @@ class UserService:
if ip_address:
existing = self._repository.get_by_ip(ip_address)
if existing and existing['id'] != user_id:
self._logger.error(f'IP address already in use: {ip_address}')
if existing and existing["id"] != user_id:
self._logger.error(f"IP address already in use: {ip_address}")
return False
serialized_model_permissions: Optional[str] = None
if model_permissions_provided:
serialized_model_permissions = self._serialize_model_permissions(model_permissions)
updated = self._repository.update(
user_id,
username,
ip_address,
whitelist_access_enabled,
serialized_model_permissions,
)
if updated:
self._invalidate_ip_cache(existing_user.get('ip_address'), ip_address)
self._invalidate_ip_cache(existing_user.get("ip_address"), ip_address)
self._logger.info(f"User updated: user_id={user_id}")
return updated
except Exception as e:
self._logger.error(f'Failed to update user: {e}')
except ValueError:
raise
except Exception as exc:
self._logger.error(f"Failed to update user: {exc}")
return False
def batch_update_model_permissions(self, user_ids: Any, model_permissions: Any) -> Dict[str, Any]:
"""批量统一设置用户模型权限。"""
normalized_user_ids = self._normalize_user_ids(user_ids)
existing_users = self._repository.get_by_ids(normalized_user_ids)
existing_users_by_id = {int(user["id"]): user for user in existing_users}
missing_user_ids = [user_id for user_id in normalized_user_ids if user_id not in existing_users_by_id]
if missing_user_ids:
if len(missing_user_ids) == 1:
raise ValueError(f"User not found: {missing_user_ids[0]}")
raise ValueError(f"Users not found: {', '.join(str(user_id) for user_id in missing_user_ids)}")
serialized_model_permissions = self._serialize_model_permissions(model_permissions)
self._repository.batch_update_model_permissions(normalized_user_ids, serialized_model_permissions)
self._invalidate_ip_cache(*(user.get("ip_address") for user in existing_users))
payload: Any = self.MODEL_PERMISSIONS_ALL
if serialized_model_permissions != self.MODEL_PERMISSIONS_ALL:
payload = json.loads(serialized_model_permissions)
self._logger.info(
"User model permissions batch updated: count=%s mode=%s",
len(normalized_user_ids),
"all" if payload == self.MODEL_PERMISSIONS_ALL else "selected",
)
return {
"count": len(normalized_user_ids),
"user_ids": normalized_user_ids,
"model_permissions": payload,
}
def delete_user(self, user_id: int) -> bool:
"""删除用户。"""
try:
existing_user = self._repository.get_by_id(user_id)
deleted = self._repository.delete(user_id)
if deleted and existing_user:
self._invalidate_ip_cache(existing_user.get('ip_address'))
self._invalidate_ip_cache(existing_user.get("ip_address"))
self._logger.info(f"User deleted: user_id={user_id}")
return deleted
except Exception as e:
self._logger.error(f'Failed to delete user: {e}')
except Exception as exc:
self._logger.error(f"Failed to delete user: {exc}")
return False
def toggle_user_status(self, user_id: int) -> bool:
@@ -150,16 +339,16 @@ class UserService:
return False
updated = self._repository.update(
user_id,
whitelist_access_enabled=not bool(user['whitelist_access_enabled']),
whitelist_access_enabled=not bool(user["whitelist_access_enabled"]),
)
if updated:
self._invalidate_ip_cache(user.get('ip_address'))
self._invalidate_ip_cache(user.get("ip_address"))
self._logger.info(
f"User whitelist toggled: user_id={user_id}, enabled={not bool(user['whitelist_access_enabled'])}"
)
return updated
except Exception as e:
self._logger.error(f'Failed to toggle user status: {e}')
except Exception as exc:
self._logger.error(f"Failed to toggle user status: {exc}")
return False
def get_user_by_ip(
@@ -179,9 +368,117 @@ class UserService:
if not cached_user:
return None
if require_whitelist_access and not bool(cached_user.get('whitelist_access_enabled')):
if require_whitelist_access and not bool(cached_user.get("whitelist_access_enabled")):
return None
return self._normalize_user_timestamps(cached_user)
except Exception as e:
self._logger.error(f'Failed to get user by IP: {e}')
return self._decorate_user(cached_user)
except Exception as exc:
self._logger.error(f"Failed to get user by IP: {exc}")
return None
def get_accessible_models_for_user(
self,
user: Optional[Dict[str, Any]],
available_models: Optional[Sequence[str]] = None,
) -> List[str]:
"""返回用户在给定模型集合内可访问的模型。"""
if not user:
return []
resolved_available_models = list(
self._get_available_model_names()
if available_models is None
else available_models
)
permissions = user.get("model_permissions")
if permissions == self.MODEL_PERMISSIONS_ALL or user.get("model_permissions_mode") == "all":
return resolved_available_models
if isinstance(permissions, list):
explicit_models = self._dedupe_models(permissions)
else:
parsed_permissions = self._deserialize_model_permissions(permissions)
if parsed_permissions is None:
return resolved_available_models
explicit_models = list(parsed_permissions)
if not resolved_available_models:
return explicit_models
available_model_set = set(resolved_available_models)
return [model_name for model_name in explicit_models if model_name in available_model_set]
def can_user_access_model(
self,
user: Optional[Dict[str, Any]],
model_name: str,
available_models: Optional[Sequence[str]] = None,
) -> bool:
"""判断用户是否可访问指定模型。"""
normalized_model_name = str(model_name or "").strip()
if not normalized_model_name or not user:
return False
permissions = user.get("model_permissions")
if (
permissions == self.MODEL_PERMISSIONS_ALL
or user.get("model_permissions_mode") == "all"
) and available_models is None:
return True
return normalized_model_name in set(
self.get_accessible_models_for_user(user, available_models=available_models)
)
def sync_model_permissions(self) -> int:
"""同步并清理已删除模型对应的显式授权。"""
try:
available_models = set(self._get_available_model_names())
updated_count = 0
for user in self._repository.list_all():
current_raw = str(user.get("model_permissions") or "").strip() or self.MODEL_PERMISSIONS_ALL
parsed_permissions = self._deserialize_model_permissions(current_raw)
if parsed_permissions is None:
expected_raw = self.MODEL_PERMISSIONS_ALL
else:
filtered_permissions = [
model_name
for model_name in parsed_permissions
if model_name in available_models
]
expected_raw = json.dumps(filtered_permissions, ensure_ascii=True)
if current_raw == expected_raw:
continue
if self._repository.update(int(user["id"]), model_permissions=expected_raw):
updated_count += 1
self._invalidate_ip_cache(user.get("ip_address"))
if updated_count:
self._logger.info("User model permissions synced: updated=%s", updated_count)
return updated_count
except Exception as exc:
self._logger.error("Failed to sync user model permissions: %s", exc)
return 0
@staticmethod
def _normalize_user_ids(user_ids: Any) -> List[int]:
"""标准化批量操作中的用户 ID 列表。"""
if not isinstance(user_ids, list):
raise ValueError("User ids must be a non-empty list")
normalized_user_ids: List[int] = []
seen_user_ids: set[int] = set()
for raw_user_id in user_ids:
try:
user_id = int(raw_user_id)
except (TypeError, ValueError) as exc:
raise ValueError("User ids must be integers") from exc
if user_id <= 0 or user_id in seen_user_ids:
continue
seen_user_ids.add(user_id)
normalized_user_ids.append(user_id)
if not normalized_user_ids:
raise ValueError("User ids must be a non-empty list")
return normalized_user_ids
+115 -6
View File
@@ -42,16 +42,38 @@ class FakeLogger:
class FakeConfigManager:
@staticmethod
def is_chat_whitelist_enabled() -> bool:
return False
def __init__(self, whitelist_enabled: bool = False) -> None:
self._whitelist_enabled = whitelist_enabled
def is_chat_whitelist_enabled(self) -> bool:
return self._whitelist_enabled
class FakeUserService:
@staticmethod
def get_user_by_ip(ip_address: str, require_whitelist_access: bool = True):
_UNSET = object()
def __init__(self, *, user=_UNSET, accessible_models=None) -> None:
self._user = {"username": "tester", "model_permissions": "*"} if user is self._UNSET else user
self._accessible_models = None if accessible_models is None else list(accessible_models)
def get_user_by_ip(self, ip_address: str, require_whitelist_access: bool = True):
del ip_address, require_whitelist_access
return {"username": "tester"}
return self._user
def can_user_access_model(self, user, model_name: str, available_models=None) -> bool:
del user
if self._accessible_models is None:
return True
return model_name in set(self._accessible_models)
def get_accessible_models_for_user(self, user, available_models=None):
del user
if self._accessible_models is None:
return list(available_models or [])
if available_models is None:
return list(self._accessible_models)
available_set = set(available_models)
return [model_name for model_name in self._accessible_models if model_name in available_set]
class FakeLogService:
@@ -154,6 +176,93 @@ class ProxyControllerErrorFormatTests(unittest.TestCase):
response.get_json(),
)
def test_list_models_filters_to_models_allowed_for_whitelisted_user(self) -> None:
provider = LLMProvider(
name="demo",
api="https://example.com/v1/chat/completions",
model_list=("gpt-4.1", "gpt-4.1-mini"),
)
app = Flask(__name__)
ctx = AppContext(
logger=FakeLogger(),
config_manager=FakeConfigManager(whitelist_enabled=True),
root_path=Path(__file__).resolve().parents[1],
flask_app=app,
)
ProxyController(
ctx,
StubProxyService((None, 200, None)),
FakeUserService(accessible_models=["demo/gpt-4.1-mini"]),
FakeLogService(),
FakeProviderManager(provider),
)
response = app.test_client().get("/v1/models", environ_base={"REMOTE_ADDR": "127.0.0.1"})
self.assertEqual(200, response.status_code)
payload = response.get_json()
self.assertEqual(["demo/gpt-4.1-mini"], [item["id"] for item in payload["data"]])
def test_list_models_rejects_non_whitelisted_ip_when_whitelist_enabled(self) -> None:
provider = LLMProvider(
name="demo",
api="https://example.com/v1/chat/completions",
model_list=("gpt-4.1",),
)
app = Flask(__name__)
ctx = AppContext(
logger=FakeLogger(),
config_manager=FakeConfigManager(whitelist_enabled=True),
root_path=Path(__file__).resolve().parents[1],
flask_app=app,
)
ProxyController(
ctx,
StubProxyService((None, 200, None)),
FakeUserService(user=None),
FakeLogService(),
FakeProviderManager(provider),
)
response = app.test_client().get("/v1/models", environ_base={"REMOTE_ADDR": "127.0.0.1"})
self.assertEqual(403, response.status_code)
self.assertEqual("ip_not_whitelisted", response.get_json()["error"]["code"])
def test_chat_completions_rejects_model_not_allowed_for_whitelisted_user(self) -> None:
provider = LLMProvider(
name="demo",
api="https://example.com/v1/chat/completions",
model_list=("gpt-4.1", "gpt-4.1-mini"),
)
logger = FakeLogger()
app = Flask(__name__)
ctx = AppContext(
logger=logger,
config_manager=FakeConfigManager(whitelist_enabled=True),
root_path=Path(__file__).resolve().parents[1],
flask_app=app,
)
ProxyController(
ctx,
StubProxyService((None, 200, None)),
FakeUserService(accessible_models=["demo/gpt-4.1-mini"]),
FakeLogService(),
FakeProviderManager(provider),
)
response = app.test_client().post(
"/v1/chat/completions",
json={"model": "demo/gpt-4.1", "messages": [{"role": "user", "content": "hi"}]},
environ_base={"REMOTE_ADDR": "127.0.0.1"},
)
self.assertEqual(403, response.status_code)
self.assertEqual("model_not_allowed", response.get_json()["error"]["code"])
self.assertTrue(
any("is not allowed to access model=demo/gpt-4.1" in msg for msg in logger.messages("warning"))
)
def test_chat_completions_returns_openai_style_error_payload_for_upstream_failures(self) -> None:
provider = LLMProvider(
name="demo",
+3 -12
View File
@@ -409,28 +409,19 @@ class ProviderTemplateTransportTests(unittest.TestCase):
self.assertIn("id=\"selectedProviderCount\"", html)
self.assertIn("id=\"enableSelectedProvidersBtn\"", html)
self.assertIn("id=\"disableSelectedProvidersBtn\"", html)
self.assertIn("id=\"deleteSelectedProvidersBtn\"", html)
self.assertIn("id=\"clearSelectedProvidersBtn\"", html)
self.assertNotIn("id=\"deleteSelectedProvidersBtn\"", html)
self.assertNotIn("id=\"clearSelectedProvidersBtn\"", html)
self.assertIn("function updateProviderSelectionUi()", html)
self.assertIn("function toggleProviderSelection(name, checked)", html)
self.assertIn("function toggleAllProvidersSelection(checked)", html)
self.assertIn("function clearSelectedProviders()", html)
self.assertIn("function setProviderEnabled(name, enabled)", html)
self.assertIn("function runProviderBatchAction(action)", html)
self.assertIn("function openProviderBatchDeleteModal(names)", html)
self.assertIn("function confirmProviderBatchDelete()", html)
self.assertIn("function syncProviderBatchDeleteModal()", html)
self.assertIn("/api/providers/batch", html)
self.assertIn("/api/providers/${encodeURIComponent(normalizedName)}/${enabled ? 'enable' : 'disable'}", html)
self.assertIn('id="providerSelectAllCheckbox"', html)
self.assertIn('id="providerBatchDeleteModal"', html)
self.assertIn('id="providerBatchDeleteMessage"', html)
self.assertIn('id="confirmProviderBatchDeleteBtn"', html)
self.assertIn('class="modal-dialog modal-dialog-centered provider-batch-delete-modal-dialog"', html)
self.assertIn('data-provider-row-checkbox="${encodedProviderName}"', html)
self.assertIn("class=\"btn-action ${isEnabled ? 'btn-delete' : 'btn-edit'}\"", html)
self.assertIn("providerBatchDeleteModalInstance = new bootstrap.Modal(providerBatchDeleteModalElement);", html)
self.assertNotIn('id="providerBatchDeleteModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false"', html)
self.assertNotIn("window.confirm(`确认批量删除已选中的 ${selectedNames.length} 个 Provider?`)", html)
self.assertNotIn("setupCustomSelect('providerFormat');", html)
self.assertNotIn("setupCustomSelect('providerStreamFormat');", html)
@@ -511,7 +502,7 @@ class ProviderTemplateTransportTests(unittest.TestCase):
self.assertIn(".providers-page .provider-help-popover {", css)
self.assertIn(".providers-page .provider-batch-summary {", css)
self.assertIn(".providers-page .provider-batch-delete-modal-dialog {", css)
self.assertNotIn(".providers-page .provider-batch-delete-modal-dialog {", css)
self.assertIn(".providers-page .provider-table-checkbox {", css)
self.assertIn(".providers-page .field-label-with-help {", css)
self.assertIn(".providers-page .entry-import-editor {", css)
+117
View File
@@ -0,0 +1,117 @@
import sys
import tempfile
import unittest
from copy import deepcopy
from pathlib import Path
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.repositories import UserRepository
from src.services import UserService
from src.utils.database import create_connection_factory
class DummyLogger:
def info(self, *args, **kwargs):
return None
def error(self, *args, **kwargs):
return None
def warning(self, *args, **kwargs):
return None
def debug(self, *args, **kwargs):
return None
class FakeConfigManager:
def __init__(self, payload):
self.payload = payload
def get_raw_config(self):
return deepcopy(self.payload)
class UserModelPermissionTests(unittest.TestCase):
def setUp(self) -> None:
self._tempdir = tempfile.TemporaryDirectory()
self._db_path = Path(self._tempdir.name) / "requests.db"
self._get_connection = create_connection_factory(self._db_path)
self._config = {
"providers": [
{
"name": "demo",
"api": "https://example.com/v1/chat/completions",
"api_key": "sk-demo",
"model_list": ["gpt-4.1", "gpt-4.1-mini"],
}
]
}
self._ctx = SimpleNamespace(
logger=DummyLogger(),
config_manager=FakeConfigManager(self._config),
)
self._repository = UserRepository(self._get_connection)
self._service = UserService(self._ctx, self._repository)
def tearDown(self) -> None:
self._tempdir.cleanup()
def test_user_repository_defaults_model_permissions_to_wildcard(self) -> None:
user_id = self._repository.create("alice", "127.0.0.1")
user = self._repository.get_by_id(user_id)
self.assertEqual("*", user["model_permissions"])
def test_update_user_model_permissions_supports_explicit_models(self) -> None:
user_id = self._repository.create("alice", "127.0.0.1")
updated = self._service.update_user(
user_id,
model_permissions_provided=True,
model_permissions=["demo/gpt-4.1-mini"],
)
user = self._service.get_user_by_id(user_id)
self.assertTrue(updated)
self.assertEqual(["demo/gpt-4.1-mini"], user["model_permissions"])
self.assertFalse(self._service.can_user_access_model(user, "demo/gpt-4.1"))
self.assertTrue(self._service.can_user_access_model(user, "demo/gpt-4.1-mini"))
def test_sync_model_permissions_prunes_deleted_models_from_explicit_users(self) -> None:
user_id = self._repository.create("alice", "127.0.0.1")
self._service.update_user(
user_id,
model_permissions_provided=True,
model_permissions=["demo/gpt-4.1", "demo/gpt-4.1-mini"],
)
self._config["providers"][0]["model_list"] = ["gpt-4.1"]
updated_count = self._service.sync_model_permissions()
user = self._service.get_user_by_id(user_id)
self.assertEqual(1, updated_count)
self.assertEqual(["demo/gpt-4.1"], user["model_permissions"])
def test_batch_update_model_permissions_updates_multiple_users(self) -> None:
alice_id = self._repository.create("alice", "127.0.0.1")
bob_id = self._repository.create("bob", "127.0.0.2")
result = self._service.batch_update_model_permissions(
[alice_id, bob_id],
["demo/gpt-4.1-mini"],
)
alice = self._service.get_user_by_id(alice_id)
bob = self._service.get_user_by_id(bob_id)
self.assertEqual(2, result["count"])
self.assertEqual(["demo/gpt-4.1-mini"], result["model_permissions"])
self.assertEqual(["demo/gpt-4.1-mini"], alice["model_permissions"])
self.assertEqual(["demo/gpt-4.1-mini"], bob["model_permissions"])
if __name__ == "__main__":
unittest.main()