feat: add account roles audit and task authorization
This commit is contained in:
1 parent
337aaf7c88
commit
191c1a1aad
15 files changed
+4961
-457
No files matched your search
@@ -13,12 +13,16 @@ import {
|
||||
buildImmediateParserPromotionGates,
|
||||
classifyExpiredExecutionResult,
|
||||
classifyExecutionResult,
|
||||
canAccessTask,
|
||||
canExecuteBusinessRoute,
|
||||
canViewOperationsDashboard,
|
||||
executionLifecycleFacts,
|
||||
failureSummary,
|
||||
executionStatusImmutable,
|
||||
executionUpdateAllowed,
|
||||
hasPrewriteNoErpEvidence,
|
||||
hasLifecycleTestContext,
|
||||
isTaskOwnerRestricted,
|
||||
isLifecycleOperation,
|
||||
parseLifecycleFacts,
|
||||
prepareParsedOperationForConfirmation,
|
||||
@@ -41,6 +45,51 @@ test('message routing starts a new session for a business directive, not for a s
|
||||
assert.equal(isNewDirectiveMessage('数量改为 2'), false);
|
||||
});
|
||||
|
||||
test('task access contract isolates users and team leads while preserving administrator and worker access', () => {
|
||||
const cases = [
|
||||
{ name: 'administrator sees another user manual task', access: { userId: 'admin', role: 'admin' as const }, createdBy: 'user-a', source: 'manual' as const, allowed: true },
|
||||
{ name: 'trusted worker sees AgentBus task', access: { userId: '', role: undefined }, createdBy: null, source: 'agentbus' as const, allowed: true },
|
||||
{ name: 'team lead sees own manual task', access: { userId: 'lead-a', role: 'team_lead' as const }, createdBy: 'lead-a', source: 'manual' as const, allowed: true },
|
||||
{ name: 'team lead cannot use the normal task path for another manual task', access: { userId: 'lead-a', role: 'team_lead' as const }, createdBy: 'user-b', source: 'manual' as const, allowed: false },
|
||||
{ name: 'team lead cannot use the normal task path for AgentBus work', access: { userId: 'lead-a', role: 'team_lead' as const }, createdBy: 'lead-a', source: 'agentbus' as const, allowed: false },
|
||||
{ name: 'ordinary user sees own manual task', access: { userId: 'user-a', role: 'user' as const }, createdBy: 'user-a', source: 'manual' as const, allowed: true },
|
||||
{ name: 'ordinary user cannot see another manual task', access: { userId: 'user-a', role: 'user' as const }, createdBy: 'user-b', source: 'manual' as const, allowed: false },
|
||||
{ name: 'ordinary user cannot see AgentBus task', access: { userId: 'user-a', role: 'user' as const }, createdBy: 'user-a', source: 'agentbus' as const, allowed: false },
|
||||
{ name: 'ordinary user without an actor cannot see a task', access: { userId: '', role: 'user' as const }, createdBy: '', source: 'manual' as const, allowed: false }
|
||||
];
|
||||
for (const item of cases) {
|
||||
assert.equal(canAccessTask(item.access, { createdBy: item.createdBy, source: item.source }), item.allowed, item.name);
|
||||
}
|
||||
assert.equal(isTaskOwnerRestricted('admin'), false);
|
||||
assert.equal(isTaskOwnerRestricted('team_lead'), true);
|
||||
assert.equal(isTaskOwnerRestricted('user'), true);
|
||||
assert.equal(canViewOperationsDashboard('admin'), true);
|
||||
assert.equal(canViewOperationsDashboard('team_lead'), true);
|
||||
assert.equal(canViewOperationsDashboard('user'), false);
|
||||
});
|
||||
|
||||
test('business route authorization is an explicit allowlist for team leads and ordinary users', () => {
|
||||
const routeId = 'arrangement_hotel_create' as const;
|
||||
assert.equal(canExecuteBusinessRoute({
|
||||
role: 'admin', source: 'manual', routeId: null, authorizedRouteIds: []
|
||||
}), true, 'administrators retain all registered and unclassified manual intake');
|
||||
assert.equal(canExecuteBusinessRoute({
|
||||
role: 'team_lead', source: 'manual', routeId, authorizedRouteIds: [routeId]
|
||||
}), true, 'team lead can use a granted route');
|
||||
assert.equal(canExecuteBusinessRoute({
|
||||
role: 'team_lead', source: 'manual', routeId, authorizedRouteIds: []
|
||||
}), false, 'team lead cannot use an ungranted route');
|
||||
assert.equal(canExecuteBusinessRoute({
|
||||
role: 'user', source: 'manual', routeId, authorizedRouteIds: [routeId]
|
||||
}), true, 'ordinary user can use a granted route');
|
||||
assert.equal(canExecuteBusinessRoute({
|
||||
role: 'user', source: 'manual', routeId: null, authorizedRouteIds: [routeId]
|
||||
}), false, 'unclassified manual input fails closed for non-administrators');
|
||||
assert.equal(canExecuteBusinessRoute({
|
||||
role: undefined, source: 'agentbus', routeId: null, authorizedRouteIds: []
|
||||
}), true, 'trusted AgentBus intake retains its separate administrator-controlled boundary');
|
||||
});
|
||||
|
||||
test('field encryption round-trips without storing plaintext', () => {
|
||||
const config = loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
@@ -262,7 +311,7 @@ test('control plane requires the latest durable task-outcome migration before re
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const db = await readFile(new URL('../src/db.ts', import.meta.url), 'utf8');
|
||||
const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
|
||||
assert.equal(REQUIRED_SCHEMA_VERSION, '014_task_input_attachments');
|
||||
assert.equal(REQUIRED_SCHEMA_VERSION, '017_user_business_route_authorizations');
|
||||
assert.match(db, /schema_migrations/);
|
||||
assert.match(db, /databaseReadiness/);
|
||||
assert.match(db, /assertDatabaseSchema/);
|
||||
@@ -1176,8 +1225,8 @@ test('operator page has a login gate and uses the durable task API', async () =>
|
||||
const inpage = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8');
|
||||
assert.match(index, /id="loginPanel"/);
|
||||
assert.match(index, /id="workbench"[^>]*hidden/);
|
||||
assert.match(index, /styles\.css\?v=20260827-roster-attachment-1/);
|
||||
assert.match(index, /app\.js\?v=20260830-team-order-validation-scope-1/);
|
||||
assert.match(index, /styles\.css\?v=20260901-business-authorization-1/);
|
||||
assert.match(index, /app\.js\?v=20260901-business-authorization-1/);
|
||||
assert.match(index, /id="statusDetailsPopover"/);
|
||||
assert.match(index, /id="statusDetailsRefresh"/);
|
||||
assert.match(app, /apiRequest\(`\/api\/tasks\?\$\{params\.toString\(\)\}`/);
|
||||
@@ -1289,7 +1338,7 @@ test('operator page has a login gate and uses the durable task API', async () =>
|
||||
assert.match(app, /data-history-task-delete/);
|
||||
assert.match(app, /const historySelectedTaskIds = new Set\(\)/);
|
||||
assert.match(app, /async function deleteHistorySelectedTasks\(\)/);
|
||||
assert.match(app, /apiRequest\('\/api\/tasks\/bulk-delete'/);
|
||||
assert.match(app, /apiRequest\('\/api\/tasks\/bulk-archive'/);
|
||||
assert.match(app, /function renderHistoryBatchActions\(visibleTasks/);
|
||||
assert.match(app, /historySelectedTaskIds\.clear\(\)/);
|
||||
assert.match(app, /recorded_at/);
|
||||
@@ -1335,30 +1384,30 @@ test('operator page has a login gate and uses the durable task API', async () =>
|
||||
assert.match(styles, /\.task-operation-review-card/);
|
||||
assert.match(styles, /\.task-important-message-card\.state-ok/);
|
||||
assert.match(app, /operation_contract_validation/);
|
||||
assert.match(app, /api\/tasks\/\$\{encodeURIComponent\(taskId\)\}.*method: 'DELETE'/s);
|
||||
assert.match(app, /api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/archive/);
|
||||
assert.match(app, /api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/restore/);
|
||||
assert.match(app, /const taskDeleteStates = new Map\(\)/);
|
||||
assert.match(app, /taskDeleteStates\.get\(task\.task_id\)/);
|
||||
assert.match(app, /taskDeleteStates\.set\(taskId, 'deleting'\)/);
|
||||
assert.match(app, /deleteButton\.textContent = '删除中…'/);
|
||||
assert.match(app, /void sendToExtension\('DELETE_TASK'/);
|
||||
assert.doesNotMatch(app, /await sendToExtension\('DELETE_TASK'/);
|
||||
assert.match(taskService, /async hardDeleteTask\(/);
|
||||
assert.match(taskService, /async hardDeleteTasks\(/);
|
||||
assert.match(taskService, /task_id = ANY\(\$2::text\[\]\)/);
|
||||
assert.match(app, /deleteButton\.textContent = task\?\.archived_at \? '恢复中…' : '归档中…'/);
|
||||
assert.doesNotMatch(app, /sendToExtension\('DELETE_TASK'/);
|
||||
assert.match(taskService, /async archiveTask\(/);
|
||||
assert.match(taskService, /async archiveTasks\(/);
|
||||
assert.match(taskService, /async restoreTask\(/);
|
||||
assert.match(taskService, /archived_at = now\(\), archived_by = \$1/);
|
||||
assert.match(taskService, /SET archived_at = NULL, archived_by = NULL, archive_reason = NULL/);
|
||||
assert.match(taskService, /const missingTaskIds = normalizedTaskIds\.filter/);
|
||||
assert.match(taskService, /DELETE FROM audit_events[\s\S]+entity_id = ANY\(\$2::text\[\]\)/);
|
||||
assert.match(taskService, /DELETE FROM audit_events/);
|
||||
const hardDeleteTasksSource = taskService.slice(
|
||||
taskService.indexOf('async hardDeleteTasks('),
|
||||
taskService.indexOf('async cancelTask(')
|
||||
);
|
||||
assert.match(hardDeleteTasksSource, /FROM task_artifacts a/);
|
||||
assert.match(hardDeleteTasksSource, /FOR UPDATE OF a/);
|
||||
assert.match(hardDeleteTasksSource, /this\.artifactStore\.cleanup\(outcome\.artifacts\)/);
|
||||
assert.doesNotMatch(taskService, /async hardDeleteTask\(/);
|
||||
assert.doesNotMatch(taskService, /DELETE FROM audit_events/);
|
||||
assert.doesNotMatch(taskService, /DELETE FROM tasks/);
|
||||
assert.match(server, /taskBulkDeleteSchema/);
|
||||
assert.match(server, /\.max\(100\)/);
|
||||
assert.match(server, /app\.post\('\/api\/tasks\/bulk-delete'/);
|
||||
assert.match(server, /app\.post\('\/api\/tasks\/bulk-archive'/);
|
||||
assert.match(server, /app\.delete\('\/api\/tasks\/:taskId'/);
|
||||
assert.match(server, /app\.post\('\/api\/tasks\/:taskId\/archive'/);
|
||||
assert.match(server, /app\.post\('\/api\/tasks\/:taskId\/restore'/);
|
||||
assert.doesNotMatch(server, /tasks\.hardDelete/);
|
||||
assert.match(bridge, /status: 'deleted'/);
|
||||
assert.match(background, /LTJT_HARD_DELETE_TASK/);
|
||||
assert.doesNotMatch(app, /task-json-output|taskResponseJson/);
|
||||
|
||||
Reference in new issue
Block a user