fix(auth): add node-admin token scope for panel updates (#6666)

* feat(auth): add node-admin API token scope

Keep ordinary node-sync credentials unable to self-update a remote panel while allowing explicitly elevated node administrators to inherit synchronization access and invoke updatePanel.

* style(docs): format API scope table

* fix(auth): preserve mTLS node-sync semantics

Allow only an explicit node-admin bearer to elevate a verified mTLS request. Existing admin, monitor, and node-sync bearer values continue to use the historical node-sync identity on mTLS connections.

* chore(ci): rerun flaky checks

---------

Co-authored-by: libmur-dev <333915961+libmur-dev@users.noreply.github.com>
This commit is contained in:
libmur-dev
2026-10-18 00:45:26 +03:00
committed by GitHub
parent a1f2589eb5
commit a8d0700bf2
42 changed files with 506 additions and 123 deletions
@@ -49,6 +49,17 @@
gap: 2px;
}
.api-token-name-line {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.api-token-name-line .ant-tag {
margin-inline-end: 0;
}
.api-token-name {
font-weight: 600;
font-size: 13.5px;
+67 -6
View File
@@ -1,6 +1,19 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Empty, Form, Input, Modal, Space, Spin, Switch, Tabs, message } from 'antd';
import {
Button,
Empty,
Form,
Input,
Modal,
Select,
Space,
Spin,
Switch,
Tabs,
Tag,
message,
} from 'antd';
import { ApiOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
import { ClipboardManager, HttpUtil, IntlUtil, RandomUtil } from '@/utils';
import type { AllSetting } from '@/models/setting';
@@ -16,12 +29,14 @@ interface ApiMsg<T = unknown> {
obj?: T;
}
type ApiTokenScope = 'admin' | 'monitor' | 'node-sync' | 'node-admin';
interface ApiTokenRow {
id: number;
name: string;
enabled: boolean;
createdAt: number;
scope: 'admin' | 'monitor' | 'node-sync';
scope: ApiTokenScope;
expiresAt: number;
}
@@ -76,6 +91,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
const [apiTokensLoading, setApiTokensLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState('');
const [createScope, setCreateScope] = useState<ApiTokenScope>('admin');
const [creating, setCreating] = useState(false);
const [createdToken, setCreatedToken] = useState<{ name: string; token: string } | null>(null);
@@ -157,6 +173,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
function openCreateModal() {
setCreateName('');
setCreateScope('admin');
setCreateOpen(true);
}
@@ -168,9 +185,10 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
}
setCreating(true);
try {
const msg = (await HttpUtil.post('/panel/api/setting/apiTokens/create', { name })) as ApiMsg<{
token?: string;
}>;
const msg = (await HttpUtil.post('/panel/api/setting/apiTokens/create', {
name,
scope: createScope,
})) as ApiMsg<{ token?: string }>;
if (msg?.success) {
setCreateOpen(false);
await loadApiTokens();
@@ -217,6 +235,29 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
return IntlUtil.formatDate(apiTokenCreatedAtMilliseconds(ts));
}
const scopeMeta: Record<ApiTokenScope, { label: string; description: string; color: string }> = {
admin: {
label: t('pages.settings.security.apiTokenScopeAdmin'),
description: t('pages.settings.security.apiTokenScopeAdminDesc'),
color: 'red',
},
monitor: {
label: t('pages.settings.security.apiTokenScopeMonitor'),
description: t('pages.settings.security.apiTokenScopeMonitorDesc'),
color: 'blue',
},
'node-sync': {
label: t('pages.settings.security.apiTokenScopeNodeSync'),
description: t('pages.settings.security.apiTokenScopeNodeSyncDesc'),
color: 'gold',
},
'node-admin': {
label: t('pages.settings.security.apiTokenScopeNodeAdmin'),
description: t('pages.settings.security.apiTokenScopeNodeAdminDesc'),
color: 'purple',
},
};
function toggleTwoFactor() {
if (!allSetting.twoFactorEnable) {
const newToken = RandomUtil.randomBase32String();
@@ -345,7 +386,12 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
<div key={row.id} className={`api-token-row${row.enabled ? '' : ' disabled'}`}>
<div className="api-token-row-head">
<div className="api-token-name-wrap">
<span className="api-token-name">{row.name}</span>
<div className="api-token-name-line">
<span className="api-token-name">{row.name}</span>
<Tag color={scopeMeta[row.scope].color}>
{scopeMeta[row.scope].label}
</Tag>
</div>
<span className="api-token-created">
{formatTokenDate(row.createdAt)}
</span>
@@ -396,6 +442,21 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
onPressEnter={confirmCreateToken}
/>
</Form.Item>
<Form.Item
label={t('pages.settings.security.apiTokenScope')}
extra={scopeMeta[createScope].description}
required
>
<Select
aria-label={t('pages.settings.security.apiTokenScope')}
value={createScope}
options={(Object.keys(scopeMeta) as ApiTokenScope[]).map((scope) => ({
value: scope,
label: scopeMeta[scope].label,
}))}
onChange={setCreateScope}
/>
</Form.Item>
</Form>
</Modal>