feat: isolate administrators from task data plane
This commit is contained in:
1 parent
03d01315a9
commit
fd39347603
13 files changed
+644
-112
No files matched your search
@@ -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, /历史验证失败(不影响链路状态)/);
|
||||
|
||||
Reference in new issue
Block a user