import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { login, call, root, work, report } from './role-permissions-live.mjs'; // Run only after migration, service restart and the separate prepare fixture have completed. // Credentials remain inside the existing login helper; no response token, password or user payload is reported. const checks = []; const sessions = []; const withoutFixtures = process.argv.includes('--without-fixtures'); const outcome = { startedAt: new Date().toISOString(), mode: 'LIVE_API', checks, limitations: [] }; let activeCheck = 'initialization'; const rows = value => value?.records ?? value ?? []; const sameId = (left, right) => String(left) === String(right); const ids = values => values.map(String).sort(); function check(name, condition, evidence = {}) { activeCheck = name; checks.push({ name, result: condition ? 'PASS' : 'FAIL', ...evidence }); assert.ok(condition, name); } async function read(endpoint, token) { activeCheck = `读取 ${endpoint.split('?')[0]}`; const result = await call(endpoint, token); assert.equal(result.status, 200, activeCheck); return result.data; } async function failedLogin(username, requestId) { const response = await fetch('http://127.0.0.1:6180/api/auth/v1/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Request-Id': requestId }, body: JSON.stringify({ username, password: `Bad!${crypto.randomBytes(7).toString('hex')}`, roleCode: 'admin', rememberMe: false }), signal: AbortSignal.timeout(30000), }); const value = await response.json(); return { status: response.status, requestId: value.requestId || response.headers.get('X-Request-Id') }; } function databaseSnapshot(requestIds = []) { const source = String.raw` import importlib.util,json,sys from pathlib import Path p=Path(sys.argv[1])/'ute2e'/'tools'/'review-db.py' spec=importlib.util.spec_from_file_location('review_db_private',p) module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) connection=module.connect() try: connection.execute('BEGIN READ ONLY') result={} with connection.cursor() as cursor: cursor.execute("SELECT COUNT(*) AS total, COUNT(*) FILTER(WHERE result_code=1) AS success, COUNT(*) FILTER(WHERE result_code=0) AS failed FROM ut_sys_audit_log WHERE is_delete=0 AND visibility_scope='BUSINESS'") result['businessAudit']=cursor.fetchone() cursor.execute("SELECT COUNT(*) AS total, COUNT(*) FILTER(WHERE result_code=1) AS success, COUNT(*) FILTER(WHERE result_code=0) AS failed FROM ut_sys_audit_log WHERE is_delete=0 AND visibility_scope='INTERNAL'") result['internalAudit']=cursor.fetchone() cursor.execute("SELECT COUNT(*) AS total, COUNT(*) FILTER(WHERE status=1) AS enabled FROM ut_sys_user WHERE is_delete=0 AND is_protected=0") result['ordinaryUsers']=cursor.fetchone() cursor.execute("SELECT COUNT(*) AS total FROM ut_sys_role WHERE is_delete=0 AND role_code<>'root'") result['ordinaryRoles']=cursor.fetchone() cursor.execute("SELECT id, request_id, visibility_scope, result_code, actor_user_id, target_type, target_id FROM ut_sys_audit_log WHERE request_id=ANY(%s) ORDER BY id",(json.loads(sys.argv[2]),)) result['events']=cursor.fetchall() connection.rollback() print(json.dumps(result,default=str)) finally: connection.close() `; const result = spawnSync('python', ['-c', source, root, JSON.stringify(requestIds)], { encoding: 'utf8', windowsHide: true }); // Neither stderr nor the private source configuration is copied into the report. assert.equal(result.status, 0, '只读数据库证据查询成功'); return JSON.parse(result.stdout.trim()); } function exportedContains(data, needle) { const source = String.raw` import base64,io,json,sys,zipfile payload=json.load(sys.stdin) with zipfile.ZipFile(io.BytesIO(base64.b64decode(payload['contentBase64']))) as archive: contents=b'\n'.join(archive.read(name) for name in archive.namelist() if name.startswith('xl/') and name.endswith('.xml')) print(json.dumps({'contains':payload['needle'].encode('utf-8') in contents})) `; const result = spawnSync('python', ['-c', source], { input: JSON.stringify({ contentBase64: data.contentBase64, needle }), encoding: 'utf8', windowsHide: true, }); assert.equal(result.status, 0, '导出文件内容校验成功'); return JSON.parse(result.stdout).contains; } function roleCommand(role, overrides = {}) { return { code: role.code, name: role.name, shortName: role.shortName, description: role.description, dataScope: role.dataScope, enabled: role.enabled, sortOrder: role.sortOrder, departmentScopeIds: role.departmentScopeIds, version: role.dataVersion, ...overrides }; } function userCommand(user, overrides = {}) { return { username: user.username, displayName: user.displayName, departmentId: user.departmentId, roleIds: user.roleIds, defaultRoleId: user.defaultRoleId, enabled: user.enabled, remark: user.remark, version: user.dataVersion, ...overrides }; } async function main() { const fixture = withoutFixtures ? {} : JSON.parse(fs.readFileSync(path.join(work, 'fixtures.json'), 'utf8')); if (!withoutFixtures) assert.ok(fixture.user?.id && fixture.adminRoleId, '隔离测试账号已由 prepare 创建'); else outcome.limitations.push('首阶段不依赖隔离账号,内部角色赋值拒绝及测试账号版本保持由完整运行补测。'); const admin = await login('admin'); sessions.push(admin.accessToken); const maintenance = await login('root'); sessions.push(maintenance.accessToken); const token = admin.accessToken, rootToken = maintenance.accessToken; const me = await read('auth/v1/auth/me', token), rootMe = await read('auth/v1/auth/me', rootToken); const rootUserId = rootMe.id, internalRole = rootMe.roles.find(role => role.code === 'root'); check('维护账号使用已分配的内部角色,普通管理员不带维护标记', !!internalRole && rootMe.maintenanceAccount === true && me.maintenanceAccount === false); for (const [actor, actorToken] of [['admin', token], ['root', rootToken]]) { const all = rows(await read('auth/v1/roles/all', actorToken)); const page = rows(await read('auth/v1/roles?size=200', actorToken)); check(`${actor} 的普通角色目录和分页都隐藏内部角色`, [...all, ...page].every(role => role.code !== 'root')); for (const suffix of ['', '/users', '/permissions']) { const result = await call(`auth/v1/roles/${internalRole.id}${suffix}`, actorToken); check(`${actor} 不能按已知内部角色 ID 读取${suffix || '/详情'}`, result.status === 404, { status: result.status }); } } const ordinaryRoles = rows(await read('auth/v1/roles/all', token)); for (const role of ordinaryRoles) { const linked = rows(await read(`auth/v1/roles/${role.id}/users`, token)); check(`角色 ${role.code} 的关联用户排除维护账号`, linked.every(user => !sameId(user.id, rootUserId))); } const lookup = rows(await read(`auth/v1/directory/teaching-members?keyword=${encodeURIComponent(rootMe.username)}&limit=100`, token)); check('教学人员搜索不暴露维护账号', lookup.every(user => !sameId(user.id, rootUserId))); const resolved = await call('auth/v1/directory/teaching-members/resolve', token, { ids: [rootUserId] }); check('教学人员按 ID 解析拒绝维护账号', resolved.status === 400, { status: resolved.status }); const candidates = rows(await read(`auth/v1/auth/login-identities?roleCode=teacher&departmentId=${rootMe.departmentId}`)); check('公开登录候选列表不暴露维护账号', candidates.every(user => !sameId(user.id, rootUserId))); if (fixture.user?.id) { const originalUser = await read(`auth/v1/users/${fixture.user.id}`, token); for (const [actor, actorToken] of [['admin', token], ['root', rootToken]]) { const attempt = await call(`auth/v1/users/${fixture.user.id}`, actorToken, userCommand(originalUser, { roleIds: [...originalUser.roleIds, internalRole.id] }), 'PUT'); if (attempt.status === 200) { // The only mutable target is the isolated fixture. Restore before reporting an unexpected authorization failure. const current = await read(`auth/v1/users/${fixture.user.id}`, rootToken); await call(`auth/v1/users/${fixture.user.id}`, rootToken, userCommand(originalUser, { version: current.dataVersion }), 'PUT'); } check(`${actor} 无法通过普通用户接口给测试账号授予内部角色`, [400, 403].includes(attempt.status), { status: attempt.status }); } const afterUser = await read(`auth/v1/users/${fixture.user.id}`, token); check('拒绝内部角色分配后测试账号原角色和版本保持不变', JSON.stringify(ids(afterUser.roleIds)) === JSON.stringify(ids(originalUser.roleIds)) && afterUser.dataVersion === originalUser.dataVersion); } const adminRoleId = fixture.adminRoleId ?? ordinaryRoles.find(role => role.code === 'admin')?.id; const fixed = await read(`auth/v1/roles/${adminRoleId}`, token); for (const [actor, actorToken] of [['admin', token], ['root', rootToken]]) { const scope = await call(`auth/v1/roles/${fixed.id}`, actorToken, roleCommand(fixed, { dataScope: 'SELF' }), 'PUT'); if (scope.status === 200) { const current = await read(`auth/v1/roles/${fixed.id}`, rootToken); await call(`auth/v1/roles/${fixed.id}`, rootToken, roleCommand(fixed, { version: current.dataVersion }), 'PUT'); } check(`${actor} 无法通过普通编辑更改固定管理员数据范围`, scope.status === 403, { status: scope.status }); const status = await call(`auth/v1/roles/${fixed.id}/status`, actorToken, { enabled: false, version: fixed.dataVersion }, 'PUT'); if (status.status === 200) { const current = await read(`auth/v1/roles/${fixed.id}`, rootToken); await call(`auth/v1/roles/${fixed.id}/status`, rootToken, { enabled: true, version: current.dataVersion }, 'PUT'); } check(`${actor} 无法停用固定管理员角色`, status.status === 403, { status: status.status }); } const afterRole = await read(`auth/v1/roles/${fixed.id}`, token); check('固定管理员角色状态、范围、版本未被边界请求改变', afterRole.enabled === fixed.enabled && afterRole.dataScope === fixed.dataScope && afterRole.dataVersion === fixed.dataVersion); for (const suffix of ['', '/stats', '/export']) { const denied = await call(`auth/v1/maintenance/audit-logs${suffix}`, token); check(`普通管理员不能读取维护审计${suffix || '/列表'}`, denied.status === 403, { status: denied.status }); } const sequence = crypto.randomBytes(6).toString('hex'); const rootAttempt = await failedLogin(rootMe.username, `maint-root-failure-${sequence}`); const unknownAttempt = await failedLogin(`missing_${sequence}`, `maint-unknown-failure-${sequence}`); check('已知维护账号错误密码和未知账号均返回统一认证失败', rootAttempt.status === 401 && unknownAttempt.status === 401); const rootFilter = encodeURIComponent(rootAttempt.requestId), unknownFilter = encodeURIComponent(unknownAttempt.requestId); const ordinaryKnown = rows(await read(`auth/v1/audit-logs?keyword=${rootFilter}`, token)); const internalKnown = rows(await read(`auth/v1/maintenance/audit-logs?keyword=${rootFilter}`, rootToken)); const ordinaryUnknown = rows(await read(`auth/v1/audit-logs?keyword=${unknownFilter}`, token)); check('维护登录失败在普通列表不可见且内部审计保留真实 USER 目标', ordinaryKnown.length === 0 && internalKnown.some(event => event.targetType === 'USER' && sameId(event.targetId, rootUserId) && event.success === false)); check('未知身份失败仍可在普通安全审计中查到', ordinaryUnknown.some(event => event.requestId === unknownAttempt.requestId && event.success === false)); const ordinaryExport = await read(`auth/v1/audit-logs/export?keyword=${rootFilter}`, token); const internalExport = await read(`auth/v1/maintenance/audit-logs/export?keyword=${rootFilter}`, rootToken); check('普通审计导出不包含维护记录,维护导出保留该记录', ordinaryExport.recordCount === 0 && !exportedContains(ordinaryExport, rootAttempt.requestId) && internalExport.recordCount >= 1 && exportedContains(internalExport, rootAttempt.requestId)); const before = databaseSnapshot([rootAttempt.requestId, unknownAttempt.requestId]); const auditStats = await read('auth/v1/audit-logs/stats', token); const internalStats = await read('auth/v1/maintenance/audit-logs/stats', rootToken); const userStats = await read('auth/v1/users/stats', token); const roleStats = await read('auth/v1/roles/stats', token); const dashboard = await read('auth/v1/dashboard/platform', token); const after = databaseSnapshot([rootAttempt.requestId, unknownAttempt.requestId]); const between = (value, first, last) => value >= first && value <= last; check('普通审计统计只计算 BUSINESS,维护统计只计算 INTERNAL', ['total', 'success', 'failed'].every(key => between(auditStats[key], before.businessAudit[key], after.businessAudit[key]) && between(internalStats[key], before.internalAudit[key], after.internalAudit[key]))); check('普通用户和角色统计排除内部账号及内部角色', between(userStats.total, before.ordinaryUsers.total, after.ordinaryUsers.total) && between(roleStats.total, before.ordinaryRoles.total, after.ordinaryRoles.total)); check('首页人数不计维护账号,最近动态不显示维护失败日志', between(dashboard.metrics.userTotal, before.ordinaryUsers.total, after.ordinaryUsers.total) && !dashboard.activities.some(activity => internalKnown.some(event => sameId(activity.id, event.id)))); const knownDb = after.events.find(event => event.request_id === rootAttempt.requestId); const unknownDb = after.events.find(event => event.request_id === unknownAttempt.requestId); check('数据库只读核验:已知维护失败 INTERNAL,未知失败 BUSINESS', knownDb?.visibility_scope === 'INTERNAL' && sameId(knownDb.target_id, rootUserId) && unknownDb?.visibility_scope === 'BUSINESS' && !unknownDb.target_id && !unknownDb.actor_user_id); outcome.databaseEvidence = { ordinaryUserCount: after.ordinaryUsers.total, ordinaryRoleCount: after.ordinaryRoles.total, businessAuditCount: after.businessAudit.total, internalAuditCount: after.internalAudit.total, knownFailureScope: knownDb?.visibility_scope, unknownFailureScope: unknownDb?.visibility_scope, knownFailurePreservesTarget: sameId(knownDb?.target_id, rootUserId), }; // Restore the failed-login counter by a normal successful authentication; no direct account or credential writes. const verified = await login('root'); sessions.push(verified.accessToken); const normalAdmin = await read(`auth/v1/users/${me.id}`, token); check('维护再次登录后普通 admin 仍未被重新保护', normalAdmin.isProtected === false); outcome.bootstrapReview = { result: 'PASS', note: '初始化无密码配置时立即返回;已有凭据不覆盖;成功时只将旧普通管理员保护置0,没有将其恢复为1的路径。主代理已移除一次性环境变量并重启Auth,本脚本在该服务上核验admin保护仍为false。' }; outcome.status = withoutFixtures ? 'PASS_WITH_LIMITATIONS' : 'PASS'; } if (process.argv[1] === fileURLToPath(import.meta.url)) { try { await main(); } catch (error) { outcome.status = 'FAIL'; outcome.failedStep = activeCheck; outcome.error = error?.name === 'AssertionError' ? '验证断言失败,参见最后一项或 failedStep' : '请求或验证未完成,未输出原始异常以避免泄漏认证信息'; process.exitCode = 1; } finally { for (const token of sessions) await call('auth/v1/auth/logout', token, {}).catch(() => {}); outcome.finishedAt = new Date().toISOString(); fs.writeFileSync(path.join(report, withoutFixtures ? 'maintenance-boundaries-initial.json' : 'maintenance-boundaries.json'), JSON.stringify(outcome, null, 2)); console.log(JSON.stringify({ status: outcome.status, passed: checks.filter(item => item.result === 'PASS').length, failed: checks.filter(item => item.result === 'FAIL').length, failedStep: outcome.failedStep ?? null })); } }