Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 
 
 

238 Zeilen
16 KiB

  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import assert from 'node:assert/strict';
  5. import { spawnSync } from 'node:child_process';
  6. import { fileURLToPath } from 'node:url';
  7. import { login, call, root, work, report } from './role-permissions-live.mjs';
  8. // Run only after migration, service restart and the separate prepare fixture have completed.
  9. // Credentials remain inside the existing login helper; no response token, password or user payload is reported.
  10. const checks = [];
  11. const sessions = [];
  12. const withoutFixtures = process.argv.includes('--without-fixtures');
  13. const outcome = { startedAt: new Date().toISOString(), mode: 'LIVE_API', checks, limitations: [] };
  14. let activeCheck = 'initialization';
  15. const rows = value => value?.records ?? value ?? [];
  16. const sameId = (left, right) => String(left) === String(right);
  17. const ids = values => values.map(String).sort();
  18. function check(name, condition, evidence = {}) {
  19. activeCheck = name;
  20. checks.push({ name, result: condition ? 'PASS' : 'FAIL', ...evidence });
  21. assert.ok(condition, name);
  22. }
  23. async function read(endpoint, token) {
  24. activeCheck = `读取 ${endpoint.split('?')[0]}`;
  25. const result = await call(endpoint, token);
  26. assert.equal(result.status, 200, activeCheck);
  27. return result.data;
  28. }
  29. async function failedLogin(username, requestId) {
  30. const response = await fetch('http://127.0.0.1:6180/api/auth/v1/auth/login', {
  31. method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Request-Id': requestId },
  32. body: JSON.stringify({ username, password: `Bad!${crypto.randomBytes(7).toString('hex')}`, roleCode: 'admin', rememberMe: false }),
  33. signal: AbortSignal.timeout(30000),
  34. });
  35. const value = await response.json();
  36. return { status: response.status, requestId: value.requestId || response.headers.get('X-Request-Id') };
  37. }
  38. function databaseSnapshot(requestIds = []) {
  39. const source = String.raw`
  40. import importlib.util,json,sys
  41. from pathlib import Path
  42. p=Path(sys.argv[1])/'ute2e'/'tools'/'review-db.py'
  43. spec=importlib.util.spec_from_file_location('review_db_private',p)
  44. module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
  45. connection=module.connect()
  46. try:
  47. connection.execute('BEGIN READ ONLY')
  48. result={}
  49. with connection.cursor() as cursor:
  50. 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'")
  51. result['businessAudit']=cursor.fetchone()
  52. 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'")
  53. result['internalAudit']=cursor.fetchone()
  54. 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")
  55. result['ordinaryUsers']=cursor.fetchone()
  56. cursor.execute("SELECT COUNT(*) AS total FROM ut_sys_role WHERE is_delete=0 AND role_code<>'root'")
  57. result['ordinaryRoles']=cursor.fetchone()
  58. 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]),))
  59. result['events']=cursor.fetchall()
  60. connection.rollback()
  61. print(json.dumps(result,default=str))
  62. finally:
  63. connection.close()
  64. `;
  65. const result = spawnSync('python', ['-c', source, root, JSON.stringify(requestIds)], { encoding: 'utf8', windowsHide: true });
  66. // Neither stderr nor the private source configuration is copied into the report.
  67. assert.equal(result.status, 0, '只读数据库证据查询成功');
  68. return JSON.parse(result.stdout.trim());
  69. }
  70. function exportedContains(data, needle) {
  71. const source = String.raw`
  72. import base64,io,json,sys,zipfile
  73. payload=json.load(sys.stdin)
  74. with zipfile.ZipFile(io.BytesIO(base64.b64decode(payload['contentBase64']))) as archive:
  75. contents=b'\n'.join(archive.read(name) for name in archive.namelist() if name.startswith('xl/') and name.endswith('.xml'))
  76. print(json.dumps({'contains':payload['needle'].encode('utf-8') in contents}))
  77. `;
  78. const result = spawnSync('python', ['-c', source], {
  79. input: JSON.stringify({ contentBase64: data.contentBase64, needle }), encoding: 'utf8', windowsHide: true,
  80. });
  81. assert.equal(result.status, 0, '导出文件内容校验成功');
  82. return JSON.parse(result.stdout).contains;
  83. }
  84. function roleCommand(role, overrides = {}) {
  85. return { code: role.code, name: role.name, shortName: role.shortName, description: role.description,
  86. dataScope: role.dataScope, enabled: role.enabled, sortOrder: role.sortOrder,
  87. departmentScopeIds: role.departmentScopeIds, version: role.dataVersion, ...overrides };
  88. }
  89. function userCommand(user, overrides = {}) {
  90. return { username: user.username, displayName: user.displayName, departmentId: user.departmentId,
  91. roleIds: user.roleIds, defaultRoleId: user.defaultRoleId, enabled: user.enabled, remark: user.remark,
  92. version: user.dataVersion, ...overrides };
  93. }
  94. async function main() {
  95. const fixture = withoutFixtures ? {} : JSON.parse(fs.readFileSync(path.join(work, 'fixtures.json'), 'utf8'));
  96. if (!withoutFixtures) assert.ok(fixture.user?.id && fixture.adminRoleId, '隔离测试账号已由 prepare 创建');
  97. else outcome.limitations.push('首阶段不依赖隔离账号,内部角色赋值拒绝及测试账号版本保持由完整运行补测。');
  98. const admin = await login('admin'); sessions.push(admin.accessToken);
  99. const maintenance = await login('root'); sessions.push(maintenance.accessToken);
  100. const token = admin.accessToken, rootToken = maintenance.accessToken;
  101. const me = await read('auth/v1/auth/me', token), rootMe = await read('auth/v1/auth/me', rootToken);
  102. const rootUserId = rootMe.id, internalRole = rootMe.roles.find(role => role.code === 'root');
  103. check('维护账号使用已分配的内部角色,普通管理员不带维护标记',
  104. !!internalRole && rootMe.maintenanceAccount === true && me.maintenanceAccount === false);
  105. for (const [actor, actorToken] of [['admin', token], ['root', rootToken]]) {
  106. const all = rows(await read('auth/v1/roles/all', actorToken));
  107. const page = rows(await read('auth/v1/roles?size=200', actorToken));
  108. check(`${actor} 的普通角色目录和分页都隐藏内部角色`, [...all, ...page].every(role => role.code !== 'root'));
  109. for (const suffix of ['', '/users', '/permissions']) {
  110. const result = await call(`auth/v1/roles/${internalRole.id}${suffix}`, actorToken);
  111. check(`${actor} 不能按已知内部角色 ID 读取${suffix || '/详情'}`, result.status === 404, { status: result.status });
  112. }
  113. }
  114. const ordinaryRoles = rows(await read('auth/v1/roles/all', token));
  115. for (const role of ordinaryRoles) {
  116. const linked = rows(await read(`auth/v1/roles/${role.id}/users`, token));
  117. check(`角色 ${role.code} 的关联用户排除维护账号`, linked.every(user => !sameId(user.id, rootUserId)));
  118. }
  119. const lookup = rows(await read(`auth/v1/directory/teaching-members?keyword=${encodeURIComponent(rootMe.username)}&limit=100`, token));
  120. check('教学人员搜索不暴露维护账号', lookup.every(user => !sameId(user.id, rootUserId)));
  121. const resolved = await call('auth/v1/directory/teaching-members/resolve', token, { ids: [rootUserId] });
  122. check('教学人员按 ID 解析拒绝维护账号', resolved.status === 400, { status: resolved.status });
  123. const candidates = rows(await read(`auth/v1/auth/login-identities?roleCode=teacher&departmentId=${rootMe.departmentId}`));
  124. check('公开登录候选列表不暴露维护账号', candidates.every(user => !sameId(user.id, rootUserId)));
  125. if (fixture.user?.id) {
  126. const originalUser = await read(`auth/v1/users/${fixture.user.id}`, token);
  127. for (const [actor, actorToken] of [['admin', token], ['root', rootToken]]) {
  128. const attempt = await call(`auth/v1/users/${fixture.user.id}`, actorToken,
  129. userCommand(originalUser, { roleIds: [...originalUser.roleIds, internalRole.id] }), 'PUT');
  130. if (attempt.status === 200) {
  131. // The only mutable target is the isolated fixture. Restore before reporting an unexpected authorization failure.
  132. const current = await read(`auth/v1/users/${fixture.user.id}`, rootToken);
  133. await call(`auth/v1/users/${fixture.user.id}`, rootToken, userCommand(originalUser, { version: current.dataVersion }), 'PUT');
  134. }
  135. check(`${actor} 无法通过普通用户接口给测试账号授予内部角色`, [400, 403].includes(attempt.status), { status: attempt.status });
  136. }
  137. const afterUser = await read(`auth/v1/users/${fixture.user.id}`, token);
  138. check('拒绝内部角色分配后测试账号原角色和版本保持不变',
  139. JSON.stringify(ids(afterUser.roleIds)) === JSON.stringify(ids(originalUser.roleIds)) && afterUser.dataVersion === originalUser.dataVersion);
  140. }
  141. const adminRoleId = fixture.adminRoleId ?? ordinaryRoles.find(role => role.code === 'admin')?.id;
  142. const fixed = await read(`auth/v1/roles/${adminRoleId}`, token);
  143. for (const [actor, actorToken] of [['admin', token], ['root', rootToken]]) {
  144. const scope = await call(`auth/v1/roles/${fixed.id}`, actorToken, roleCommand(fixed, { dataScope: 'SELF' }), 'PUT');
  145. if (scope.status === 200) {
  146. const current = await read(`auth/v1/roles/${fixed.id}`, rootToken);
  147. await call(`auth/v1/roles/${fixed.id}`, rootToken, roleCommand(fixed, { version: current.dataVersion }), 'PUT');
  148. }
  149. check(`${actor} 无法通过普通编辑更改固定管理员数据范围`, scope.status === 403, { status: scope.status });
  150. const status = await call(`auth/v1/roles/${fixed.id}/status`, actorToken, { enabled: false, version: fixed.dataVersion }, 'PUT');
  151. if (status.status === 200) {
  152. const current = await read(`auth/v1/roles/${fixed.id}`, rootToken);
  153. await call(`auth/v1/roles/${fixed.id}/status`, rootToken, { enabled: true, version: current.dataVersion }, 'PUT');
  154. }
  155. check(`${actor} 无法停用固定管理员角色`, status.status === 403, { status: status.status });
  156. }
  157. const afterRole = await read(`auth/v1/roles/${fixed.id}`, token);
  158. check('固定管理员角色状态、范围、版本未被边界请求改变',
  159. afterRole.enabled === fixed.enabled && afterRole.dataScope === fixed.dataScope && afterRole.dataVersion === fixed.dataVersion);
  160. for (const suffix of ['', '/stats', '/export']) {
  161. const denied = await call(`auth/v1/maintenance/audit-logs${suffix}`, token);
  162. check(`普通管理员不能读取维护审计${suffix || '/列表'}`, denied.status === 403, { status: denied.status });
  163. }
  164. const sequence = crypto.randomBytes(6).toString('hex');
  165. const rootAttempt = await failedLogin(rootMe.username, `maint-root-failure-${sequence}`);
  166. const unknownAttempt = await failedLogin(`missing_${sequence}`, `maint-unknown-failure-${sequence}`);
  167. check('已知维护账号错误密码和未知账号均返回统一认证失败', rootAttempt.status === 401 && unknownAttempt.status === 401);
  168. const rootFilter = encodeURIComponent(rootAttempt.requestId), unknownFilter = encodeURIComponent(unknownAttempt.requestId);
  169. const ordinaryKnown = rows(await read(`auth/v1/audit-logs?keyword=${rootFilter}`, token));
  170. const internalKnown = rows(await read(`auth/v1/maintenance/audit-logs?keyword=${rootFilter}`, rootToken));
  171. const ordinaryUnknown = rows(await read(`auth/v1/audit-logs?keyword=${unknownFilter}`, token));
  172. check('维护登录失败在普通列表不可见且内部审计保留真实 USER 目标', ordinaryKnown.length === 0
  173. && internalKnown.some(event => event.targetType === 'USER' && sameId(event.targetId, rootUserId) && event.success === false));
  174. check('未知身份失败仍可在普通安全审计中查到', ordinaryUnknown.some(event => event.requestId === unknownAttempt.requestId && event.success === false));
  175. const ordinaryExport = await read(`auth/v1/audit-logs/export?keyword=${rootFilter}`, token);
  176. const internalExport = await read(`auth/v1/maintenance/audit-logs/export?keyword=${rootFilter}`, rootToken);
  177. check('普通审计导出不包含维护记录,维护导出保留该记录', ordinaryExport.recordCount === 0
  178. && !exportedContains(ordinaryExport, rootAttempt.requestId) && internalExport.recordCount >= 1
  179. && exportedContains(internalExport, rootAttempt.requestId));
  180. const before = databaseSnapshot([rootAttempt.requestId, unknownAttempt.requestId]);
  181. const auditStats = await read('auth/v1/audit-logs/stats', token);
  182. const internalStats = await read('auth/v1/maintenance/audit-logs/stats', rootToken);
  183. const userStats = await read('auth/v1/users/stats', token);
  184. const roleStats = await read('auth/v1/roles/stats', token);
  185. const dashboard = await read('auth/v1/dashboard/platform', token);
  186. const after = databaseSnapshot([rootAttempt.requestId, unknownAttempt.requestId]);
  187. const between = (value, first, last) => value >= first && value <= last;
  188. check('普通审计统计只计算 BUSINESS,维护统计只计算 INTERNAL',
  189. ['total', 'success', 'failed'].every(key => between(auditStats[key], before.businessAudit[key], after.businessAudit[key])
  190. && between(internalStats[key], before.internalAudit[key], after.internalAudit[key])));
  191. check('普通用户和角色统计排除内部账号及内部角色',
  192. between(userStats.total, before.ordinaryUsers.total, after.ordinaryUsers.total)
  193. && between(roleStats.total, before.ordinaryRoles.total, after.ordinaryRoles.total));
  194. check('首页人数不计维护账号,最近动态不显示维护失败日志',
  195. between(dashboard.metrics.userTotal, before.ordinaryUsers.total, after.ordinaryUsers.total)
  196. && !dashboard.activities.some(activity => internalKnown.some(event => sameId(activity.id, event.id))));
  197. const knownDb = after.events.find(event => event.request_id === rootAttempt.requestId);
  198. const unknownDb = after.events.find(event => event.request_id === unknownAttempt.requestId);
  199. check('数据库只读核验:已知维护失败 INTERNAL,未知失败 BUSINESS',
  200. knownDb?.visibility_scope === 'INTERNAL' && sameId(knownDb.target_id, rootUserId)
  201. && unknownDb?.visibility_scope === 'BUSINESS' && !unknownDb.target_id && !unknownDb.actor_user_id);
  202. outcome.databaseEvidence = {
  203. ordinaryUserCount: after.ordinaryUsers.total, ordinaryRoleCount: after.ordinaryRoles.total,
  204. businessAuditCount: after.businessAudit.total, internalAuditCount: after.internalAudit.total,
  205. knownFailureScope: knownDb?.visibility_scope, unknownFailureScope: unknownDb?.visibility_scope,
  206. knownFailurePreservesTarget: sameId(knownDb?.target_id, rootUserId),
  207. };
  208. // Restore the failed-login counter by a normal successful authentication; no direct account or credential writes.
  209. const verified = await login('root'); sessions.push(verified.accessToken);
  210. const normalAdmin = await read(`auth/v1/users/${me.id}`, token);
  211. check('维护再次登录后普通 admin 仍未被重新保护', normalAdmin.isProtected === false);
  212. outcome.bootstrapReview = { result: 'PASS', note: '初始化无密码配置时立即返回;已有凭据不覆盖;成功时只将旧普通管理员保护置0,没有将其恢复为1的路径。主代理已移除一次性环境变量并重启Auth,本脚本在该服务上核验admin保护仍为false。' };
  213. outcome.status = withoutFixtures ? 'PASS_WITH_LIMITATIONS' : 'PASS';
  214. }
  215. if (process.argv[1] === fileURLToPath(import.meta.url)) {
  216. try { await main(); }
  217. catch (error) {
  218. outcome.status = 'FAIL'; outcome.failedStep = activeCheck;
  219. outcome.error = error?.name === 'AssertionError' ? '验证断言失败,参见最后一项或 failedStep' : '请求或验证未完成,未输出原始异常以避免泄漏认证信息';
  220. process.exitCode = 1;
  221. } finally {
  222. for (const token of sessions) await call('auth/v1/auth/logout', token, {}).catch(() => {});
  223. outcome.finishedAt = new Date().toISOString();
  224. fs.writeFileSync(path.join(report, withoutFixtures ? 'maintenance-boundaries-initial.json' : 'maintenance-boundaries.json'), JSON.stringify(outcome, null, 2));
  225. console.log(JSON.stringify({ status: outcome.status, passed: checks.filter(item => item.result === 'PASS').length,
  226. failed: checks.filter(item => item.result === 'FAIL').length, failedStep: outcome.failedStep ?? null }));
  227. }
  228. }