fix(rbac): protect built-in administrator from losing access

This commit is contained in:
Ed1s0nZ committed 2026-10-19 16:44:14 +08:00
1 parent e6121e83eb
commit be2c8c04e5
12 files changed
+319 -3

No files matched your search

+2
View File
@@ -4793,6 +4793,8 @@
"deleteRoleConfirm": "Delete this platform role?",
"scopeHint": "Resource scope and operation permissions jointly determine effective access.",
"builtinAccount": "Built-in account",
"protectBuiltinAdminRole": "The built-in administrator must retain the admin role",
"protectBuiltinAdminEnabled": "The built-in administrator cannot be disabled",
"statusEnabled": "Enabled",
"statusDisabled": "Disabled",
"noRoleAssigned": "No role assigned",
+2
View File
@@ -4806,6 +4806,8 @@
"deleteRoleConfirm": "Удалить эту роль платформы?",
"scopeHint": "Область ресурсов и права на операции вместе определяют фактический доступ.",
"builtinAccount": "Встроенная учётная запись",
"protectBuiltinAdminRole": "Встроенный администратор должен сохранять роль admin",
"protectBuiltinAdminEnabled": "Встроенного администратора нельзя отключить",
"statusEnabled": "Включена",
"statusDisabled": "Отключена",
"noRoleAssigned": "Роль не назначена",
+2
View File
@@ -4781,6 +4781,8 @@
"deleteRoleConfirm": "确认删除该平台角色?",
"scopeHint": "资源范围与操作权限共同决定最终访问能力。",
"builtinAccount": "内置账号",
"protectBuiltinAdminRole": "内置管理员必须保留管理员角色",
"protectBuiltinAdminEnabled": "内置管理员不能停用",
"statusEnabled": "启用",
"statusDisabled": "停用",
"noRoleAssigned": "未分配角色",
+84
View File
@@ -0,0 +1,84 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function setup() {
const elements = new Map([
'rbac-user-id', 'rbac-username', 'rbac-display-name', 'rbac-password',
'rbac-user-enabled', 'rbac-user-roles-create-section', 'rbac-user-roles-edit-section',
'rbac-user-roles-readonly', 'rbac-user-role-checkboxes', 'rbac-roles-list',
].map(id => [id, { value: '', checked: false, disabled: false, dataset: {}, innerHTML: '' }]));
const context = vm.createContext({
window: {}, console,
document: { getElementById: id => elements.get(id) || null, querySelector: () => null, addEventListener() {} },
openAppModal() {},
escapeHtml: text => String(text),
});
vm.runInContext(fs.readFileSync(path.join(__dirname, 'rbac.js'), 'utf8') + '\nglobalThis.state = rbacState;', context);
context.state.users = [
{ id: 'admin', username: 'admin', enabled: true, is_builtin: true, roles: ['admin'] },
{ id: 'member', username: 'member', enabled: true, roles: ['admin', 'operator'] },
];
context.state.roles = [
{ id: 'admin', name: 'Administrator', scope: 'all', is_system: true },
{ id: 'operator', name: 'Operator', scope: 'assigned', is_system: true },
];
return { context, elements };
}
function adminCheckbox(html) {
return html.match(/<input[^>]+onchange="toggleSelectedUserRole\('admin'[^>]+>/)[0];
}
test('built-in enabled control is locked, then resets for member editing and creation', () => {
const { context, elements } = setup();
const input = elements.get('rbac-user-enabled');
context.openRbacUserModal('admin');
assert.equal(input.checked, true);
assert.equal(input.disabled, true);
assert.equal(input.dataset.stateDisabled, 'true');
assert.ok(input.title);
context.openRbacUserModal('member');
assert.equal(input.disabled, false);
assert.equal(input.dataset.stateDisabled, 'false');
assert.equal(input.title, '');
context.openRbacUserModal('admin');
context.openRbacUserModal();
assert.equal(input.disabled, false);
assert.equal(input.checked, true);
assert.equal(elements.get('rbac-user-id').value, '');
});
test('built-in admin role cannot be unchecked; another admin account can be demoted', () => {
const { context, elements } = setup();
context.state.selectedUserId = 'admin';
context.renderRbacRoles();
let checkbox = adminCheckbox(elements.get('rbac-roles-list').innerHTML);
assert.match(checkbox, /\bchecked\b/);
assert.match(checkbox, /\bdisabled\b/);
context.toggleSelectedUserRole('admin', false);
assert.equal(context.state.pendingUserRoles.has('admin'), true);
context.state.selectedUserId = 'member';
context.renderRbacRoles();
checkbox = adminCheckbox(elements.get('rbac-roles-list').innerHTML);
assert.doesNotMatch(checkbox, /\bdisabled\b/);
context.toggleSelectedUserRole('admin', false);
assert.equal(context.state.pendingUserRoles.has('admin'), false);
assert.equal(context.state.pendingUserRoles.has('operator'), true);
});
test('protection still permits explicitly restoring a historically damaged built-in account', () => {
const { context, elements } = setup();
const admin = context.state.users[0];
admin.enabled = false;
admin.roles = [];
context.openRbacUserModal('admin');
assert.equal(elements.get('rbac-user-enabled').disabled, false);
context.state.selectedUserId = 'admin';
context.renderRbacRoles();
assert.doesNotMatch(adminCheckbox(elements.get('rbac-roles-list').innerHTML), /\bdisabled\b/);
context.toggleSelectedUserRole('admin', true);
assert.match(adminCheckbox(elements.get('rbac-roles-list').innerHTML), /\bdisabled\b/);
});
+9 -3
View File
@@ -714,6 +714,7 @@ function renderRbacRoles() {
const userRoles = user ? rbacState.pendingUserRoles : new Set();
box.innerHTML = rbacState.roles.map(role => {
const checked = userRoles.has(role.id);
const protectedRole = rbacUserIsBuiltin(user) && role.id === 'admin' && checked;
const permissionCount = (role.permissions || []).length;
const assignedUsers = rbacState.users.filter(u => rbacUserRoles(u).includes(role.id)).length;
const scope = rbacScopeInfo(role.scope);
@@ -721,7 +722,7 @@ function renderRbacRoles() {
<article class="rbac-role-card${checked ? ' is-selected' : ''}">
<div class="rbac-role-card-head">
<label class="checkbox-label">
<input type="checkbox" class="modern-checkbox" ${checked ? 'checked' : ''} ${!user ? 'disabled' : ''} onchange="toggleSelectedUserRole('${rbacEscape(role.id)}', this.checked)">
<input type="checkbox" class="modern-checkbox" ${checked ? 'checked' : ''} ${!user || protectedRole ? 'disabled' : ''} title="${protectedRole ? rbacEscape(rbacT('rbac.protectBuiltinAdminRole', '内置管理员必须保留管理员角色')) : ''}" onchange="toggleSelectedUserRole('${rbacEscape(role.id)}', this.checked)">
<span class="checkbox-custom"></span>
<span class="checkbox-text"><strong>${rbacEscape(rbacRoleName(role))}</strong></span>
</label>
@@ -751,7 +752,8 @@ function ensurePendingUserRoles(user) {
}
function toggleSelectedUserRole(roleId, enabled) {
if (!selectedRbacUser()) return;
const user = selectedRbacUser();
if (!user || (rbacUserIsBuiltin(user) && roleId === 'admin' && !enabled)) return;
if (enabled) rbacState.pendingUserRoles.add(roleId);
else rbacState.pendingUserRoles.delete(roleId);
renderRbacRoles();
@@ -1232,7 +1234,11 @@ function openRbacUserModal(userId = '') {
document.getElementById('rbac-username').disabled = !!user;
document.getElementById('rbac-display-name').value = user ? (user.displayName || user.display_name || '') : '';
document.getElementById('rbac-password').value = '';
document.getElementById('rbac-user-enabled').checked = user ? !!user.enabled : true;
const enabledInput = document.getElementById('rbac-user-enabled');
enabledInput.checked = user ? !!user.enabled : true;
enabledInput.disabled = rbacUserIsBuiltin(user) && enabledInput.checked;
enabledInput.dataset.stateDisabled = String(enabledInput.disabled);
enabledInput.title = enabledInput.disabled ? rbacT('rbac.protectBuiltinAdminEnabled', '内置管理员不能停用') : '';
const createRoles = document.getElementById('rbac-user-roles-create-section');
const editRoles = document.getElementById('rbac-user-roles-edit-section');
if (createRoles) createRoles.hidden = !!user;