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
@@ -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/);
|
||||
});
|
||||
@@ -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 写入已发起,但系统尚未确认最终结果。请勿重复提交同一任务,等待任务所属账号只读核验。'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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