diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts index b427f67..23285d4 100644 --- a/backend/src/__tests__/auth.test.ts +++ b/backend/src/__tests__/auth.test.ts @@ -15,11 +15,12 @@ describe('Auth', () => { expect(res.body.message).toBeDefined(); }); - it('rejects duplicate email with 409', async () => { + it('returns 200 with generic message for duplicate email (gap-15: no enumeration)', async () => { const e = email(); - await api.post('/api/auth/register').send({ email: e, name: 'A', password: 'Password1' }); - const res = await api.post('/api/auth/register').send({ email: e, name: 'B', password: 'Password1' }); - expect(res.status).toBe(409); + const first = await api.post('/api/auth/register').send({ email: e, name: 'A', password: 'Password1' }); + const second = await api.post('/api/auth/register').send({ email: e, name: 'B', password: 'Password1' }); + expect(second.status).toBe(200); + expect(second.body.message).toBe(first.body.message); }); it('rejects weak password with 400', async () => { diff --git a/backend/src/__tests__/security/ib-access-control.test.ts b/backend/src/__tests__/security/ib-access-control.test.ts new file mode 100644 index 0000000..a661f78 --- /dev/null +++ b/backend/src/__tests__/security/ib-access-control.test.ts @@ -0,0 +1,237 @@ +/** + * BDD: ИБ — Управление правами доступа (RBAC / Record-level) + * Feature: specs/security/ib-access-control.feature + * + * GAP-статус: + * РЕАЛИЗОВАНО: workspace RBAC (OWNER/MEMBER/VIEWER), superadmin guard + * НЕ РЕАЛИЗОВАНО: isActive/blocking flag, AuditLog для role-changes с oldValue/newValue, + * API-key scope enforcement, доступ к audit-log сам логируется + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { api, uid, registerUser, cleanupTestData, auth } from '../helpers.js'; +import { prisma } from '../../prisma/client.js'; +import { hashPassword } from '../../shared/utils/password.js'; +import { config } from '../../config.js'; + +const email = () => `${uid()}@test.com`; + +async function getSuperadminToken(): Promise { + const pw = 'Password1'; + await prisma.user.upsert({ + where: { email: config.SUPERADMIN_EMAIL }, + update: { password: await hashPassword(pw), isSuperadmin: true }, + create: { email: config.SUPERADMIN_EMAIL, name: 'Superadmin', password: await hashPassword(pw), isSuperadmin: true }, + }); + const res = await api.post('/api/auth/login').send({ email: config.SUPERADMIN_EMAIL, password: pw }); + return res.body.accessToken as string; +} + +async function createWorkspace(token: string, name = `ws-${uid()}`) { + const res = await api.post('/api/workspaces').set(auth(token)).send({ name, slug: name }); + return res.body as { id: string; name: string }; +} + +async function getLastAuditLog(action: string) { + return prisma.auditLog.findFirst({ where: { action }, orderBy: { createdAt: 'desc' } }); +} + +describe('RBAC: Управление правами доступа — ИБ-требования', () => { + let superToken: string; + let ownerUser: { token: string; userId: string; email: string }; + let memberUser: { token: string; userId: string; email: string }; + let viewerUser: { token: string; userId: string; email: string }; + let outsiderUser: { token: string; userId: string; email: string }; + let workspaceId: string; + + beforeAll(async () => { + superToken = await getSuperadminToken(); + [ownerUser, memberUser, viewerUser, outsiderUser] = await Promise.all([ + registerUser(), + registerUser(), + registerUser(), + registerUser(), + ]); + + const ws = await createWorkspace(ownerUser.token); + workspaceId = ws.id; + + // Add member and viewer + await api + .post(`/api/workspaces/${workspaceId}/members`) + .set(auth(ownerUser.token)) + .send({ userId: memberUser.userId, role: 'MEMBER' }); + await api + .post(`/api/workspaces/${workspaceId}/members`) + .set(auth(ownerUser.token)) + .send({ userId: viewerUser.userId, role: 'VIEWER' }); + }); + + afterAll(async () => { + // Delete superadmin's workspaces first — Workspace.creatorId has no cascade on user delete + const sa = await prisma.user.findUnique({ where: { email: config.SUPERADMIN_EMAIL }, select: { id: true } }); + if (sa) { + await prisma.workspace.deleteMany({ where: { creatorId: sa.id } }); + await prisma.user.delete({ where: { id: sa.id } }); + } + await cleanupTestData(); + }); + + // ─── Принцип минимальных полномочий ──────────────────────────────────────── + + describe('Минимальные полномочия (Req §1.3.1, §1.4.1)', () => { + it('VIEWER не может удалить воркспейс — 403', async () => { + const res = await api.delete(`/api/workspaces/${workspaceId}`).set(auth(viewerUser.token)); + expect(res.status).toBe(403); + }); + + it('MEMBER не может удалить воркспейс — 403', async () => { + const res = await api.delete(`/api/workspaces/${workspaceId}`).set(auth(memberUser.token)); + expect(res.status).toBe(403); + }); + + it('OWNER может обновить настройки воркспейса', async () => { + const res = await api + .patch(`/api/workspaces/${workspaceId}`) + .set(auth(ownerUser.token)) + .send({ description: 'updated' }); + expect(res.status).toBe(200); + }); + + it('без аутентификации любой запрос возвращает 401', async () => { + const res = await api.get(`/api/workspaces/${workspaceId}`); + expect(res.status).toBe(401); + }); + }); + + // ─── Изоляция по воркспейсу (Record-level) ───────────────────────────────── + + describe('Record-level isolation (Req §1.3.3, §1.7)', () => { + let board: { id: string }; + let task: { id: string }; + + beforeAll(async () => { + // Create a workflow and board in the workspace + const wfRes = await api + .post(`/api/workspaces/${workspaceId}/workflows`) + .set(auth(ownerUser.token)) + .send({ name: 'Default', mode: 'BIDIRECTIONAL' }); + + // Add status to workflow + await api + .post(`/api/workflow-statuses`) + .set(auth(ownerUser.token)) + .send({ workflowId: wfRes.body.id, name: 'Todo', color: '#aaa', position: 0, category: 'OPEN' }); + + const wfWithStatuses = await api + .get(`/api/workflows/${wfRes.body.id}`) + .set(auth(ownerUser.token)); + const statusId = wfWithStatuses.body?.statuses?.[0]?.id; + + const boardRes = await api + .post(`/api/workspaces/${workspaceId}/boards`) + .set(auth(ownerUser.token)) + .send({ name: 'Board1', prefix: `B${uid().slice(0, 3).toUpperCase()}`, workflowId: wfRes.body.id }); + board = boardRes.body; + + if (board?.id && statusId) { + const taskRes = await api + .post(`/api/boards/${board.id}/tasks`) + .set(auth(ownerUser.token)) + .send({ title: 'Task1', statusId }); + task = taskRes.body; + } + }); + + it('пользователь без членства (outsider) не видит задачи воркспейса', async () => { + if (!task?.id) return; + const res = await api.get(`/api/tasks/${task.id}`).set(auth(outsiderUser.token)); + expect([403, 404]).toContain(res.status); + }); + + it('MEMBER видит задачи своего воркспейса', async () => { + if (!board?.id) return; + const res = await api.get(`/api/boards/${board.id}/tasks`).set(auth(memberUser.token)); + expect(res.status).toBe(200); + }); + }); + + // ─── Superadmin полномочия ───────────────────────────────────────────────── + + describe('Superadmin полный доступ (Req §1.5.3)', () => { + it('superadmin видит всех пользователей через /api/admin/users', async () => { + const res = await api.get('/api/admin/users').set(auth(superToken)); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.users ?? res.body)).toBe(true); + }); + + it('обычный пользователь не может вызвать /api/admin/users — 403', async () => { + const res = await api.get('/api/admin/users').set(auth(memberUser.token)); + expect(res.status).toBe(403); + }); + }); + + // ─── AuditLog для изменений прав ────────────────────────────────────────── + + describe('AuditLog для изменений прав (ГОСТ 57580 УЗП.24, УЗП.25)', () => { + it('добавление участника записывается в WorkspaceEvent', async () => { + const newUser = await registerUser(); + await api + .post(`/api/workspaces/${workspaceId}/members`) + .set(auth(ownerUser.token)) + .send({ userId: newUser.userId, role: 'MEMBER' }); + + const event = await prisma.workspaceEvent.findFirst({ + where: { workspaceId, action: 'member_added' }, + orderBy: { createdAt: 'desc' }, + }); + expect(event).not.toBeNull(); + expect(event!.userId).toBe(ownerUser.userId); + }); + + it.todo('изменение роли записывается с oldRole и newRole в мета (было-стало)', async () => { + await api + .patch(`/api/workspaces/${workspaceId}/members/${viewerUser.userId}`) + .set(auth(ownerUser.token)) + .send({ role: 'MEMBER' }); + + const event = await prisma.workspaceEvent.findFirst({ + where: { workspaceId, action: 'member_role_changed' }, + orderBy: { createdAt: 'desc' }, + }); + expect(event).not.toBeNull(); + const meta = event!.meta as Record; + expect(meta.oldRole).toBe('VIEWER'); + expect(meta.newRole).toBe('MEMBER'); + }); + }); + + // ─── Блокировка пользователя ────────────────────────────────────────────── + + describe('Блокировка пользователя администратором (Req логирование §2.2)', () => { + it.todo('заблокированный пользователь получает 403 при попытке входа', async () => { + const blocked = await registerUser(); + // Admin sets isActive=false (endpoint to be implemented) + await api + .patch(`/api/admin/users/${blocked.userId}`) + .set(auth(superToken)) + .send({ isActive: false }); + + const res = await api.post('/api/auth/login').send({ email: blocked.email, password: blocked.password }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ACCOUNT_DISABLED'); + }); + + it.todo('блокировка пользователя создаёт AuditLog с action=admin.user.deactivate', async () => { + const blocked = await registerUser(); + await api + .patch(`/api/admin/users/${blocked.userId}`) + .set(auth(superToken)) + .send({ isActive: false }); + + const log = await getLastAuditLog('admin.user.deactivate'); + expect(log).not.toBeNull(); + expect(log!.targetId).toBe(blocked.userId); + }); + }); +}); diff --git a/backend/src/__tests__/security/ib-audit-logging.test.ts b/backend/src/__tests__/security/ib-audit-logging.test.ts new file mode 100644 index 0000000..0d5da66 --- /dev/null +++ b/backend/src/__tests__/security/ib-audit-logging.test.ts @@ -0,0 +1,223 @@ +/** + * BDD: ИБ — Регистрация событий и аудит (SIEM-интеграция) + * Feature: specs/security/ib-audit-logging.feature + * + * GAP-статус: + * РЕАЛИЗОВАНО: AuditLog модель (admin actions), TaskHistory (было-стало), WorkspaceEvent, + * структурированный JSON-лог, редакция sensitive-полей в логгере + * НЕ РЕАЛИЗОВАНО: SIEM-транспорт (syslog/HTTP-sink), multi-sink, обязательные SIEM-поля, + * SIEM-теги, маскировка ПДн, системные события (start/stop/update), + * буферизация при недоступном SIEM + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { api, uid, registerUser, cleanupTestData, auth, createBoard, createTask } from '../helpers.js'; +import { prisma } from '../../prisma/client.js'; +import { hashPassword } from '../../shared/utils/password.js'; +import { config } from '../../config.js'; + +const email = () => `${uid()}@test.com`; + +async function getSuperadminToken(): Promise { + const pw = 'Password1'; + await prisma.user.upsert({ + where: { email: config.SUPERADMIN_EMAIL }, + update: { password: await hashPassword(pw), isSuperadmin: true }, + create: { email: config.SUPERADMIN_EMAIL, name: 'SA', password: await hashPassword(pw), isSuperadmin: true }, + }); + const res = await api.post('/api/auth/login').send({ email: config.SUPERADMIN_EMAIL, password: pw }); + return res.body.accessToken as string; +} + +async function getLastAuditLog(action: string) { + return prisma.auditLog.findFirst({ where: { action }, orderBy: { createdAt: 'desc' } }); +} + +describe('Аудит: Регистрация событий — ИБ-требования', () => { + let superToken: string; + let ownerUser: { token: string; userId: string; email: string }; + + beforeAll(async () => { + superToken = await getSuperadminToken(); + ownerUser = await registerUser(); + }); + + afterAll(async () => { + // Delete superadmin's workspaces first — Workspace.creatorId has no cascade on user delete + const sa = await prisma.user.findUnique({ where: { email: config.SUPERADMIN_EMAIL }, select: { id: true } }); + if (sa) { + await prisma.workspace.deleteMany({ where: { creatorId: sa.id } }); + await prisma.user.delete({ where: { id: sa.id } }); + } + await cleanupTestData(); + }); + + // ─── TaskHistory: было-стало ─────────────────────────────────────────────── + + describe('Изменения в формате "было-стало" (Req логирование §1)', () => { + let workspaceId: string; + let boardId: string; + let taskId: string; + + beforeAll(async () => { + const ws = await api.post('/api/workspaces').set(auth(ownerUser.token)).send({ + name: `ws-${uid()}`, slug: `ws-${uid()}`, + }); + workspaceId = ws.body.id; + + // createBoard uses the default workflow auto-created on workspace creation + const board = await createBoard(ownerUser.token, workspaceId); + boardId = board.id; + + // createTask auto-resolves statusId to the first status in the workflow + const task = await createTask(ownerUser.token, boardId, { title: 'Original Title', priority: 'HIGH' }); + taskId = task.id; + }); + + it('изменение priority создаёт TaskHistory с oldValue и newValue', async () => { + await api.patch(`/api/tasks/${taskId}`).set(auth(ownerUser.token)).send({ priority: 'LOW' }); + + const history = await prisma.taskHistory.findFirst({ + where: { taskId, field: 'priority' }, + orderBy: { createdAt: 'desc' }, + }); + expect(history).not.toBeNull(); + expect(history!.oldValue).toBe('HIGH'); + expect(history!.newValue).toBe('LOW'); + }); + + it('изменение title создаёт TaskHistory с корректными oldValue/newValue', async () => { + await api.patch(`/api/tasks/${taskId}`).set(auth(ownerUser.token)).send({ title: 'New Title' }); + + const history = await prisma.taskHistory.findFirst({ + where: { taskId, field: 'title' }, + orderBy: { createdAt: 'desc' }, + }); + expect(history).not.toBeNull(); + expect(history!.oldValue).toBe('Original Title'); + expect(history!.newValue).toBe('New Title'); + }); + }); + + // ─── Admin AuditLog ──────────────────────────────────────────────────────── + + describe('Аудит действий администраторов (Req логирование §2.3, ГОСТ УЗП.22)', () => { + it('одобрение регистрации создаёт AuditLog с action=request.approve', async () => { + const newUser = await registerUser(); + + const requests = await api.get('/api/admin/registration-requests').set(auth(superToken)); + const pending = (requests.body as Array<{ id: string; email: string }>) + .find((r) => r.email === newUser.email); + + if (!pending) return; + + await api.post(`/api/admin/registration-requests/${pending.id}/approve`).set(auth(superToken)); + + const log = await getLastAuditLog('request.approve'); + expect(log).not.toBeNull(); + expect(log!.actorId).toBeTruthy(); + }); + + it.todo('изменение настроек системы создаёт AuditLog с action=admin.config.change', async () => { + await api.patch('/api/admin/config').set(auth(superToken)).send({ registrationDomain: 'newdomain.ru' }); + const log = await getLastAuditLog('admin.config.change'); + expect(log).not.toBeNull(); + const meta = log!.meta as Record; + expect(meta.setting).toBe('registrationDomain'); + expect(typeof meta.oldValue).toBe('string'); + expect(meta.newValue).toBe('newdomain.ru'); + }); + }); + + // ─── SIEM-формат обязательных полей ──────────────────────────────────────── + + describe('Обязательные поля SIEM-события (Req логирование §1)', () => { + it.todo('каждый AuditLog-event содержит обязательные SIEM-поля', async () => { + const logs = await prisma.auditLog.findMany({ take: 10, orderBy: { createdAt: 'desc' } }); + + const requiredFields = ['source', 'subject', 'tech_segment', 'tags', 'session_id', 'result']; + + for (const log of logs) { + const meta = (log.meta ?? {}) as Record; + for (const field of requiredFields) { + expect(meta[field], `AuditLog ${log.id} missing field: ${field}`).toBeDefined(); + } + // tags должен быть массивом из 5 элементов + expect(Array.isArray(meta.tags)).toBe(true); + expect((meta.tags as unknown[]).length).toBeGreaterThanOrEqual(3); + } + }); + + it.todo('createdAt в AuditLog соответствует формату ISO-8601', async () => { + const log = await prisma.auditLog.findFirst({ orderBy: { createdAt: 'desc' } }); + expect(log).not.toBeNull(); + expect(() => new Date(log!.createdAt).toISOString()).not.toThrow(); + }); + }); + + // ─── SIEM-тегирование ────────────────────────────────────────────────────── + + describe('SIEM-тегирование (Req логирование §4)', () => { + it.todo('тег auth.login содержит ["flowtasks","auth","iia","PROD",]', async () => { + const user = await registerUser(); + await api.post('/api/auth/login').send({ email: user.email, password: user.password }); + + const log = await getLastAuditLog('auth.login'); + const meta = (log!.meta ?? {}) as Record; + const tags = meta.tags as string[]; + expect(tags[0]).toBe('flowtasks'); + expect(tags[1]).toBe('auth'); + expect(tags[2]).toBe('iia'); + }); + }); + + // ─── Маскировка ПДн ────────────────────────────────────────────────────── + + describe('Маскировка ПДн в SIEM-событиях (Req логирование §2.4)', () => { + it.todo('email пользователя в AuditLog.meta маскируется перед отправкой в SIEM', async () => { + // При реализации SIEM-транспорта: проверить что email передаётся как hash/mask + const log = await getLastAuditLog('auth.login'); + const meta = (log!.meta ?? {}) as Record; + if (meta.actorEmail) { + expect(meta.actorEmail as string).toMatch(/\*{2,}|[a-f0-9]{64}/); + } + }); + }); + + // ─── Системные события ──────────────────────────────────────────────────── + + describe('Системные события (Req логирование §2.6, ГОСТ ЦЗИ.30)', () => { + it.todo('при старте сервера пишется событие system.service.start', async () => { + // Проверить через специальный health+events endpoint или stdout capture + const res = await api.get('/api/health'); + expect(res.status).toBe(200); + // TODO: проверить что при старте был записан системный event + }); + }); + + // ─── Валидационные ошибки ──────────────────────────────────────────────── + + describe('Ошибки валидации логируются (Req логирование §2.6)', () => { + it.todo('POST /api/auth/login с невалидным телом создаёт событие system.validation.error', async () => { + await api.post('/api/auth/login').send({ email: 'not-an-email', password: '' }); + const log = await getLastAuditLog('system.validation.error'); + expect(log).not.toBeNull(); + }); + }); + + // ─── WorkspaceEvent для ресурсных операций (ГОСТ ИУ.7) ──────────────────── + + describe('Регистрация операций над ресурсами БД (ГОСТ 57580 ИУ.7)', () => { + it('создание воркспейса создаёт WorkspaceEvent с action=workspace_created', async () => { + const wsRes = await api.post('/api/workspaces').set(auth(ownerUser.token)).send({ + name: `ws-${uid()}`, slug: `ws-${uid()}`, + }); + const wsId = wsRes.body.id; + + const event = await prisma.workspaceEvent.findFirst({ + where: { workspaceId: wsId, action: 'workspace_created' }, + }); + expect(event).not.toBeNull(); + }); + }); +}); diff --git a/backend/src/__tests__/security/ib-authentication.test.ts b/backend/src/__tests__/security/ib-authentication.test.ts new file mode 100644 index 0000000..90ec37c --- /dev/null +++ b/backend/src/__tests__/security/ib-authentication.test.ts @@ -0,0 +1,255 @@ +/** + * BDD: ИБ — Аутентификация (ИАА / iia) + * Feature: specs/security/ib-authentication.feature + * + * GAP-статус: большинство тестов — PENDING (требуется реализация AuditLog для auth-событий) + * Реализовано: brute-force блокировка, JWT, SSO-flow + * Не реализовано: запись auth-событий в AuditLog, clientSignature, sessionId в логах + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { api, uid, registerUser, cleanupTestData, auth } from '../helpers.js'; +import { prisma } from '../../prisma/client.js'; +import { hashPassword } from '../../shared/utils/password.js'; + +const email = () => `${uid()}@test.com`; + +async function getLastAuditLog(action: string) { + return prisma.auditLog.findFirst({ + where: { action }, + orderBy: { createdAt: 'desc' }, + }); +} + +describe('ИАА: Аутентификация — ИБ-требования', () => { + afterAll(cleanupTestData); + + // ─── Уникальность учётных записей ───────────────────────────────────────── + + describe('Уникальность учётных записей (Req basic §1.2)', () => { + it('дублирующийся email возвращает 200 с тем же сообщением (gap-15: no enumeration)', async () => { + const e = email(); + const first = await api.post('/api/auth/register').send({ email: e, name: 'A', password: 'Password1' }); + const second = await api.post('/api/auth/register').send({ email: e, name: 'B', password: 'Password1' }); + expect(second.status).toBe(200); + expect(second.body.message).toBe(first.body.message); + }); + }); + + // ─── Email enumeration (gap-15) ─────────────────────────────────────────── + + describe('Отсутствие email enumeration через /register (gap-15)', () => { + it('три запроса с разным статусом email возвращают одинаковый message', async () => { + // existing user + const existingUser = await registerUser(); + + // pending request (direct via API) + const pendingEmail = email(); + const pendingRes = await api + .post('/api/auth/register') + .send({ email: pendingEmail, name: 'Pending', password: 'Password1' }); + expect(pendingRes.status).toBe(200); + + // new email (never seen) + const newEmail = email(); + const newRes = await api + .post('/api/auth/register') + .send({ email: newEmail, name: 'New', password: 'Password1' }); + + // re-submit existing + const existingRes = await api + .post('/api/auth/register') + .send({ email: existingUser.email, name: 'Dup', password: 'Password1' }); + + // re-submit pending + const pendingDupRes = await api + .post('/api/auth/register') + .send({ email: pendingEmail, name: 'Dup', password: 'Password1' }); + + expect(newRes.status).toBe(200); + expect(existingRes.status).toBe(200); + expect(pendingDupRes.status).toBe(200); + expect(existingRes.body.message).toBe(newRes.body.message); + expect(pendingDupRes.body.message).toBe(newRes.body.message); + }); + }); + + // ─── Успешный / неуспешный вход → AuditLog ──────────────────────────────── + + describe('Событие auth.login записывается в AuditLog (ГОСТ 57580 РД-40)', () => { + let testEmail: string; + let testPassword: string; + + beforeAll(async () => { + const user = await registerUser(); + testEmail = user.email; + testPassword = user.password; + }); + + it.todo('успешный login создаёт AuditLog с action=auth.login и result=SUCCESS', async () => { + await api.post('/api/auth/login').send({ email: testEmail, password: testPassword }); + const log = await getLastAuditLog('auth.login'); + expect(log).not.toBeNull(); + expect((log!.meta as Record)?.result).toBe('SUCCESS'); + }); + + it.todo('неуспешный login создаёт AuditLog с action=auth.login и result=FAIL', async () => { + await api.post('/api/auth/login').send({ email: testEmail, password: 'WrongPass1' }); + const log = await getLastAuditLog('auth.login'); + expect(log).not.toBeNull(); + expect((log!.meta as Record)?.result).toBe('FAIL'); + }); + + it.todo('событие login содержит ip и userAgent из заголовков запроса', async () => { + await api + .post('/api/auth/login') + .set('User-Agent', 'TestBrowser/1.0') + .set('X-Real-IP', '10.0.0.1') + .send({ email: testEmail, password: testPassword }); + const log = await getLastAuditLog('auth.login'); + const meta = log!.meta as Record; + expect(meta.ip).toBe('10.0.0.1'); + expect(meta.userAgent).toContain('TestBrowser'); + }); + + it.todo('событие login содержит sessionId', async () => { + await api.post('/api/auth/login').send({ email: testEmail, password: testPassword }); + const log = await getLastAuditLog('auth.login'); + const meta = log!.meta as Record; + expect(typeof meta.sessionId).toBe('string'); + expect((meta.sessionId as string).length).toBeGreaterThan(0); + }); + }); + + // ─── Rate-limit по email (gap-14) ──────────────────────────────────────── + + describe('Rate-limit на /login ключируется по email, а не по IP (gap-14)', () => { + it('10 запросов с разными X-Forwarded-For на один email → 11-й блокируется (429)', async () => { + const victim = await registerUser(); + + for (let i = 1; i <= 10; i++) { + await api + .post('/api/auth/login') + .set('X-Forwarded-For', `10.99.${i}.1`) + .send({ email: victim.email, password: 'WrongPass!' }); + } + + const blocked = await api + .post('/api/auth/login') + .set('X-Forwarded-For', '10.99.99.1') + .send({ email: victim.email, password: 'WrongPass!' }); + + expect(blocked.status).toBe(429); + }); + + it('/register: 10 запросов с разными X-Forwarded-For на один email → 11-й блокируется (429)', async () => { + const targetEmail = email(); + + for (let i = 1; i <= 10; i++) { + await api + .post('/api/auth/register') + .set('X-Forwarded-For', `10.88.${i}.1`) + .send({ email: targetEmail, name: 'Flood', password: 'Password1' }); + } + + const blocked = await api + .post('/api/auth/register') + .set('X-Forwarded-For', '10.88.99.1') + .send({ email: targetEmail, name: 'Flood', password: 'Password1' }); + + expect(blocked.status).toBe(429); + }); + }); + + // ─── Блокировка по brute-force ──────────────────────────────────────────── + + describe('Блокировка аккаунта (Req логирование §2.1)', () => { + it.skip('после 5 неудачных попыток возвращает 429', async () => { + // Redis brute-force counter disabled in NODE_ENV=test (getRedisClientInternal returns null) + const e = email(); + await registerUser({ email: e }); + for (let i = 0; i < 5; i++) { + await api.post('/api/auth/login').send({ email: e, password: 'WrongPass1' }); + } + const res = await api.post('/api/auth/login').send({ email: e, password: 'WrongPass1' }); + expect(res.status).toBe(429); + }); + + it.todo('блокировка создаёт AuditLog с action=auth.lockout', async () => { + const e = email(); + await registerUser({ email: e }); + for (let i = 0; i < 5; i++) { + await api.post('/api/auth/login').send({ email: e, password: 'WrongPass1' }); + } + const log = await getLastAuditLog('auth.lockout'); + expect(log).not.toBeNull(); + }); + }); + + // ─── Logout ─────────────────────────────────────────────────────────────── + + describe('Выход из системы (ГОСТ 57580 РД-41)', () => { + it('POST /api/auth/logout возвращает 200', async () => { + const user = await registerUser(); + const res = await api.post('/api/auth/logout').set(auth(user.token)); + expect(res.status).toBe(200); + }); + + it.todo('logout создаёт AuditLog с action=auth.logout и reason=user_initiated', async () => { + const user = await registerUser(); + await api.post('/api/auth/logout').set(auth(user.token)); + const log = await getLastAuditLog('auth.logout'); + expect(log).not.toBeNull(); + const meta = log!.meta as Record; + expect(meta.reason).toBe('user_initiated'); + }); + }); + + // ─── Смена пароля (ГОСТ 57580 РД-43) ───────────────────────────────────── + + describe('Изменение аутентификационных данных (ГОСТ 57580 РД-43)', () => { + it.todo('смена пароля создаёт AuditLog с action=auth.credential.change', async () => { + const user = await registerUser(); + await api + .patch('/api/auth/profile') + .set(auth(user.token)) + .send({ currentPassword: user.password, newPassword: 'NewPassword1' }); + const log = await getLastAuditLog('auth.credential.change'); + expect(log).not.toBeNull(); + }); + }); + + // ─── SIEM-тег ───────────────────────────────────────────────────────────── + + describe('SIEM-тегирование событий (Req логирование §4)', () => { + it.todo('событие auth.login содержит SIEM-тег в формате [system,type,segment,env,dc]', async () => { + const user = await registerUser(); + await api.post('/api/auth/login').send({ email: user.email, password: user.password }); + const log = await getLastAuditLog('auth.login'); + const meta = log!.meta as Record; + expect(Array.isArray(meta.tags)).toBe(true); + const tags = meta.tags as string[]; + expect(tags[0]).toBe('flowtasks'); + expect(tags[1]).toBe('auth'); + expect(tags[2]).toBe('iia'); + }); + }); + + // ─── API Key auth ───────────────────────────────────────────────────────── + + describe('API Key аутентификация (Req §1.3.4)', () => { + it.todo('использование валидного API-ключа создаёт AuditLog с action=auth.apikey.use', async () => { + // Setup: create API key via /api/auth/api-keys endpoint + const user = await registerUser(); + const keyRes = await api + .post('/api/auth/api-keys') + .set(auth(user.token)) + .send({ label: 'test-key' }); + const rawKey = keyRes.body.key as string; + + await api.get('/api/my-tasks').set('Authorization', `Bearer ${rawKey}`); + const log = await getLastAuditLog('auth.apikey.use'); + expect(log).not.toBeNull(); + }); + }); +}); diff --git a/backend/src/modules/admin/admin.dto.ts b/backend/src/modules/admin/admin.dto.ts index d39163b..0957ebb 100644 --- a/backend/src/modules/admin/admin.dto.ts +++ b/backend/src/modules/admin/admin.dto.ts @@ -14,6 +14,11 @@ export const updateUserDto = z.object({ isSuperadmin: z.boolean(), }); +export const SetUserActiveSchema = z.object({ + isActive: z.boolean(), +}); + export type CreateUserDto = z.infer; export type ReviewRequestDto = z.infer; export type UpdateUserDto = z.infer; +export type SetUserActiveDto = z.infer; diff --git a/backend/src/modules/admin/admin.router.ts b/backend/src/modules/admin/admin.router.ts index ebca7f3..d88b058 100644 --- a/backend/src/modules/admin/admin.router.ts +++ b/backend/src/modules/admin/admin.router.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import { authenticate } from '../../shared/middleware/auth.js'; import { requireSuperadmin } from '../../shared/middleware/require-superadmin.js'; import { validate } from '../../shared/middleware/validate.js'; -import { createUserDto, reviewRequestDto, updateUserDto } from './admin.dto.js'; +import { createUserDto, reviewRequestDto, updateUserDto, SetUserActiveSchema } from './admin.dto.js'; import * as adminService from './admin.service.js'; import type { AuthRequest } from '../../shared/types/index.js'; @@ -59,6 +59,19 @@ router.patch('/registration-requests/:id', validate(reviewRequestDto), async (re } }); +router.patch('/users/:id/active', validate(SetUserActiveSchema), async (req: AuthRequest, res, next) => { + try { + const result = await adminService.setUserActive( + req.user!.userId, + req.params.id as string, + req.body.isActive, + ); + res.json(result); + } catch (err) { + next(err); + } +}); + router.get('/audit-log', async (req, res, next) => { try { const rawLimit = parseInt(String(req.query.limit ?? '100'), 10); diff --git a/backend/src/modules/admin/admin.service.ts b/backend/src/modules/admin/admin.service.ts index f1bcd2b..618d8f0 100644 --- a/backend/src/modules/admin/admin.service.ts +++ b/backend/src/modules/admin/admin.service.ts @@ -5,9 +5,10 @@ import { hashPassword } from '../../shared/utils/password.js'; import { AppError } from '../../shared/middleware/error-handler.js'; import { logger } from '../../shared/utils/logger.js'; import { config } from '../../config.js'; +import { auditLog } from '../../shared/utils/audit-logger.js'; import type { CreateUserDto, ReviewRequestDto } from './admin.dto.js'; -function auditLog( +function writeAuditLog( actorId: string, action: string, targetId?: string, @@ -63,7 +64,7 @@ export async function setUserSuperadmin(actorId: string, userId: string, isSuper data: { isSuperadmin }, select: { id: true, email: true, name: true, isSuperadmin: true }, }); - auditLog(actorId, 'user.set_superadmin', userId, { isSuperadmin }); + writeAuditLog(actorId, 'user.set_superadmin', userId, { isSuperadmin }); return updated; } @@ -83,7 +84,7 @@ export async function createUser(actorId: string, dto: CreateUserDto) { select: { id: true, email: true, name: true, avatar: true, loginCount: true, createdAt: true }, }); - auditLog(actorId, 'user.create', user.id, { email: user.email }); + writeAuditLog(actorId, 'user.create', user.id, { email: user.email }); logger.info('admin_user_created', { userId: user.id, email: user.email }); return { user, generatedPassword }; } @@ -131,13 +132,13 @@ export async function reviewRegistrationRequest( data: { status: 'APPROVED', reviewedBy: reviewerUserId, reviewedAt: new Date() }, }), ]); - auditLog(reviewerUserId, 'request.approve', requestId, { email: request.email }); + writeAuditLog(reviewerUserId, 'request.approve', requestId, { email: request.email }); } else { await prisma.registrationRequest.update({ where: { id: requestId }, data: { status: 'REJECTED', reviewedBy: reviewerUserId, reviewedAt: new Date() }, }); - auditLog(reviewerUserId, 'request.reject', requestId, { email: request.email }); + writeAuditLog(reviewerUserId, 'request.reject', requestId, { email: request.email }); } } @@ -152,3 +153,20 @@ export async function listAuditLogs(limit = 100, offset = 0) { ]); return { logs, total }; } + +export async function setUserActive(actorId: string, targetId: string, isActive: boolean) { + const user = await prisma.user.findUnique({ where: { id: targetId } }); + if (!user) throw new AppError(404, 'Пользователь не найден'); + + await prisma.user.update({ where: { id: targetId }, data: { isActive } }); + + void auditLog({ + actorId, + action: isActive ? 'admin.user.activate' : 'admin.user.deactivate', + targetId, + result: 'SUCCESS', + meta: { email: user.email, isActive }, + }); + + return { id: targetId, isActive }; +} diff --git a/backend/src/modules/auth/auth.router.ts b/backend/src/modules/auth/auth.router.ts index f1061f1..bb297a5 100644 --- a/backend/src/modules/auth/auth.router.ts +++ b/backend/src/modules/auth/auth.router.ts @@ -1,12 +1,23 @@ import { Router } from 'express'; +import type { Request } from 'express'; import { validate } from '../../shared/middleware/validate.js'; import { authenticate } from '../../shared/middleware/auth.js'; +import { rateLimit, RATE_LIMITS } from '../../shared/middleware/rate-limit.js'; import { registerDto, loginDto, updateProfileDto, forgotPasswordDto, resetPasswordDto } from './auth.dto.js'; import * as authService from './auth.service.js'; import { AppError } from '../../shared/middleware/error-handler.js'; import { config } from '../../config.js'; import ssoRouter from './sso/sso.router.js'; import type { AuthRequest } from '../../shared/types/index.js'; +import { extractClientMeta } from '../../shared/utils/audit-logger.js'; + +// Key by email so rotating X-Forwarded-For doesn't bypass the limit. +// Fall back to 'no-email' (single shared bucket) — not req.ip — so that +// malformed requests without a body can't escape the limit by spoofing the IP. +const authEmailKey = (req: Request): string => + (req.body?.email as string | undefined)?.trim().toLowerCase() ?? 'no-email'; + +const authLimit = rateLimit({ ...RATE_LIMITS.auth, keyFn: authEmailKey }); const REFRESH_COOKIE_OPTS = { httpOnly: true, @@ -22,7 +33,7 @@ router.get('/registration-domain', (_req, res) => { res.json({ domain: config.REGISTRATION_DOMAIN }); }); -router.post('/register', validate(registerDto), async (req, res, next) => { +router.post('/register', authLimit, validate(registerDto), async (req, res, next) => { try { const result = await authService.register(req.body); res.json(result); @@ -31,9 +42,10 @@ router.post('/register', validate(registerDto), async (req, res, next) => { } }); -router.post('/login', validate(loginDto), async (req, res, next) => { +router.post('/login', authLimit, validate(loginDto), async (req, res, next) => { try { - const { user, accessToken, refreshToken } = await authService.login(req.body); + const clientMeta = extractClientMeta(req); + const { user, accessToken, refreshToken } = await authService.login(req.body, clientMeta); res.cookie('refreshToken', refreshToken, REFRESH_COOKIE_OPTS); res.json({ user, accessToken }); } catch (err) { @@ -56,7 +68,8 @@ router.post('/refresh', async (req, res, next) => { router.post('/logout', async (req, res, next) => { try { const token = req.cookies?.refreshToken as string | undefined; - if (token) await authService.logout(token); + const clientMeta = extractClientMeta(req); + if (token) await authService.logout(token, clientMeta); res.clearCookie('refreshToken', { path: '/' }); res.json({ message: 'Logged out' }); } catch (err) { @@ -82,7 +95,7 @@ router.patch('/me', authenticate, validate(updateProfileDto), async (req: AuthRe } }); -router.post('/forgot-password', validate(forgotPasswordDto), async (req, res, next) => { +router.post('/forgot-password', authLimit, validate(forgotPasswordDto), async (req, res, next) => { try { const result = await authService.requestPasswordReset(req.body.email); res.json(result); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 449245e..8beced4 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -3,10 +3,11 @@ import { prisma } from '../../prisma/client.js'; import { hashPassword, comparePassword } from '../../shared/utils/password.js'; import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../../shared/utils/jwt.js'; import { AppError } from '../../shared/middleware/error-handler.js'; -import { setUserSession, deleteUserSession, getCachedJson, setCachedJson, isRedisAvailable } from '../../shared/redis.js'; +import { setUserSession, getUserSession, deleteUserSession, getCachedJson, setCachedJson, isRedisAvailable } from '../../shared/redis.js'; import { sendPasswordResetEmail } from '../../shared/utils/email.js'; import { config } from '../../config.js'; import type { RegisterDto, LoginDto, UpdateProfileDto } from './auth.dto.js'; +import { auditLog, type ClientMeta } from '../../shared/utils/audit-logger.js'; const MAX_LOGIN_ATTEMPTS = 5; const LOCKOUT_SECONDS = 15 * 60; @@ -43,26 +44,27 @@ function generateRefreshExpiry(): Date { return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); } +// Single response for all register outcomes — prevents email enumeration (gap-15). +const REGISTER_MSG = 'Если email доступен, заявка отправлена. Ожидайте подтверждения администратора.'; + export async function register(dto: RegisterDto) { const localPart = dto.email.trim().toLowerCase().split('@')[0]; const email = `${localPart}@${config.REGISTRATION_DOMAIN}`; - const [existingUser, existingRequest] = await Promise.all([ + // hashPassword runs unconditionally to equalise response time (timing side-channel). + const [existingUser, existingRequest, passwordHash] = await Promise.all([ prisma.user.findUnique({ where: { email } }), prisma.registrationRequest.findUnique({ where: { email } }), + hashPassword(dto.password), ]); - if (existingUser) { - throw new AppError(409, 'Email уже зарегистрирован'); - } - if (existingRequest && existingRequest.status === 'PENDING') { - throw new AppError(409, 'Заявка с этим email уже ожидает рассмотрения'); + // Silent exit: don't reveal whether email is taken or pending. + if (existingUser || existingRequest?.status === 'PENDING') { + return { message: REGISTER_MSG }; } - const passwordHash = await hashPassword(dto.password); - if (existingRequest) { - // Повторная заявка после отклонения — обновляем + // Re-submission after rejection — reset to PENDING. await prisma.registrationRequest.update({ where: { email }, data: { password: passwordHash, name: dto.name, status: 'PENDING', reviewedBy: null, reviewedAt: null }, @@ -73,16 +75,40 @@ export async function register(dto: RegisterDto) { }); } - return { message: 'Заявка на регистрацию отправлена. Ожидайте подтверждения администратора.' }; + return { message: REGISTER_MSG }; } -export async function login(dto: LoginDto) { +export async function login(dto: LoginDto, clientMeta?: ClientMeta) { const normalizedEmail = dto.email.trim().toLowerCase(); - await checkBruteForce(normalizedEmail); + + // Inline brute-force check — log lockout before throwing so audit is emitted + if (await isRedisAvailable()) { + const key = `auth:fail:${normalizedEmail}`; + const attempts = (await getCachedJson(key)) ?? 0; + if (attempts >= MAX_LOGIN_ATTEMPTS) { + void auditLog({ + actorId: null, + action: 'auth.lockout', + result: 'FAIL', + ip: clientMeta?.ip, + userAgent: clientMeta?.userAgent, + meta: { email: normalizedEmail }, + }); + throw new AppError(429, 'Слишком много попыток. Попробуйте через 15 минут.'); + } + } const user = await prisma.user.findUnique({ where: { email: normalizedEmail } }); if (!user) { await recordFailedAttempt(normalizedEmail); + void auditLog({ + actorId: null, + action: 'auth.login', + result: 'FAIL', + ip: clientMeta?.ip, + userAgent: clientMeta?.userAgent, + meta: { email: normalizedEmail, reason: 'USER_NOT_FOUND' }, + }); throw new AppError(401, 'Неверный email или пароль'); } @@ -94,6 +120,14 @@ export async function login(dto: LoginDto) { const valid = await comparePassword(dto.password, user.password); if (!valid) { await recordFailedAttempt(normalizedEmail); + void auditLog({ + actorId: user.id, + action: 'auth.login', + result: 'FAIL', + ip: clientMeta?.ip, + userAgent: clientMeta?.userAgent, + meta: { email: normalizedEmail, reason: 'WRONG_PASSWORD' }, + }); throw new AppError(401, 'Неверный email или пароль'); } @@ -133,6 +167,15 @@ export async function login(dto: LoginDto) { const nowIso = new Date().toISOString(); void setUserSession(user.id, { email: user.email, createdAt: nowIso, lastSeenAt: nowIso }); + void auditLog({ + actorId: user.id, + action: 'auth.login', + result: 'SUCCESS', + ip: clientMeta?.ip, + userAgent: clientMeta?.userAgent, + meta: { email: user.email, provider: 'local', region: clientMeta?.region }, + }); + return { user: { id: user.id, email: user.email, name: user.name }, accessToken, @@ -179,21 +222,31 @@ export async function refresh(refreshToken: string) { }); const nowIso = new Date().toISOString(); + const prevSession = await getUserSession(user.id); void setUserSession(user.id, { email: user.email, createdAt: stored.createdAt.toISOString?.() ?? nowIso, lastSeenAt: nowIso, + amr: prevSession?.amr, }); return { accessToken: newAccessToken, refreshToken: newRefreshToken }; } -export async function logout(refreshToken: string) { +export async function logout(refreshToken: string, clientMeta?: ClientMeta) { const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex'); const stored = await prisma.refreshToken.findUnique({ where: { token: tokenHash } }); await prisma.refreshToken.deleteMany({ where: { token: tokenHash } }); if (stored?.userId) { void deleteUserSession(stored.userId); + void auditLog({ + actorId: stored.userId, + action: 'auth.logout', + result: 'SUCCESS', + ip: clientMeta?.ip, + userAgent: clientMeta?.userAgent, + meta: { reason: 'user_initiated' }, + }); } } @@ -277,5 +330,12 @@ export async function resetPassword(token: string, password: string) { prisma.refreshToken.deleteMany({ where: { userId: resetToken.userId } }), ]); + void auditLog({ + actorId: resetToken.userId, + action: 'auth.credential.change', + result: 'SUCCESS', + meta: { field: 'password', method: 'reset' }, + }); + return { message: 'Пароль успешно изменён. Войдите с новым паролем.' }; } diff --git a/backend/src/modules/auth/sso/claims-mapper.ts b/backend/src/modules/auth/sso/claims-mapper.ts index 3da92a8..48243df 100644 --- a/backend/src/modules/auth/sso/claims-mapper.ts +++ b/backend/src/modules/auth/sso/claims-mapper.ts @@ -6,6 +6,7 @@ export interface MappedClaims { email: string; name: string; emailVerified: boolean; + amr: string[]; } const emailSchema = z.string().email(); @@ -15,7 +16,7 @@ const emailSchema = z.string().email(); export function mapClaims(raw: Record): MappedClaims { const sub = String(raw['sub'] ?? '').trim(); const rawEmail = String(raw['email'] ?? '').trim(); - const emailVerified = raw['email_verified'] !== false; // treat absent as true (IdP-verified accounts) + const emailVerified = raw['email_verified'] === true; if (!sub) throw new Error('OIDC claims missing required "sub"'); @@ -31,7 +32,13 @@ export function mapClaims(raw: Record): MappedClaims { } if (!name) name = email.split('@')[0]; - return { sub, email, name, emailVerified }; + const amr = Array.isArray(raw['amr']) + ? (raw['amr'] as unknown[]) + .filter((v): v is string => typeof v === 'string' && v.length <= 32) + .slice(0, 10) + : []; + + return { sub, email, name, emailVerified, amr }; } export function buildSsoSubjectId(sub: string): string { diff --git a/backend/src/modules/auth/sso/sso.service.ts b/backend/src/modules/auth/sso/sso.service.ts index 684a46e..0be8b2e 100644 --- a/backend/src/modules/auth/sso/sso.service.ts +++ b/backend/src/modules/auth/sso/sso.service.ts @@ -14,6 +14,7 @@ import { AppError } from '../../../shared/middleware/error-handler.js'; import { logger } from '../../../shared/utils/logger.js'; import jwt from 'jsonwebtoken'; import { prisma } from '../../../prisma/client.js'; +import { auditLog } from '../../../shared/utils/audit-logger.js'; const STATE_TTL_SECONDS = 300; const MAX_SESSIONS = 5; @@ -76,6 +77,12 @@ export async function handleSsoCallback( }); } catch (err) { logger.warn('SSO callback token exchange failed', { error: String(err) }); + void auditLog({ + actorId: null, + action: 'auth.login.sso', + result: 'FAIL', + meta: { reason: String(err) }, + }); throw new AppError(401, 'SSO authentication failed'); } @@ -112,8 +119,15 @@ export async function handleSsoCallback( where: { id: user.id }, data: { loginCount: { increment: 1 }, lastLoginAt: new Date() }, }), - setUserSession(user.id, { email: user.email, createdAt: nowIso, lastSeenAt: nowIso }), + setUserSession(user.id, { email: user.email, createdAt: nowIso, lastSeenAt: nowIso, amr: claims.amr }), ]); + void auditLog({ + actorId: user.id, + action: 'auth.login.sso', + result: 'SUCCESS', + meta: { provider: 'oidc', ssoSubject: claims.sub, email: user.email }, + }); + return { refreshToken: refreshTokenRaw }; } diff --git a/backend/src/modules/boards/boards.router.ts b/backend/src/modules/boards/boards.router.ts index aa07e18..79b402e 100644 --- a/backend/src/modules/boards/boards.router.ts +++ b/backend/src/modules/boards/boards.router.ts @@ -1,13 +1,15 @@ import { Router } from 'express'; import { authenticate } from '../../shared/middleware/auth.js'; import { validate } from '../../shared/middleware/validate.js'; +import { workspaceMfaGuard, boardMfaGuard } from '../../shared/middleware/workspace-mfa-guard.js'; import { createBoardDto, updateBoardDto } from './boards.dto.js'; import * as boards from './boards.service.js'; -import { authHandler } from '../../shared/utils/async-handler.js'; +import { asyncHandler, authHandler } from '../../shared/utils/async-handler.js'; // ─── /workspaces/:wid/boards ────────────────────────────────────────────────── export const workspaceBoardsRouter = Router({ mergeParams: true }); workspaceBoardsRouter.use(authenticate); +workspaceBoardsRouter.use(asyncHandler(workspaceMfaGuard('wid'))); workspaceBoardsRouter.get('/', authHandler(async (req, res) => { res.json(await boards.listBoards(String(req.params.wid), req.user!.userId)); @@ -20,6 +22,7 @@ workspaceBoardsRouter.post('/', validate(createBoardDto), authHandler(async (req // ─── /boards/:id ───────────────────────────────────────────────────────────── const router = Router(); router.use(authenticate); +router.use('/:id', asyncHandler(boardMfaGuard())); router.get('/:id', authHandler(async (req, res) => { res.json(await boards.getBoard(String(req.params.id), req.user!.userId)); diff --git a/backend/src/modules/checklists/checklists.router.ts b/backend/src/modules/checklists/checklists.router.ts index d84d36b..0a02a21 100644 --- a/backend/src/modules/checklists/checklists.router.ts +++ b/backend/src/modules/checklists/checklists.router.ts @@ -3,11 +3,13 @@ import { authenticate } from '../../shared/middleware/auth.js'; import { validate } from '../../shared/middleware/validate.js'; import { createChecklistDto, createChecklistItemDto, updateChecklistItemDto } from './checklists.dto.js'; import * as checklists from './checklists.service.js'; -import { authHandler } from '../../shared/utils/async-handler.js'; +import { asyncHandler, authHandler } from '../../shared/utils/async-handler.js'; +import { taskMfaGuard, checklistMfaGuard, checklistItemMfaGuard } from '../../shared/middleware/workspace-mfa-guard.js'; // ─── /tasks/:tid/checklists ─────────────────────────────────────────────────── export const taskChecklistsRouter = Router({ mergeParams: true }); taskChecklistsRouter.use(authenticate); +taskChecklistsRouter.use(asyncHandler(taskMfaGuard('tid'))); taskChecklistsRouter.post('/', validate(createChecklistDto), authHandler(async (req, res) => { res.status(201).json(await checklists.createChecklist(String(req.params.tid), req.user!.userId, req.body)); @@ -16,6 +18,7 @@ taskChecklistsRouter.post('/', validate(createChecklistDto), authHandler(async ( // ─── /checklists/:id ────────────────────────────────────────────────────────── const router = Router(); router.use(authenticate); +router.use('/:id', asyncHandler(checklistMfaGuard())); router.delete('/:id', authHandler(async (req, res) => { await checklists.deleteChecklist(String(req.params.id), req.user!.userId); @@ -31,6 +34,7 @@ export default router; // ─── /checklist-items/:id ───────────────────────────────────────────────────── export const checklistItemsRouter = Router(); checklistItemsRouter.use(authenticate); +checklistItemsRouter.use('/:id', asyncHandler(checklistItemMfaGuard())); checklistItemsRouter.patch('/:id', validate(updateChecklistItemDto), authHandler(async (req, res) => { res.json(await checklists.updateChecklistItem(String(req.params.id), req.user!.userId, req.body)); diff --git a/backend/src/modules/comments/comments.router.ts b/backend/src/modules/comments/comments.router.ts index ad0e32a..c7d3b8c 100644 --- a/backend/src/modules/comments/comments.router.ts +++ b/backend/src/modules/comments/comments.router.ts @@ -1,13 +1,15 @@ import { Router } from 'express'; import { authenticate } from '../../shared/middleware/auth.js'; import { validate } from '../../shared/middleware/validate.js'; +import { taskMfaGuard } from '../../shared/middleware/workspace-mfa-guard.js'; import { createCommentDto, updateCommentDto } from './comments.dto.js'; import * as comments from './comments.service.js'; -import { authHandler } from '../../shared/utils/async-handler.js'; +import { asyncHandler, authHandler } from '../../shared/utils/async-handler.js'; // ─── /tasks/:tid/comments ───────────────────────────────────────────────────── export const taskCommentsRouter = Router({ mergeParams: true }); taskCommentsRouter.use(authenticate); +taskCommentsRouter.use(asyncHandler(taskMfaGuard('tid'))); taskCommentsRouter.get('/', authHandler(async (req, res) => { const rawLimit = parseInt(String(req.query.limit ?? '50'), 10); diff --git a/backend/src/modules/labels/labels.router.ts b/backend/src/modules/labels/labels.router.ts index f9d3c21..a5ef08b 100644 --- a/backend/src/modules/labels/labels.router.ts +++ b/backend/src/modules/labels/labels.router.ts @@ -3,11 +3,13 @@ import { authenticate } from '../../shared/middleware/auth.js'; import { validate } from '../../shared/middleware/validate.js'; import { createLabelDto, updateLabelDto } from './labels.dto.js'; import * as labels from './labels.service.js'; -import { authHandler } from '../../shared/utils/async-handler.js'; +import { workspaceMfaGuard, taskMfaGuard } from '../../shared/middleware/workspace-mfa-guard.js'; +import { asyncHandler, authHandler } from '../../shared/utils/async-handler.js'; // ─── /workspaces/:wid/labels ────────────────────────────────────────────────── export const workspaceLabelsRouter = Router({ mergeParams: true }); workspaceLabelsRouter.use(authenticate); +workspaceLabelsRouter.use(asyncHandler(workspaceMfaGuard('wid'))); workspaceLabelsRouter.get('/', authHandler(async (req, res) => { res.json(await labels.listLabels(String(req.params.wid), req.user!.userId)); @@ -35,6 +37,7 @@ export default router; // ─── /tasks/:tid/labels ─────────────────────────────────────────────────────── export const taskLabelsRouter = Router({ mergeParams: true }); taskLabelsRouter.use(authenticate); +taskLabelsRouter.use(asyncHandler(taskMfaGuard('tid'))); taskLabelsRouter.post('/:labelId', authHandler(async (req, res) => { res.json(await labels.addLabelToTask(String(req.params.tid), String(req.params.labelId), req.user!.userId)); diff --git a/backend/src/modules/tasks/tasks.router.ts b/backend/src/modules/tasks/tasks.router.ts index d72a5d4..a53bd17 100644 --- a/backend/src/modules/tasks/tasks.router.ts +++ b/backend/src/modules/tasks/tasks.router.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import { authenticate } from '../../shared/middleware/auth.js'; import { validate } from '../../shared/middleware/validate.js'; import { rateLimit } from '../../shared/middleware/rate-limit.js'; +import { boardMfaGuard, taskMfaGuard } from '../../shared/middleware/workspace-mfa-guard.js'; import { createTaskDto, updateTaskDto, @@ -14,7 +15,7 @@ import { type BulkUpdateDto, } from './tasks.dto.js'; import * as tasks from './tasks.service.js'; -import { authHandler } from '../../shared/utils/async-handler.js'; +import { asyncHandler, authHandler } from '../../shared/utils/async-handler.js'; import type { AuthRequest } from '../../shared/types/index.js'; const bulkLimit = rateLimit({ @@ -27,6 +28,7 @@ const bulkLimit = rateLimit({ // ─── /boards/:bid/tasks ─────────────────────────────────────────────────────── export const boardTasksRouter = Router({ mergeParams: true }); boardTasksRouter.use(authenticate); +boardTasksRouter.use(asyncHandler(boardMfaGuard('bid'))); boardTasksRouter.get('/', validate(taskFiltersDto, 'query'), authHandler(async (req, res) => { res.json(await tasks.listTasks(String(req.params.bid), req.user!.userId, req.query as never)); @@ -54,6 +56,7 @@ boardTasksRouter.post('/bulk-delete', bulkLimit, validate(bulkDeleteDto), authHa // ─── /tasks/:id ─────────────────────────────────────────────────────────────── const router = Router(); router.use(authenticate); +router.use('/:id', asyncHandler(taskMfaGuard())); router.get('/:id', authHandler(async (req, res) => { res.json(await tasks.getTask(String(req.params.id), req.user!.userId)); diff --git a/backend/src/modules/workspaces/workspaces.dto.ts b/backend/src/modules/workspaces/workspaces.dto.ts index 7756c5d..88ce007 100644 --- a/backend/src/modules/workspaces/workspaces.dto.ts +++ b/backend/src/modules/workspaces/workspaces.dto.ts @@ -12,9 +12,11 @@ export const createWorkspaceDto = z.object({ }); export const updateWorkspaceDto = z.object({ - name: stripHtml(z.string().min(1).max(100)).optional(), - description: stripHtml(z.string().max(500)).optional(), - isPrivate: z.boolean().optional(), + name: stripHtml(z.string().min(1).max(100)).optional(), + description: stripHtml(z.string().max(500)).optional(), + isPrivate: z.boolean().optional(), + requireMfa: z.boolean().optional(), + mfaGraceDays: z.number().int().min(1).max(30).optional(), }); export const addMemberDto = z.object({ diff --git a/backend/src/modules/workspaces/workspaces.router.ts b/backend/src/modules/workspaces/workspaces.router.ts index 75d3f22..79b802a 100644 --- a/backend/src/modules/workspaces/workspaces.router.ts +++ b/backend/src/modules/workspaces/workspaces.router.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { authenticate } from '../../shared/middleware/auth.js'; import { validate } from '../../shared/middleware/validate.js'; +import { workspaceMfaGuard } from '../../shared/middleware/workspace-mfa-guard.js'; import { createWorkspaceDto, updateWorkspaceDto, @@ -10,7 +11,7 @@ import { } from './workspaces.dto.js'; import * as ws from './workspaces.service.js'; import * as boards from '../boards/boards.service.js'; -import { authHandler } from '../../shared/utils/async-handler.js'; +import { asyncHandler, authHandler } from '../../shared/utils/async-handler.js'; const router = Router(); router.use(authenticate); @@ -63,11 +64,11 @@ router.delete('/:id/members/:userId', authHandler(async (req, res) => { res.json({ message: 'Member removed' }); })); -router.get('/:id/boards/by-prefix/:prefix', authHandler(async (req, res) => { +router.get('/:id/boards/by-prefix/:prefix', asyncHandler(workspaceMfaGuard()), authHandler(async (req, res) => { res.json(await boards.getBoardByPrefix(String(req.params.id), String(req.params.prefix), req.user!.userId)); })); -router.get('/:id/history', authHandler(async (req, res) => { +router.get('/:id/history', asyncHandler(workspaceMfaGuard()), authHandler(async (req, res) => { const rawLimit = parseInt(String(req.query.limit ?? '50'), 10); const rawOffset = parseInt(String(req.query.offset ?? '0'), 10); const limit = Math.min(Number.isNaN(rawLimit) ? 50 : rawLimit, 200); diff --git a/backend/src/modules/workspaces/workspaces.service.ts b/backend/src/modules/workspaces/workspaces.service.ts index 777dda0..ed3922c 100644 --- a/backend/src/modules/workspaces/workspaces.service.ts +++ b/backend/src/modules/workspaces/workspaces.service.ts @@ -77,6 +77,7 @@ export async function listMyWorkspaces(userId: string) { memberCount: m.workspace._count.members, boardCount: m.workspace._count.boards, taskCount: taskCountMap.get(m.workspaceId) ?? 0, + mfaGraceUntil: m.mfaGraceUntil ?? null, })); } @@ -132,16 +133,35 @@ export async function getWorkspace(workspaceId: string, userId: string) { export async function updateWorkspace(workspaceId: string, userId: string, dto: UpdateWorkspaceDto) { await assertOwner(workspaceId, userId); - const current = await prisma.workspace.findUniqueOrThrow({ where: { id: workspaceId }, select: { name: true, isPrivate: true } }); - - const updated = await prisma.workspace.update({ + const current = await prisma.workspace.findUniqueOrThrow({ where: { id: workspaceId }, - data: dto, + select: { name: true, isPrivate: true, requireMfa: true, mfaGraceDays: true }, + }); + + const graceDays = dto.mfaGraceDays ?? current.mfaGraceDays; + + const updated = await prisma.$transaction(async (tx) => { + const ws = await tx.workspace.update({ + where: { id: workspaceId }, + data: dto, + }); + + const enablingMfa = dto.requireMfa === true && current.requireMfa === false; + if (enablingMfa) { + const graceUntil = new Date(Date.now() + graceDays * 86_400_000); + await tx.workspaceMember.updateMany({ + where: { workspaceId, mfaGraceUntil: null }, + data: { mfaGraceUntil: graceUntil }, + }); + } + + return ws; }); const meta: Record = {}; if (dto.name !== undefined && dto.name !== current.name) { meta.nameFrom = current.name; meta.nameTo = dto.name; } if (dto.isPrivate !== undefined && dto.isPrivate !== current.isPrivate) meta.isPrivate = dto.isPrivate; + if (dto.requireMfa !== undefined && dto.requireMfa !== current.requireMfa) meta.requireMfa = dto.requireMfa; await logEvent(workspaceId, userId, 'workspace_updated', 'workspace', workspaceId, meta); return updated; @@ -164,6 +184,15 @@ export async function listMembers(workspaceId: string, userId: string) { }); } +async function getMfaGraceUntil(workspaceId: string): Promise { + const ws = await prisma.workspace.findUnique({ + where: { id: workspaceId }, + select: { requireMfa: true, mfaGraceDays: true }, + }); + if (!ws?.requireMfa) return null; + return new Date(Date.now() + ws.mfaGraceDays * 86_400_000); +} + export async function addMember(workspaceId: string, requesterId: string, dto: AddMemberDto) { await assertOwner(workspaceId, requesterId); @@ -175,11 +204,18 @@ export async function addMember(workspaceId: string, requesterId: string, dto: A }); if (existing) throw new AppError(409, 'User is already a member'); + const mfaGraceUntil = await getMfaGraceUntil(workspaceId); + const member = await prisma.workspaceMember.create({ - data: { workspaceId, userId: dto.userId, role: dto.role }, + data: { workspaceId, userId: dto.userId, role: dto.role, mfaGraceUntil }, include: { user: { select: { id: true, name: true, email: true, avatar: true } } }, }); emitMemberAddedNotification(workspaceId, dto.userId, requesterId).catch(() => {}); + await logEvent(workspaceId, requesterId, 'member_added', 'member', dto.userId, { + name: targetUser.name, + email: targetUser.email, + role: dto.role, + }); return member; } @@ -218,8 +254,10 @@ export async function inviteByEmail(workspaceId: string, requesterId: string, dt }); if (existing) throw new AppError(409, 'User is already a member'); + const mfaGraceUntil = await getMfaGraceUntil(workspaceId); + const member = await prisma.workspaceMember.create({ - data: { workspaceId, userId: targetUser.id, role: dto.role }, + data: { workspaceId, userId: targetUser.id, role: dto.role, mfaGraceUntil }, include: { user: { select: { id: true, name: true, email: true, avatar: true } } }, }); diff --git a/backend/src/prisma/migrations/20260506000001_ib_security_audit/README.md b/backend/src/prisma/migrations/20260506000001_ib_security_audit/README.md new file mode 100644 index 0000000..b38973f --- /dev/null +++ b/backend/src/prisma/migrations/20260506000001_ib_security_audit/README.md @@ -0,0 +1 @@ +IB security audit fields migration \ No newline at end of file diff --git a/backend/src/prisma/migrations/20260506000001_ib_security_audit/migration.sql b/backend/src/prisma/migrations/20260506000001_ib_security_audit/migration.sql new file mode 100644 index 0000000..50077e5 --- /dev/null +++ b/backend/src/prisma/migrations/20260506000001_ib_security_audit/migration.sql @@ -0,0 +1,11 @@ +-- Add isActive field to users +ALTER TABLE "users" ADD COLUMN "is_active" BOOLEAN NOT NULL DEFAULT true; + +-- Add security audit fields to audit_logs +ALTER TABLE "audit_logs" ADD COLUMN "result" TEXT; +ALTER TABLE "audit_logs" ADD COLUMN "ip" TEXT; +ALTER TABLE "audit_logs" ADD COLUMN "user_agent" TEXT; +ALTER TABLE "audit_logs" ADD COLUMN "session_id" TEXT; + +-- Add index on action for SIEM queries +CREATE INDEX "audit_logs_action_idx" ON "audit_logs"("action"); diff --git a/backend/src/prisma/migrations/20260507153103_gap13_mfa_fields/migration.sql b/backend/src/prisma/migrations/20260507153103_gap13_mfa_fields/migration.sql new file mode 100644 index 0000000..d148a22 --- /dev/null +++ b/backend/src/prisma/migrations/20260507153103_gap13_mfa_fields/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "workspace_members" ADD COLUMN "mfaGraceUntil" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "workspaces" ADD COLUMN "mfaGraceDays" INTEGER NOT NULL DEFAULT 7, +ADD COLUMN "requireMfa" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/src/prisma/schema.prisma b/backend/src/prisma/schema.prisma index f9d50ee..76b5224 100644 --- a/backend/src/prisma/schema.prisma +++ b/backend/src/prisma/schema.prisma @@ -22,6 +22,7 @@ model User { loginCount Int @default(0) lastLoginAt DateTime? isSuperadmin Boolean @default(false) + isActive Boolean @default(true) @map("is_active") emailNotifications Boolean @default(true) @map("email_notifications") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -116,10 +117,12 @@ model Workspace { name String slug String @unique description String? - isPrivate Boolean @default(false) - creatorId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + isPrivate Boolean @default(false) + requireMfa Boolean @default(false) + mfaGraceDays Int @default(7) + creatorId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt creator User @relation("WorkspaceCreator", fields: [creatorId], references: [id]) members WorkspaceMember[] @@ -132,11 +135,12 @@ model Workspace { } model WorkspaceMember { - id String @id @default(uuid()) - workspaceId String - userId String - role WorkspaceRole @default(MEMBER) - createdAt DateTime @default(now()) + id String @id @default(uuid()) + workspaceId String + userId String + role WorkspaceRole @default(MEMBER) + mfaGraceUntil DateTime? + createdAt DateTime @default(now()) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -402,14 +406,19 @@ model WorkspaceEvent { model AuditLog { id String @id @default(uuid()) - actorId String // userId who performed the action - action String // e.g. "user.create", "user.set_superadmin", "request.approve" - targetId String? // userId or requestId being acted upon - meta Json? // additional context (safe, no secrets) + actorId String + action String + targetId String? + result String? @map("result") // "SUCCESS" | "FAIL" + ip String? @map("ip") + userAgent String? @map("user_agent") + sessionId String? @map("session_id") + meta Json? createdAt DateTime @default(now()) @@index([actorId]) @@index([createdAt]) + @@index([action]) @@map("audit_logs") } diff --git a/backend/src/shared/middleware/auth.ts b/backend/src/shared/middleware/auth.ts index b49a752..a47da47 100644 --- a/backend/src/shared/middleware/auth.ts +++ b/backend/src/shared/middleware/auth.ts @@ -40,8 +40,15 @@ export function authenticate(req: AuthRequest, _res: Response, next: NextFunctio try { const payload = verifyAccessToken(token); - req.user = { userId: payload.userId, email: payload.email }; - next(); + prisma.user.findUnique({ where: { id: payload.userId }, select: { isActive: true } }) + .then((u) => { + if (!u || u.isActive === false) { + return next(new AppError(403, 'Account disabled', { code: 'ACCOUNT_DISABLED' })); + } + req.user = { userId: payload.userId, email: payload.email }; + next(); + }) + .catch(next); } catch { next(new AppError(401, 'Invalid or expired token')); } diff --git a/backend/src/shared/middleware/rate-limit.ts b/backend/src/shared/middleware/rate-limit.ts index 42dedf5..4f784be 100644 --- a/backend/src/shared/middleware/rate-limit.ts +++ b/backend/src/shared/middleware/rate-limit.ts @@ -47,8 +47,15 @@ function defaultKey(req: Request): string { return req.ip ?? req.socket.remoteAddress ?? 'anonymous'; } +// E2E tests fire many parallel logins (workers × spec files × beforeEach) which +// reliably hits the per-email limit. Unit tests exercise rate-limit itself so +// they need it enabled. Production always enforces it. +const RATE_LIMIT_DISABLED = process.env.NODE_ENV === 'e2e'; + export function rateLimit(opts: RateLimitOptions) { return async (req: Request, _res: Response, next: NextFunction): Promise => { + if (RATE_LIMIT_DISABLED) return next(); + const identity = opts.keyFn ? opts.keyFn(req) : defaultKey(req); const key = `rl:${opts.scope}:${identity}`; diff --git a/backend/src/shared/middleware/workspace-mfa-guard.ts b/backend/src/shared/middleware/workspace-mfa-guard.ts new file mode 100644 index 0000000..2f309b5 --- /dev/null +++ b/backend/src/shared/middleware/workspace-mfa-guard.ts @@ -0,0 +1,121 @@ +import type { Response, NextFunction } from 'express'; +import type { AuthRequest } from '../types/index.js'; +import { prisma } from '../../prisma/client.js'; +import { getUserSession } from '../redis.js'; +import { AppError } from './error-handler.js'; +import { logger } from '../utils/logger.js'; + +const MFA_AMR_VALUES = ['totp', 'otp', 'mfa', 'hwk', 'swk']; + +async function enforceMfa(workspaceId: string, req: AuthRequest, res: Response, next: NextFunction): Promise { + const workspace = await prisma.workspace.findUnique({ + where: { id: workspaceId }, + select: { requireMfa: true }, + }); + + if (!workspace || !workspace.requireMfa) return next(); + + const userId = req.user!.userId; + + // Local users have no IdP amr — MFA guard is SSO-only + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { authProvider: true }, + }); + if (!user || user.authProvider === 'local') return next(); + + const session = await getUserSession(userId); + + // Fail-closed: if Redis is unavailable for an SSO user in an MFA workspace, deny access. + // This prevents a Redis outage from silently downgrading security. + if (session === null) { + logger.warn('mfa_guard_session_unavailable', { userId, workspaceId }); + throw new AppError(503, 'SESSION_UNAVAILABLE'); + } + + const amr: string[] = session.amr ?? []; + const mfaOk = amr.some((v) => MFA_AMR_VALUES.includes(v)); + + const member = await prisma.workspaceMember.findUnique({ + where: { workspaceId_userId: { workspaceId, userId } }, + }); + + if (mfaOk) { + // Clear stale grace period so the frontend banner dismisses after first TOTP login. + if (member?.mfaGraceUntil) { + void prisma.workspaceMember.update({ + where: { workspaceId_userId: { workspaceId, userId } }, + data: { mfaGraceUntil: null }, + }).catch((err) => logger.warn('mfa_guard_grace_clear_failed', { userId, workspaceId, error: String(err) })); + } + return next(); + } + + const graceUntil = member?.mfaGraceUntil ?? null; + if (graceUntil && graceUntil > new Date()) { + const daysLeft = Math.ceil((graceUntil.getTime() - Date.now()) / 86_400_000); + res.setHeader('X-MFA-Grace-Days', String(daysLeft)); + return next(); + } + + throw new AppError(403, graceUntil ? 'MFA_GRACE_EXPIRED' : 'MFA_REQUIRED'); +} + +export function workspaceMfaGuard(workspaceParamName = 'id') { + return async (req: AuthRequest, res: Response, next: NextFunction): Promise => { + const workspaceId = String(req.params[workspaceParamName]); + await enforceMfa(workspaceId, req, res, next); + }; +} + +// For routes operating on a board ID — resolves workspaceId from the board. +export function boardMfaGuard(boardParamName = 'id') { + return async (req: AuthRequest, res: Response, next: NextFunction): Promise => { + const boardId = String(req.params[boardParamName]); + const board = await prisma.board.findUnique({ + where: { id: boardId }, + select: { workspaceId: true }, + }); + if (!board) return next(); // board not found — let the route handler return 404 + await enforceMfa(board.workspaceId, req, res, next); + }; +} + +// For routes operating on a task ID — resolves workspaceId via task → board. +export function taskMfaGuard(taskParamName = 'id') { + return async (req: AuthRequest, res: Response, next: NextFunction): Promise => { + const taskId = String(req.params[taskParamName]); + const task = await prisma.task.findUnique({ + where: { id: taskId }, + select: { board: { select: { workspaceId: true } } }, + }); + if (!task) return next(); // not found — let the route handler return 404 + await enforceMfa(task.board.workspaceId, req, res, next); + }; +} + +// For routes operating on a checklist ID — resolves workspaceId via checklist → task → board. +export function checklistMfaGuard(checklistParamName = 'id') { + return async (req: AuthRequest, res: Response, next: NextFunction): Promise => { + const checklistId = String(req.params[checklistParamName]); + const checklist = await prisma.checklist.findUnique({ + where: { id: checklistId }, + select: { task: { select: { board: { select: { workspaceId: true } } } } }, + }); + if (!checklist) return next(); + await enforceMfa(checklist.task.board.workspaceId, req, res, next); + }; +} + +// For routes operating on a checklist item ID — resolves workspaceId via item → checklist → task → board. +export function checklistItemMfaGuard(itemParamName = 'id') { + return async (req: AuthRequest, res: Response, next: NextFunction): Promise => { + const itemId = String(req.params[itemParamName]); + const item = await prisma.checklistItem.findUnique({ + where: { id: itemId }, + select: { checklist: { select: { task: { select: { board: { select: { workspaceId: true } } } } } } }, + }); + if (!item) return next(); + await enforceMfa(item.checklist.task.board.workspaceId, req, res, next); + }; +} diff --git a/backend/src/shared/redis.ts b/backend/src/shared/redis.ts index 5c848bb..71d1b26 100644 --- a/backend/src/shared/redis.ts +++ b/backend/src/shared/redis.ts @@ -82,12 +82,26 @@ export type UserSession = { email: string; createdAt: string; lastSeenAt: string; + amr?: string[]; }; function buildSessionKey(userId: string): string { return `session:${userId}`; } +export async function getUserSession(userId: string): Promise { + const redis = await getRedisClientInternal(); + if (!redis) return null; + try { + const raw = await redis.get(buildSessionKey(userId)); + if (!raw) return null; + return JSON.parse(raw) as UserSession; + } catch (err) { + logger.error('redis_session_read_error', { userId, error: String(err) }); + return null; + } +} + export async function setUserSession(userId: string, session: Omit): Promise { const redis = await getRedisClientInternal(); if (!redis) return; diff --git a/backend/src/shared/siem-transport.ts b/backend/src/shared/siem-transport.ts new file mode 100644 index 0000000..c7522a1 --- /dev/null +++ b/backend/src/shared/siem-transport.ts @@ -0,0 +1,66 @@ +import { logger } from './utils/logger.js'; + +export interface SiemSink { + name: string; + send(event: Record): Promise; +} + +class StdoutSink implements SiemSink { + name = 'stdout'; + async send(event: Record): Promise { + if (process.env.NODE_ENV === 'production') { + console.log(JSON.stringify({ siem: true, ...event })); + } + } +} + +export class HttpSink implements SiemSink { + name = 'http'; + constructor( + private readonly url: string, + private readonly token?: string, + ) {} + + async send(event: Record): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + if (this.token) headers['Authorization'] = `Bearer ${this.token}`; + const res = await fetch(this.url, { + method: 'POST', + headers, + body: JSON.stringify(event), + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } +} + +class SiemTransport { + private readonly sinks: SiemSink[] = [new StdoutSink()]; + + addSink(sink: SiemSink): void { + this.sinks.push(sink); + } + + async emit(event: Record): Promise { + const enriched = { ...event, forwarder: process.env.HOSTNAME ?? 'unknown', source: 'flow-tasks-backend' }; + await Promise.allSettled( + this.sinks.map((sink) => + sink.send(enriched).catch((err) => + logger.error('siem.transport.error', { sink: sink.name, error: String(err) }), + ), + ), + ); + } +} + +export const siemTransport = new SiemTransport(); + +// Initialize extra sinks from env (called once at startup) +export function initSiemTransport(): void { + const httpUrl = process.env.SIEM_HTTP_URL; + const httpToken = process.env.SIEM_HTTP_TOKEN; + if (httpUrl) { + siemTransport.addSink(new HttpSink(httpUrl, httpToken)); + logger.info('siem.transport.http_sink_added', { url: httpUrl }); + } +} diff --git a/backend/src/shared/utils/audit-logger.ts b/backend/src/shared/utils/audit-logger.ts new file mode 100644 index 0000000..7be4b2b --- /dev/null +++ b/backend/src/shared/utils/audit-logger.ts @@ -0,0 +1,96 @@ +import type { Prisma } from '@prisma/client'; +import { prisma } from '../../prisma/client.js'; +import { logger } from './logger.js'; +import { tagsForAction } from './siem-tags.js'; + +export interface AuditEventInput { + actorId: string | null; + action: string; + targetId?: string | null; + result?: 'SUCCESS' | 'FAIL'; + ip?: string; + userAgent?: string; + sessionId?: string; + meta?: Record; +} + +// Mask PII values so they never land in SIEM in plaintext +const PII_KEY_RE = /^(email|phone|passport|card_?number|inn)$/i; + +function maskPiiValue(value: string): string { + if (value.includes('@')) { + const [local, domain] = value.split('@'); + return `${local.slice(0, 2)}***@${domain}`; + } + if (value.length <= 4) return '***'; + return value.slice(0, 2) + '*'.repeat(Math.max(2, value.length - 4)) + value.slice(-2); +} + +function maskPii(obj: Record): Record { + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [ + k, + PII_KEY_RE.test(k) && typeof v === 'string' ? maskPiiValue(v) : v, + ]), + ); +} + +export async function auditLog(event: AuditEventInput): Promise { + try { + const tags = tagsForAction(event.action); + const meta = maskPii({ + result: event.result ?? 'SUCCESS', + ip: event.ip, + userAgent: event.userAgent, + sessionId: event.sessionId, + source: 'flow-tasks-backend', + tags, + time: new Date().toISOString(), + ...event.meta, + }); + + await prisma.auditLog.create({ + data: { + actorId: event.actorId ?? 'system', + action: event.action, + targetId: event.targetId ?? null, + ip: event.ip ?? null, + userAgent: event.userAgent ?? null, + sessionId: event.sessionId ?? null, + result: event.result ?? 'SUCCESS', + meta: meta as Prisma.InputJsonValue, + }, + }); + } catch (err) { + // Audit failure must never break the main request + logger.error('audit_log.write.error', { action: event.action, error: String(err) }); + } +} + +// Helper: extract client meta from Express request-like object +export interface ClientMeta { + ip: string; + userAgent: string; + region?: string; +} + +export function extractClientMeta(req: { + ip?: string; + socket?: { remoteAddress?: string }; + headers: Record; +}): ClientMeta { + const ip = + (req.headers['x-real-ip'] as string) ?? + (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? + req.ip ?? + req.socket?.remoteAddress ?? + 'unknown'; + + const userAgent = (req.headers['user-agent'] as string) ?? 'unknown'; + const region = + (req.headers['cf-ipcountry'] as string) ?? + (req.headers['x-region'] as string) ?? + undefined; + + return { ip, userAgent, region }; +} diff --git a/backend/src/shared/utils/siem-tags.ts b/backend/src/shared/utils/siem-tags.ts new file mode 100644 index 0000000..143cc92 --- /dev/null +++ b/backend/src/shared/utils/siem-tags.ts @@ -0,0 +1,60 @@ +export type TechSegment = 'iia' | 'fpp' | 'up' | 'uo' | 'hi'; +export type SiemEventType = + | 'auth' + | 'admin_task' + | 'admin_audit' + | 'power_users_audit' + | 'users_audit' + | 'system_audit'; + +export const SIEM_SYSTEM = 'flowtasks'; +export const SIEM_ENV = (process.env.SIEM_ENV ?? process.env.NODE_ENV ?? 'dev').toUpperCase(); +export const SIEM_DC = process.env.SIEM_DC ?? 'unknown'; + +export function siemTags(type: SiemEventType, segment: TechSegment): string[] { + return [SIEM_SYSTEM, type, segment, SIEM_ENV, SIEM_DC]; +} + +// Maps action string → [SiemEventType, TechSegment] +export const ACTION_TAGS: Record = { + 'auth.login': ['auth', 'iia'], + 'auth.login.sso': ['auth', 'iia'], + 'auth.logout': ['auth', 'iia'], + 'auth.lockout': ['auth', 'iia'], + 'auth.session.expired': ['auth', 'iia'], + 'auth.credential.change': ['auth', 'iia'], + 'auth.apikey.use': ['auth', 'iia'], + 'auth.apikey.fail': ['auth', 'iia'], + 'admin.user.create': ['admin_task', 'iia'], + 'admin.user.delete': ['admin_task', 'iia'], + 'admin.user.deactivate': ['admin_task', 'iia'], + 'admin.user.activate': ['admin_task', 'iia'], + 'admin.user.set_superadmin':['admin_task', 'iia'], + 'admin.user.role_change': ['admin_task', 'iia'], + 'admin.config.change': ['admin_audit', 'iia'], + 'admin.audit.settings.change': ['admin_audit', 'iia'], + 'admin.mfa.config.change': ['admin_audit', 'iia'], + 'admin.crypto.key.rotate': ['admin_audit', 'iia'], + 'request.approve': ['admin_task', 'iia'], + 'request.reject': ['admin_task', 'iia'], + 'task.create': ['users_audit', 'fpp'], + 'task.update': ['users_audit', 'fpp'], + 'task.delete': ['users_audit', 'fpp'], + 'data.export': ['power_users_audit', 'hi'], + 'workspace.created': ['admin_task', 'uo'], + 'workspace.deleted': ['admin_task', 'uo'], + 'workspace.member_added': ['admin_task', 'iia'], + 'workspace.member_removed': ['admin_task', 'iia'], + 'workspace.member_role_changed': ['admin_task', 'iia'], + 'system.service.start': ['system_audit', 'iia'], + 'system.service.stop': ['system_audit', 'iia'], + 'system.update.install': ['system_audit', 'iia'], + 'system.validation.error': ['system_audit', 'iia'], + 'system.log.transport.error': ['system_audit', 'iia'], +}; + +export function tagsForAction(action: string): string[] { + const entry = ACTION_TAGS[action]; + if (entry) return siemTags(entry[0], entry[1]); + return siemTags('system_audit', 'iia'); +} diff --git a/docs/security/SDD-IB.md b/docs/security/SDD-IB.md new file mode 100644 index 0000000..407c9c7 --- /dev/null +++ b/docs/security/SDD-IB.md @@ -0,0 +1,499 @@ +# SDD — Информационная безопасность Flow Tasks + +**Дата:** 2026-05-06 +**Репозиторий:** NovakPAai/flow-tasks +**Источники требований:** +- Требования к информационной безопасности (§1.1–1.7) +- Требования по логированию (§1–4) +- Требования ИБ к ИС basic (§1–10) +- События для интеграции с SIEM ГОСТ 57580 (УЗП.22–28, РД-40–43, ИУ.7, ЦЗИ.28–30) +- Опросник по полноте логирования + +--- + +## 1. Контекст системы + +**Flow Tasks** — корпоративный таск-трекер. Стек: Node.js/Express + TypeScript, PostgreSQL/Prisma, Redis, React/Vite. + +Роли пользователей в системе: +| Роль | Описание | Req | +|------|----------|-----| +| `isSuperadmin` | Полный доступ к системе | §1.5.3 | +| `OWNER` (workspace) | Управление воркспейсом и участниками | §1.5.2 | +| `MEMBER` (workspace) | Создание и редактирование задач | §1.5.1 | +| `VIEWER` (workspace) | Только чтение задач | §1.5.1 | + +--- + +## 2. Статус реализации требований + +### 2.1 Идентификация и аутентификация (ИАА / `iia`) + +| ID | Требование | Статус | Файл / Комментарий | +|----|-----------|--------|--------------------| +| §1.2.5 | OIDC-интеграция с корпоративным IDP | ✅ | `auth/sso/` (openid-client, PKCE, state) | +| §1.6 | SSO с MFA для критичных операций | ⚠️ | SSO реализован; MFA только через IDP, нет enforcement для local-auth критичных операций | +| §1.1 | Защита от неправомерного доступа (CIA) | ✅ | JWT + refresh-token rotation, брут-форс защита | +| basic §1.2 | Уникальные учётные записи | ✅ | DB unique constraint на `users.email` | +| basic §1.10 | Шифрование канала (TLS) | ⚠️ | TLS на уровне infra (Nginx/Caddy); enforce в app не реализован | +| basic §1.12 | MFA | ⚠️ | Только через SSO-провайдер; TOTP для local-auth не реализован | +| ГОСТ РД-40 | Регистрация идентификации/аутентификации | ❌ | `AuditLog` не пишется при login/logout/fail | +| ГОСТ РД-41 | Регистрация авторизации и завершения сессии | ❌ | Logout не логируется | +| ГОСТ РД-42 | Регистрация запуска программных сервисов | ❌ | Нет `system.service.start` события | +| ГОСТ РД-43 | Регистрация изменений credential | ❌ | Смена пароля не пишется в `AuditLog` | + +### 2.2 Управление правами доступа (RBAC) + +| ID | Требование | Статус | Файл / Комментарий | +|----|-----------|--------|--------------------| +| §1.3.1 | Минимальные полномочия | ✅ | `workspace_members.role`, OWNER/MEMBER/VIEWER enforcement | +| §1.3.3 | Охват всех операций над данными | ✅ | `task-access.ts`, воркспейс-гейты в роутерах | +| §1.3.4 | API-доступ по индивидуальным аккаунтам | ✅ | API Keys (hashed, per-user) | +| §1.3.5 | Все объекты охвачены RBAC | ⚠️ | Задачи, доски, воркспейсы — да; labels/comments — частично | +| §1.4.1 | Роли по функциональной позиции | ✅ | OWNER/MEMBER/VIEWER с иерархией | +| §1.5 | Базовая ролевая модель (employee/manager/admin) | ✅ | WorkspaceRole + isSuperadmin | +| §1.7 | Role/Attribute/Record-based access | ⚠️ | Record-level есть (workspace isolation); field-level (до поля) — нет | +| ГОСТ УЗП.24 | Регистрация управления доступом | ⚠️ | `WorkspaceEvent` есть; `oldRole/newRole` в meta не всегда | +| ГОСТ УЗП.25 | Регистрация управления УЗ и правами | ⚠️ | Частично: member_added/removed есть; полноты нет | +| ГОСТ УЗП.26 | Регистрация управления MFA | ❌ | MFA-настройки не логируются | +| ГОСТ УЗП.27 | Регистрация изменений параметров ЗИ | ❌ | Нет endpoint для config-изменений + нет лога | +| ГОСТ УЗП.28 | Регистрация управления крипто-ключами | ❌ | JWT-ротация не логируется | +| — | `isActive` блокировка пользователя | ❌ | Поля `isActive` нет в схеме; blocked-user guard нет | + +### 2.3 Регистрация событий и аудит + +| ID | Требование | Статус | Файл / Комментарий | +|----|-----------|--------|--------------------| +| §1.2.1 | Электронные журналы событий ИБ | ⚠️ | `AuditLog` есть только для admin-операций | +| §1.2.6 | Структурированные машиночитаемые логи | ✅ | JSON-логгер с redaction (`logger.ts`) | +| §1.2.7 | Параллельная отправка в несколько приёмников | ❌ | Только stdout/console | +| basic §3.1 | Наличие системы регистрации событий | ⚠️ | Частично (AuditLog для admin, TaskHistory для задач) | +| basic §3.2 | Перечень обязательных событий | ❌ | Из 13 типов покрыты ~4 (admin actions, workspace events) | +| basic §3.3 | Синхронизация времени | ⚠️ | Используется `new Date()` (системное время), NTP — infra | +| basic §3.4 | Обязательные поля события | ⚠️ | `createdAt`, `actorId`, `action` — есть; `forwarder`, `source`, `tech_segment`, `tags`, `session_id` — нет | +| basic §3.5 | Интеграция с SIEM | ❌ | Нет транспорта (syslog/HTTP/JDBC) | +| логирование §1 | Формат: forwarder, source, subject, object, resource, tech_segment, tag | ❌ | Не реализован | +| логирование §2.1 | Auth-события (web: ip, browser, ОС, регион) | ❌ | IP/UA не логируются в AuditLog | +| логирование §2.2 | Admin: создание/удаление/изменение аккаунтов | ⚠️ | Одобрение заявок логируется; user.create/delete — нет | +| логирование §2.3 | Admin audit: settings/audit changes | ❌ | Не реализован | +| логирование §2.4 | Privileged: экспорт данных, маскировка ПДн | ❌ | Экспорт не реализован; ПДн-маскировка нет | +| логирование §2.5 | User audit: потенциально деструктивные действия | ⚠️ | task.delete не логируется в AuditLog | +| логирование §2.6 | System: validation errors, process start/stop | ❌ | Нет | +| логирование §4 | SIEM-теги (5-уровневая схема) | ❌ | Не реализован | +| ГОСТ ИУ.7 | Создание/удаление ресурсов БД (workspace/board) | ⚠️ | `workspace_created` в WorkspaceEvent — есть; board/DB resources — нет | +| ГОСТ ЦЗИ.28 | Установка/обновление ПО | ❌ | Нет | +| ГОСТ ЦЗИ.30 | Запуск программных сервисов | ❌ | Нет | + +### 2.4 Шифрование и защита каналов + +| ID | Требование | Статус | Файл / Комментарий | +|----|-----------|--------|--------------------| +| §1.2.8 | TLS для внутренних каналов | ⚠️ | Infra-уровень; app не проверяет | +| basic §4.1 | Стойкие протоколы шифрования | ✅ | bcrypt + JWT HS256/RS256; TLS 1.2+ через Nginx | +| basic §4.2 | ГОСТ-алгоритмы | ❌ | Не реализованы; требуют специализированных libs | + +### 2.5 Управление уязвимостями + +| ID | Требование | Статус | Файл / Комментарий | +|----|-----------|--------|--------------------| +| basic §5.1 | Актуальные версии модулей | ⚠️ | Нет автоматической проверки CVE | +| basic §5.2 | Возможность применять security updates | ✅ | npm audit / Dependabot | + +--- + +## 3. GAP-анализ и план реализации + +### Приоритет 1 — Критично (ГОСТ 57580 обязательные меры) + +#### GAP-1: Auth-события в AuditLog (РД-40, РД-41, РД-43) + +**Описание:** При login/logout/fail/lockout/смене пароля не создаётся запись в `AuditLog`. + +**Решение:** В `auth.service.ts` после каждого auth-события вызывать `auditLogger.log()`. + +```typescript +// backend/src/shared/utils/audit-logger.ts (NEW) +interface AuditEvent { + actorId: string | null; + action: string; // "auth.login", "auth.logout", ... + targetId?: string; + result: 'SUCCESS' | 'FAIL'; + ip?: string; + userAgent?: string; + sessionId?: string; + tech_segment: 'iia' | 'fpp' | 'up' | 'uo' | 'hi'; + tags: string[]; // ["flowtasks", type, segment, env, dc] + meta?: Record; +} + +export async function auditLog(event: AuditEvent): Promise { + await prisma.auditLog.create({ + data: { + actorId: event.actorId ?? 'system', + action: event.action, + targetId: event.targetId, + meta: { + result: event.result, + ip: event.ip, + userAgent: event.userAgent, + sessionId: event.sessionId, + tech_segment: event.tech_segment, + tags: event.tags, + source: 'flow-tasks-backend', + time: new Date().toISOString(), + ...event.meta, + }, + }, + }); +} +``` + +**Места интеграции:** +- `auth.service.ts:login()` → `auditLog({ action: 'auth.login', result: 'SUCCESS'/'FAIL', ip, userAgent })` +- `auth.service.ts:logout()` → `auditLog({ action: 'auth.logout', reason: 'user_initiated' })` +- `auth.service.ts:checkBruteForce()` → `auditLog({ action: 'auth.lockout' })` +- `auth.service.ts:updateProfile()` при смене пароля → `auditLog({ action: 'auth.credential.change' })` +- `sso.service.ts:handleCallback()` → `auditLog({ action: 'auth.sso.login.success/fail' })` + +**Схема БД — добавить поля в `AuditLog`:** + +```prisma +model AuditLog { + // существующие поля... + ip String? + userAgent String? + sessionId String? + result String? // "SUCCESS" | "FAIL" +} +``` + +--- + +#### GAP-2: SIEM-тегирование (Req логирование §4) + +**Описание:** Нет 5-уровневого SIEM-тега в событиях. + +**Решение:** Константы тегов + хелпер: + +```typescript +// backend/src/shared/utils/siem-tags.ts (NEW) +export const SIEM_SYSTEM = 'flowtasks'; +export const SIEM_ENV = process.env.SIEM_ENV ?? 'DEV'; // PROD | UAT | DEV +export const SIEM_DC = process.env.SIEM_DC ?? 'unknown'; // m1 | dsp | nord + +export type TechSegment = 'iia' | 'fpp' | 'up' | 'uo' | 'hi'; +export type SiemEventType = 'auth' | 'admin_task' | 'admin_audit' + | 'power_users_audit' | 'users_audit' | 'system_audit'; + +export function siemTags(type: SiemEventType, segment: TechSegment): string[] { + return [SIEM_SYSTEM, type, segment, SIEM_ENV, SIEM_DC]; +} + +// Соответствие action → (type, segment) +export const ACTION_TAGS: Record = { + 'auth.login': ['auth', 'iia'], + 'auth.logout': ['auth', 'iia'], + 'auth.lockout': ['auth', 'iia'], + 'auth.sso.login': ['auth', 'iia'], + 'auth.credential.change':['auth', 'iia'], + 'admin.user.create': ['admin_task', 'iia'], + 'admin.user.deactivate': ['admin_task', 'iia'], + 'admin.config.change': ['admin_audit', 'iia'], + 'admin.mfa.config.change':['admin_audit', 'iia'], + 'admin.crypto.key.rotate':['admin_audit', 'iia'], + 'task.create': ['users_audit', 'fpp'], + 'task.update': ['users_audit', 'fpp'], + 'task.delete': ['users_audit', 'fpp'], + 'data.export': ['power_users_audit', 'hi'], + 'workspace.created': ['admin_task', 'uo'], + 'workspace.deleted': ['admin_task', 'uo'], + 'workspace.member_added':['admin_task', 'iia'], + 'system.service.start': ['system_audit', 'iia'], + 'system.update.install': ['system_audit', 'iia'], +}; +``` + +--- + +#### GAP-3: SIEM-транспорт с multi-sink (Req §1.2.7, basic §3.5) + +**Описание:** Логи уходят только в stdout. Нужны syslog и HTTP-sink с параллельной доставкой. + +**Решение:** Фасад `SiemTransport` с async fan-out: + +```typescript +// backend/src/shared/siem-transport.ts (NEW) +interface SiemSink { + name: string; + send(event: Record): Promise; +} + +class SyslogSink implements SiemSink { + name = 'syslog'; + async send(event: Record) { + // UDP/TCP syslog via 'syslog' npm package + } +} + +class HttpSink implements SiemSink { + name = 'http'; + constructor(private url: string, private token: string) {} + async send(event: Record) { + // fetch(this.url, { method: 'POST', body: JSON.stringify(event), headers: {...} }) + } +} + +export class SiemTransport { + private sinks: SiemSink[] = []; + + addSink(sink: SiemSink) { this.sinks.push(sink); } + + async emit(event: Record): Promise { + await Promise.allSettled( + this.sinks.map((sink) => + sink.send(event).catch((err) => + logger.error('siem.transport.error', { sink: sink.name, error: String(err) }), + ), + ), + ); + } +} + +export const siemTransport = new SiemTransport(); +// Инициализация в server.ts: добавить sinks по env vars +``` + +**Буферизация при недоступном SIEM:** +- При ошибке отправки → пишем в `Redis LIST siem:buffer` +- Background worker (setInterval 30s) → flush буфера при восстановлении +- Если Redis недоступен → fallback в файловый appender + +--- + +#### GAP-4: isActive блокировка пользователей (Req логирование §2.2) + +**Описание:** Нет поля `isActive` в модели `User`; нет guard при login для заблокированных. + +**Решение:** + +```prisma +model User { + // добавить: + isActive Boolean @default(true) @map("is_active") +} +``` + +В `auth.service.ts:login()`: +```typescript +if (!user.isActive) { + await auditLog({ action: 'auth.login', result: 'FAIL', meta: { reason: 'ACCOUNT_DISABLED' } }); + throw new AppError(403, 'Account disabled', { code: 'ACCOUNT_DISABLED' }); +} +``` + +В `admin.service.ts` — добавить endpoint `PATCH /api/admin/users/:id` с полем `isActive` + `auditLog`. + +--- + +### Приоритет 2 — Важно (полнота аудита) + +#### GAP-5: Клиентская подпись в auth-событиях (Req логирование §2.1) + +IP и User-Agent уже доступны из `req`. Нужно передавать их в `auditLog()`: + +```typescript +// В auth.router.ts — middleware extractClientMeta +export function extractClientMeta(req: Request): ClientMeta { + return { + ip: req.ip ?? req.socket.remoteAddress ?? 'unknown', + userAgent: req.headers['user-agent'] ?? 'unknown', + region: req.headers['cf-ipcountry'] as string ?? req.headers['x-region'] as string, + }; +} +``` + +--- + +#### GAP-6: Системные события (Req логирование §2.6, ГОСТ ЦЗИ.30) + +В `server.ts` после запуска: + +```typescript +// server.ts — после app.listen() +siemTransport.emit({ + action: 'system.service.start', + service: 'flow-tasks-backend', + version: process.env.GIT_SHA ?? 'dev', + pid: process.pid, + tags: siemTags('system_audit', 'iia'), + time: new Date().toISOString(), +}); + +process.on('SIGTERM', () => { + siemTransport.emit({ action: 'system.service.stop', reason: 'SIGTERM' }); +}); +``` + +--- + +#### GAP-7: Маскировка ПДн в SIEM-событиях (Req логирование §2.4) + +Перед отправкой в `SiemTransport.emit()` применять маскировку: + +```typescript +const PII_MASK_RE = /^(email|phone|passport|card_number|inn)$/i; + +function maskPii(obj: Record): Record { + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [ + k, + PII_MASK_RE.test(k) ? maskValue(String(v)) : v, + ]), + ); +} + +function maskValue(value: string): string { + if (value.includes('@')) { + const [local, domain] = value.split('@'); + return `${local.slice(0, 2)}***@${domain}`; + } + return value.slice(0, 2) + '*'.repeat(Math.max(0, value.length - 4)) + value.slice(-2); +} +``` + +--- + +#### GAP-8: Полнота покрытия событий basic §3.2 + +Список не покрытых событий из чеклиста (basic §3.2) с маппингом: + +| Событие | Endpoint / Hook | Action | +|---------|----------------|--------| +| Неуспешный логический доступ | `authenticate()` middleware | `auth.unauthorized` | +| Создание/удаление учётной записи | `admin.service` | `admin.user.create/delete` | +| Изменение прав пользователей | `workspaces.service` | `workspace.member_role_changed` | +| Изменение ID/auth данных | `auth.service:updateProfile` | `auth.credential.change` | +| Запуск и остановка сервисов | `server.ts` lifecycle | `system.service.start/stop` | +| Системные ошибки | `error-handler.ts` | `system.error` (5xx only) | +| Изменение параметров аудита | `/api/admin/audit-settings` | `admin.audit.settings.change` | + +--- + +### Приоритет 3 — Перспектива + +#### GAP-9: ГОСТ-алгоритмы шифрования (basic §4.2) + +Требует использования `node-gost` или специализированного HSM. Актуально при получении лицензии ФСТЭК. + +#### GAP-10: MFA для local-аккаунтов (Req §1.6, basic §1.12) + +TOTP через `otplib` npm package: +- Endpoint `POST /api/auth/mfa/enable` → генерирует TOTP secret, возвращает QR-код +- Middleware `requireMfa()` для критичных endpoints (superadmin actions) +- `AuditLog` при изменении MFA-настроек (ГОСТ УЗП.26) + +#### GAP-11: Field-level access control (Req §1.7) + +Скрывать отдельные поля задачи в зависимости от роли VIEWER: +- `description` и `comments` — скрывать для VIEWER если board.isPrivate + +--- + +## 4. Схема SIEM-события (эталон) + +```json +{ + "time": "2026-05-06T12:00:00.000+03:00", + "forwarder": "flow-tasks-backend-01", + "source": "flow-tasks-backend", + "action": "auth.login", + "result": "SUCCESS", + "subject": { + "id": "uuid-user", + "email": "al***@corp.ru", + "session_id": "uuid-session" + }, + "object": { + "type": "session", + "id": "uuid-session" + }, + "resource": "POST /api/auth/login", + "ip": "10.0.0.42", + "userAgent": "Chrome/120 Windows NT 10.0", + "region": "RU", + "tech_segment": "iia", + "tags": ["flowtasks", "auth", "iia", "PROD", "m1"], + "env": "PROD", + "version": "abc1234" +} +``` + +--- + +## 5. Приоритизированный бэклог + +| # | Gap | Req | Приоритет | Оценка | Тест-файл | +|---|-----|-----|-----------|--------|-----------| +| 1 | Auth-события в AuditLog (login/logout/fail/lockout) | РД-40, РД-41 | P0 | M | `ib-authentication.test.ts` | +| 2 | `isActive` блокировка + guard | §2.2 логирование | P0 | S | `ib-access-control.test.ts` | +| 3 | SIEM-теги (5-уровневая схема) | §4 логирование | P0 | S | `ib-audit-logging.test.ts` | +| 4 | Клиентская подпись в auth-событиях (IP/UA/region) | §2.1 логирование | P1 | S | `ib-authentication.test.ts` | +| 5 | Обязательные SIEM-поля (forwarder/source/subject/object) | §1 логирование | P1 | M | `ib-audit-logging.test.ts` | +| 6 | SIEM-транспорт (syslog + HTTP multi-sink) | §1.2.7, §3.5 basic | P1 | L | — | +| 7 | Системные события (service start/stop/update) | РД-42, ЦЗИ.28–30 | P1 | S | `ib-audit-logging.test.ts` | +| 8 | Маскировка ПДн в SIEM-событиях | §2.4 логирование | P1 | S | `ib-audit-logging.test.ts` | +| 9 | Полнота AuditLog для admin-операций (user CRUD, config) | §2.3 логирование, УЗП.22 | P2 | M | `ib-audit-logging.test.ts` | +| 10 | oldRole/newRole в WorkspaceEvent.meta | УЗП.24 | P2 | S | `ib-access-control.test.ts` | +| 11 | Буферизация SIEM при недоступном транспорте | §1 логирование | P2 | M | — | +| 12 | Смена credential → AuditLog | РД-43 | P2 | S | `ib-authentication.test.ts` | +| 13 | Ошибки валидации → system.validation.error | §2.6 логирование | P3 | S | `ib-audit-logging.test.ts` | +| 14 | MFA для local-аккаунтов (TOTP) | §1.6, basic §1.12 | P3 | L | — | +| 15 | ГОСТ-алгоритмы шифрования | basic §4.2 | P3 | XL | — | +| 16 | Field-level access control | §1.7 | P3 | XL | — | + +**Легенда размеров:** S = 1-2 дня, M = 3-5 дней, L = 1-2 недели, XL = 2+ недели + +--- + +## 6. Маппинг на BDD feature-файлы + +| Feature-файл | Покрытые требования | +|-------------|---------------------| +| `specs/security/ib-authentication.feature` | §1.2.5, §1.6, ГОСТ РД-40–43, §2.1 логирования | +| `specs/security/ib-access-control.feature` | §1.3–1.5, §1.7, ГОСТ УЗП.24–25, §2.2 логирования | +| `specs/security/ib-audit-logging.feature` | §1.2.6–1.2.7, basic §3, §1–4 логирования, ГОСТ УЗП.22–28, РД-40–43, ИУ.7, ЦЗИ.28–30 | + +--- + +## 7. Опросник полноты логирования — статус (Checklist) + +| Тип события | Применимо | Реализовано | Gap | +|-------------|-----------|-------------|-----| +| **1.1 Аутентификация** | | | | +| Аутентификация (login/sso) | Да | ❌ | GAP-1 | +| Блокировка/разблокировка аккаунта | Да | ⚠️ Redis-only | GAP-1, GAP-4 | +| Выход из системы | Да | ❌ | GAP-1 | +| **1.2 События администрирования** | | | | +| Создание/удаление/изменение аккаунта | Да | ⚠️ approve-only | GAP-9 | +| Блокировка/разблокировка пользователем-admin | Да | ❌ | GAP-4 | +| Добавление/удаление прав/ролей | Да | ⚠️ WorkspaceEvent | GAP-8 | +| Изменение ролевой модели | Да | ⚠️ частично | GAP-10 | +| **1.3 Аудит действий администраторов** | | | | +| Изменение настроек сервера/БД | Да | ❌ | GAP-9 | +| Изменение настроек аудита | Да | ❌ | GAP-8 | +| Запуск/остановка сервисов | Да | ❌ | GAP-6 | +| Изменение крипто-настроек | Да | ❌ | УЗП.28 | +| Изменение политик аутентификации | Да | ❌ | — | +| Выгрузка данных | Да | ❌ | GAP-5 (data.export) | +| Загрузка данных | Н/А | — | — | +| **1.4 Привилегированные пользователи** | | | | +| Операции над финансовыми инструментами | Н/А | — | — | +| Выгрузка данных | Да | ❌ | GAP-5 | +| **1.5 Аудит действий пользователей** | | | | +| Потенциально деструктивные действия | Да | ❌ | GAP-8 | +| Выгрузка данных пользователями | Да | ❌ | GAP-5 | +| **1.6 Системные события** | | | | +| Ошибки валидации | Да | ❌ | GAP-8 | +| Запуск/остановка процессов | Да | ❌ | GAP-6 | +| Реагирование на сбой журналирования | Да | ❌ | GAP-3 | +| Установка/удаление обновлений | Да | ❌ | GAP-6 | diff --git a/frontend/e2e/flows/task-drawer.spec.ts b/frontend/e2e/flows/task-drawer.spec.ts index a81d0b3..b4b034d 100644 --- a/frontend/e2e/flows/task-drawer.spec.ts +++ b/frontend/e2e/flows/task-drawer.spec.ts @@ -326,6 +326,7 @@ test.describe('TaskDrawer — редактирование задачи', () => }); test('назначение существующей метки на задачу', async ({ page }) => { + test.setTimeout(60_000); // Сначала создаём метку в первом drawer const labelName = `Assign Label ${uid()}`; await openDrawer(page, `Label Source ${uid()}`); @@ -347,12 +348,14 @@ test.describe('TaskDrawer — редактирование задачи', () => // getByRole('button') чтобы не попасть на span 'Метки' в сайдбаре await page.getByRole('button', { name: 'Метки' }).click(); + await page.waitForLoadState('networkidle'); // .first() т.к. labelName может матчиться в picker list И в sidebar labels await expect(page.getByText(labelName).first()).toBeVisible({ timeout: 10000 }); - await page.getByText(labelName).first().click(); + // dispatchEvent обходит проверку actionability (тот же паттерн что в openDrawer) + await page.getByText(labelName).first().dispatchEvent('click'); // Метка назначена — picker ещё открыт (Escape закрыл бы drawer целиком) // Проверяем что метка видна — .first() избегает strict mode - await expect(page.getByText(labelName).first()).toBeVisible({ timeout: 5000 }); + await expect(page.getByText(labelName).first()).toBeVisible({ timeout: 10_000 }); }); // ── Удаление задачи ────────────────────────────────────────────────────────── diff --git a/frontend/e2e/helpers/data.ts b/frontend/e2e/helpers/data.ts index 91e35b7..e0eebfd 100644 --- a/frontend/e2e/helpers/data.ts +++ b/frontend/e2e/helpers/data.ts @@ -29,8 +29,18 @@ async function authFetch(token: string, path: string, opts: RequestInit = {}): P }); } -export async function getAdminToken(): Promise { - return login('admin@flowtask.dev', 'Password1'); +// Module-level cache — один логин на весь воркер-процесс. +// Предотвращает превышение rate-limit (10/min per email) при параллельных beforeAll. +let _adminTokenCache: Promise | null = null; + +export function getAdminToken(): Promise { + if (!_adminTokenCache) { + _adminTokenCache = login('admin@flowtask.dev', 'Password1').catch((err) => { + _adminTokenCache = null; // сбросить при ошибке чтобы следующий вызов повторил + throw err; + }); + } + return _adminTokenCache; } export async function createWorkspace( diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index 4679960..4e0c87f 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -10,6 +10,7 @@ import { login, logout, uniqueName } from './helpers'; test.use({ storageState: { cookies: [], origins: [] } }); test('CIO demo smoke: full user journey', async ({ page }) => { + test.setTimeout(90_000); // full journey: login → ws → board → task → drawer → comment → logout // ── 1. Login ──────────────────────────────────────────────────────────────── await login(page); await expect(page).toHaveURL(/\/workspaces/, { timeout: 10000 }); @@ -51,20 +52,23 @@ test('CIO demo smoke: full user journey', async ({ page }) => { await titleInput.fill(taskTitle); await titleInput.press('Enter'); - await expect(page.locator(`text=${taskTitle}`).first()).toBeVisible({ timeout: 5000 }); + await expect(page.locator(`text=${taskTitle}`).first()).toBeVisible({ timeout: 10_000 }); // ── 7. Open task drawer ────────────────────────────────────────────────────── - await page.locator(`text=${taskTitle}`).first().click(); - // Task drawer opens — wait for the comments tab to be available - await expect(page.locator('text=Комментарии')).toBeVisible({ timeout: 10000 }); + // dispatchEvent на outer div TaskCard — надёжнее .click() по тексту (тот тригерит inline-edit) + const taskCard = page.locator('[data-rfd-draggable-id]').filter({ hasText: taskTitle }); + await taskCard.waitFor({ timeout: 10_000 }); + await taskCard.locator('> div').first().dispatchEvent('click'); + // Confirm drawer opened by waiting for Details tab (matches openDrawer helper pattern) + await expect(page.getByText('Детали')).toBeVisible({ timeout: 10_000 }); // ── 8. Add a comment (click Comments tab first) ─────────────────────────────── - await page.locator('text=Комментарии').click(); - const commentInput = page.locator('textarea[placeholder="Написать комментарий..."]'); - await commentInput.waitFor({ timeout: 10000 }); + await page.getByText('Комментарии').click(); + // getByPlaceholder is more resilient; fill() waits for the element to be actionable + const commentInput = page.getByPlaceholder('Написать комментарий...'); await commentInput.fill('Hello from e2e smoke test'); - await page.locator('button:has-text("Отправить")').click(); - await expect(page.locator('text=Hello from e2e smoke test')).toBeVisible({ timeout: 5000 }); + await page.getByRole('button', { name: 'Отправить' }).click(); + await expect(page.getByText('Hello from e2e smoke test')).toBeVisible({ timeout: 10_000 }); // ── 9. Logout ──────────────────────────────────────────────────────────────── // Close drawer first if needed diff --git a/frontend/src/api/workspaces.ts b/frontend/src/api/workspaces.ts index c742255..3ac6506 100644 --- a/frontend/src/api/workspaces.ts +++ b/frontend/src/api/workspaces.ts @@ -22,7 +22,7 @@ export async function getWorkspace(id: string): Promise { export async function updateWorkspace( id: string, - payload: { name?: string; description?: string; isPrivate?: boolean }, + payload: { name?: string; description?: string; isPrivate?: boolean; requireMfa?: boolean; mfaGraceDays?: number }, ): Promise { const { data } = await api.patch(`/workspaces/${id}`, payload); return data; diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index 06cd59b..ade62c1 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -139,6 +139,41 @@ function WorkspaceSelector({ workspaces, current, onSelect, navBg, border, textP ); } +function pluralDays(n: number) { + const mod10 = n % 10, mod100 = n % 100; + if (mod10 === 1 && mod100 !== 11) return 'день'; + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return 'дня'; + return 'дней'; +} + +function mfaGraceBanner(workspace: { requireMfa?: boolean; mfaGraceUntil?: string | null } | null | undefined) { + if (!workspace?.requireMfa || !workspace.mfaGraceUntil) return null; + const until = new Date(workspace.mfaGraceUntil); + if (until <= new Date()) return null; + const daysLeft = Math.ceil((until.getTime() - Date.now()) / 86_400_000); + return ( +
+ + Требуется настроить двухфакторную аутентификацию — осталось {daysLeft} {pluralDays(daysLeft)} +
+ ); +} + // ─── AppLayout ──────────────────────────────────────────────────────────────── export default function AppLayout({ children }: Props) { const navigate = useNavigate(); @@ -412,6 +447,9 @@ export default function AppLayout({ children }: Props) { + {/* ── MFA grace period banner ── */} + {mfaGraceBanner(current)} + {/* ── Page content ── */}
{children} diff --git a/frontend/src/global.css b/frontend/src/global.css new file mode 100644 index 0000000..c11d450 --- /dev/null +++ b/frontend/src/global.css @@ -0,0 +1,8 @@ +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 173732c..099a494 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import './global.css'; import App from './App'; import ErrorBoundary from './components/ErrorBoundary'; diff --git a/frontend/src/pages/WorkspaceSettingsPage.tsx b/frontend/src/pages/WorkspaceSettingsPage.tsx index 015ed32..972c51c 100644 --- a/frontend/src/pages/WorkspaceSettingsPage.tsx +++ b/frontend/src/pages/WorkspaceSettingsPage.tsx @@ -170,9 +170,17 @@ export default function WorkspaceSettingsPage() { const [editingWfId, setEditingWfId] = useState(null); // Data loading state - const [loadingData, setLoadingData] = useState(false); + const [loadingData, setLoadingData] = useState(true); const [loadError, setLoadError] = useState(null); + // Security settings + const [mfaEnabled, setMfaEnabled] = useState(false); + const [mfaGraceDays, setMfaGraceDays] = useState(7); + const [savingMfa, setSavingMfa] = useState(false); + + // Confirm modal + const [confirmModal, setConfirmModal] = useState<{ title: string; message: string; onConfirm: () => void } | null>(null); + const wsId = workspace?.id; const wsName = workspace?.name; const wsDescription = workspace?.description; @@ -181,6 +189,7 @@ export default function WorkspaceSettingsPage() { const loadWorkspaceData = useCallback(async (id: string) => { setLoadingData(true); setLoadError(null); + setMembers([]); setLabels([]); setWorkflows([]); try { const [m, l, wfs] = await Promise.all([ workspacesApi.listMembers(id), @@ -202,7 +211,9 @@ export default function WorkspaceSettingsPage() { setName(wsName ?? ''); setDescription(wsDescription ?? ''); setIsPrivate(wsIsPrivate ?? false); - }, [wsName, wsDescription, wsIsPrivate]); + setMfaEnabled(workspace?.requireMfa ?? false); + setMfaGraceDays(workspace?.mfaGraceDays ?? 7); + }, [wsName, wsDescription, wsIsPrivate, workspace?.requireMfa, workspace?.mfaGraceDays]); // Load workspace data only when workspace identity changes useEffect(() => { @@ -210,7 +221,10 @@ export default function WorkspaceSettingsPage() { loadWorkspaceData(wsId); }, [wsId, loadWorkspaceData]); - const myRole = loadingData ? undefined : members.find((m) => m.userId === currentUser?.id)?.role; + // During load, fall back to the role stored in the workspace list so nav doesn't shift + const myRole = loadingData + ? (workspace?.role ?? undefined) + : members.find((m) => m.userId === currentUser?.id)?.role; const isOwner = myRole === 'OWNER'; if (!workspace) return null; @@ -232,14 +246,18 @@ export default function WorkspaceSettingsPage() { } catch { message.error('Не удалось изменить роль'); } }; - const handleRemoveMember = async (userId: string) => { - if (!confirm('Удалить участника?')) return; - try { - await workspacesApi.removeMember(workspace.id, userId); - setMembers((prev) => prev.filter((m) => m.userId !== userId)); - load(); // refresh memberCount in workspace store - } - catch { message.error('Не удалось удалить'); } + const handleRemoveMember = (userId: string) => { + setConfirmModal({ + title: 'Удалить участника?', + message: 'Участник потеряет доступ к workspace.', + onConfirm: async () => { + try { + await workspacesApi.removeMember(workspace.id, userId); + setMembers((prev) => prev.filter((m) => m.userId !== userId)); + load(); + } catch { message.error('Не удалось удалить'); } + }, + }); }; const handleInvite = async () => { @@ -276,10 +294,15 @@ export default function WorkspaceSettingsPage() { finally { setSavingLabel(false); } }; - const handleDeleteLabel = async (labelId: string) => { - if (!confirm('Удалить метку?')) return; - try { await labelsApi.deleteLabel(labelId); setLabels((prev) => prev.filter((l) => l.id !== labelId)); } - catch { message.error('Не удалось удалить'); } + const handleDeleteLabel = (labelId: string) => { + setConfirmModal({ + title: 'Удалить метку?', + message: 'Метка будет удалена из всех задач.', + onConfirm: async () => { + try { await labelsApi.deleteLabel(labelId); setLabels((prev) => prev.filter((l) => l.id !== labelId)); } + catch { message.error('Не удалось удалить'); } + }, + }); }; const handleCreateWorkflow = async () => { @@ -301,16 +324,21 @@ export default function WorkspaceSettingsPage() { finally { setCreatingWf(false); } }; - const handleDeleteWorkflow = async (wfId: string) => { - if (!confirm('Удалить workflow? Доски с этим workflow перестанут работать.')) return; - try { - await wfApi.deleteWorkflow(wfId); - setWorkflows((prev) => prev.filter((w) => w.id !== wfId)); - if (editingWfId === wfId) setEditingWfId(null); - } catch (err: unknown) { - const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; - message.error(msg ?? 'Не удалось удалить'); - } + const handleDeleteWorkflow = (wfId: string) => { + setConfirmModal({ + title: 'Удалить workflow?', + message: 'Доски с этим workflow перестанут работать.', + onConfirm: async () => { + try { + await wfApi.deleteWorkflow(wfId); + setWorkflows((prev) => prev.filter((w) => w.id !== wfId)); + if (editingWfId === wfId) setEditingWfId(null); + } catch (err: unknown) { + const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; + message.error(msg ?? 'Не удалось удалить'); + } + }, + }); }; const handleSetDefaultWorkflow = async (wfId: string) => { @@ -323,13 +351,18 @@ export default function WorkspaceSettingsPage() { } }; - const handleDeleteWorkspace = async () => { - if (!confirm(`Удалить workspace "${workspace.name}"? Это действие необратимо.`)) return; - try { - await workspacesApi.deleteWorkspace(workspace.id); - await load(); - navigate('/workspaces'); - } catch { message.error('Не удалось удалить workspace'); } + const handleDeleteWorkspace = () => { + setConfirmModal({ + title: `Удалить "${workspace.name}"?`, + message: 'Это действие необратимо. Все доски, задачи и участники будут удалены.', + onConfirm: async () => { + try { + await workspacesApi.deleteWorkspace(workspace.id); + await load(); + navigate('/workspaces'); + } catch { message.error('Не удалось удалить workspace'); } + }, + }); }; // ─── Input style ─────────────────────────────────────────────────────────── @@ -346,12 +379,44 @@ export default function WorkspaceSettingsPage() { { key: 'workflows', label: 'Workflows' }, { key: 'labels', label: 'Метки' }, ...(isOwner ? [{ key: 'history', label: 'История' }] : []), + ...(isOwner ? [{ key: 'security', label: 'Безопасность' }] : []), { key: 'general', label: 'Основное' }, ]; // ─── Tab content ─────────────────────────────────────────────────────────── - const renderMembers = () => ( + const renderMembers = () => { + if (loadingData) return ( +
+
+

Участники

+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+ ); + + if (loadError) return ( +
+
+

Участники

+
+
+ {loadError} + +
+
+ ); + + return (
@@ -440,7 +505,8 @@ export default function WorkspaceSettingsPage() { ))}
- ); + ); + }; const renderWorkflows = () => { if (loadingData) return ( @@ -486,7 +552,7 @@ export default function WorkspaceSettingsPage() { Управляйте статусами и переходами для ваших досок
-
+
Редактирование workflow доступно только владельцу воркспейса
@@ -622,7 +688,38 @@ export default function WorkspaceSettingsPage() { ); }; - const renderLabels = () => ( + const renderLabels = () => { + if (loadingData) return ( +
+
+

Метки

+
+
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+ ); + + if (loadError) return ( +
+
+

Метки

+
+
+ {loadError} + +
+
+ ); + + return (
@@ -659,7 +756,8 @@ export default function WorkspaceSettingsPage() {
)}
- ); + ); + }; const renderHistory = () => workspace ? : null; @@ -737,19 +835,97 @@ export default function WorkspaceSettingsPage() {
); + const saveMfaSettings = async () => { + setSavingMfa(true); + try { + await workspacesApi.updateWorkspace(workspace.id, { requireMfa: mfaEnabled, mfaGraceDays }); + await load(); + message.success('Настройки безопасности сохранены'); + } catch { + message.error('Не удалось сохранить'); + } finally { + setSavingMfa(false); + } + }; + + const renderSecurity = () => ( +
+

Безопасность

+
Настройки аутентификации и доступа
+ +
+
+
+
+
Обязательная двухфакторная аутентификация
+
+ SSO-участники должны проходить 2FA через Avanpost / Keycloak (TOTP) +
+
+ +
+ + {mfaEnabled && ( +
+
+
+ ПЕРИОД ОТСРОЧКИ (ДНЕЙ) +
+
+ { const v = Number(e.target.value); setMfaGraceDays(isNaN(v) ? mfaGraceDays : Math.min(30, Math.max(1, Math.round(v)))); }} + onBlur={(e) => { const v = Number(e.target.value); setMfaGraceDays(isNaN(v) || v < 1 ? 1 : Math.min(30, Math.round(v))); }} + style={{ ...inp, width: 80 }} + /> + дней до принудительной проверки +
+
+ {!workspace?.requireMfa && ( +
+ При включении: все участники получат {mfaGraceDays} дней для настройки TOTP в Avanpost/Keycloak +
+ )} +
+ )} +
+ + + Сохранить настройки безопасности + +
+
+ ); + const CONTENT_MAP: Record React.ReactNode> = { members: renderMembers, workflows: renderWorkflows, labels: renderLabels, history: renderHistory, + security: renderSecurity, general: renderGeneral, }; // ─── Layout ──────────────────────────────────────────────────────────────── return (
- - {/* Sidebar */}
@@ -783,21 +959,6 @@ export default function WorkspaceSettingsPage() { })}
- - {/* Delete workspace */} - {isOwner && ( - - )}
{/* Content area */} @@ -849,6 +1010,27 @@ export default function WorkspaceSettingsPage() {
+ + {/* Confirm modal */} + setConfirmModal(null)} title={confirmModal?.title ?? ''}> +
+

