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

@@ -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,