feat: isolate administrators from task data plane

This commit is contained in:
inman
2026-09-07 20:19:25 +08:00
parent 03d01315a9
commit fd39347603
13 changed files with 644 additions and 112 deletions

View File

@@ -575,7 +575,7 @@ function conciseErrorText(value: unknown): string {
if (!normalized) return '任务处理失败,请检查任务信息后重试。';
const firstMessage = normalized.split(/[;;]/u, 1)[0]?.trim() || normalized;
if (/ERP 写入结果不确定|reconciliation_pending|saved_unverified|execution_uncertain/iu.test(normalized)) {
return 'ERP 写入已发起,但系统尚未确认最终结果。请勿重复提交同一任务,等待管理员只读核验。';
return 'ERP 写入已发起,但系统尚未确认最终结果。请勿重复提交同一任务,等待任务所属账号只读核验。';
}
if (/required fields still blank|必填字段/iu.test(firstMessage)) {
return '任务缺少必要信息,未写入 ERP,请补充后重试。';

View File

@@ -148,7 +148,7 @@ function mapAccount(row: Record<string, unknown>): PublicAccount {
role,
erp_account: normalizeErpAccount(row.erp_account),
is_active: row.is_active === true || String(row.is_active) === 'true',
authorized_business_route_ids: role === 'admin' ? [...ALL_BUSINESS_ROUTE_IDS] : storedRouteIds,
authorized_business_route_ids: role === 'admin' ? [] : storedRouteIds,
business_authorization_revision: Math.max(0, Number(row.business_authorization_revision || 0)),
last_login_at: isoOrNull(row.last_login_at),
created_at: isoOrNull(row.created_at) || new Date(0).toISOString(),
@@ -569,7 +569,7 @@ export class AuthService {
if (!target.rowCount) throw new AuthError('account_not_found', '账号不存在。', 404);
const row = target.rows[0] as Record<string, unknown>;
if (normalizeRole(row.role) === 'admin') {
throw new AuthError('admin_business_authorization_fixed', '管理员固定拥有全部业务权限,无需单独授权。', 409);
throw new AuthError('admin_business_authorization_fixed', '管理员不参与业务任务,任务权限固定为空。', 409);
}
const currentRevision = Math.max(0, Number(row.business_authorization_revision || 0));
if (currentRevision !== expectedRevision) {

View File

@@ -5,7 +5,7 @@ import { writeEmergencyDiagnostic } from './diagnostics.js';
const { Pool } = pg;
let pool: pg.Pool | null = null;
export const REQUIRED_SCHEMA_VERSION = '020_leader_task_summary_notifications';
export const REQUIRED_SCHEMA_VERSION = '021_admin_task_data_plane_isolation';
export interface DatabaseReadiness {
ready: boolean;

View File

@@ -22,6 +22,7 @@ import { LeaderNotificationService } from './leader-notification-service.js';
import {
TaskError,
TaskService,
canUseTaskDataPlane,
canViewOperationsDashboard,
type ParseDecisionInput,
type ParseTaskClaim,
@@ -336,6 +337,18 @@ function setAuthNoStore(reply: FastifyReply): void {
reply.header('Pragma', 'no-cache');
}
export function isTaskDataPlaneRoute(routePath: unknown): boolean {
const path = String(routePath || '');
return path === '/api/messages'
|| path === '/api/connections/heartbeat'
|| path === '/api/events'
|| path === '/api/tasks'
|| path.startsWith('/api/tasks/')
|| path.startsWith('/api/parser-decisions/')
|| path === '/api/operations-dashboard'
|| path.startsWith('/api/operations-dashboard/');
}
function attachmentContentDisposition(fileName: string): string {
const safeName = String(fileName || 'team-file.bin')
.replace(/[\\"\r\n\u0000-\u001f\u007f]/g, '_')
@@ -545,7 +558,14 @@ export async function buildServer({
const requireLeadership = (session: ActiveSession): ActiveSession => {
if (!canViewOperationsDashboard(session.user.role)) {
throw new AuthError('leadership_required', '需要组长或管理员权限。', 403);
throw new AuthError('leadership_required', '需要组长权限。', 403);
}
return session;
};
const requireTaskDataPlane = (session: ActiveSession): ActiveSession => {
if (!canUseTaskDataPlane(session.user.role)) {
throw new AuthError('task_access_forbidden', '管理员账号仅用于平台管理,不能访问业务任务。', 403);
}
return session;
};
@@ -570,6 +590,14 @@ export async function buildServer({
const requireMutationSession = requireAuthenticatedMutationSession;
const requireTaskSession = async (request: FastifyRequest): Promise<ActiveSession> => (
requireTaskDataPlane(await getSession(request))
);
const requireTaskMutationSession = async (request: FastifyRequest): Promise<ActiveSession> => (
requireTaskDataPlane(await requireMutationSession(request))
);
const requireAdminSession = async (request: FastifyRequest): Promise<ActiveSession> => (
requireAdmin(await getSession(request))
);
@@ -582,6 +610,11 @@ export async function buildServer({
requireLeadership(await getSession(request))
);
app.addHook('preHandler', async (request) => {
if (!isTaskDataPlaneRoute(request.routeOptions.url)) return;
requireTaskDataPlane(await getSession(request));
});
async function persistParseOutcome(
claim: ParseTaskClaim,
result: unknown,
@@ -1090,7 +1123,7 @@ export async function buildServer({
});
app.post('/api/tasks/:taskId/reparse', async (request) => {
const session = await requireAdminMutationSession(request);
const session = requireTaskDataPlane(await requireAdminMutationSession(request));
const taskId = String((request.params as { taskId?: string }).taskId || '');
const body = parserReparseSchema.parse(request.body);
const task = await tasks.reparseTaskWithAi(contextFor(session, request), taskId, body.reason);
@@ -1102,7 +1135,7 @@ export async function buildServer({
});
app.put('/api/parser-decisions/:decisionId/review', async (request) => {
const session = await requireAdminMutationSession(request);
const session = requireTaskDataPlane(await requireAdminMutationSession(request));
const decisionId = String((request.params as { decisionId?: string }).decisionId || '');
const body = parserDecisionReviewSchema.parse(request.body);
return {
@@ -1192,7 +1225,7 @@ export async function buildServer({
});
app.get('/api/tasks', async (request) => {
const session = await getSession(request);
const session = await requireTaskSession(request);
const query = listTasksQuerySchema.parse(request.query || {});
const page = await tasks.listTasksPage(session.user.organizationId, {
status: query.status || undefined,
@@ -1217,7 +1250,7 @@ export async function buildServer({
});
app.post('/api/tasks', async (request) => {
const session = await requireMutationSession(request);
const session = await requireTaskMutationSession(request);
const body = createTaskSchema.parse(request.body);
const task = await tasks.createTask(
contextFor(session, request),
@@ -1231,7 +1264,7 @@ export async function buildServer({
});
app.post('/api/messages', async (request) => {
const session = await requireMutationSession(request);
const session = await requireTaskMutationSession(request);
const body = taskMessageSchema.parse(request.body);
const result = await tasks.ingestMessage(contextFor(session, request), {
message: body.message,
@@ -1245,19 +1278,19 @@ export async function buildServer({
});
app.get('/api/tasks/:taskId', async (request) => {
const session = await getSession(request);
const session = await requireTaskSession(request);
const params = request.params as { taskId: string };
return { ok: true, task: await tasks.getTask(session.user.organizationId, params.taskId, contextFor(session, request)) };
});
app.get('/api/tasks/:taskId/input-history', async (request) => {
const session = await getSession(request);
const session = await requireTaskSession(request);
const params = request.params as { taskId: string };
return { ok: true, ...(await tasks.getTaskInputHistory(contextFor(session, request), params.taskId)) };
});
app.get('/api/tasks/:taskId/artifacts/:artifactId', async (request, reply) => {
const session = await getSession(request);
const session = await requireTaskSession(request);
const params = request.params as { taskId: string; artifactId: string };
const artifactId = z.string().uuid().safeParse(params.artifactId);
if (!artifactId.success) throw new TaskError('artifact_not_found', '附件不存在或无权访问。', 404);
@@ -1280,13 +1313,13 @@ export async function buildServer({
});
app.post('/api/tasks/:taskId/confirm', async (request) => {
const session = await requireMutationSession(request);
const session = await requireTaskMutationSession(request);
const params = request.params as { taskId: string };
return { ok: true, task: await tasks.confirmTask(contextFor(session, request), params.taskId) };
});
app.post('/api/tasks/:taskId/claim', async (request) => {
const session = await requireMutationSession(request);
const session = await requireTaskMutationSession(request);
const body = claimSchema.parse(request.body);
const params = request.params as { taskId: string };
const claim = await tasks.claimForBrowser(contextFor(session, request), params.taskId, body.connection_id);
@@ -1302,7 +1335,7 @@ export async function buildServer({
});
app.post('/api/tasks/:taskId/result', async (request) => {
const session = await requireMutationSession(request);
const session = await requireTaskMutationSession(request);
const body = resultSchema.parse(request.body);
const params = request.params as { taskId: string };
return {
@@ -1318,13 +1351,13 @@ export async function buildServer({
});
app.post('/api/tasks/:taskId/cancel', async (request) => {
const session = await requireMutationSession(request);
const session = await requireTaskMutationSession(request);
const params = request.params as { taskId: string };
return { ok: true, task: await tasks.cancelTask(contextFor(session, request), params.taskId) };
});
app.post('/api/tasks/bulk-delete', async (request) => {
const session = await requireMutationSession(request);
const session = await requireTaskMutationSession(request);
const body = taskBulkDeleteSchema.parse(request.body);
return {
ok: true,
@@ -1334,7 +1367,7 @@ export async function buildServer({
});
app.post('/api/tasks/bulk-archive', async (request) => {
const session = await requireMutationSession(request);
const session = await requireTaskMutationSession(request);
const body = taskBulkArchiveSchema.parse(request.body);
return {
ok: true,
@@ -1344,7 +1377,7 @@ export async function buildServer({
});
app.delete('/api/tasks/:taskId', async (request) => {
const session = await requireMutationSession(request);
const session = await requireTaskMutationSession(request);
const params = request.params as { taskId: string };
return {
ok: true,
@@ -1353,7 +1386,7 @@ export async function buildServer({
});
app.post('/api/tasks/:taskId/archive', async (request) => {
const session = await requireMutationSession(request);
const session = await requireTaskMutationSession(request);
const params = request.params as { taskId: string };
const body = taskArchiveSchema.parse(request.body || {});
return {
@@ -1364,13 +1397,13 @@ export async function buildServer({
});
app.post('/api/tasks/:taskId/restore', async (request) => {
const session = await requireMutationSession(request);
const session = await requireTaskMutationSession(request);
const params = request.params as { taskId: string };
return { ok: true, restored: true, task: await tasks.restoreTask(contextFor(session, request), params.taskId) };
});
app.post('/api/connections/heartbeat', async (request) => {
const session = await requireMutationSession(request);
const session = await requireTaskMutationSession(request);
const body = heartbeatSchema.parse(request.body);
const worker = await tasks.heartbeat(
contextFor(session, request),
@@ -1445,7 +1478,7 @@ export async function buildServer({
});
app.get('/api/events', async (request, reply) => {
const session = await getSession(request);
const session = await requireTaskSession(request);
const query = taskEventsQuerySchema.parse(request.query || {});
const querySince = query.since;
const reconnectSince = Number(request.headers['last-event-id'] || 0);
@@ -1463,9 +1496,8 @@ export async function buildServer({
});
const send = (event: TaskEvent) => {
if (event.organization_id !== session.user.organizationId) return;
// This is the executable wake-up feed, not the administrator's read
// model. Every role, including admin, receives only its own assigned
// task events so visibility can never turn into plugin dispatch.
// This is an employee execution feed. Administrators are rejected
// before the stream is opened, and workers receive only assigned work.
if (event.owner_user_id !== session.user.id) return;
const publicEvent = {
id: event.id,

View File

@@ -551,14 +551,19 @@ export function isTaskOwnerRestricted(role: TaskRole | undefined): boolean {
return role === 'team_lead' || role === 'user';
}
export function canUseTaskDataPlane(role: TaskRole | undefined): boolean {
return role === 'team_lead' || role === 'user';
}
export function canViewOperationsDashboard(role: TaskRole | undefined): boolean {
return role === 'admin' || role === 'team_lead';
return role === 'team_lead';
}
export function canAccessTask(
access: TaskAccessScope,
task: { assignedUserId: string | null | undefined }
): boolean {
if (access.role === 'admin') return false;
if (!isTaskOwnerRestricted(access.role)) return true;
return Boolean(access.userId)
&& task.assignedUserId === access.userId;
@@ -575,7 +580,7 @@ export function canExecuteBusinessRoute({
routeId: BusinessRouteId | null;
authorizedRouteIds: readonly BusinessRouteId[];
}): boolean {
if (role === 'admin') return source === 'manual';
if (role === 'admin') return false;
if (!isTaskOwnerRestricted(role) || !routeId) return false;
return authorizedRouteIds.includes(routeId);
}
@@ -1587,7 +1592,7 @@ export function failureSummary(
? erpFeedback || text(result.failure_message)
: text(result.failure_message))
|| (uncertainStatus
? `ERP 写入结果不确定,请勿重复提交同一任务,等待管理员只读核验:${blockers.join(';') || text(result.message) || '未取得确定回执。'}`
? `ERP 写入结果不确定,请勿重复提交同一任务,等待任务所属账号只读核验:${blockers.join(';') || text(result.message) || '未取得确定回执。'}`
: contractFailure
? `Agent 已返回,但 operation 未通过标准契约校验${blockers[0] ? `:${blockers[0]}` : ''};未进入插件,也未写入 ERP。`
: blockers.length
@@ -2497,9 +2502,15 @@ export class TaskService {
return isTaskOwnerRestricted(context.role);
}
private requireTaskDataPlaneAccess(access: TaskAccessScope): void {
if (access.role === 'admin') {
throw new TaskError('task_access_forbidden', '管理员账号仅用于平台管理,不能访问业务任务。', 403);
}
}
private requireOperationsDashboardAccess(context: TaskContext): void {
if (!canViewOperationsDashboard(context.role)) {
throw new TaskError('leadership_required', '需要组长或管理员权限。', 403);
throw new TaskError('leadership_required', '需要组长权限。', 403);
}
}
@@ -2667,6 +2678,7 @@ export class TaskService {
taskId: string,
{ allowArchived = false }: { allowArchived?: boolean } = {}
): Promise<Record<string, unknown>> {
this.requireTaskDataPlaneAccess(context);
const result = await client.query(
`SELECT * FROM tasks
WHERE organization_id = $1
@@ -3281,6 +3293,7 @@ export class TaskService {
verdict: ParserReviewStatus,
note?: string
): Promise<Record<string, unknown>> {
this.requireTaskDataPlaneAccess(context);
const allowed: ParserReviewStatus[] = ['equivalent', 'program_correct', 'ai_correct', 'both_wrong'];
if (!allowed.includes(verdict)) throw new TaskError('parser_review_invalid', '人工判定值无效。', 400);
const normalizedNote = String(note || '').trim().slice(0, 2_000) || null;
@@ -3328,6 +3341,7 @@ export class TaskService {
}
async reparseTaskWithAi(context: TaskContext, taskId: string, reason: string): Promise<PublicTask> {
this.requireTaskDataPlaneAccess(context);
const normalizedReason = String(reason || '').trim().slice(0, 1_000);
if (!normalizedReason) throw new TaskError('reason_required', 'AI 重解析必须填写原因。', 400);
const event = await withTransaction(this.config, async (client) => {
@@ -3663,6 +3677,7 @@ export class TaskService {
attachment: TaskInputAttachmentInput,
selection: PassengerRosterAttachmentSelection
): Promise<TaskMessageResult> {
this.requireTaskDataPlaneAccess(context);
const target = await this.passengerRosterAttachmentTarget(context, selection);
const digest = sha256Bytes(attachment.content);
const requestHash = sha256Text(`passenger-workbook\0${digest}`);
@@ -4030,6 +4045,7 @@ export class TaskService {
conversationId?: string,
attachments: TaskInputAttachmentInput[] = []
): Promise<PublicTask> {
this.requireTaskDataPlaneAccess(context);
const normalized = String(rawText || '').trim();
if (!normalized) throw new TaskError('empty_input', '输入区域不能为空。', 400);
if (Buffer.byteLength(normalized, 'utf8') > 200_000) throw new TaskError('input_too_large', '输入内容超过限制。', 413);
@@ -4158,6 +4174,7 @@ export class TaskService {
}
async ingestMessage(context: TaskContext, input: TaskMessageInput): Promise<TaskMessageResult> {
this.requireTaskDataPlaneAccess(context);
const normalizedMessage = String(input.message || '').trim();
const attachments = Array.isArray(input.attachments) ? input.attachments : [];
if (attachments.length > 1) throw new TaskError('too_many_attachments', '名单业务每次只能发送一个 Excel 附件。', 400);
@@ -4567,6 +4584,7 @@ export class TaskService {
}
async getTask(organizationId: string, taskId: string, access?: TaskAccessScope): Promise<PublicTask> {
if (access) this.requireTaskDataPlaneAccess(access);
const result = await getPool(this.config).query(
`SELECT t.*, uc.display_name AS channel_name, s.conversation_id,
creator.id AS creator_id, creator.username AS creator_username,
@@ -4627,6 +4645,7 @@ export class TaskService {
messages: PublicTaskInputHistoryEntry[];
attachments: PublicTaskInputAttachmentAudit[];
}> {
this.requireTaskDataPlaneAccess(context);
const task = await getPool(this.config).query(
`SELECT id FROM tasks
WHERE organization_id = $1 AND task_id = $2
@@ -5315,6 +5334,7 @@ export class TaskService {
artifactId: string,
access?: TaskAccessScope
): Promise<import('./artifact-store.js').DownloadableTaskArtifact> {
if (access) this.requireTaskDataPlaneAccess(access);
const authorized = await getPool(this.config).query(
`SELECT 1 FROM tasks
WHERE organization_id = $1 AND task_id = $2
@@ -5369,6 +5389,7 @@ export class TaskService {
access?: TaskAccessScope;
} = {}
): Promise<PublicTaskPage> {
if (options.access) this.requireTaskDataPlaneAccess(options.access);
const status = text(options.status).trim();
const search = text(options.search).trim();
const params: unknown[] = [organizationId];
@@ -6132,6 +6153,7 @@ export class TaskService {
}
async confirmTask(context: TaskContext, taskId: string): Promise<PublicTask> {
this.requireTaskDataPlaneAccess(context);
const outcome = await withTransaction(this.config, async (client) => {
const row = await this.lockTaskForAccess(client, context, taskId);
if (!context.userId || text(row.assigned_user_id) !== context.userId) {
@@ -6193,6 +6215,7 @@ export class TaskService {
}
async claimForBrowser(context: TaskContext, taskId: string, connectionId: string): Promise<BrowserTaskClaim> {
this.requireTaskDataPlaneAccess(context);
const leaseOwner = `browser:${connectionId}`;
const outcome = await withTransaction(this.config, async (client) => {
const organization = await client.query(
@@ -6392,6 +6415,7 @@ export class TaskService {
executionId: string,
options: { skipArtifactPersistence?: boolean } = {}
): Promise<PublicTask> {
this.requireTaskDataPlaneAccess(context);
const suppliedResult = jsonObject(resultPayload);
if (suppliedResult.execution_id && text(suppliedResult.execution_id) !== executionId) {
throw new TaskError('execution_id_mismatch', '插件回执与已领取的执行记录不一致。');
@@ -6470,7 +6494,7 @@ export class TaskService {
resultObject.status = status;
const lifecycle = executionLifecycleFacts(resultObject, status, true);
Object.assign(resultObject, lifecycle);
const message = text(resultObject.message || (uncertain ? 'ERP 结果不确定,请勿重复提交同一任务,等待管理员只读核验。' : '插件已返回状态。')).slice(0, 2_000);
const message = text(resultObject.message || (uncertain ? 'ERP 结果不确定,请勿重复提交同一任务,等待任务所属账号只读核验。' : '插件已返回状态。')).slice(0, 2_000);
const executionFailure = failureSummary(resultObject, '', status, stage);
const operation = decryptedJson(this.config, row.operation_ciphertext) || row.operation || null;
const baseSuccessReceipt = status === 'completed' ? successReceiptFromResult(resultObject) : null;
@@ -6626,8 +6650,9 @@ export class TaskService {
message: string,
details: Record<string, unknown> = {}
): Promise<PublicTask> {
this.requireTaskDataPlaneAccess(context);
const normalizedReason = text(reasonCode || 'erp_result_uncertain').slice(0, 160);
const normalizedMessage = text(message || 'ERP 执行结果不确定,已停止自动执行。请勿重复提交同一任务,等待管理员只读核验。').slice(0, 2_000);
const normalizedMessage = text(message || 'ERP 执行结果不确定,已停止自动执行。请勿重复提交同一任务,等待任务所属账号只读核验。').slice(0, 2_000);
const outcome = await withTransaction(this.config, async (client) => {
const lookup = await client.query(
'SELECT * FROM tasks WHERE organization_id = $1 AND task_id = $2 FOR UPDATE',
@@ -7025,6 +7050,7 @@ export class TaskService {
taskIds: string[],
reason = ''
): Promise<{ task_ids: string[]; archived_count: number }> {
this.requireTaskDataPlaneAccess(context);
const normalizedTaskIds = [...new Set(taskIds.map((taskId) => text(taskId).trim()).filter(Boolean))];
if (!normalizedTaskIds.length) throw new TaskError('invalid_task_ids', '请至少选择一个待归档任务。', 400);
const archiveReason = text(reason).trim().slice(0, 500) || null;
@@ -7079,6 +7105,7 @@ export class TaskService {
}
async restoreTask(context: TaskContext, taskId: string): Promise<PublicTask> {
this.requireTaskDataPlaneAccess(context);
const outcome = await withTransaction(this.config, async (client) => {
const row = await this.lockTaskForAccess(client, context, taskId, { allowArchived: true });
if (!row.archived_at) return { event: null };
@@ -7115,6 +7142,7 @@ export class TaskService {
context: TaskContext,
taskIds: string[]
): Promise<{ task_ids: string[]; deleted_count: number }> {
this.requireTaskDataPlaneAccess(context);
const normalizedTaskIds = [...new Set(taskIds.map((taskId) => text(taskId).trim()).filter(Boolean))];
if (!normalizedTaskIds.length) throw new TaskError('invalid_task_ids', '请至少选择一个待删除任务。', 400);
const outcome = await withTransaction(this.config, async (client) => {
@@ -7225,6 +7253,7 @@ export class TaskService {
}
async cancelTask(context: TaskContext, taskId: string): Promise<PublicTask> {
this.requireTaskDataPlaneAccess(context);
const outcome = await withTransaction(this.config, async (client) => {
const row = await this.lockTaskForAccess(client, context, taskId);
if (['completed', 'reconciliation_pending'].includes(text(row.status))) {
@@ -7274,6 +7303,7 @@ export class TaskService {
metadata: Record<string, unknown> = {},
routing: { erpAccount?: string; erpAccountMatched?: boolean } = {}
): Promise<{ execution_ready: boolean; erp_account_matched: boolean; worker_connection_id: string }> {
this.requireTaskDataPlaneAccess(context);
return withTransaction(this.config, async (client) => {
const account = await client.query(
`SELECT id, role, is_active, erp_account
@@ -7288,15 +7318,15 @@ export class TaskService {
const accountRow = account.rows[0] as Record<string, unknown>;
const expectedErpAccount = text(accountRow.erp_account);
const reportedErpAccount = text(routing.erpAccount);
const isAdmin = text(accountRow.role) === 'admin';
const erpAccountMatched = isAdmin
? true
: Boolean(
expectedErpAccount
&& routing.erpAccountMatched === true
&& reportedErpAccount.toLocaleLowerCase() === expectedErpAccount.toLocaleLowerCase()
);
const executionReady = isAdmin || erpAccountMatched;
if (text(accountRow.role) === 'admin') {
throw new TaskError('task_access_forbidden', '管理员账号不能注册任务执行浏览器。', 403);
}
const erpAccountMatched = Boolean(
expectedErpAccount
&& routing.erpAccountMatched === true
&& reportedErpAccount.toLocaleLowerCase() === expectedErpAccount.toLocaleLowerCase()
);
const executionReady = erpAccountMatched;
if (executionReady) {
const competing = await client.query(