feat: isolate administrators from task data plane

This commit is contained in:
inman committed 2026-09-07 20:25:24 +08:00
1 parent 03d01315a9
commit fd39347603
13 files changed
+644 -112

No files matched your search

@@ -71,6 +71,30 @@ test('AgentBus account-worker migration adds fail-closed channel, task, browser,
assert.match(sql, /owner_user_id IS NULL[\s\S]+enabled = true/);
});
test('administrator task-isolation migration removes legacy bindings and rejects future data-plane principals', async () => {
const sql = await source('../migrations/021_admin_task_data_plane_isolation.sql');
assert.match(sql, /UPDATE tasks task[\s\S]+assigned_user_id = NULL[\s\S]+account\.role = 'admin'/);
assert.match(sql, /UPDATE user_channels channel[\s\S]+owner_user_id = NULL[\s\S]+account\.role = 'admin'/);
assert.match(sql, /UPDATE browser_connections connection[\s\S]+status = 'superseded'[\s\S]+account\.role = 'admin'/);
assert.match(sql, /DELETE FROM user_business_route_authorizations[\s\S]+account\.role = 'admin'/);
assert.match(sql, /UPDATE leader_task_summary_subscriptions subscription[\s\S]+enabled = false[\s\S]+account\.role = 'admin'/);
assert.match(sql, /UPDATE leader_task_summary_deliveries delivery[\s\S]+delivery_status = 'cancelled'[\s\S]+account\.role = 'admin'/);
assert.match(sql, /CREATE OR REPLACE FUNCTION reject_admin_task_principal/);
assert.match(sql, /FOR SHARE/);
for (const constraint of [
'tasks_admin_assignee_forbidden',
'tasks_admin_creator_forbidden',
'user_channels_admin_owner_forbidden',
'browser_connections_admin_worker_forbidden',
'user_business_routes_admin_grantee_forbidden',
'leader_task_summary_admin_subscriber_forbidden',
'leader_task_summary_admin_recipient_forbidden'
]) assert.match(sql, new RegExp(constraint));
assert.match(sql, /CREATE OR REPLACE FUNCTION isolate_administrator_from_task_runtime/);
assert.match(sql, /AFTER UPDATE OF role ON users/);
assert.doesNotMatch(sql, /DELETE FROM tasks/);
});
test('AgentBus channel keys and owners are unique so one inbound identity cannot fan out to multiple employees', async () => {
const channels = await source('../src/agentbus-channels.ts');
assert.match(channels, /requireAssignableOwner/);
@@ -109,7 +133,7 @@ test('account lifecycle is administrator-gated and protects passwords, sessions,
assert.doesNotMatch(publicUser, /organization/);
});
test('administrators manage task-type grants and manual intake enforces them before parsing or ERP dispatch', async () => {
test('administrators manage employee task-type grants while remaining outside manual intake', async () => {
const [auth, tasks, server] = await Promise.all([
source('../src/auth.ts'),
source('../src/task-service.ts'),
@@ -120,6 +144,7 @@ test('administrators manage task-type grants and manual intake enforces them bef
assert.match(auth, /business_authorization_revision_conflict/);
assert.match(auth, /account\.business_authorizations_updated/);
assert.match(auth, /admin_business_authorization_fixed/);
assert.match(auth, /authorized_business_route_ids: role === 'admin' \? \[\] : storedRouteIds/);
assert.match(server, /task_types: BUSINESS_ROUTES\.map/);
assert.match(server, /app\.put\('\/api\/accounts\/:userId\/business-authorizations'[\s\S]+requireAdminMutationSession\(request\)/);
assert.match(tasks, /export function canExecuteBusinessRoute/);
@@ -155,7 +180,7 @@ test('operations dashboard is leadership-gated, cross-source, business-facing, a
source('../src/task-service.ts'),
source('../src/server.ts')
]);
assert.match(tasks, /canViewOperationsDashboard[\s\S]+role === 'admin' \|\| role === 'team_lead'/);
assert.match(tasks, /canViewOperationsDashboard[\s\S]+return role === 'team_lead'/);
assert.match(tasks, /isTaskOwnerRestricted[\s\S]+role === 'team_lead' \|\| role === 'user'/);
assert.match(tasks, /async listOperationsDashboard[\s\S]+t\.assigned_user_id IS NOT NULL[\s\S]+t\.source IN \('manual', 'agentbus'\)/);
assert.match(tasks, /actorUserId[\s\S]+t\.assigned_user_id = \$\$\{params\.length\}/);
@@ -243,9 +268,11 @@ test('ordinary task access is enforced across reads, mutations, artifacts, event
assert.match(server, /tasks\.listTasksPage[\s\S]+access: contextFor\(session, request\)/);
assert.match(server, /tasks\.getTaskArtifact[\s\S]+contextFor\(session, request\)/);
assert.match(server, /tasks\.eventsSince\(session\.user\.organizationId, session\.user\.id, since\)/);
assert.match(server, /app\.addHook\('preHandler'[\s\S]+isTaskDataPlaneRoute\(request\.routeOptions\.url\)[\s\S]+requireTaskDataPlane\(await getSession\(request\)\)/);
assert.match(server, /task_access_forbidden/);
});
test('administrator visibility is isolated from executable events and plugin result routing', async () => {
test('administrators are excluded from task events and plugin result routing', async () => {
const [tasks, server, app] = await Promise.all([
source('../src/task-service.ts'),
source('../src/server.ts'),
@@ -264,6 +291,7 @@ test('administrator visibility is isolated from executable events and plugin res
assert.match(eventRoute, /command\.assigned_user_id !== session\.user\.id/);
assert.match(eventRoute, /event: browser-command/);
assert.match(eventRoute, /tasks\.eventsSince\(session\.user\.organizationId, session\.user\.id, since\)/);
assert.match(eventRoute, /requireTaskSession\(request\)/);
const eventStream = app.slice(
app.indexOf('function startRemoteEventStream()'),
@@ -377,6 +405,12 @@ test('operator UI exposes role-aware accounts, executive drill-through, archive,
assert.match(app, /\/business-authorizations/);
assert.match(app, /当前默认不能执行任何业务/);
assert.match(app, /function canViewOperationsDashboard/);
assert.match(app, /function canUseTaskDataPlane/);
assert.match(app, /isAdministrator\(\) && \(IS_TASK_PAGE \|\| IS_OPERATIONS_DASHBOARD_PAGE\)[\s\S]+window\.location\.replace\('\/accounts'\)/);
assert.match(app, /browserConnectionId = canUseTaskDataPlane\(\) \? connectionIdForUser\(user\) : ''/);
assert.match(app, /if \(bridgeState\) bridgeState\.hidden = !canUseTaskDataPlane\(\)/);
assert.match(app, /if \(!canUseTaskDataPlane\(\)\) return false;[\s\S]+sendToExtension\('PING'/);
assert.match(app, /account\.role === 'admin' \? '不参与任务' : '任务权限'/);
assert.match(app, /\/api\/operations-dashboard\?/);
assert.match(app, /\/api\/operations-dashboard\/tasks\/\$\{encodeURIComponent\(taskId\)\}/);
assert.match(app, /business_route_id/);
@@ -457,6 +491,6 @@ test('account authorization editor uses a scroll-safe open layout without overri
assert.match(openLayoutSource, /overflow:\s*visible/);
assert.doesNotMatch(styles, /\.account-panel\s*\{\s*grid-template-rows:/);
assert.match(index, /styles\.css\?v=20260907-leader-summary-2/);
assert.match(index, /app\.js\?v=20260907-leader-summary-2/);
assert.match(index, /styles\.css\?v=20260907-admin-task-isolation-1/);
assert.match(index, /app\.js\?v=20260907-admin-task-isolation-1/);
});
+1 -1
View File
@@ -734,7 +734,7 @@ test('AgentBus result text uses the unified important message and preserves the
assert.equal(taskResultStatus(uncertainWrite), 'failed');
assert.equal(
taskResultText(uncertainWrite),
'ERP 写入已发起,但系统尚未确认最终结果。请勿重复提交同一任务,等待管理员只读核验。'
'ERP 写入已发起,但系统尚未确认最终结果。请勿重复提交同一任务,等待任务所属账号只读核验。'
);
});
+140 -10
View File
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
import test from 'node:test';
import { loadConfig } from '../src/config.js';
import { decryptBytes, decryptText, encryptBytes, encryptText, hashToken, sameTokenHash, sha256Bytes } from '../src/crypto.js';
import { aiServiceConnected, buildServer } from '../src/server.js';
import { aiServiceConnected, buildServer, isTaskDataPlaneRoute } from '../src/server.js';
import { REQUIRED_SCHEMA_VERSION } from '../src/db.js';
import {
TaskService,
@@ -15,6 +15,7 @@ import {
classifyExecutionResult,
canAccessTask,
canExecuteBusinessRoute,
canUseTaskDataPlane,
canViewOperationsDashboard,
executionLifecycleFacts,
failureSummary,
@@ -45,9 +46,10 @@ 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', () => {
test('task access contract excludes administrators and isolates employee owners', () => {
const cases = [
{ name: 'administrator can inspect another assigned task', access: { userId: 'admin', role: 'admin' as const }, assignedUserId: 'user-a', allowed: true },
{ name: 'administrator cannot inspect another assigned task', access: { userId: 'admin', role: 'admin' as const }, assignedUserId: 'user-a', allowed: false },
{ name: 'administrator cannot inspect a legacy admin-assigned task', access: { userId: 'admin', role: 'admin' as const }, assignedUserId: 'admin', allowed: false },
{ name: 'trusted worker can inspect an unassigned task', access: { userId: '', role: undefined }, assignedUserId: null, allowed: true },
{ name: 'team lead sees own manual task', access: { userId: 'lead-a', role: 'team_lead' as const }, assignedUserId: 'lead-a', allowed: true },
{ name: 'team lead cannot use the normal task path for another task', access: { userId: 'lead-a', role: 'team_lead' as const }, assignedUserId: 'user-b', allowed: false },
@@ -63,16 +65,85 @@ test('task access contract isolates users and team leads while preserving admini
assert.equal(isTaskOwnerRestricted('admin'), false);
assert.equal(isTaskOwnerRestricted('team_lead'), true);
assert.equal(isTaskOwnerRestricted('user'), true);
assert.equal(canViewOperationsDashboard('admin'), true);
assert.equal(canUseTaskDataPlane('admin'), false);
assert.equal(canUseTaskDataPlane('team_lead'), true);
assert.equal(canUseTaskDataPlane('user'), true);
assert.equal(canUseTaskDataPlane(undefined), false);
assert.equal(canViewOperationsDashboard('admin'), false);
assert.equal(canViewOperationsDashboard('team_lead'), true);
assert.equal(canViewOperationsDashboard('user'), false);
});
test('task data-plane HTTP classifier covers every task-bearing surface and excludes management APIs', () => {
for (const route of [
'/api/tasks',
'/api/tasks/:taskId',
'/api/messages',
'/api/connections/heartbeat',
'/api/events',
'/api/parser-decisions/:decisionId/review',
'/api/operations-dashboard',
'/api/operations-dashboard/tasks/:taskId'
]) assert.equal(isTaskDataPlaneRoute(route), true, route);
for (const route of [
'/api/accounts',
'/api/channels',
'/api/settings/parser-routing',
'/api/settings/automation',
'/api/audit'
]) assert.equal(isTaskDataPlaneRoute(route), false, route);
});
test('task service rejects administrator operations before touching task storage', async () => {
const config = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 19).toString('base64'),
DATABASE_URL: 'postgresql://invalid:invalid@127.0.0.1:1/invalid'
});
const service = new TaskService(config);
const context = {
organizationId: '11111111-1111-4111-8111-111111111111',
userId: '22222222-2222-4222-8222-222222222222',
requestId: 'admin-task-isolation-test',
role: 'admin' as const,
source: 'manual' as const
};
const operations: Array<[string, () => Promise<unknown>]> = [
['create', () => service.createTask(context, '安排用车')],
['message', () => service.ingestMessage(context, { message: '安排用车' })],
['attachment', () => service.attachPassengerRosterAttachment(context, {
fileName: 'roster.xlsx', contentType: 'application/octet-stream', content: Buffer.from('x'), source: 'manual'
}, { taskId: 'TASK-1' })],
['list', () => service.listTasksPage(context.organizationId, { access: context })],
['read', () => service.getTask(context.organizationId, 'TASK-1', context)],
['input history', () => service.getTaskInputHistory(context, 'TASK-1')],
['artifact', () => service.getTaskArtifact(context.organizationId, 'TASK-1', '33333333-3333-4333-8333-333333333333', context)],
['confirm', () => service.confirmTask(context, 'TASK-1')],
['claim', () => service.claimForBrowser(context, 'TASK-1', 'platform-browser:test')],
['result', () => service.recordExecutionResult(context, 'TASK-1', {}, 'platform-browser:test', '44444444-4444-4444-8444-444444444444')],
['reconcile', () => service.markReconciliationRequired(context, 'TASK-1', 'uncertain', '')],
['archive', () => service.archiveTasks(context, ['TASK-1'])],
['restore', () => service.restoreTask(context, 'TASK-1')],
['delete', () => service.hardDeleteTasks(context, ['TASK-1'])],
['cancel', () => service.cancelTask(context, 'TASK-1')],
['heartbeat', () => service.heartbeat(context, 'platform-browser:test', '0.0.0')],
['AI reparse', () => service.reparseTaskWithAi(context, 'TASK-1', 'test')],
['parser review', () => service.reviewParserDecision(context, '33333333-3333-4333-8333-333333333333', 'equivalent')]
];
for (const [name, operation] of operations) {
await assert.rejects(operation, (error: unknown) => (
error instanceof Error
&& 'code' in error
&& error.code === 'task_access_forbidden'
), name);
}
});
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');
}), false, 'administrators cannot create manual tasks');
assert.equal(canExecuteBusinessRoute({
role: 'team_lead', source: 'manual', routeId, authorizedRouteIds: [routeId]
}), true, 'team lead can use a granted route');
@@ -293,6 +364,65 @@ test('control plane exposes a live health endpoint without a database connection
await app.close();
});
test('administrator sessions receive 403 before every task data-plane handler', async () => {
const config = loadConfig({
NODE_ENV: 'test',
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 20).toString('base64'),
DATABASE_URL: 'postgresql://invalid:invalid@127.0.0.1:1/invalid'
});
const { app, auth } = await buildServer({
config,
startParserLoop: false,
parser: {
async parse() { return { blockers: ['test parser'] }; },
async checkConnection() { return { ok: false, configured: false }; }
}
});
(auth as unknown as { getActiveSession: () => Promise<unknown> }).getActiveSession = async () => ({
id: '33333333-3333-4333-8333-333333333333',
csrfTokenHash: Buffer.alloc(32),
user: {
id: '22222222-2222-4222-8222-222222222222',
organizationId: '11111111-1111-4111-8111-111111111111',
username: 'admin',
role: 'admin',
erpAccount: null
}
});
const requests = [
{ method: 'GET', url: '/api/tasks' },
{ method: 'POST', url: '/api/tasks', payload: { raw_text: '安排用车' } },
{ method: 'POST', url: '/api/messages', payload: { message: '安排用车' } },
{ method: 'GET', url: '/api/tasks/TASK-1' },
{ method: 'GET', url: '/api/tasks/TASK-1/input-history' },
{ method: 'GET', url: '/api/tasks/TASK-1/artifacts/44444444-4444-4444-8444-444444444444' },
{ method: 'POST', url: '/api/tasks/TASK-1/confirm', payload: {} },
{ method: 'POST', url: '/api/tasks/TASK-1/claim', payload: { connection_id: 'platform-browser:1234567890abcdef' } },
{ method: 'POST', url: '/api/tasks/TASK-1/result', payload: { result: {}, connection_id: 'platform-browser:1234567890abcdef', execution_id: '44444444-4444-4444-8444-444444444444' } },
{ method: 'POST', url: '/api/tasks/TASK-1/cancel', payload: {} },
{ method: 'POST', url: '/api/tasks/bulk-delete', payload: { task_ids: ['TASK-1'] } },
{ method: 'POST', url: '/api/tasks/bulk-archive', payload: { task_ids: ['TASK-1'] } },
{ method: 'DELETE', url: '/api/tasks/TASK-1' },
{ method: 'POST', url: '/api/tasks/TASK-1/archive', payload: {} },
{ method: 'POST', url: '/api/tasks/TASK-1/restore', payload: {} },
{ method: 'POST', url: '/api/connections/heartbeat', payload: { connection_id: 'platform-browser:1234567890abcdef' } },
{ method: 'GET', url: '/api/events' },
{ method: 'GET', url: '/api/operations-dashboard' },
{ method: 'GET', url: '/api/operations-dashboard/tasks/TASK-1' },
{ method: 'POST', url: '/api/tasks/TASK-1/reparse', payload: { engine: 'ai', reason: 'test' } },
{ method: 'PUT', url: '/api/parser-decisions/44444444-4444-4444-8444-444444444444/review', payload: { verdict: 'equivalent' } }
];
try {
for (const request of requests) {
const response = await app.inject(request as any);
assert.equal(response.statusCode, 403, `${request.method} ${request.url}`);
assert.equal(response.json().error_code, 'task_access_forbidden', `${request.method} ${request.url}`);
}
} finally {
await app.close();
}
});
test('AI primary connection state follows service reachability, not historical authentication evidence', () => {
assert.equal(aiServiceConnected(true, {
configured: true,
@@ -313,11 +443,11 @@ test('migration contains the durable state tables and safety fields', async () =
}
});
test('control plane requires the latest durable task-outcome migration before readiness', async () => {
test('control plane requires the administrator task-isolation migration before readiness', async () => {
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, '020_leader_task_summary_notifications');
assert.equal(REQUIRED_SCHEMA_VERSION, '021_admin_task_data_plane_isolation');
assert.match(db, /schema_migrations/);
assert.match(db, /databaseReadiness/);
assert.match(db, /assertDatabaseSchema/);
@@ -1231,8 +1361,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=20260907-leader-summary-2/);
assert.match(index, /app\.js\?v=20260907-leader-summary-2/);
assert.match(index, /styles\.css\?v=20260907-admin-task-isolation-1/);
assert.match(index, /app\.js\?v=20260907-admin-task-isolation-1/);
assert.match(index, /id="statusDetailsPopover"/);
assert.match(index, /id="statusDetailsRefresh"/);
assert.match(app, /apiRequest\(`\/api\/tasks\?\$\{params\.toString\(\)\}`/);
@@ -1312,7 +1442,7 @@ test('operator page has a login gate and uses the durable task API', async () =>
assert.match(app, /fetchWithTimeout\('\/api\/auth\/login'/);
assert.match(app, /showAuthChecking\(\);[\s\S]*pingAi\(\)\.catch/);
assert.match(app, /cache: options\.cache \|\| 'no-store'/);
assert.match(app, /showAuthenticatedApp\(me\.user\);[\s\S]*任务同步失败/);
assert.match(app, /if \(!showAuthenticatedApp\(me\.user\)\) return false;[\s\S]*任务同步失败/);
assert.match(app, /async function toggleStatusDetails/);
assert.match(app, /已连接,有告警/);
assert.match(app, /历史验证失败(不影响链路状态)/);