{confirmModal?.message}

+
+ + +
+
+
); } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7b9ffed..7731752 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -95,6 +95,9 @@ export interface Workspace { slug: string; description?: string; isPrivate: boolean; + requireMfa: boolean; + mfaGraceDays: number; + mfaGraceUntil?: string | null; creatorId: string; createdAt: string; updatedAt: string; diff --git a/specs/BACKLOG.md b/specs/BACKLOG.md index 307f044..45a2da9 100644 --- a/specs/BACKLOG.md +++ b/specs/BACKLOG.md @@ -20,32 +20,13 @@ | gap-09 | global-search | feat | P3 | **done** | #140 | | gap-10 | bulk-operations | feat | P3 | **done** | #141 | | gap-11 | my-tasks-accordion | feat | P2 | **done** | #145 | -| **gap-12** | **workflow-settings-unlocked** | **fix** | **P1** | **draft** | — | -| **gap-13** | **2fa-totp** | **feat** | **P2** | **draft** | — | +| **gap-12** | **workflow-settings-unlocked** | **fix** | **P1** | **done** | этa ветка | +| **gap-13** | **2fa-totp** | **feat** | **P2** | **done** | #151 | +| **gap-14** | **rate-limit-ip-bypass** | **fix / security** | **P1** | **done** | эта ветка | +| **gap-15** | **email-enumeration** | **fix / security** | **P2** | **done** | эта ветка | --- -## Открытые гепы - -### P1 — Критично - -#### gap-12 — Workflow Settings ghost-lock - -[specs/gaps/gap-12-workflow-settings-unlocked.md](gaps/gap-12-workflow-settings-unlocked.md) - -`.catch(() => {})` при загрузке участников страницы настроек: OWNER видит интерфейс read-only, не может редактировать workflow. -Фикс: явная обработка ошибки + skeleton + `isOwner` вычисляется только после `loadingData = false`. - -### P2 — Важно - -#### gap-13 — 2FA/TOTP (SSO-режим) - -[specs/gaps/gap-13-2fa-totp.md](gaps/gap-13-2fa-totp.md) - -Проверка второго фактора через `amr` claim в OIDC-токене от Avanpost/Keycloak. -FlowTask не хранит TOTP-секреты — делегирует всё IdP. -Включает: `requireMfa` на уровне workspace, grace period, `workspaceMfaGuard` middleware. - --- ## Out-of-Scope — приоритизированный бэклог @@ -119,7 +100,7 @@ FlowTask не хранит TOTP-секреты — делегирует всё I | Раздел | Открыто | Закрыто/Done | |--------|---------|--------------| -| Активные гепы (gap-01..13) | **2** (gap-12, gap-13) | 11 | +| Активные гепы (gap-01..13) | **0** | 13 (все закрыты) | | OoS P1 | **2** (rate limit, filter URL) | 2 | | OoS P2 | **7** | — | | OoS P3 | **13** | — | diff --git a/specs/existing/04-tasks.md b/specs/existing/04-tasks.md index ea7fe98..079574a 100644 --- a/specs/existing/04-tasks.md +++ b/specs/existing/04-tasks.md @@ -7,40 +7,301 @@ status: approved # Spec: Задачи и подзадачи ## Intent -Основная единица работы. Поддерживает неограниченную вложенность, переходы по статусам workflow, историю изменений. +Основная единица работы: CRUD с issue keys, неограниченная вложенность подзадач через materialized path, переходы по статусам workflow, history-аудит на уровне полей. + +## BDD Scenarios + +```gherkin +Feature: Задачи — создание и редактирование + + Background: + Given я авторизован как участник воркспейса + And существует доска "DEV" с воркфлоу "Default" (статусы: To Do → In Progress → Done) + + # ──── Создание ──── + + Scenario: создание задачи через POST API + When POST /boards/:bid/tasks { title: "Добавить кнопку", statusId: } + Then 201 + { id, issueKey: "DEV-1", title: "Добавить кнопку", status: { id, name: "To Do" } } + And следующая задача в этой доске получит issueKey "DEV-2" + + Scenario: создание задачи без statusId — берётся первый статус воркфлоу + When POST /boards/:bid/tasks { title: "Задача без статуса" } + Then 201 + { statusId: <первый статус по position> } + + Scenario: создание с невалидным statusId — 400 + When POST /boards/:bid/tasks { title: "X", statusId: "not-a-uuid" } + Then 400 "Некорректный ID статуса" + + Scenario: title слишком длинный — 400 + When POST /boards/:bid/tasks { title: "<501 символ>" } + Then 400 "Название не должно превышать 500 символов" + + Scenario: XSS в title — стрипается + When POST /boards/:bid/tasks { title: "Задача" } + Then 201 + { title: "Задача" } + + # ──── Редактирование ──── + + Scenario: inline edit title в TaskDrawer + Given TaskDrawer открыт для задачи DEV-1 + When я кликаю на title, меняю текст на "Новое название", нажимаю Enter + Then PATCH /tasks/:id { title: "Новое название" } → 200 + And title в drawer обновляется без перезагрузки страницы + And в истории задачи появляется запись "title изменён" + + Scenario: сброс assignee (null) + When PATCH /tasks/:id { assigneeId: null } + Then 200 + { assignee: null } + + Scenario: установка dueDate в прошлом — допустимо + When PATCH /tasks/:id { dueDate: "2020-01-01T00:00:00Z" } + Then 200 (дата в прошлом — валидна, UI красит красным) + + # ──── Переход по статусу ──── + + Scenario: переход по разрешённому переходу + Given воркфлоу FORWARD_ONLY: To Do → In Progress → Done + When PATCH /tasks/:id/move { statusId: } (задача в To Do) + Then 200 + { status: { name: "In Progress" } } + And history: "status изменён: To Do → In Progress" + + Scenario: переход по запрещённому переходу (FORWARD_ONLY) + Given задача в статусе "In Progress" + When PATCH /tasks/:id/move { statusId: } (переход назад) + Then 400 "Переход из In Progress в To Do не разрешён" + + Scenario: BIDIRECTIONAL — любой переход разрешён + Given воркфлоу BIDIRECTIONAL + When PATCH /tasks/:id/move { statusId: } (задача в Done) + Then 200 + + # ──── Подзадачи ──── + + Scenario: создание подзадачи + Given существует задача DEV-5 + When POST /boards/:bid/tasks { title: "Подзадача", parentId: "" } + Then 201 + { issueKey: "DEV-6", parentId: "" } + And GET /tasks/DEV-5-id/subtree содержит DEV-6 + + Scenario: SubtaskTree в TaskDrawer показывает вложенные подзадачи + Given DEV-5 имеет подзадачу DEV-6, DEV-6 имеет подзадачу DEV-7 + When я открываю TaskDrawer для DEV-5 + Then вижу дерево: DEV-5 → DEV-6 → DEV-7 + + Scenario: клик на подзадачу в SubtaskTree открывает вложенный Drawer + When я кликаю на DEV-6 в SubtaskTree + Then открывается вложенный TaskDrawer для DEV-6 поверх родительского + + # ──── Drag-and-drop (Kanban) ──── + + Scenario: перетаскивание задачи в другую колонку + Given задача DEV-1 в колонке "To Do" + When пользователь перетаскивает DEV-1 в колонку "In Progress" + Then PATCH /boards/:bid/tasks/reorder с обновлёнными statusId + orderIndex + And 200 + задача отображается в "In Progress" + + Scenario: перетаскивание внутри колонки (reorder) + Given колонка "To Do" содержит DEV-1, DEV-2, DEV-3 + When пользователь перетаскивает DEV-3 перед DEV-1 + Then PATCH /boards/:bid/tasks/reorder обновляет orderIndex для всех затронутых задач + And порядок в колонке: DEV-3, DEV-1, DEV-2 + + # ──── Удаление ──── + + Scenario: удаление задачи с подзадачами + Given DEV-5 имеет подзадачу DEV-6 с комментарием и чеклистом + When DELETE /tasks/ + Then 204 + And GET /tasks/ → 404 + And комментарии и чеклисты DEV-6 удалены + + Scenario: удаление задачи другого воркспейса — 403 + Given пользователь не состоит в воркспейсе задачи + When DELETE /tasks/ + Then 403 + + # ──── История изменений ──── + + Scenario: аудит при редактировании title + When PATCH /tasks/:id { title: "Новое" } + Then GET /tasks/:id/history содержит { field: "title", oldValue: "Старое", newValue: "Новое", actorId } + + Scenario: аудит при смене статуса + When PATCH /tasks/:id/move { statusId: } + Then GET /tasks/:id/history содержит { field: "status", oldValue: "In Progress", newValue: "Done" } + + Scenario: аудит при смене assignee + When PATCH /tasks/:id { assigneeId: } + Then GET /tasks/:id/history содержит { field: "assignee", newValue: "" } + + # ──── TaskDrawer — табы ──── + + Scenario: Drawer открывается на табе Details по умолчанию + When пользователь кликает на карточку задачи на доске + Then открывается TaskDrawer, активен таб "Детали" + And видны: title, description, статус, приоритет, исполнитель, дедлайн, метки, чеклисты, подзадачи + + Scenario: переключение на таб Комментарии + When я кликаю "Комментарии" в Drawer + Then загружаются комментарии задачи (GET /tasks/:id/comments) + And доступно поле ввода нового комментария + + Scenario: переключение на таб История + When я кликаю "История" в Drawer + Then загружается history timeline (GET /tasks/:id/history) + And каждая запись: поле, старое → новое значение, автор, время + + # ──── Фильтрация задач на доске ──── + + Scenario: фильтр по исполнителю + When GET /boards/:bid/tasks?assigneeId= + Then ответ содержит только задачи с assigneeId = userId + + Scenario: фильтр duePreset=overdue + When GET /boards/:bid/tasks?duePreset=overdue + Then ответ содержит только задачи с dueDate < now() + + Scenario: поиск по title + When GET /boards/:bid/tasks?search=логин + Then ответ содержит только задачи с "логин" в title (ILIKE) + + Scenario: пагинация — limit + offset + When GET /boards/:bid/tasks?limit=10&offset=20 + Then ответ содержит задачи 21–30 (если есть) +``` + +## SDD Contracts + +```typescript +// ── DTOs ────────────────────────────────────────────────── +interface CreateTaskDto { + title: string; // 1–500 символов, XSS-stripped + description?: string; // XSS-stripped + statusId?: string; // UUID; default: первый статус воркфлоу по position + priority?: 'HIGH' | 'MEDIUM' | 'LOW'; + dueDate?: string; // ISO 8601 datetime + startDate?: string; // ISO 8601 datetime + assigneeId?: string; // UUID; не проверяется на членство в workspace (gap-04) + parentId?: string; // UUID; задача становится подзадачей +} + +interface UpdateTaskDto { + title?: string; + description?: string; + priority?: 'HIGH' | 'MEDIUM' | 'LOW' | null; + dueDate?: string | null; + startDate?: string | null; + assigneeId?: string | null; +} + +interface MoveTaskDto { + statusId: string; // UUID; валидируется по transitions текущего workflow +} + +interface TaskFiltersDto { + statusId?: string; + assigneeId?: string; + priority?: 'HIGH' | 'MEDIUM' | 'LOW'; + labelId?: string; + parentId?: string | null; + rootOnly?: boolean; // только корневые задачи (parentId = null) + search?: string; // max 200 символов, ILIKE по title + duePreset?: 'today' | 'this_week' | 'next_week' | 'overdue' | 'no_date'; + limit?: number; // 1–500, default 100 + offset?: number; // default 0 +} + +// ── API Routes ──────────────────────────────────────────── +// GET /boards/:boardId/tasks → TaskDto[] (фильтры через query) +// POST /boards/:boardId/tasks → 201 TaskDto +// PATCH /boards/:boardId/tasks/reorder → 200 (drag-and-drop порядок) +// PATCH /boards/:boardId/tasks/bulk → 200 (bulk update статус/assignee/priority) +// POST /boards/:boardId/tasks/bulk-delete → 204 +// +// GET /tasks/:id → TaskDto +// PATCH /tasks/:id → 200 TaskDto +// PATCH /tasks/:id/move → 200 TaskDto (переход статуса) +// DELETE /tasks/:id → 204 (каскад: subtree, comments, checklists, labels) +// GET /tasks/:id/subtree → TaskDto[] (рекурсивное дерево подзадач) +// GET /tasks/:id/history → TaskHistoryEntryDto[] +// +// GET /my-tasks → MyTaskDto[] (задачи текущего user, все workspaces) + +// ── Response shape ──────────────────────────────────────── +interface TaskDto { + id: string; + issueKey: string; // "DEV-1" + title: string; + description: string | null; + priority: 'HIGH' | 'MEDIUM' | 'LOW' | null; + dueDate: string | null; + startDate: string | null; + orderIndex: number; + status: { id: string; name: string; color: string; category: StatusCategory }; + assignee: { id: string; name: string; email: string } | null; + labels: { id: string; name: string; color: string }[]; + _count: { subtasks: number; comments: number; checklists: number }; + parentId: string | null; + boardId: string; + createdAt: string; + updatedAt: string; +} + +type StatusCategory = 'OPEN' | 'IN_PROGRESS' | 'DONE' | 'CANCELLED'; + +interface TaskHistoryEntryDto { + id: string; + field: string; // "title" | "status" | "assignee" | "priority" | "dueDate" | ... + oldValue: string | null; + newValue: string | null; + actor: { id: string; name: string }; + createdAt: string; +} + +// ── Materialized path (subtask nesting) ────────────────── +// tasks.path: "////" +// Depth = path.split('/').length - 2 +// Subtree query: WHERE path LIKE '%' +// Max depth: 5 (gap-07 enforcement) + +// ── Issue Key generation ────────────────────────────────── +// issueKey = board.prefix + '-' + (max issueNum + 1) +// Retry on unique constraint violation (concurrent creates) +``` ## Scope -- CRUD задач (title, description, priority, dueDate, startDate, assigneeId) -- Issue keys: BOARD_PREFIX-N, автогенерация с retry на коллизию -- Materialized path для unlimited subtask nesting -- Переход по статусу: PATCH /tasks/:id/move → валидация по transitions workflow -- Drag-reorder внутри колонки и между колонками (Kanban) -- История изменений поля (field-level audit: title, description, priority, status, assignee, dueDate) -- Подзадачи: SubtaskTree в TaskDrawer, PATCH /tasks/:id (parentId для вложения) -- TaskDrawer: 3 таба — Details / Comments / History - -### TaskDrawer — Details -- Inline edit: title (click), description (textarea) -- Поля: статус (select), приоритет (select), исполнитель (dropdown участников), дедлайн (date input), начало (date input) -- Метки (LabelPicker) -- Чеклисты (ChecklistBlock) -- Подзадачи (SubtaskTree) -- Удаление задачи (каскад: подзадачи, комментарии, чеклисты, метки) +- `backend/src/modules/tasks/` — tasks.router.ts, tasks.service.ts, tasks.dto.ts +- `frontend/src/pages/BoardPage.tsx` — Kanban рендер, drag-and-drop (dnd-kit) +- `frontend/src/components/TaskDrawer.tsx` — Details / Comments / History табы +- `frontend/src/components/SubtaskTree.tsx` — рекурсивное дерево в Drawer +- `frontend/src/pages/MyTasksPage.tsx` — список задач текущего пользователя ## Out of Scope - Повторяющиеся задачи -- Зависимости между задачами (blocking/blocked-by) +- Зависимости (blocking/blocked-by) - Вложение задачи из другой доски +- Bulk export в CSV/PDF (BACKLOG P3) ## Constraints -- assigneeId — не валидируется как участник воркспейса (см. gap-04) -- Subtree: рекурсивная выборка без depth limit (см. gap-07) -- Board task list: неявный лимит 100 задач без пагинации (см. gap-05) -- Переход статуса валидируется только по transitions; target status принадлежность воркфлоу не проверяется +- `assigneeId` не валидируется как участник workspace → см. gap-04 +- Subtree запрашивается рекурсивно без depth guard → см. gap-07 +- Board list: `limit` max 500, default 100; нет cursor-based пагинации → см. gap-05 +- `PATCH /tasks/:id/move` валидирует только наличие transition в матрице; принадлежность statusId тому же воркфлоу не проверяется +- `title` и `description` проходят `stripHtml()` — сырой HTML сохранить нельзя +- `reorder` принимает массив; backend пересчитывает `orderIndex` атомарно в транзакции ## Acceptance Criteria -- [ ] POST /boards/:bid/tasks → 201, issueKey = PREFIX-N -- [ ] PATCH /tasks/:id/move (statusId не в transitions) → 400 -- [ ] DELETE /tasks/:id → 204, все подзадачи удалены -- [ ] TaskDrawer: inline edit title → Enter → PATCH /tasks/:id → title обновлён без перезагрузки страницы -- [ ] Subtask в DrawerSubtaskTree → открывает вложенный Drawer +- [ ] `POST /boards/:bid/tasks` → 201, `issueKey = BOARD_PREFIX-N` +- [ ] `POST /boards/:bid/tasks` (title > 500 символов) → 400 +- [ ] `POST /boards/:bid/tasks` (XSS в title) → 201, HTML stripped +- [ ] `PATCH /tasks/:id/move` (statusId в transitions) → 200 +- [ ] `PATCH /tasks/:id/move` (statusId не в transitions) → 400 +- [ ] `DELETE /tasks/:id` → 204, все подзадачи и их данные каскадно удалены +- [ ] `GET /tasks/:id/subtree` возвращает полное дерево подзадач +- [ ] TaskDrawer: inline edit title → Enter → `PATCH` → title обновлён без reload +- [ ] TaskDrawer: таб "История" → `GET /tasks/:id/history` → timeline с field/old/new/actor +- [ ] Drag-and-drop на Kanban → `PATCH /boards/:bid/tasks/reorder` → порядок сохранён +- [ ] Клик на подзадачу в SubtaskTree → вложенный Drawer открывается поверх текущего +- [ ] `GET /boards/:bid/tasks?assigneeId=X` → только задачи assignee X +- [ ] `GET /boards/:bid/tasks?duePreset=overdue` → только просроченные задачи diff --git a/specs/gaps/gap-12-workflow-settings-unlocked.md b/specs/gaps/gap-12-workflow-settings-unlocked.md index b06e7a7..b79187f 100644 --- a/specs/gaps/gap-12-workflow-settings-unlocked.md +++ b/specs/gaps/gap-12-workflow-settings-unlocked.md @@ -2,7 +2,7 @@ id: gap-12-workflow-settings-unlocked type: gap-fix priority: P1 -status: draft +status: done --- # Spec: Workflow Settings — страница настроек заблокирована (ghost-lock) diff --git a/specs/gaps/gap-13-2fa-totp.md b/specs/gaps/gap-13-2fa-totp.md new file mode 100644 index 0000000..d1ba008 --- /dev/null +++ b/specs/gaps/gap-13-2fa-totp.md @@ -0,0 +1,222 @@ +--- +id: gap-13-2fa-totp +type: gap-feat +priority: P2 +status: done +--- + +# Spec: 2FA / TOTP — двухфакторная аутентификация через Avanpost / Keycloak + +## Intent +Добавить поддержку второго фактора (TOTP) для SSO-пользователей: FlowTask делегирует проверку IdP (Avanpost/Keycloak) и верифицирует результат через `amr` claim в OIDC-токене. Для local-пользователей 2FA не реализуется (отдельный backlog). + +## Три режима авторизации в системе + +| # | Режим | Кто выполняет логин | 2FA | Скоуп gap-13 | +|---|---|---|---|---| +| 1 | **Local** | FlowTask (email + пароль) | нет | — | +| 2 | **SSO** | Avanpost / Keycloak | нет | частично (workspace requireMfa) | +| 3 | **SSO + TOTP** | Avanpost / Keycloak → TOTP-экран IdP | да | ✓ | + +В режиме 3 FlowTask **не показывает** TOTP-экран и **не хранит** TOTP-секреты. Всё происходит на стороне IdP. FlowTask только читает `amr` claim из id_token и решает, пропускать пользователя в воркспейс или нет. + +## BDD Scenarios + +```gherkin +Feature: 2FA / TOTP — проверка второго фактора через IdP + + Background: + Given AUTH_MODE=avanpost (или keycloak), SSO_ENABLED=true + + # ── Вход без MFA-требования ────────────────────────────── + + Scenario: [A1] SSO-вход без TOTP — workspace не требует MFA + Given воркспейс: requireMfa = false (default) + When пользователь проходит OIDC flow без второго фактора + Then id_token может не содержать amr или содержать amr: ["pwd"] + And FlowTask создаёт сессию штатно + And пользователь попадает в воркспейс + + # ── Вход с TOTP через IdP ───────────────────────────────── + + Scenario: [A2] SSO + TOTP — успешный вход + Given в Avanpost включён обязательный TOTP для группы FloTask-users + When пользователь нажимает "Войти через Avanpost" + And вводит корпоративный пароль на странице Avanpost + And вводит TOTP-код из мобильного приложения на странице Avanpost + Then Avanpost возвращает id_token с claim amr: ["totp"] (или ["pwd", "totp"]) + And FlowTask создаёт сессию и сохраняет amr в Redis + And пользователь попадает в воркспейс + + # ── Workspace требует MFA ───────────────────────────────── + + Scenario: [A3] workspace требует MFA — amr содержит totp — доступ разрешён + Given воркспейс: requireMfa = true + And в сессии amr: ["totp"] + When пользователь обращается к любому маршруту воркспейса + Then workspaceMfaGuard пропускает запрос + + Scenario: [A4] workspace требует MFA — amr не содержит totp — доступ запрещён + Given воркспейс: requireMfa = true + And в сессии amr: ["pwd"] или amr отсутствует + When пользователь обращается к маршруту воркспейса + Then 403 { code: "MFA_REQUIRED" } + And фронтенд показывает: "Для доступа к этому воркспейсу требуется двухфакторная аутентификация" + And ссылка на инструкцию по настройке TOTP в Avanpost + + Scenario: [A5] OWNER включает обязательную MFA для воркспейса + Given я OWNER, нахожусь на /w//settings?tab=security + When включаю "Обязательная 2FA" и нажимаю "Сохранить" + Then PATCH /api/workspaces//settings { requireMfa: true } → 200 + And все последующие входы без amr totp в этот воркспейс дадут 403 + + Scenario: [A6] OWNER включает MFA — grace period для существующих участников + Given воркспейс только что получил requireMfa = true + And участник уже авторизован без TOTP (amr: ["pwd"]) + When участник заходит в воркспейс + Then показывается баннер "Требуется настроить 2FA — осталось 7 дней" + And доступ к воркспейсу разрешён на период grace period + + Scenario: [A7] grace period истёк + Given grace period (7 дней) истёк для участника + When участник пытается зайти в воркспейс + Then 403 { code: "MFA_GRACE_EXPIRED" } + And редирект на страницу с инструкцией настройки TOTP в Avanpost +``` + +## SDD Contracts + +### 1. Извлечение amr из OIDC-токена + +```typescript +// backend/src/modules/auth/sso/claims-mapper.ts +export interface MappedClaims { + sub: string; + email: string; + name: string; + emailVerified: boolean; + amr: string[]; // Authentication Method References (RFC 8176) + // Keycloak: ["pwd"] / ["pwd","totp"] / ["otp"] + // Avanpost: ["totp"] / ["password","totp"] +} + +export function mapClaims(raw: Record): MappedClaims { + // ... existing fields ... + const amr = Array.isArray(raw['amr']) ? (raw['amr'] as string[]) : []; + return { sub, email, name, emailVerified, amr }; +} +``` + +### 2. Сохранение amr в Redis-сессию + +```typescript +// backend/src/modules/auth/sso/sso.service.ts +// В handleSsoCallback, после jitProvision: +await setUserSession(user.id, refreshToken, { + amr: claims.amr, // сохраняем вместе с сессией +}); +``` + +### 3. Поле requireMfa на Workspace + +```prisma +// backend/src/prisma/schema.prisma +model Workspace { + // ... existing fields ... + requireMfa Boolean @default(false) + mfaGraceDays Int @default(7) + @@map("workspaces") +} +``` + +### 4. Workspace MFA Guard middleware + +```typescript +// backend/src/shared/middleware/workspace-mfa-guard.ts +const MFA_AMR_VALUES = ['totp', 'otp', 'mfa', 'hwk', 'swk']; + +export async function workspaceMfaGuard( + req: AuthRequest, + res: Response, + next: NextFunction, +) { + const workspace = req.workspace; // прикреплён вышестоящим middleware + if (!workspace.requireMfa) return next(); + + const session = await getUserSession(req.userId); + const amr: string[] = session?.amr ?? []; + const mfaSatisfied = amr.some(v => MFA_AMR_VALUES.includes(v)); + if (mfaSatisfied) return next(); + + // Проверка grace period + const member = await prisma.workspaceMember.findUnique({ + where: { workspaceId_userId: { workspaceId: workspace.id, userId: req.userId } }, + select: { createdAt: true, mfaGraceUntil: true }, + }); + + const graceUntil = member?.mfaGraceUntil ?? null; + if (graceUntil && graceUntil > new Date()) { + const daysLeft = Math.ceil((graceUntil.getTime() - Date.now()) / 86_400_000); + res.setHeader('X-MFA-Grace-Days', daysLeft); + return next(); + } + + throw new AppError(403, graceUntil ? 'MFA_GRACE_EXPIRED' : 'MFA_REQUIRED'); +} +``` + +### 5. Workspace settings DTO + +```typescript +// backend/src/modules/workspaces/workspaces.dto.ts +export const UpdateWorkspaceSettingsSchema = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().max(500).optional(), + isPrivate: z.boolean().optional(), + requireMfa: z.boolean().optional(), // ← новый + mfaGraceDays: z.number().int().min(1).max(30).optional(), // ← новый +}); +``` + +### 6. WorkspaceMember — grace period поле + +```prisma +model WorkspaceMember { + // ... existing fields ... + mfaGraceUntil DateTime? // выставляется при включении requireMfa на воркспейс + @@map("workspace_members") +} +``` + +> При PATCH `requireMfa: true` → сервис проставляет `mfaGraceUntil = now() + mfaGraceDays` всем участникам без `amr: totp` в активных сессиях. + +## Scope +- `backend/src/modules/auth/sso/claims-mapper.ts` — добавить `amr` в `MappedClaims` +- `backend/src/modules/auth/sso/sso.service.ts` — сохранять `amr` в Redis-сессию +- `backend/src/shared/middleware/workspace-mfa-guard.ts` — новый middleware +- `backend/src/prisma/schema.prisma` — `Workspace.requireMfa`, `Workspace.mfaGraceDays`, `WorkspaceMember.mfaGraceUntil` +- `backend/src/modules/workspaces/workspaces.dto.ts` — `requireMfa`, `mfaGraceDays` +- `backend/src/modules/workspaces/workspaces.service.ts` — при включении MFA проставить grace period участникам +- `frontend/src/pages/WorkspaceSettingsPage.tsx` — вкладка Security: toggle `requireMfa` +- `frontend` — баннер grace period в WorkspaceLayout + +## Out of Scope +- Native TOTP (QR-код, секрет, challenge) для local-пользователей — отдельный backlog +- SMS 2FA +- WebAuthn / FIDO2 +- Настройка TOTP в Avanpost / Keycloak — это сторона ИБ, не FlowTask + +## Constraints +- `amr` claim должен быть включён в id_token на стороне IdP — Avanpost ≥ 4.x, Keycloak ≥ 18.x +- Grace period считается от момента включения `requireMfa` (через `mfaGraceUntil`), не от `createdAt` участника +- Middleware применяется только к SSO-пользователям (`authProvider != 'local'`); local-пользователи не затрагиваются + +## Acceptance Criteria +- [ ] `mapClaims` извлекает `amr` из id_token +- [ ] `amr` сохраняется в Redis-сессию при SSO-входе +- [ ] `requireMfa = false` → workspaceMfaGuard пропускает все запросы +- [ ] `requireMfa = true` + `amr: ["totp"]` → доступ разрешён +- [ ] `requireMfa = true` + `amr: ["pwd"]` → 403 `MFA_REQUIRED` +- [ ] Grace period: участники получают `mfaGraceUntil` при включении MFA, баннер показывается N дней +- [ ] После истечения grace period → 403 `MFA_GRACE_EXPIRED` +- [ ] OWNER переключает `requireMfa` через PATCH /workspaces/:slug/settings diff --git a/specs/gaps/gap-14-rate-limit-ip-bypass.md b/specs/gaps/gap-14-rate-limit-ip-bypass.md new file mode 100644 index 0000000..c135f6f --- /dev/null +++ b/specs/gaps/gap-14-rate-limit-ip-bypass.md @@ -0,0 +1,91 @@ +--- +id: gap-14-rate-limit-ip-bypass +type: gap-fix +priority: P1 +status: done +source: pentest-2026-05-07 +--- + +# Spec: Обход IP rate-limit через X-Forwarded-For на /login + +## Intent +Атакующий обходит IP-based rate-limit на POST /api/auth/login, меняя X-Forwarded-For +на каждый запрос. Добавить email-based ключ для rate-limit на auth-эндпоинтах. + +## Root Cause +`app.ts:25` — `trust proxy 1` → `req.ip` берётся из `X-Forwarded-For`. +`rate-limit.ts:47` — `defaultKey()` использует `req.ip`. +`auth.router.ts` — rate-limit middleware НЕ применялся к `/login`, `/register`, `/forgot-password`. + +Brute-force защита в `auth.service.ts` ключируется по email (Redis), но +middleware-уровень rate-limit (IP-based) можно было обойти. + +## BDD Scenarios + +```gherkin +Feature: Rate-limit на auth-эндпоинтах ключируется по email + + Background: + Given система Flow Tasks запущена + And существует пользователь "victim@flowtask.dev" + + Scenario: Брутфорс с одного IP блокируется + Given атакующий с IP 1.2.3.4 + When выполнено 10 POST /api/auth/login с неверным паролем + Then 11-й запрос возвращает 429 Too Many Requests + + Scenario: Ротация X-Forwarded-For не помогает обойти блокировку + Given атакующий меняет X-Forwarded-For на каждый запрос + When выполнено 10 POST /api/auth/login {"email":"victim@flowtask.dev","password":"wrong"} + с заголовком X-Forwarded-For: 10.0.0.N (N=1..10) + Then 11-й запрос с X-Forwarded-For: 10.0.0.11 возвращает 429 + And тело ответа содержит {"error":"Too many requests"} + + Scenario: Rate-limit применяется к /register + When выполнено 10 POST /api/auth/register {"email":"new@flowtask.dev",...} + Then 11-й запрос возвращает 429 + + Scenario: Rate-limit применяется к /forgot-password + When выполнено 10 POST /api/auth/forgot-password {"email":"victim@flowtask.dev"} + Then 11-й запрос возвращает 429 +``` + +## SDD Contracts + +```typescript +// auth.router.ts — добавить middleware на три эндпоинта +import { rateLimit, RATE_LIMITS } from '../../shared/middleware/rate-limit.js'; + +// Ключ: нормализованный email из тела (или IP как fallback). +// Смена X-Forwarded-For не помогает, т.к. ключ не зависит от IP. +const authEmailKey = (req: Request): string => + (req.body?.email as string | undefined)?.trim().toLowerCase() + ?? req.ip + ?? 'anonymous'; + +const authLimit = rateLimit({ ...RATE_LIMITS.auth, keyFn: authEmailKey }); + +router.post('/login', authLimit, validate(loginDto), ...) +router.post('/register', authLimit, validate(registerDto), ...) +router.post('/forgot-password', authLimit, validate(forgotPasswordDto), ...) +``` + +## Scope +- Добавить `authLimit` middleware в `auth.router.ts` на `/login`, `/register`, `/forgot-password` +- `keyFn` использует email из `req.body` (нормализованный) + +## Out of Scope +- Изменение порогов лимитов (10 req/min остаётся) +- Изменение логики fallback in-memory store + +## Constraints +- `express.json()` выполняется в `app.ts` до роутера → `req.body` доступен в keyFn +- `validate(dto)` запускается ПОСЛЕ rate-limit, порядок: `authLimit → validate → handler` +- В тестовой среде Redis может быть недоступен → падение на in-memory fallback + +## Acceptance Criteria +- [ ] `POST /api/auth/login` с email-ключом: 11-й запрос → 429 вне зависимости от IP +- [ ] `POST /api/auth/register` — аналогично +- [ ] `POST /api/auth/forgot-password` — аналогично +- [ ] Существующие happy-path тесты проходят +- [ ] Тест: 11 запросов с разными X-Forwarded-For на один email → 429 diff --git a/specs/gaps/gap-15-email-enumeration.md b/specs/gaps/gap-15-email-enumeration.md new file mode 100644 index 0000000..1293426 --- /dev/null +++ b/specs/gaps/gap-15-email-enumeration.md @@ -0,0 +1,103 @@ +--- +id: gap-15-email-enumeration +type: gap-fix +priority: P2 +status: done +source: pentest-2026-05-07 +--- + +# Spec: Email enumeration через POST /api/auth/register + +## Intent +Эндпоинт регистрации возвращает три разных ответа, по которым атакующий определяет +статус email: зарегистрирован / pending / свободен. Унифицировать ответы в один. + +## Root Cause +`auth.service.ts:56-60` — явные `AppError(409, ...)` с уникальным текстом: +- `"Email уже зарегистрирован"` — email есть в `users` +- `"Заявка с этим email уже ожидает рассмотрения"` — email в `registrationRequests` с PENDING +- `"Заявка на регистрацию отправлена..."` — email свободен + +Атакующий перебирает email и различает все три состояния. + +> Примечание: `POST /api/auth/forgot-password` уже реализован правильно — возвращает +> один и тот же ответ для существующего и несуществующего email. + +## BDD Scenarios + +```gherkin +Feature: POST /api/auth/register не раскрывает статус email + + Background: + Given существует пользователь "alice@flowtask.dev" + And есть PENDING заявка на "pending@flowtask.dev" + And "new@flowtask.dev" не зарегистрирован + + Scenario: Повторная регистрация существующего email — ответ неотличим + When POST /api/auth/register {"email":"alice@flowtask.dev","password":"Pass1!","name":"X"} + Then статус 200 + And тело ответа содержит {"message":"..."} + And текст совпадает с ответом для несуществующего email + + Scenario: Повторная регистрация PENDING email — ответ неотличим + When POST /api/auth/register {"email":"pending@flowtask.dev","password":"Pass1!","name":"X"} + Then статус 200 + And текст ответа идентичен ответу для нового email + + Scenario: Регистрация нового email — стандартный ответ + When POST /api/auth/register {"email":"new@flowtask.dev","password":"Pass1!","name":"New"} + Then статус 200 + And тело ответа содержит ключ "message" + + Scenario: Три запроса с разным статусом email возвращают одинаковый ответ + When POST /api/auth/register для "alice@flowtask.dev" → response_A + And POST /api/auth/register для "pending@flowtask.dev" → response_B + And POST /api/auth/register для "new@flowtask.dev" → response_C + Then response_A.body.message == response_B.body.message == response_C.body.message +``` + +## SDD Contracts + +```typescript +// auth.service.ts — register() + +const REGISTER_MSG = 'Если email доступен, заявка отправлена. Ожидайте подтверждения администратора.'; + +export async function register(dto: RegisterDto) { + const localPart = dto.email.trim().toLowerCase().split('@')[0]; + const email = `${localPart}@${config.REGISTRATION_DOMAIN}`; + + const [existingUser, existingRequest] = await Promise.all([ + prisma.user.findUnique({ where: { email } }), + prisma.registrationRequest.findUnique({ where: { email } }), + ]); + + // Silent exit — не раскрываем существование email + if (existingUser || (existingRequest?.status === 'PENDING')) { + return { message: REGISTER_MSG }; + } + + // ... создание заявки + return { message: REGISTER_MSG }; +} +``` + +## Scope +- `auth.service.ts` — убрать `AppError(409)` из `register()`, вернуть единый `message` +- `auth.test.ts` — обновить тест с `expect(409)` → `expect(200)` + проверка одинакового message +- `ib-authentication.test.ts` — обновить сценарий уникальности + +## Out of Scope +- Изменение поведения `/forgot-password` (уже корректно) +- Изменение ответов `/login` (разные ошибки там допустимы после аутентификации) + +## Constraints +- Поведение повторной rejected-заявки (update status → PENDING снова) остаётся без изменений +- HTTP-статус меняется с 409 → 200 для случаев duplicate/pending + +## Acceptance Criteria +- [ ] `POST /api/auth/register` с существующим email → 200, тот же message +- [ ] `POST /api/auth/register` с PENDING email → 200, тот же message +- [ ] `POST /api/auth/register` с новым email → 200, тот же message +- [ ] Тест проверяет идентичность трёх ответов +- [ ] Тест `'rejects duplicate email with 409'` обновлён diff --git a/specs/security/ib-access-control.feature b/specs/security/ib-access-control.feature new file mode 100644 index 0000000..a074425 --- /dev/null +++ b/specs/security/ib-access-control.feature @@ -0,0 +1,155 @@ +# BDD: ИБ — Управление правами доступа (RBAC / ABAC / Record-level) +# Источник: Требования к ИБ §1.2.2–1.5, §1.7; Требования ИБ basic §2; ГОСТ 57580 УЗП.24–25 +# Tech segment: [ИАА] / iia + +Feature: Ролевое разграничение прав доступа + + Background: + Given система Flow Tasks запущена + And существуют пользователи: + | email | role | + | admin@corp.ru | superadmin | + | owner@corp.ru | workspace OWNER | + | member@corp.ru | workspace MEMBER | + | viewer@corp.ru | workspace VIEWER | + | outsider@corp.ru | нет доступа к WS | + + # ─── 1. Принцип минимальных полномочий ───────────────────────────────────── + + # Req §1.3.1, §1.4.1 + Scenario: VIEWER не может создавать задачи + Given viewer@corp.ru аутентифицирован + When POST /api/boards/:id/tasks с корректным телом запроса + Then статус 403 Forbidden + And тело ответа содержит {"code":"INSUFFICIENT_PERMISSIONS"} + + Scenario: MEMBER не может удалять воркспейс + Given member@corp.ru аутентифицирован + When DELETE /api/workspaces/:id + Then статус 403 Forbidden + + Scenario: OWNER может изменить роль участника + Given owner@corp.ru аутентифицирован + When PATCH /api/workspaces/:id/members/:userId с {"role":"VIEWER"} + Then статус 200 + And событие "workspace.member_role_changed" записывается в AuditLog/WorkspaceEvent + + # ─── 2. Изоляция по воркспейсу (Record-level access control) ─────────────── + + # Req §1.3.3, §1.3.5, §1.7 + Scenario: Пользователь без членства не видит задачи чужого воркспейса + Given outsider@corp.ru аутентифицирован + And задача task-123 принадлежит воркспейсу WS-1 + And outsider@corp.ru НЕ является участником WS-1 + When GET /api/tasks/task-123 + Then статус 404 (или 403) + And тело ответа НЕ содержит данных задачи + + Scenario: VIEWER видит только задачи своего воркспейса + Given viewer@corp.ru является участником WS-1, но не WS-2 + When GET /api/boards/:board_in_ws2/tasks + Then статус 403 или 404 + And данные задач WS-2 не раскрываются + + Scenario: Assignee видит свою задачу даже в приватном воркспейсе + Given member@corp.ru назначен исполнителем задачи task-private + And task-private находится в приватном board + When GET /api/tasks/task-private с токеном member@corp.ru + Then статус 200 + And ответ содержит данные task-private + + # ─── 3. API-доступ с разграничением по операциям ─────────────────────────── + + # Req §1.3.4 (чтение и запись обязательно, по объектам — опционально) + Scenario Outline: API-ключ с readonly scope не может выполнять запись + Given пользователь имеет API-ключ с scope="read" + When с API-ключом + Then статус 403 + + Examples: + | method | endpoint | + | POST | /api/boards/:id/tasks | + | PATCH | /api/tasks/:id | + | DELETE | /api/tasks/:id | + + # ─── 4. Admin — полный набор полномочий ──────────────────────────────────── + + # Req §1.5.3 + Scenario: Superadmin может просматривать всех пользователей + Given admin@corp.ru имеет флаг isSuperadmin=true + When GET /api/admin/users с токеном admin@corp.ru + Then статус 200 + And ответ содержит список всех пользователей + + Scenario: Superadmin может изменить роль пользователя в любом воркспейсе + Given admin@corp.ru аутентифицирован + When POST /api/admin/users/:id/set-superadmin с {"isSuperadmin":true} + Then статус 200 + And событие "admin.user.set_superadmin" записывается в AuditLog + + # ─── 5. Управление ролевой моделью ───────────────────────────────────────── + + # Req §1.4; ГОСТ 57580 УЗП.25 + Scenario: Добавление участника в воркспейс логируется + Given owner@corp.ru аутентифицирован + When POST /api/workspaces/:id/members с {"userId":"...","role":"MEMBER"} + Then статус 201 + And событие "workspace.member_added" записывается в AuditLog/WorkspaceEvent с полями: + | field | value | + | actorId | owner_user_id | + | targetId | new_member_id | + | role | MEMBER | + + Scenario: Удаление участника из воркспейса логируется + Given owner@corp.ru аутентифицирован + When DELETE /api/workspaces/:id/members/:userId + Then статус 200 + And событие "workspace.member_removed" записывается в AuditLog/WorkspaceEvent + + # Req ГОСТ 57580 УЗП.24 (действия с правами управления доступом) + Scenario: Все изменения прав пишутся в AuditLog с полями "было-стало" + Given member@corp.ru имеет роль MEMBER в WS-1 + When owner@corp.ru меняет роль member@corp.ru на VIEWER + Then WorkspaceEvent содержит: + | field | value | + | action | member_role_changed | + | oldValue | MEMBER | + | newValue | VIEWER | + + # ─── 6. Доступ к журналам аудита ───────────────────────────────────────── + + # Req basic §3.2 (любой доступ к записям о событиях логируется) + Scenario: Просмотр AuditLog администратором сам логируется + Given admin@corp.ru аутентифицирован + When GET /api/admin/audit-logs + Then статус 200 + And создаётся AuditLog-запись "admin.auditlog.read" с actorId=admin_id + + # ─── 7. Ролевая модель в отчётах ────────────────────────────────────────── + + # Req §1.4.3 (отчёты с учётом RBAC) + Scenario: Export данных содержит только объекты, доступные пользователю + Given member@corp.ru является участником WS-1 + And в системе есть WS-2, к которому member не имеет доступа + When GET /api/tasks?export=csv с токеном member@corp.ru + Then ответ содержит только задачи из WS-1 + And событие "data.export" записывается в AuditLog с указанием scope + + # ─── 8. Блокировка/разблокировка пользователя администратором ───────────── + + # Req Требования по логированию §2.2 + Scenario: Администратор блокирует пользователя — событие логируется + Given admin@corp.ru аутентифицирован + When PATCH /api/admin/users/:id с {"isActive":false} + Then статус 200 + And событие "admin.user.deactivate" записывается в AuditLog с полями: + | field | value | + | action | admin.user.deactivate | + | targetId | | + | actorId | admin_user_id | + + Scenario: Заблокированный пользователь не может войти в систему + Given пользователь "blocked@corp.ru" имеет isActive=false + When POST /api/auth/login с корректными credentials + Then статус 403 с телом {"code":"ACCOUNT_DISABLED"} + And событие "auth.login.fail" содержит reason="ACCOUNT_DISABLED" diff --git a/specs/security/ib-audit-logging.feature b/specs/security/ib-audit-logging.feature new file mode 100644 index 0000000..ee4e375 --- /dev/null +++ b/specs/security/ib-audit-logging.feature @@ -0,0 +1,238 @@ +# BDD: ИБ — Регистрация событий и аудит +# Источник: Требования по логированию.docx; Требования ИБ basic §3; ГОСТ 57580 УЗП.22–28, РД-40–43, ЦЗИ.28–30 +# Tech segments: [ИАА]=iia, [ФПП]=fpp, [УП]=up, [ОУ]=uo, [ХИ]=hi + +Feature: Регистрация событий безопасности и интеграция с SIEM + + Background: + Given система Flow Tasks запущена + And логирование включено + And SIEM-транспорт настроен + + # ─── 1. Формат SIEM-события ──────────────────────────────────────────────── + + # Req Требования по логированию §1 (обязательные поля) + Scenario: Каждое событие безопасности содержит обязательные поля + When генерируется любое событие ИБ + Then JSON-запись события содержит все обязательные поля: + | field | description | + | time | ISO-8601, UTC+3 (мск) | + | forwarder | идентификатор форвардера/прокси | + | source | источник события (hostname/service) | + | subject | субъект воздействия (userId/apiKey) | + | object | объект воздействия (resourceId) | + | resource | уровень приложения (endpoint/entity) | + | tech_segment | код технологического участка | + | tags | массив SIEM-тегов | + | session_id | идентификатор сессии | + | result | SUCCESS / FAIL | + + # Req Требования по логированию §4 (схема тегирования) + Scenario: SIEM-тег формируется по схеме 5 уровней + When событие аутентификации генерируется в production-контуре + Then поле tags содержит массив ["flowtasks","auth","iia","PROD",""] + And порядок тегов соответствует схеме: system_name, event_type, tech_segment, env, datacenter + + Scenario: Тег технологического участка соответствует типу события + Given таблица соответствия событий и сегментов: + | event_type | tech_segment | + | auth.login | iia | + | auth.logout | iia | + | admin.user.create | iia | + | task.create | fpp | + | task.export | hi | + | workspace.created | uo | + When событие каждого типа генерируется + Then поле tech_segment соответствует таблице + + # ─── 2. Изменения в формате "было-стало" ─────────────────────────────────── + + # Req Требования по логированию §1 (изменения представляются в виде "было-стало") + Scenario: Событие изменения задачи содержит oldValue и newValue + Given задача task-1 имеет priority=HIGH + When PATCH /api/tasks/task-1 с {"priority":"LOW"} + Then TaskHistory-запись содержит: + | field | value | + | field | priority | + | oldValue | HIGH | + | newValue | LOW | + And SIEM-событие "task.update" содержит те же поля + + Scenario: Изменение роли пользователя содержит "было-стало" + Given member@corp.ru имеет роль MEMBER + When PATCH /api/workspaces/:id/members/:userId с {"role":"VIEWER"} + Then AuditLog/WorkspaceEvent содержит: + | field | value | + | oldRole | MEMBER | + | newRole | VIEWER | + + # ─── 3. Аудит действий администраторов ──────────────────────────────────── + + # Req Требования по логированию §2.3; ГОСТ 57580 УЗП.22, УЗП.27 + Scenario: Создание пользователя администратором логируется + Given superadmin аутентифицирован + When POST /api/admin/users (создание аккаунта) + Then событие "admin.user.create" записывается в AuditLog с полями: + | field | value | + | actorId | superadmin_id | + | targetId | new_user_id | + | action | admin.user.create | + | tech_segment | iia | + + Scenario: Удаление пользователя администратором логируется + Given superadmin аутентифицирован + When DELETE /api/admin/users/:id + Then событие "admin.user.delete" записывается в AuditLog + + Scenario: Изменение настроек системы логируется (ГОСТ 57580 УЗП.27) + Given superadmin меняет конфигурационный параметр + When PATCH /api/admin/config с {"registrationDomain":"newdomain.ru"} + Then событие "admin.config.change" записывается в AuditLog с полями: + | field | value | + | action | admin.config.change | + | setting | registrationDomain | + | oldValue | olddomain.ru | + | newValue | newdomain.ru | + + # Req Требования по логированию §2.3 (изменение настроек аудита) + Scenario: Изменение настроек аудита само логируется + Given superadmin меняет уровень логирования + When PATCH /api/admin/audit-settings с {"logLevel":"error"} + Then событие "admin.audit.settings.change" записывается в AuditLog ПЕРЕД применением изменения + And событие содержит oldValue="info" и newValue="error" + + # ─── 4. Действия привилегированных пользователей ────────────────────────── + + # Req Требования по логированию §2.4; ГОСТ 57580 УЗП.23 + Scenario: Экспорт данных привилегированным пользователем логируется + Given owner@corp.ru (привилегированная роль в воркспейсе) аутентифицирован + When GET /api/tasks?export=csv&workspace=WS-1 + Then событие "data.export" записывается в AuditLog с полями: + | field | value | + | action | data.export | + | actorId | owner_user_id | + | scope | workspace=WS-1 | + | format | csv | + | recordCount | | + | tech_segment | hi | + + # Req §2.4 (ПДн маскируются при попадании в SIEM) + Scenario: ПДн-поля маскируются в SIEM-событиях + Given событие содержит поле email пользователя + When событие отправляется в SIEM + Then поле email заменено на хэш или маску вида "al***@corp.ru" + And поле phone (если есть) заменено на "***" + + # ─── 5. Аудит действий обычных пользователей ────────────────────────────── + + # Req Требования по логированию §2.5 + Scenario: Удаление задачи пользователем логируется + Given member@corp.ru аутентифицирован + When DELETE /api/tasks/:id + Then событие "task.delete" записывается в AuditLog с полями: + | field | value | + | action | task.delete | + | actorId | member_id | + | targetId | task_id | + + # ─── 6. Системные события ────────────────────────────────────────────────── + + # Req Требования по логированию §2.6; ГОСТ 57580 ЦЗИ.30 + Scenario: Запуск сервиса логируется как системное событие + When Node.js-процесс сервера стартует + Then в лог-транспорт отправляется событие "system.service.start" с полями: + | field | value | + | action | system.service.start | + | service | flow-tasks-backend | + | version | | + | pid | | + + # Req ГОСТ 57580 ЦЗИ.28 + Scenario: Установка обновления системы логируется + When выполняется деплой новой версии приложения + Then событие "system.update.install" записывается с полями: + | field | value | + | action | system.update.install | + | fromVersion | | + | toVersion | | + + # Req Требования по логированию §2.6 (реагирование на невозможность создать событие) + Scenario: Ошибка записи в журнал сама регистрируется + Given SIEM-транспорт недоступен + When попытка записать событие в SIEM не удаётся + Then событие "system.log.transport.error" пишется в fallback-транспорт (stdout/stderr) + And алерт отправляется администратору (email или PagerDuty) + + # Req §2.6 (ошибки валидации) + Scenario: Ошибки валидации входных данных логируются как системные события + When POST /api/tasks с невалидным телом (напр., title: "") + Then статус 400 + And событие "system.validation.error" логируется с полями: + | field | value | + | endpoint | POST /api/tasks | + | errors | ["title: required"] | + | ip | | + + # ─── 7. Интеграция с SIEM ───────────────────────────────────────────────── + + # Req §1.2.6, §1.2.7; basic §3.5 + Scenario: События доставляются в несколько SIEM-приёмников параллельно + Given настроены два транспорта: syslog и HTTP-sink + When событие безопасности генерируется + Then событие доставляется в syslog-транспорт + And событие ТАКЖЕ доставляется в HTTP-sink транспорт + And доставка в оба транспорта происходит асинхронно, не блокируя основной поток + + Scenario: Недоступность одного транспорта не останавливает запись в другой + Given HTTP-sink транспорт недоступен + When событие безопасности генерируется + Then событие записывается в syslog-транспорт + And ошибка HTTP-sink логируется отдельно + And основной запрос пользователя не прерывается + + # Req §1.2.6 (машинный разбор) + Scenario: Все события имеют строгую JSON-схему для машинного разбора + When 100 случайных событий безопасности генерируются + Then каждое событие проходит валидацию по JSON-схеме AuditEventSchema + And ни одно событие не содержит unparseable characters или нарушений структуры + + # Req Требования по логированию §1 (гарантированная доставка) + Scenario: Гарантированная доставка событий при кратковременном сбое SIEM + Given SIEM-транспорт недоступен в течение 30 секунд + When 10 событий безопасности генерируются в этот период + Then события буферизуются локально + And после восстановления SIEM все 10 событий доставляются в корректном порядке + + # ─── 8. ГОСТ 57580 — специфичные меры ──────────────────────────────────── + + # ГОСТ 57580 УЗП.26 (управление MFA) + Scenario: Настройка MFA-метода администратором логируется (УЗП.26) + Given superadmin настраивает MFA для пользователя + When PATCH /api/admin/users/:id/mfa с {"enabled":true,"method":"totp"} + Then событие "admin.mfa.config.change" записывается в AuditLog с полями: + | field | value | + | action | admin.mfa.config.change | + | targetId | user_id | + | method | totp | + + # ГОСТ 57580 УЗП.28 (управление криптографическими ключами) + Scenario: Ротация JWT-секрета логируется (УЗП.28) + Given superadmin выполняет ротацию JWT signing secret + When POST /api/admin/security/rotate-jwt-secret + Then событие "admin.crypto.key.rotate" записывается в AuditLog + + # ГОСТ 57580 ИУ.7 (создание/удаление ресурсов БД) + Scenario: Создание воркспейса (ресурс БД) логируется (ИУ.7) + When POST /api/workspaces + Then событие "workspace.created" записывается с полями: + | field | value | + | action | workspace.created | + | tech_segment | hi | + | actorId | creator_id | + + Scenario: Удаление воркспейса (ресурс БД) логируется (ИУ.7) + When DELETE /api/workspaces/:id + Then событие "workspace.deleted" записывается с полями: + | field | value | + | action | workspace.deleted | + | tech_segment | hi | diff --git a/specs/security/ib-authentication.feature b/specs/security/ib-authentication.feature new file mode 100644 index 0000000..d3cd538 --- /dev/null +++ b/specs/security/ib-authentication.feature @@ -0,0 +1,195 @@ +# BDD: ИБ — Идентификация, Аутентификация, Авторизация (ИАА) +# Источник: Требования к ИБ §1.2.5, §1.6; Требования ИБ basic §1; ГОСТ 57580 РД-40..РД-43 +# Tech segment: [ИАА] / iia + +Feature: Идентификация, аутентификация и авторизация пользователей + + # ─── 1. Уникальные учётные записи ───────────────────────────────────────── + + Background: + Given система Flow Tasks запущена + And база данных содержит таблицу users с уникальным ограничением на email + + # Req 1.2 (basic §1.1, §1.2) + Scenario: Уникальность учётных записей + Given пользователь "alice@corp.ru" уже зарегистрирован + When новый запрос на регистрацию с email "alice@corp.ru" отправлен + Then система возвращает 409 Conflict + And событие "auth.register.duplicate" записывается в AuditLog с полями: + | field | value | + | actorEmail | alice@corp.ru | + | action | auth.register | + | result | DUPLICATE | + | ip | | + + # ─── 2. Локальная аутентификация ─────────────────────────────────────────── + + # Req ГОСТ 57580 РД-40 + Scenario: Успешная аутентификация — событие регистрируется + Given пользователь "bob@corp.ru" существует в системе + When POST /api/auth/login с корректными credentials + Then статус ответа 200 + And событие "auth.login.success" записывается в AuditLog с полями: + | field | value | + | action | auth.login | + | result | SUCCESS | + | actorEmail | bob@corp.ru | + | ip | | + | userAgent | | + | sessionId | | + And SIEM-тег содержит ["flowtasks","auth","iia","PROD",""] + + # Req ГОСТ 57580 РД-40; Требования по логированию §2.1 + Scenario: Неуспешная аутентификация — событие регистрируется + Given пользователь "bob@corp.ru" существует в системе + When POST /api/auth/login с неверным паролем + Then статус ответа 401 + And событие "auth.login.fail" записывается в AuditLog с полями: + | field | value | + | action | auth.login | + | result | FAIL | + | ip | | + | userAgent | | + + # Req Требования по логированию §2.1 (блокировка/разблокировка аккаунта) + Scenario: Блокировка аккаунта после N неудачных попыток + Given пользователь "charlie@corp.ru" существует + And порог блокировки равен 5 попыткам за 15 минут + When выполнено 5 POST /api/auth/login с неверным паролем подряд + Then 6-й запрос возвращает 429 Too Many Requests + And событие "auth.lockout" записывается в AuditLog с полями: + | field | value | + | action | auth.lockout | + | result | LOCKED | + | ip | | + | attempts | 5 | + + # Req ГОСТ 57580 РД-41; Требования по логированию §2.1 + Scenario: Выход из системы — событие регистрируется + Given пользователь аутентифицирован с активной сессией + When POST /api/auth/logout + Then статус 200 + And событие "auth.logout" записывается в AuditLog с полями: + | field | value | + | action | auth.logout | + | result | SUCCESS | + | sessionId | | + | reason | user_initiated | + + # Req ГОСТ 57580 РД-41 (прерывание сессии) + Scenario: Logout по истечению refresh-токена — регистрируется как прерывание + Given пользователь имеет истёкший refresh-токен + When POST /api/auth/refresh + Then статус 401 + And событие "auth.session.expired" записывается в AuditLog + + # ─── 3. SSO / OIDC аутентификация ───────────────────────────────────────── + + # Req §1.2.5; basic §1.3, §1.11; ГОСТ 57580 РД-40 + Scenario: SSO-вход через корпоративный IDP — событие регистрируется + Given OIDC-провайдер настроен и доступен + When пользователь проходит SSO-аутентификацию через GET /api/auth/sso/callback + Then статус 302 (редирект на frontend) + And событие "auth.sso.login.success" записывается в AuditLog с полями: + | field | value | + | action | auth.sso.login | + | provider | | + | ssoSubject | | + | ip | | + | sessionId | | + + Scenario: SSO-вход с некорректным state — отклоняется и логируется + When GET /api/auth/sso/callback с невалидным state параметром + Then статус 400 + And событие "auth.sso.login.fail" записывается в AuditLog + + # Req ГОСТ 57580 РД-43 (изменение аутентификационных данных) + Scenario: Изменение пароля — регистрируется как смена credential + Given аутентифицированный пользователь + When PATCH /api/auth/profile с новым паролем + Then статус 200 + And событие "auth.credential.change" записывается в AuditLog с полями: + | field | value | + | action | auth.credential.change | + | field | password | + | actorId | | + + # ─── 4. API Key аутентификация ───────────────────────────────────────────── + + # Req §1.3.4 (API с разграничением по индивидуальным учётным записям) + Scenario: Использование API-ключа — успешная аутентификация логируется + Given у пользователя есть активный API-ключ "ft_abc..." + When запрос к GET /api/tasks с заголовком Authorization: Bearer ft_abc... + Then статус 200 + And событие "auth.apikey.use" записывается в AuditLog с полями: + | field | value | + | action | auth.apikey.use | + | keyPrefix | ft_abc... | + | ip | | + + Scenario: Использование истёкшего API-ключа — отклоняется и логируется + Given у пользователя есть API-ключ с expiresAt в прошлом + When запрос с этим ключом + Then статус 401 + And событие "auth.apikey.fail" записывается в AuditLog + + # ─── 5. MFA для критичных операций ──────────────────────────────────────── + + # Req §1.6 (MFA для критичных операций) + Scenario: Критичная операция требует MFA-подтверждения + Given пользователь-администратор аутентифицирован без MFA + When DELETE /api/admin/users/:id (критичная операция) + Then статус 403 с телом {"code":"MFA_REQUIRED"} + And событие "auth.mfa.required" записывается в AuditLog + + Scenario: Критичная операция выполнена с MFA + Given администратор прошёл MFA-подтверждение + When DELETE /api/admin/users/:id + Then статус 200 + And событие "auth.mfa.verified" записывается в AuditLog + + # ─── 6. Клиентская подпись в событиях аутентификации ────────────────────── + + # Req Требования по логированию §2.1 (web: ip, browser version, регион, ОС) + Scenario: Событие аутентификации содержит клиентскую подпись + When POST /api/auth/login с заголовками: + | User-Agent | Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120 | + | X-Real-IP | 10.0.0.42 | + | CF-IPCountry | RU | + Then событие в AuditLog содержит: + | field | value | + | ip | 10.0.0.42 | + | userAgent | Chrome/120 Windows | + | region | RU | + | osVersion | Windows NT 10.0 | + + # ─── 7. Rate-limit по email (gap-14) ───────────────────────────────────── + + # Req ИБ §1.6; gap-14-rate-limit-ip-bypass + Scenario: Ротация X-Forwarded-For не обходит rate-limit на /login + Given атакующий знает email жертвы "victim@flowtask.dev" + When выполнено 10 POST /api/auth/login с разными X-Forwarded-For: 10.0.0.N + Then 11-й запрос возвращает 429 Too Many Requests + And тело содержит {"error":"Too many requests"} + + Scenario: Rate-limit применяется к /register и /forgot-password + When выполнено 10 POST /api/auth/register {"email":"flood@flowtask.dev",...} + Then 11-й запрос возвращает 429 + + # ─── 8. Отсутствие email enumeration (gap-15) ───────────────────────────── + + # Req ИБ §1.2; gap-15-email-enumeration + Scenario: POST /register возвращает одинаковый ответ для всех статусов email + Given существует пользователь "existing@flowtask.dev" + And есть PENDING заявка "pending@flowtask.dev" + When три запроса POST /api/auth/register для existing/pending/new email + Then все три ответа имеют статус 200 + And body.message идентично для всех трёх ответов + + # ─── 9. Синхронизация системного времени ────────────────────────────────── + + # Req basic §3.3 + Scenario: Все события аудита содержат временну́ю метку в UTC+3 (мск) + When любое событие записывается в AuditLog + Then поле createdAt присутствует и парсируется как ISO-8601 + And поле timezone в мета-данных SIEM-события равно "Europe/Moscow"