feat: isolate administrators from task data plane
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user