diff --git a/.gitignore b/.gitignore index 890b45514..00cb70ec4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ certificates/web_certificates/*.key local_dev/ .serena/ .tokensave +.claude/ diff --git a/README.md b/README.md index 58ac7272d..f56788e03 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ git clone https://github.com/dfir-iris/iris-web.git cd iris-web # Checkout to the last tagged version -git checkout v2.4.20 +git checkout v2.5.0-beta.1 # Copy the environment file cp .env.model .env diff --git a/docker-compose.new-ui.yml b/docker-compose.new-ui.yml index 0f2988c36..240e0a44c 100644 --- a/docker-compose.new-ui.yml +++ b/docker-compose.new-ui.yml @@ -112,7 +112,7 @@ services: # install` on boot — the image carries everything it needs. build: context: ${IRIS_SVELTEKIT_FRONTEND_DIR:-../iris-frontend} - dockerfile: ${IRIS_FRONTEND_DOCKERFILE:-../iris-web/docker/frontend/Dockerfile} + dockerfile: ${IRIS_FRONTEND_DOCKERFILE:-Dockerfile} image: iris_frontend:newui profiles: ["new-ui"] container_name: iris_sveltekit_frontend diff --git a/docker-compose.yml b/docker-compose.yml index 91a7f796e..906666183 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: extends: file: docker-compose.base.yml service: db - image: ${DB_IMAGE_NAME:-ghcr.io/dfir-iris/iriswebapp_db}:${DB_IMAGE_TAG:-v2.4.20} + image: ${DB_IMAGE_NAME:-ghcr.io/dfir-iris/iriswebapp_db}:${DB_IMAGE_TAG:-v2.5.0-beta.1} build: docker/db/ restart: always @@ -36,7 +36,7 @@ services: extends: file: docker-compose.base.yml service: app - image: ${APP_IMAGE_NAME:-ghcr.io/dfir-iris/iriswebapp_app}:${APP_IMAGE_TAG:-v2.4.20} + image: ${APP_IMAGE_NAME:-ghcr.io/dfir-iris/iriswebapp_app}:${APP_IMAGE_TAG:-v2.5.0-beta.1} build: context: . dockerfile: docker/webApp/Dockerfile @@ -47,7 +47,7 @@ services: extends: file: docker-compose.base.yml service: worker - image: ${APP_IMAGE_NAME:-ghcr.io/dfir-iris/iriswebapp_app}:${APP_IMAGE_TAG:-v2.4.20} + image: ${APP_IMAGE_NAME:-ghcr.io/dfir-iris/iriswebapp_app}:${APP_IMAGE_TAG:-v2.5.0-beta.1} build: context: . dockerfile: docker/webApp/Dockerfile @@ -58,7 +58,7 @@ services: extends: file: docker-compose.base.yml service: nginx - image: ${NGINX_IMAGE_NAME:-ghcr.io/dfir-iris/iriswebapp_nginx}:${NGINX_IMAGE_TAG:-v2.4.20} + image: ${NGINX_IMAGE_NAME:-ghcr.io/dfir-iris/iriswebapp_nginx}:${NGINX_IMAGE_TAG:-v2.5.0-beta.1} build: context: docker/nginx/ args: diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile deleted file mode 100644 index dc4ce036b..000000000 --- a/docker/frontend/Dockerfile +++ /dev/null @@ -1,61 +0,0 @@ -# Multi-stage build for the SvelteKit new-UI frontend. -# -# Build context is the iris-frontend repo root, NOT this directory — -# see `context:` in docker-compose.new-ui.yml. -# -# Stage 1 (`builder`) — install full deps, run `npm run build`. -# Stage 2 (runtime) — copy the build output + prod node_modules, -# start `node build` as a non-root user. -# -# Pin the major-version of node so dev and prod use the same runtime. - -ARG NODE_VERSION=23-alpine - -# --------------------------------------------------------------------------- -# Stage 1: builder -# --------------------------------------------------------------------------- -FROM node:${NODE_VERSION} AS builder - -WORKDIR /app - -# `npm ci` needs both lockfile + package.json before any source is copied -# so the install layer caches independently of source edits. -COPY package.json package-lock.json ./ - -RUN npm ci --no-audit --no-fund - -# Now bring in the rest of the source and produce the production build. -# SvelteKit's adapter-node writes the runnable server bundle to /app/build. -COPY . . - -RUN npm run build - -# Strip dev dependencies so the runtime image only carries what `node -# build` needs at runtime. -RUN npm prune --omit=dev - - -# --------------------------------------------------------------------------- -# Stage 2: runtime -# --------------------------------------------------------------------------- -FROM node:${NODE_VERSION} AS runtime - -# Drop privileges. The node image ships a `node` user (uid 1000) we can -# reuse — owns nothing in /app so we chown the copied artefacts. -WORKDIR /app - -COPY --from=builder --chown=node:node /app/build ./build -COPY --from=builder --chown=node:node /app/node_modules ./node_modules -COPY --from=builder --chown=node:node /app/package.json ./package.json - -USER node - -ENV NODE_ENV=production -ENV PORT=5173 -ENV HOST=0.0.0.0 - -EXPOSE 5173 - -# SvelteKit's adapter-node produces a server entry at build/index.js; -# launching `node build` runs it. -CMD ["node", "build"] diff --git a/docker/nginx/nginx-newui.conf b/docker/nginx/nginx-newui.conf index a7f778e3b..74171f490 100644 --- a/docker/nginx/nginx-newui.conf +++ b/docker/nginx/nginx-newui.conf @@ -28,7 +28,13 @@ http { map $request_uri $csp_header { # `blob:` is required so authenticated avatars (fetched as # bytes and exposed via URL.createObjectURL) can render. - default "default-src 'self' https://analytics.dfir-iris.org https://127.0.0.1 http://app:8000; script-src 'self' 'unsafe-inline' https://analytics.dfir-iris.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; worker-src 'self' blob:;"; + # `connect-src` must be explicit — otherwise browsers fall back + # to `default-src` for XHR + WebSocket connections, and `default-src` + # doesn't cover the `ws:` / `wss:` schemes Socket.IO uses for + # the /collab namespace (real-time markdown collab in case notes, + # war-room notes, and sitreps). Without `ws: wss:` here, every + # collab editor hangs on its sync-init handshake. + default "default-src 'self' https://analytics.dfir-iris.org https://127.0.0.1 http://app:8000; connect-src 'self' https://analytics.dfir-iris.org https://127.0.0.1 http://app:8000 ws: wss:; script-src 'self' 'unsafe-inline' https://analytics.dfir-iris.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; worker-src 'self' blob:;"; } include /etc/nginx/mime.types; diff --git a/source/app/alembic/versions/0508647a855c_merge_dashboard_collab_and_war_room_heads.py b/source/app/alembic/versions/0508647a855c_merge_dashboard_collab_and_war_room_heads.py new file mode 100644 index 000000000..74987e09b --- /dev/null +++ b/source/app/alembic/versions/0508647a855c_merge_dashboard_collab_and_war_room_heads.py @@ -0,0 +1,28 @@ +"""Merge dashboard/collab merge head and war_room_teams head + +The upstream sync merge brought in war_room_teams_and_chat_attachments +(f7a3b9c1d02e), based on d5e6f7a8b9c0, on a sibling branch to the +dashboard/collab merge head (b5c6d7e8f9a0), re-forking the alembic +head into two. + +Revision ID: 0508647a855c +Revises: b5c6d7e8f9a0, f7a3b9c1d02e +Create Date: 2026-07-13 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '0508647a855c' +down_revision = ('b5c6d7e8f9a0', 'f7a3b9c1d02e') +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/source/app/alembic/versions/c3d4e5f6a9b0_add_war_room_topics.py b/source/app/alembic/versions/c3d4e5f6a9b0_add_war_room_topics.py new file mode 100644 index 000000000..5de237785 --- /dev/null +++ b/source/app/alembic/versions/c3d4e5f6a9b0_add_war_room_topics.py @@ -0,0 +1,98 @@ +"""Add war_room_topic table + topic_id column on war_room_chat_message. + +Topics are top-level partitions of the chat stream — each war room +has one non-archivable "Main" topic (created lazily by the business +layer on first read) plus any number of operator-created ones. A +message with `topic_id IS NULL` is on Main; the read path treats +NULL and the Main topic row as equivalent so pre-migration rows keep +working without a backfill. + +Archive is soft (`archived_at`). No hard delete — archived topics +still render (read-only) in the sidebar. + +Idempotent via `_has_table` / `_table_has_column` so re-running on +environments that already applied the migration is a no-op. + +Revision ID: c3d4e5f6a9b0 +Revises: f3c8d2a1b47e +Create Date: 2026-07-09 10:00:00.000000 +""" +import sqlalchemy as sa +from alembic import op + +from app.alembic.alembic_utils import _has_table, _table_has_column + + +revision = 'c3d4e5f6a9b0' +down_revision = 'f3c8d2a1b47e' +branch_labels = None +depends_on = None + + +def upgrade(): + if not _has_table('war_room_topic'): + op.create_table( + 'war_room_topic', + sa.Column('topic_id', sa.BigInteger(), primary_key=True), + sa.Column( + 'war_room_id', sa.BigInteger(), + sa.ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + nullable=False, + ), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column( + 'is_main', sa.Boolean(), nullable=False, + server_default=sa.text('false'), + ), + sa.Column( + 'created_by_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True, + ), + sa.Column( + 'created_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()'), + ), + sa.Column('archived_at', sa.DateTime(), nullable=True), + sa.UniqueConstraint('war_room_id', 'name', + name='uq_war_room_topic_name'), + ) + op.create_index( + 'ix_war_room_topic_war_room_id', + 'war_room_topic', + ['war_room_id'], + ) + + if _has_table('war_room_chat_message'): + if not _table_has_column('war_room_chat_message', 'topic_id'): + op.add_column( + 'war_room_chat_message', + sa.Column( + 'topic_id', sa.BigInteger(), + sa.ForeignKey( + 'war_room_topic.topic_id', ondelete='SET NULL' + ), + nullable=True, + ), + ) + op.create_index( + 'ix_war_room_chat_message_topic_id', + 'war_room_chat_message', + ['topic_id'], + ) + + +def downgrade(): + if _has_table('war_room_chat_message'): + if _table_has_column('war_room_chat_message', 'topic_id'): + op.drop_index( + 'ix_war_room_chat_message_topic_id', + table_name='war_room_chat_message', + ) + op.drop_column('war_room_chat_message', 'topic_id') + + if _has_table('war_room_topic'): + op.drop_index( + 'ix_war_room_topic_war_room_id', + table_name='war_room_topic', + ) + op.drop_table('war_room_topic') diff --git a/source/app/alembic/versions/d5e6f7a8b9c0_add_war_room_task_parent_and_indexes.py b/source/app/alembic/versions/d5e6f7a8b9c0_add_war_room_task_parent_and_indexes.py new file mode 100644 index 000000000..bcde722d6 --- /dev/null +++ b/source/app/alembic/versions/d5e6f7a8b9c0_add_war_room_task_parent_and_indexes.py @@ -0,0 +1,74 @@ +"""Add parent_task_id + search indexes on war_room_task. + +Subtasks are single-level: a war-room task may point at another +war-room task in the same room via `parent_task_id`. Enforcement of +the "no grand-children" rule lives in the business layer (a task +that is itself a child cannot be given children) so the DB stays +simple. `ON DELETE CASCADE` on the FK: removing a parent removes its +subtasks, matching the UX expectation ("delete a task, delete its +subtasks"). + +Also adds three helper indexes used by the new search/filter list: +- `parent_task_id` for expand-children lookups +- `status_id` for status-filter scans +- `war_room_id, parent_task_id` composite to cheaply pull the + top-level tree on the tasks page (parent_task_id IS NULL). + +Idempotent via `_has_table` / `_table_has_column` so re-running on +environments that already applied the migration is a no-op. + +Revision ID: d5e6f7a8b9c0 +Revises: c3d4e5f6a9b0 +Create Date: 2026-07-13 10:00:00.000000 +""" +import sqlalchemy as sa +from alembic import op + +from app.alembic.alembic_utils import _has_table, _table_has_column + + +revision = 'd5e6f7a8b9c0' +down_revision = 'c3d4e5f6a9b0' +branch_labels = None +depends_on = None + + +def upgrade(): + if not _has_table('war_room_task'): + return + + if not _table_has_column('war_room_task', 'parent_task_id'): + op.add_column( + 'war_room_task', + sa.Column( + 'parent_task_id', sa.BigInteger(), + sa.ForeignKey('war_room_task.task_id', ondelete='CASCADE'), + nullable=True, + ), + ) + op.create_index( + 'ix_war_room_task_parent_task_id', + 'war_room_task', + ['parent_task_id'], + ) + + op.execute( + 'CREATE INDEX IF NOT EXISTS ix_war_room_task_status_id ' + 'ON war_room_task (status_id)' + ) + op.execute( + 'CREATE INDEX IF NOT EXISTS ix_war_room_task_war_room_parent ' + 'ON war_room_task (war_room_id, parent_task_id)' + ) + + +def downgrade(): + if not _has_table('war_room_task'): + return + + op.execute('DROP INDEX IF EXISTS ix_war_room_task_war_room_parent') + op.execute('DROP INDEX IF EXISTS ix_war_room_task_status_id') + + if _table_has_column('war_room_task', 'parent_task_id'): + op.execute('DROP INDEX IF EXISTS ix_war_room_task_parent_task_id') + op.drop_column('war_room_task', 'parent_task_id') diff --git a/source/app/alembic/versions/d9f4a2c8b103_add_war_room_note_folders_and_revisions.py b/source/app/alembic/versions/d9f4a2c8b103_add_war_room_note_folders_and_revisions.py new file mode 100644 index 000000000..b1da04a11 --- /dev/null +++ b/source/app/alembic/versions/d9f4a2c8b103_add_war_room_note_folders_and_revisions.py @@ -0,0 +1,98 @@ +"""Add war-room note folders and revisions. + +Mirrors the case-notes folder + revision tables onto war rooms so the +war-room notes tab can render the same directory tree + versioning +UI. Two new tables plus a nullable `folder_id` column on the existing +`war_room_note` table. + + * `war_room_note_folder` — self-referencing adjacency-list tree, + scoped to a war room; cascade delete + on both `war_room_id` and `parent_id`. + * `war_room_note.folder_id` — nullable FK; root-level notes are `NULL`. + No cascade — folder deletion walks the + subtree in the business layer so revision + history is torn down alongside each note. + * `war_room_note_revision` — immutable per-note history, cascade delete + on `note_id`. + +Every step is guarded by `_has_table` / `_table_has_column` so the +migration is idempotent — safe to re-run on partially-upgraded +environments. + +Revision ID: d9f4a2c8b103 +Revises: f3a1b2c3d4e5 +Create Date: 2026-07-08 00:00:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +from app.alembic.alembic_utils import _has_table +from app.alembic.alembic_utils import _table_has_column + + +revision = 'd9f4a2c8b103' +down_revision = 'f3a1b2c3d4e5' +branch_labels = None +depends_on = None + + +def _create_war_room_note_folder(): + if _has_table('war_room_note_folder'): + return + op.create_table( + 'war_room_note_folder', + sa.Column('id', sa.BigInteger(), primary_key=True), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('war_room_id', sa.BigInteger(), + sa.ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + nullable=False, index=True), + sa.Column('parent_id', sa.BigInteger(), + sa.ForeignKey('war_room_note_folder.id', ondelete='CASCADE'), + nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.Column('updated_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + ) + + +def _add_note_folder_column(): + if _table_has_column('war_room_note', 'folder_id'): + return + op.add_column( + 'war_room_note', + sa.Column('folder_id', sa.BigInteger(), + sa.ForeignKey('war_room_note_folder.id'), nullable=True), + ) + + +def _create_war_room_note_revision(): + if _has_table('war_room_note_revision'): + return + op.create_table( + 'war_room_note_revision', + sa.Column('revision_id', sa.BigInteger(), primary_key=True), + sa.Column('note_id', sa.BigInteger(), + sa.ForeignKey('war_room_note.note_id', ondelete='CASCADE'), + nullable=False, index=True), + sa.Column('revision_number', sa.Integer(), nullable=False), + sa.Column('title', sa.Text(), nullable=True), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('revised_by_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True), + sa.Column('revised_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + ) + + +def upgrade(): + _create_war_room_note_folder() + _add_note_folder_column() + _create_war_room_note_revision() + + +def downgrade(): + op.drop_table('war_room_note_revision') + if _table_has_column('war_room_note', 'folder_id'): + op.drop_column('war_room_note', 'folder_id') + op.drop_table('war_room_note_folder') diff --git a/source/app/alembic/versions/e5b2a41c9d7e_war_room_timeline_event_parity.py b/source/app/alembic/versions/e5b2a41c9d7e_war_room_timeline_event_parity.py new file mode 100644 index 000000000..991228996 --- /dev/null +++ b/source/app/alembic/versions/e5b2a41c9d7e_war_room_timeline_event_parity.py @@ -0,0 +1,156 @@ +"""War-room timeline event: parity with case events. + +Extends `war_room_timeline_event` with the columns case events already +carry, plus the M2M join tables for assets and IOCs. After this +migration the two event shapes are near-siblings — the frontend event +card component ports cleanly from case-timeline to war-room-timeline +with only field-name adaptations. + + * `war_room_timeline_event.uuid`, `parent_id`, `source`, `raw`, + `tags`, `is_flagged`, `modification_history` + * `war_room_timeline_event_assets` — M2M to `case_assets` + * `war_room_timeline_event_iocs` — M2M to `ioc` + +Per-event comments are intentionally out of scope; war-room chat is the +conversation surface for a war room, not per-event threads. + +Every step is guarded by `_has_table` / `_table_has_column` so the +migration is idempotent — safe to re-run on partially-upgraded +environments. + +Revision ID: e5b2a41c9d7e +Revises: d9f4a2c8b103 +Create Date: 2026-07-08 00:00:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +from app.alembic.alembic_utils import _has_table +from app.alembic.alembic_utils import _table_has_column + + +revision = 'e5b2a41c9d7e' +down_revision = 'd9f4a2c8b103' +branch_labels = None +depends_on = None + + +def _add_columns(): + # Individual `_table_has_column` guards keep the migration + # replayable — production runs where an earlier attempt bailed + # partway through won't re-add a column that's already there. + if not _table_has_column('war_room_timeline_event', 'uuid'): + op.add_column( + 'war_room_timeline_event', + sa.Column('uuid', sa.dialects.postgresql.UUID(as_uuid=True), + nullable=False, + server_default=sa.text('gen_random_uuid()')), + ) + op.create_unique_constraint( + 'uq_war_room_timeline_event_uuid', + 'war_room_timeline_event', ['uuid'], + ) + if not _table_has_column('war_room_timeline_event', 'parent_id'): + op.add_column( + 'war_room_timeline_event', + sa.Column('parent_id', sa.BigInteger(), nullable=True), + ) + op.create_foreign_key( + 'fk_war_room_timeline_event_parent', + 'war_room_timeline_event', 'war_room_timeline_event', + ['parent_id'], ['id'], ondelete='SET NULL', + ) + if not _table_has_column('war_room_timeline_event', 'source'): + op.add_column( + 'war_room_timeline_event', + sa.Column('source', sa.Text(), nullable=True), + ) + if not _table_has_column('war_room_timeline_event', 'raw'): + op.add_column( + 'war_room_timeline_event', + sa.Column('raw', sa.Text(), nullable=True), + ) + if not _table_has_column('war_room_timeline_event', 'tags'): + op.add_column( + 'war_room_timeline_event', + sa.Column('tags', sa.Text(), nullable=True), + ) + if not _table_has_column('war_room_timeline_event', 'is_flagged'): + op.add_column( + 'war_room_timeline_event', + sa.Column('is_flagged', sa.Boolean(), nullable=False, + server_default=sa.text('false')), + ) + if not _table_has_column('war_room_timeline_event', + 'modification_history'): + op.add_column( + 'war_room_timeline_event', + sa.Column('modification_history', + sa.dialects.postgresql.JSONB(), nullable=True), + ) + + +def _create_war_room_timeline_event_assets(): + if _has_table('war_room_timeline_event_assets'): + return + op.create_table( + 'war_room_timeline_event_assets', + sa.Column('id', sa.BigInteger(), primary_key=True), + sa.Column('event_id', sa.BigInteger(), + sa.ForeignKey('war_room_timeline_event.id', + ondelete='CASCADE'), + nullable=False, index=True), + sa.Column('asset_id', sa.BigInteger(), + sa.ForeignKey('case_assets.asset_id', + ondelete='CASCADE'), + nullable=False), + sa.UniqueConstraint('event_id', 'asset_id', + name='uq_war_room_timeline_event_asset'), + ) + + +def _create_war_room_timeline_event_iocs(): + if _has_table('war_room_timeline_event_iocs'): + return + op.create_table( + 'war_room_timeline_event_iocs', + sa.Column('id', sa.BigInteger(), primary_key=True), + sa.Column('event_id', sa.BigInteger(), + sa.ForeignKey('war_room_timeline_event.id', + ondelete='CASCADE'), + nullable=False, index=True), + sa.Column('ioc_id', sa.BigInteger(), + sa.ForeignKey('ioc.ioc_id', ondelete='CASCADE'), + nullable=False), + sa.UniqueConstraint('event_id', 'ioc_id', + name='uq_war_room_timeline_event_ioc'), + ) + + +def upgrade(): + _add_columns() + _create_war_room_timeline_event_assets() + _create_war_room_timeline_event_iocs() + + +def downgrade(): + op.drop_table('war_room_timeline_event_iocs') + op.drop_table('war_room_timeline_event_assets') + if _table_has_column('war_room_timeline_event', 'modification_history'): + op.drop_column('war_room_timeline_event', 'modification_history') + if _table_has_column('war_room_timeline_event', 'is_flagged'): + op.drop_column('war_room_timeline_event', 'is_flagged') + if _table_has_column('war_room_timeline_event', 'tags'): + op.drop_column('war_room_timeline_event', 'tags') + if _table_has_column('war_room_timeline_event', 'raw'): + op.drop_column('war_room_timeline_event', 'raw') + if _table_has_column('war_room_timeline_event', 'source'): + op.drop_column('war_room_timeline_event', 'source') + if _table_has_column('war_room_timeline_event', 'parent_id'): + op.drop_constraint('fk_war_room_timeline_event_parent', + 'war_room_timeline_event', type_='foreignkey') + op.drop_column('war_room_timeline_event', 'parent_id') + if _table_has_column('war_room_timeline_event', 'uuid'): + op.drop_constraint('uq_war_room_timeline_event_uuid', + 'war_room_timeline_event', type_='unique') + op.drop_column('war_room_timeline_event', 'uuid') diff --git a/source/app/alembic/versions/f3c8d2a1b47e_war_room_chat_pin_and_polls.py b/source/app/alembic/versions/f3c8d2a1b47e_war_room_chat_pin_and_polls.py new file mode 100644 index 000000000..cbad0e0e3 --- /dev/null +++ b/source/app/alembic/versions/f3c8d2a1b47e_war_room_chat_pin_and_polls.py @@ -0,0 +1,117 @@ +"""War-room chat: pin toggle + polls schema. + +Adds analyst affordances on the war-room chat stream: + + * `war_room_chat_message.is_pinned` — sticky flag surfaced in the + "Decisions & Pins" sidebar alongside the existing `kind='pin'` + system rows. + * `war_room_chat_poll` — a poll posted inline in the stream (via a + companion `WarRoomChatMessage` with `kind='poll'`). + * `war_room_chat_poll_option` — the selectable answers for a poll. + * `war_room_chat_poll_vote` — each user's vote per option; composite + PK `(option_id, user_id)` idempotent-by-construction. + +Every step is guarded by `_has_table` / `_table_has_column` so the +migration is idempotent — safe to re-run on partially-upgraded +environments. + +Revision ID: f3c8d2a1b47e +Revises: e5b2a41c9d7e +Create Date: 2026-07-08 00:00:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +from app.alembic.alembic_utils import _has_table +from app.alembic.alembic_utils import _table_has_column + + +revision = 'f3c8d2a1b47e' +down_revision = 'e5b2a41c9d7e' +branch_labels = None +depends_on = None + + +def _add_is_pinned_column(): + if _table_has_column('war_room_chat_message', 'is_pinned'): + return + op.add_column( + 'war_room_chat_message', + sa.Column('is_pinned', sa.Boolean(), nullable=False, + server_default=sa.text('false')), + ) + + +def _create_war_room_chat_poll(): + if _has_table('war_room_chat_poll'): + return + op.create_table( + 'war_room_chat_poll', + sa.Column('poll_id', sa.BigInteger(), primary_key=True), + sa.Column('war_room_id', sa.BigInteger(), + sa.ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + nullable=False, index=True), + sa.Column('author_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True), + sa.Column('question', sa.Text(), nullable=False), + sa.Column('is_multi_select', sa.Boolean(), nullable=False, + server_default=sa.text('false')), + sa.Column('is_anonymous', sa.Boolean(), nullable=False, + server_default=sa.text('false')), + sa.Column('closes_at', sa.DateTime(), nullable=True), + sa.Column('closed_at', sa.DateTime(), nullable=True), + sa.Column('chat_message_id', sa.BigInteger(), + sa.ForeignKey('war_room_chat_message.message_id', + ondelete='SET NULL'), + nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + ) + + +def _create_war_room_chat_poll_option(): + if _has_table('war_room_chat_poll_option'): + return + op.create_table( + 'war_room_chat_poll_option', + sa.Column('option_id', sa.BigInteger(), primary_key=True), + sa.Column('poll_id', sa.BigInteger(), + sa.ForeignKey('war_room_chat_poll.poll_id', + ondelete='CASCADE'), + nullable=False, index=True), + sa.Column('label', sa.Text(), nullable=False), + sa.Column('sort_order', sa.Integer(), nullable=False, + server_default=sa.text('0')), + ) + + +def _create_war_room_chat_poll_vote(): + if _has_table('war_room_chat_poll_vote'): + return + op.create_table( + 'war_room_chat_poll_vote', + sa.Column('option_id', sa.BigInteger(), + sa.ForeignKey('war_room_chat_poll_option.option_id', + ondelete='CASCADE'), + primary_key=True, nullable=False), + sa.Column('user_id', sa.BigInteger(), + sa.ForeignKey('user.id', ondelete='CASCADE'), + primary_key=True, nullable=False), + sa.Column('voted_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + ) + + +def upgrade(): + _add_is_pinned_column() + _create_war_room_chat_poll() + _create_war_room_chat_poll_option() + _create_war_room_chat_poll_vote() + + +def downgrade(): + op.drop_table('war_room_chat_poll_vote') + op.drop_table('war_room_chat_poll_option') + op.drop_table('war_room_chat_poll') + if _table_has_column('war_room_chat_message', 'is_pinned'): + op.drop_column('war_room_chat_message', 'is_pinned') diff --git a/source/app/alembic/versions/f7a3b9c1d02e_add_war_room_teams_and_chat_attachments.py b/source/app/alembic/versions/f7a3b9c1d02e_add_war_room_teams_and_chat_attachments.py new file mode 100644 index 000000000..415bc6015 --- /dev/null +++ b/source/app/alembic/versions/f7a3b9c1d02e_add_war_room_teams_and_chat_attachments.py @@ -0,0 +1,107 @@ +"""Add per-war-room teams and chat message attachments. + +Feature 1: `war_room_team` + `war_room_team_member` support @team +mentions in chat, threads, notes and tasks. Teams are per-war-room and +cascade with the room. + +Feature 2: `war_room_chat_message.attachments` is a JSONB column that +holds inline file references (list of `{file_id, filename, mime_type, +size_bytes}`) pointing at rows in `war_room_datastore_file`. + +Idempotent via `_has_table` / `_table_has_column` so re-running is safe +across environments that already applied the migration. + +Revision ID: f7a3b9c1d02e +Revises: d5e6f7a8b9c0 +Create Date: 2026-07-13 10:00:00.000000 +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +from app.alembic.alembic_utils import _has_table, _table_has_column + + +revision = 'f7a3b9c1d02e' +down_revision = 'd5e6f7a8b9c0' +branch_labels = None +depends_on = None + + +def upgrade(): + if not _has_table('war_room_team'): + op.create_table( + 'war_room_team', + sa.Column('team_id', sa.BigInteger(), primary_key=True), + sa.Column( + 'war_room_id', sa.BigInteger(), + sa.ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + nullable=False, + ), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column( + 'created_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()'), + ), + sa.Column( + 'created_by_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True, + ), + sa.UniqueConstraint('war_room_id', 'name', + name='uq_war_room_team_name'), + ) + op.create_index( + 'ix_war_room_team_war_room_id', + 'war_room_team', + ['war_room_id'], + ) + + if not _has_table('war_room_team_member'): + op.create_table( + 'war_room_team_member', + sa.Column( + 'team_id', sa.BigInteger(), + sa.ForeignKey('war_room_team.team_id', ondelete='CASCADE'), + primary_key=True, nullable=False, + ), + sa.Column( + 'user_id', sa.BigInteger(), + sa.ForeignKey('user.id', ondelete='CASCADE'), + primary_key=True, nullable=False, + ), + sa.Column( + 'added_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()'), + ), + sa.Column( + 'added_by_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True, + ), + sa.UniqueConstraint('team_id', 'user_id', + name='uq_war_room_team_member'), + ) + + if _has_table('war_room_chat_message') \ + and not _table_has_column('war_room_chat_message', 'attachments'): + op.add_column( + 'war_room_chat_message', + sa.Column('attachments', postgresql.JSONB(), nullable=True), + ) + + +def downgrade(): + if _has_table('war_room_chat_message') \ + and _table_has_column('war_room_chat_message', 'attachments'): + op.drop_column('war_room_chat_message', 'attachments') + + if _has_table('war_room_team_member'): + op.drop_table('war_room_team_member') + + if _has_table('war_room_team'): + op.drop_index( + 'ix_war_room_team_war_room_id', + table_name='war_room_team', + ) + op.drop_table('war_room_team') diff --git a/source/app/blueprints/responses.py b/source/app/blueprints/responses.py index 3068cf2b5..36dc866ec 100644 --- a/source/app/blueprints/responses.py +++ b/source/app/blueprints/responses.py @@ -39,10 +39,23 @@ def page_not_found(e): return render_template('pages/error-404.html', template_folder=TEMPLATE_PATH), 404 -def response(status, data=None): - if data is not None: - data = json.dumps(data, cls=AlchemyEncoder) - return app.response_class(response=data, status=status, mimetype='application/json') +_UNSET = object() + + +def response(status, data=_UNSET): + # Always serialize when the caller passed *anything* — including + # `None`, which must reach the client as the JSON literal `null`, + # not an empty body. The old `data is not None` guard silently + # produced empty response bodies labelled as JSON, breaking every + # caller that returned `response_api_success(data=None)` (source + # alert cluster lookup is the trigger, but the pattern is used in + # several places). `_UNSET` preserves the "no body at all" path + # for the tiny minority of callers that genuinely want that. + if data is _UNSET: + body = None + else: + body = json.dumps(data, cls=AlchemyEncoder) + return app.response_class(response=body, status=status, mimetype='application/json') def response_error(msg, data=None, status=400): diff --git a/source/app/blueprints/rest/alerts_routes.py b/source/app/blueprints/rest/alerts_routes.py index 8c454cd2e..57c28ba44 100644 --- a/source/app/blueprints/rest/alerts_routes.py +++ b/source/app/blueprints/rest/alerts_routes.py @@ -420,6 +420,7 @@ def alerts_update_route(alert_id) -> Response: @alerts_rest_blueprint.route('/alerts/batch/update', methods=['POST']) +@endpoint_deprecated('PUT', '/api/v2/alerts/{identifier} (repeated per alert)') @ac_api_requires(Permissions.alerts_write) def alerts_batch_update_route() -> Response: """ @@ -499,6 +500,7 @@ def alerts_batch_update_route() -> Response: @alerts_rest_blueprint.route('/alerts/batch/delete', methods=['POST']) +@endpoint_deprecated('DELETE', '/api/v2/alerts/{identifier} (repeated per alert)') @ac_api_requires(Permissions.alerts_delete) def alerts_batch_delete_route() -> Response: """ @@ -591,6 +593,7 @@ def alerts_delete_route(alert_id) -> Response: @alerts_rest_blueprint.route('/alerts/escalate/', methods=['POST']) +@endpoint_deprecated('POST', '/api/v2/alerts/escalate/{identifier}') @ac_api_requires(Permissions.alerts_write) def alerts_escalate_route(alert_id) -> Response: """ @@ -662,6 +665,7 @@ def alerts_escalate_route(alert_id) -> Response: @alerts_rest_blueprint.route('/alerts/merge/', methods=['POST']) +@endpoint_deprecated('POST', '/api/v2/alerts/merge/{identifier}') @ac_api_requires(Permissions.alerts_write) def alerts_merge_route(alert_id) -> Response: """ @@ -729,6 +733,7 @@ def alerts_merge_route(alert_id) -> Response: @alerts_rest_blueprint.route('/alerts/unmerge/', methods=['POST']) +@endpoint_deprecated('POST', '/api/v2/alerts/unmerge/{identifier}') @ac_api_requires(Permissions.alerts_write) def alerts_unmerge_route(alert_id) -> Response: """ @@ -785,6 +790,7 @@ def alerts_unmerge_route(alert_id) -> Response: @alerts_rest_blueprint.route('/alerts/batch/merge', methods=['POST']) +@endpoint_deprecated('POST', '/api/v2/alerts/batch/merge') @ac_api_requires(Permissions.alerts_write) def alerts_batch_merge_route() -> Response: """ @@ -862,6 +868,7 @@ def alerts_batch_merge_route() -> Response: @alerts_rest_blueprint.route('/alerts/batch/escalate', methods=['POST']) +@endpoint_deprecated('POST', '/api/v2/alerts/batch/escalate') @ac_api_requires(Permissions.alerts_write) def alerts_batch_escalate_route() -> Response: """ @@ -965,6 +972,7 @@ def alert_comments_get(alert_id): @alerts_rest_blueprint.route('/alerts//comments//delete', methods=['POST']) +@endpoint_deprecated('DELETE', '/api/v2/alerts/{alert_identifier}/comments/{identifier}') @ac_api_requires(Permissions.alerts_write) def alert_comment_delete(alert_id, com_id): """ @@ -1028,6 +1036,7 @@ def alert_comment_get(alert_id, com_id): @alerts_rest_blueprint.route('/alerts//comments//edit', methods=['POST']) +@endpoint_deprecated('PUT', '/api/v2/alerts/{alert_identifier}/comments/{identifier}') @ac_api_requires(Permissions.alerts_write) def alert_comment_edit(alert_id, com_id): """ diff --git a/source/app/blueprints/rest/api_v2_routes.py b/source/app/blueprints/rest/api_v2_routes.py index c6494c0dc..d470d01fd 100644 --- a/source/app/blueprints/rest/api_v2_routes.py +++ b/source/app/blueprints/rest/api_v2_routes.py @@ -36,7 +36,6 @@ from app.blueprints.rest.v2.tasks import tasks_blueprint from app.blueprints.rest.v2.profile import profile_blueprint from app.blueprints.rest.v2.search import search_blueprint -from app.blueprints.rest.v2.alerts_filters import alerts_filters_blueprint from app.blueprints.rest.v2.avatars import admin_avatar_blueprint from app.blueprints.rest.v2.avatars import me_avatar_blueprint from app.blueprints.rest.v2.avatars import users_public_blueprint @@ -70,7 +69,6 @@ rest_v2_blueprint.register_blueprint(tags_blueprint) rest_v2_blueprint.register_blueprint(profile_blueprint) rest_v2_blueprint.register_blueprint(search_blueprint) -rest_v2_blueprint.register_blueprint(alerts_filters_blueprint) rest_v2_blueprint.register_blueprint(cases_filters_blueprint) rest_v2_blueprint.register_blueprint(users_public_blueprint) rest_v2_blueprint.register_blueprint(me_avatar_blueprint) diff --git a/source/app/blueprints/rest/v2/alerts.py b/source/app/blueprints/rest/v2/alerts.py index a53a68788..e25805fe8 100644 --- a/source/app/blueprints/rest/v2/alerts.py +++ b/source/app/blueprints/rest/v2/alerts.py @@ -35,16 +35,30 @@ from app.blueprints.rest.v2.alerts_routes.comments import alerts_comments_blueprint from app.blueprints.rest.v2.alerts_routes.investigation_progress import alerts_investigation_progress_blueprint from app.blueprints.iris_user import iris_current_user +from app.datamgmt.manage.manage_access_control_db import check_ua_case_client from app.business.alerts import alerts_search -from app.models.authorization import Permissions -from app.schema.marshables import AlertSchema -from app.schema.marshables import IocSchema -from app.schema.marshables import CaseAssetsSchema from app.business.alerts import alerts_create from app.business.alerts import alerts_get from app.business.alerts import alerts_update from app.business.alerts import alerts_delete from app.business.alerts import alerts_get_related +from app.business.alerts import alerts_escalate +from app.business.alerts import alerts_merge +from app.business.alerts import alerts_unmerge +from app.business.alerts import alerts_batch_escalate +from app.business.alerts import alerts_batch_merge +from app.business.alerts_filters import alert_filter_add +from app.business.alerts_filters import alert_filter_get +from app.business.alerts_filters import alert_filter_update +from app.business.alerts_filters import alert_filter_delete +from app.business.alerts_filters import alert_filter_list +from app.datamgmt.alerts.alerts_db import get_alert_by_id +from app.models.authorization import Permissions +from app.schema.marshables import AlertSchema +from app.schema.marshables import CaseSchema +from app.schema.marshables import IocSchema +from app.schema.marshables import CaseAssetsSchema +from app.schema.marshables import SavedFilterSchema from app.models.errors import BusinessProcessingError from app.models.errors import ObjectNotFoundError @@ -344,3 +358,291 @@ def delete_alert(identifier): @ac_api_requires(Permissions.alerts_read) def get_related_alerts(identifier): return alerts_operations.get_related_alerts(identifier) + + +@alerts_blueprint.post('/escalate/') +@ac_api_requires(Permissions.alerts_write) +def escalate_alert(identifier): + alert = get_alert_by_id(identifier) + if not alert: + return response_api_not_found() + + if not ac_current_user_has_customer_access(alert.alert_customer_id): + return response_api_error('User not entitled to escalate alerts for the client') + + data = request.get_json() or {} + try: + case = alerts_escalate( + alert, + iocs_import_list=data.get('iocs_import_list'), + assets_import_list=data.get('assets_import_list'), + note=data.get('note'), + import_as_event=data.get('import_as_event'), + case_tags=data.get('case_tags'), + case_title=data.get('case_title'), + case_template_id=data.get('case_template_id'), + ) + return response_api_success(CaseSchema().dump(case)) + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +@alerts_blueprint.post('/merge/') +@ac_api_requires(Permissions.alerts_write) +def merge_alert(identifier): + data = request.get_json() or {} + target_case_id = data.get('target_case_id') + if target_case_id is None: + return response_api_error('No target case id provided') + + alert = get_alert_by_id(identifier) + if not alert: + return response_api_not_found() + + if not ac_current_user_has_customer_access(alert.alert_customer_id): + return response_api_error('User not entitled to merge alerts for the client') + if not check_ua_case_client(iris_current_user.id, target_case_id): + return response_api_error('User not entitled to merge alerts for the case') + + try: + case = alerts_merge( + alert, + target_case_id=target_case_id, + iocs_import_list=data.get('iocs_import_list'), + assets_import_list=data.get('assets_import_list'), + note=data.get('note'), + import_as_event=data.get('import_as_event'), + case_tags=data.get('case_tags'), + ) + return response_api_success(CaseSchema().dump(case)) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +@alerts_blueprint.post('/unmerge/') +@ac_api_requires(Permissions.alerts_write) +def unmerge_alert(identifier): + data = request.get_json() or {} + target_case_id = data.get('target_case_id') + if target_case_id is None: + return response_api_error('No target case id provided') + + alert = get_alert_by_id(identifier) + if not alert: + return response_api_not_found() + + if not ac_current_user_has_customer_access(alert.alert_customer_id): + return response_api_error('User not entitled to unmerge alerts for the client') + if not check_ua_case_client(iris_current_user.id, target_case_id): + return response_api_error('User not entitled to unmerge alerts for the case') + + try: + alert, _message = alerts_unmerge(alert, target_case_id=target_case_id) + return response_api_success(AlertSchema().dump(alert)) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +@alerts_blueprint.post('/batch/escalate') +@ac_api_requires(Permissions.alerts_write) +def batch_escalate_alerts(): + data = request.get_json() or {} + alert_ids_raw = data.get('alert_ids') + if not alert_ids_raw: + return response_api_error('No alert ids provided') + + try: + alert_ids = parse_comma_separated_identifiers(alert_ids_raw) + except ValueError: + return response_api_error('Invalid alert id') + + # Customer-access gate for every requested alert. The legacy route did + # this inside the loop; hoisting it here keeps the business layer clean + # and prevents partial escalation before permission denial. + for alert_id in alert_ids: + alert = get_alert_by_id(alert_id) + if alert and not ac_current_user_has_customer_access(alert.alert_customer_id): + return response_api_error('User not entitled to escalate alerts for the client') + + try: + case = alerts_batch_escalate( + alert_ids, + iocs_import_list=data.get('iocs_import_list'), + assets_import_list=data.get('assets_import_list'), + note=data.get('note'), + import_as_event=data.get('import_as_event'), + case_tags=data.get('case_tags'), + case_title=data.get('case_title'), + case_template_id=data.get('case_template_id'), + ) + return response_api_success(CaseSchema().dump(case)) + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +@alerts_blueprint.post('/batch/merge') +@ac_api_requires(Permissions.alerts_write) +def batch_merge_alerts(): + data = request.get_json() or {} + target_case_id = data.get('target_case_id') + if target_case_id is None: + return response_api_error('No target case id provided') + + alert_ids_raw = data.get('alert_ids') + if not alert_ids_raw: + return response_api_error('No alert ids provided') + + try: + alert_ids = parse_comma_separated_identifiers(alert_ids_raw) + except ValueError: + return response_api_error('Invalid alert id') + + if not check_ua_case_client(iris_current_user.id, target_case_id): + return response_api_error('User not entitled to merge alerts for the case') + + for alert_id in alert_ids: + alert = get_alert_by_id(alert_id) + if alert and not ac_current_user_has_customer_access(alert.alert_customer_id): + return response_api_error('User not entitled to merge alerts for the client') + + try: + case = alerts_batch_merge( + alert_ids, + target_case_id=target_case_id, + iocs_import_list=data.get('iocs_import_list'), + assets_import_list=data.get('assets_import_list'), + note=data.get('note'), + import_as_event=data.get('import_as_event'), + case_tags=data.get('case_tags'), + ) + return response_api_success(CaseSchema().dump(case)) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +# --------------------------------------------------------------------------- +# Saved alert filters — previously mounted at /api/v2/alerts-filters. Moved +# under /api/v2/alerts/filters so the entire alert surface lives under a +# single prefix. +# --------------------------------------------------------------------------- + + +class AlertsFiltersOperations: + def __init__(self): + self._schema = SavedFilterSchema() + self._schema_many = SavedFilterSchema(many=True) + + def _load(self, request_data, **kwargs): + return self._schema.load(request_data, **kwargs) + + def create(self): + request_data = request.get_json() + request_data['created_by'] = iris_current_user.id + + try: + new_saved_filter = self._load(request_data) + alert_filter_add(new_saved_filter) + return response_api_created(self._schema.dump(new_saved_filter)) + except ValidationError as e: + return response_api_error('Data error', e.messages) + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + def list(self): + try: + filter_type = request.args.get('filter_type', 'alerts') + include_public = request.args.get('include_public', '1') == '1' + + items = alert_filter_list( + iris_current_user, + filter_type=filter_type, + include_public=include_public, + ) + return response_api_success(self._schema_many.dump(items)) + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + def get(self, identifier): + try: + saved_filter = alert_filter_get(iris_current_user, identifier) + return response_api_success(self._schema.dump(saved_filter)) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + def put(self, identifier): + request_data = request.get_json() or {} + # Pin the owner so a client can't move a filter under another user via + # mass-assignment. See the notes in the original alerts_filters module. + request_data['created_by'] = iris_current_user.id + + try: + saved_filter = alert_filter_get(iris_current_user, identifier) + if saved_filter.created_by != iris_current_user.id: + return response_api_not_found() + new_saved_filter = self._load(request_data, instance=saved_filter, partial=True) + alert_filter_update() + return response_api_success(self._schema.dump(new_saved_filter)) + except ValidationError as e: + return response_api_error('Data error', data=e.messages) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + @staticmethod + def delete(identifier): + try: + saved_filter = alert_filter_get(iris_current_user, identifier) + if saved_filter.created_by != iris_current_user.id: + return response_api_not_found() + alert_filter_delete(saved_filter) + return response_api_deleted() + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +alerts_filters_blueprint = Blueprint('alerts_filters_rest_v2', __name__, url_prefix='/filters') +alerts_filters_operations = AlertsFiltersOperations() + + +@alerts_filters_blueprint.post('') +@ac_api_requires() +def create_alert_filter(): + return alerts_filters_operations.create() + + +@alerts_filters_blueprint.get('') +@ac_api_requires() +def list_alert_filters(): + return alerts_filters_operations.list() + + +@alerts_filters_blueprint.get('/') +@ac_api_requires() +def get_alert_filter(identifier): + return alerts_filters_operations.get(identifier) + + +@alerts_filters_blueprint.put('/') +@ac_api_requires() +def update_alert_filter(identifier): + return alerts_filters_operations.put(identifier) + + +@alerts_filters_blueprint.delete('/') +@ac_api_requires() +def delete_alert_filter(identifier): + return alerts_filters_operations.delete(identifier) + + +alerts_blueprint.register_blueprint(alerts_filters_blueprint) diff --git a/source/app/blueprints/rest/v2/alerts_filters.py b/source/app/blueprints/rest/v2/alerts_filters.py deleted file mode 100644 index c9beac52e..000000000 --- a/source/app/blueprints/rest/v2/alerts_filters.py +++ /dev/null @@ -1,156 +0,0 @@ -from flask import Blueprint -from flask import request -from marshmallow import ValidationError - -from app.blueprints.access_controls import ac_api_requires -from app.blueprints.rest.endpoints import response_api_created -from app.blueprints.rest.endpoints import response_api_error -from app.blueprints.rest.endpoints import response_api_success -from app.blueprints.rest.endpoints import response_api_not_found -from app.blueprints.rest.endpoints import response_api_deleted -from app.blueprints.iris_user import iris_current_user - - -from app.schema.marshables import SavedFilterSchema -from app.models.errors import BusinessProcessingError -from app.models.errors import ObjectNotFoundError -from app.business.alerts_filters import alert_filter_add -from app.business.alerts_filters import alert_filter_get -from app.business.alerts_filters import alert_filter_update -from app.business.alerts_filters import alert_filter_delete -from app.business.alerts_filters import alert_filter_list - - -class AlertsFiltersOperations: - def __init__(self): - self._schema = SavedFilterSchema() - self._schema_many = SavedFilterSchema(many=True) - - def _load(self, request_data, **kwargs): - return self._schema.load(request_data, **kwargs) - - def create(self): - request_data = request.get_json() - request_data["created_by"] = iris_current_user.id - - try: - new_saved_filter = self._load(request_data) - alert_filter_add(new_saved_filter) - return response_api_created(self._schema.dump(new_saved_filter)) - - except ValidationError as e: - return response_api_error("Data error", e.messages) - - except BusinessProcessingError as e: - return response_api_error(e.get_message(), data=e.get_data()) - - def list(self): - try: - filter_type = request.args.get("filter_type", "alerts") - include_public = request.args.get("include_public", "1") == "1" - - items = alert_filter_list( - iris_current_user, - filter_type=filter_type, - include_public=include_public - ) - return response_api_success(self._schema_many.dump(items)) - - except BusinessProcessingError as e: - return response_api_error(e.get_message(), data=e.get_data()) - - def get(self, identifier): - try: - saved_filter = alert_filter_get(iris_current_user, identifier) - return response_api_success(self._schema.dump(saved_filter)) - - except ObjectNotFoundError: - return response_api_not_found() - - except BusinessProcessingError as e: - return response_api_error(e.get_message(), data=e.get_data()) - - def put(self, identifier): - request_data = request.get_json() or {} - # Pin the owner so a client can't move a filter under another - # user via mass-assignment. `alert_filter_get` returns a row - # the session user can *read* (own private + everyone's - # public), but write access requires ownership — enforced - # below. - request_data['created_by'] = iris_current_user.id - - try: - saved_filter = alert_filter_get(iris_current_user, identifier) - if saved_filter.created_by != iris_current_user.id: - # Read does not imply write: a public filter is - # visible to everyone but only the creator can mutate - # it. Mirror the not-found path to avoid disclosing - # existence. - return response_api_not_found() - new_saved_filter = self._load( - request_data, instance=saved_filter, partial=True - ) - alert_filter_update() - return response_api_success(self._schema.dump(new_saved_filter)) - - except ValidationError as e: - return response_api_error("Data error", data=e.messages) - - except ObjectNotFoundError: - return response_api_not_found() - - except BusinessProcessingError as e: - return response_api_error(e.get_message(), data=e.get_data()) - - @staticmethod - def delete(identifier): - try: - saved_filter = alert_filter_get(iris_current_user, identifier) - # Same ownership check as PUT: only the creator may delete - # the filter. - if saved_filter.created_by != iris_current_user.id: - return response_api_not_found() - alert_filter_delete(saved_filter) - return response_api_deleted() - - except ObjectNotFoundError: - return response_api_not_found() - - except BusinessProcessingError as e: - return response_api_error(e.get_message(), data=e.get_data()) - - -alerts_filters_blueprint = Blueprint( - "alerts_filters_rest_v2", __name__, url_prefix="/alerts-filters" -) -alerts_filters_operations = AlertsFiltersOperations() - - -@alerts_filters_blueprint.post("") -@ac_api_requires() -def create_alert_filter(): - return alerts_filters_operations.create() - - -@alerts_filters_blueprint.get("") -@ac_api_requires() -def list_alert_filters(): - return alerts_filters_operations.list() - - -@alerts_filters_blueprint.get("/") -@ac_api_requires() -def get_alert_filter(identifier): - return alerts_filters_operations.get(identifier) - - -@alerts_filters_blueprint.put("/") -@ac_api_requires() -def update_alert_filter(identifier): - return alerts_filters_operations.put(identifier) - - -@alerts_filters_blueprint.delete("/") -@ac_api_requires() -def delete_alert_filter(identifier): - return alerts_filters_operations.delete(identifier) diff --git a/source/app/blueprints/rest/v2/manage_routes/custom_attributes.py b/source/app/blueprints/rest/v2/manage_routes/custom_attributes.py index 4949e7120..2463622b8 100644 --- a/source/app/blueprints/rest/v2/manage_routes/custom_attributes.py +++ b/source/app/blueprints/rest/v2/manage_routes/custom_attributes.py @@ -180,3 +180,36 @@ def update_custom_attribute(identifier: int) -> Response: ) return response_api_success(_serialize(row)) + + +@custom_attributes_blueprint.post('/validate') +@ac_api_requires(Permissions.server_administrator) +def validate_custom_attribute() -> Response: + """Dry-run the schema validator without mutating anything. + + The SPA calls this from the edit modal so admins can see the + exact per-field error list before committing (the PUT would run + `update_all_attributes` and back-fill every existing row, which + is expensive — surfacing validation errors early avoids paying + that cost only to be rejected). + + Body: `{"attribute_content": }`. + Returns `{ok: bool, logs: [...]}` — `logs` is empty on success. + """ + if not request.is_json: + return response_api_error('Invalid request') + + body = request.get_json() or {} + raw_content = body.get('attribute_content') + if raw_content is None: + return response_api_error('Missing attribute_content') + + if isinstance(raw_content, (dict, list)): + encoded = json.dumps(raw_content) + elif isinstance(raw_content, str): + encoded = raw_content + else: + return response_api_error('attribute_content must be a string or JSON object') + + _parsed, logs = validate_attribute(encoded) + return response_api_success({'ok': not logs, 'logs': logs}) diff --git a/source/app/blueprints/rest/v2/war_rooms/chat.py b/source/app/blueprints/rest/v2/war_rooms/chat.py index abb63b866..8f9dafcc2 100644 --- a/source/app/blueprints/rest/v2/war_rooms/chat.py +++ b/source/app/blueprints/rest/v2/war_rooms/chat.py @@ -26,21 +26,31 @@ from app.blueprints.rest.endpoints import response_api_success from app.blueprints.rest.v2.war_rooms.access import require_war_room_read from app.blueprints.rest.v2.war_rooms.access import require_war_room_write +from app.business.war_room_chat import archive_topic +from app.business.war_room_chat import close_poll from app.business.war_room_chat import create_message +from app.business.war_room_chat import create_poll from app.business.war_room_chat import create_reply +from app.business.war_room_chat import create_topic from app.business.war_room_chat import delete_message from app.business.war_room_chat import follow_thread +from app.business.war_room_chat import get_poll_by_message_id +from app.business.war_room_chat import get_poll_state from app.business.war_room_chat import list_followed_thread_ids from app.business.war_room_chat import list_trace_log from app.business.war_room_chat import list_messages from app.business.war_room_chat import list_reactions from app.business.war_room_chat import list_replies from app.business.war_room_chat import list_thread_roots +from app.business.war_room_chat import list_topics from app.business.war_room_chat import parse_slash +from app.business.war_room_chat import set_message_pin from app.business.war_room_chat import set_thread_title from app.business.war_room_chat import toggle_reaction +from app.business.war_room_chat import unarchive_topic from app.business.war_room_chat import unfollow_thread from app.business.war_room_chat import update_message +from app.business.war_room_chat import vote_on_poll from app.models.authorization import Permissions from app.models.errors import BusinessProcessingError from app.models.errors import ObjectNotFoundError @@ -51,8 +61,16 @@ ) -def _serialize(row, reactions=None): - return { +def _serialize(row, reactions=None, viewer_id=None): + """Wire shape for a chat row. + + `viewer_id` is threaded through for poll hydration — `my_votes` + on a `kind='poll'` message needs to be the viewer's own votes, + not the author's. Legacy call sites that don't pass it get an + empty `my_votes` in the poll payload, which is a harmless + downgrade (the SPA can refetch with `GET /polls/` if it + cares about the caller-specific view).""" + payload = { 'message_id': row.message_id, 'war_room_id': row.war_room_id, 'author_id': row.author_id, @@ -73,10 +91,54 @@ def _serialize(row, reactions=None): # `thread_title` is set only when an operator named the topic. 'parent_message_id': getattr(row, 'parent_message_id', None), 'thread_title': getattr(row, 'thread_title', None), + # `is_pinned` may be absent on databases predating the column — + # `getattr` fallback lets the route survive a boot where the + # migration hasn't been applied yet. + 'is_pinned': bool(getattr(row, 'is_pinned', False)), + # `topic_id` mirrors the same pre-migration guard as `is_pinned`. + # NULL is normal — it means the message is on Main. + 'topic_id': getattr(row, 'topic_id', None), 'created_at': row.created_at.isoformat() if row.created_at else None, 'edited_at': row.edited_at.isoformat() if row.edited_at else None, 'deleted_at': row.deleted_at.isoformat() if row.deleted_at else None, 'reactions': reactions or [], + # Inline file attachments. May be missing on databases predating + # the migration or on virtual UserActivity rows — default to an + # empty list so the SPA can always iterate. + 'attachments': list(getattr(row, 'attachments', None) or []), + } + + # Inline the poll payload on poll-kind messages so the stream + # loads without a second RPC per poll. `get_poll_by_message_id` + # is a single indexed lookup; `get_poll_state` runs a small + # aggregation query — cheap for the "at most 20 options per + # poll" the composer enforces. Tolerates the pre-migration + # database via a defensive try/except: an ImportError-shaped + # failure would land here if the poll models aren't loaded yet. + if row.kind == 'poll' and getattr(row, 'war_room_id', None): + try: + poll = get_poll_by_message_id(row.war_room_id, row.message_id) + if poll is not None: + payload['poll'] = get_poll_state( + row.war_room_id, poll.poll_id, viewer_id + ) + except Exception: # noqa: BLE001 — poll hydration must not 500 the stream + payload['poll'] = None + + return payload + + +def _serialize_topic(row): + return { + 'topic_id': row.topic_id, + 'war_room_id': row.war_room_id, + 'name': row.name, + 'is_main': bool(row.is_main), + 'created_by_id': row.created_by_id, + 'created_at': row.created_at.isoformat() if row.created_at else None, + 'archived_at': ( + row.archived_at.isoformat() if row.archived_at else None + ), } @@ -123,11 +185,21 @@ def list_chat(war_room_id): except ValueError: return response_api_error('Invalid case_ids') + topic_ids_raw = request.args.get('topic_ids', type=str) + topic_ids = None + if topic_ids_raw is not None: + try: + topic_ids = [int(x) for x in topic_ids_raw.split(',') if x.strip()] + except ValueError: + return response_api_error('Invalid topic_ids') + rows = list_messages(war_room_id, before=before, limit=limit, - kinds=kinds, case_ids=case_ids, search=search) + kinds=kinds, case_ids=case_ids, search=search, + topic_ids=topic_ids) reactions = list_reactions([r.message_id for r in rows]) + viewer_id = iris_current_user.id return response_api_success( - data=[_serialize(r, reactions.get(r.message_id)) for r in rows] + data=[_serialize(r, reactions.get(r.message_id), viewer_id) for r in rows] ) @@ -326,6 +398,29 @@ def _resolve_slash(war_room_id, cmd, rest): 'sitrep', sit.sitrep_id, None, ) + if cmd == 'topic': + # `/topic ` — create (or switch to) a top-level topic. + # Rides through the normal system-message pipeline with a + # sentinel `ref_type` so the post handler can create the topic + # in the same request and echo its id back to the caller. + from app.business.war_room_chat import _topics_supported + if not _topics_supported(): + raise BusinessProcessingError( + 'Topics are not enabled on this server yet — ' + 'apply the latest migrations.' + ) + name = rest.strip() + if not name: + raise BusinessProcessingError('Usage: /topic ') + if len(name) > 80: + raise BusinessProcessingError( + 'Topic name must be at most 80 characters' + ) + # Sentinel `ref_type` is consumed by post_chat which calls + # `create_topic` and appends the topic id onto the response. + return ('system', f'Opened topic #{name}', + '__create_topic__', None, None) + if cmd == 'thread': # `/thread ` — open a named topic the team can rally # replies under. The resolved row becomes a normal `message` with @@ -353,7 +448,7 @@ def _resolve_slash(war_room_id, cmd, rest): if cmd in ('help', '?'): body = ( 'Commands: /note /pin /decision /attach /detach /task /assign ' - '/sitrep /summary /state /priority /thread' + '/sitrep /summary /state /priority /thread /topic' ) return ('system', body, None, None, None) @@ -402,8 +497,24 @@ def post_chat(war_room_id): if not isinstance(raw, dict): return response_api_error('Invalid request') body = raw.get('body') + file_ids = raw.get('file_ids') or [] if not isinstance(body, str): - return response_api_error('body is required') + # An attachment-only post is legal — coerce a missing body to + # empty string; `create_message` will accept it when + # `file_ids` is non-empty. + if file_ids: + body = '' + else: + return response_api_error('body is required') + # Optional `topic_id` — the currently-selected topic in the SPA. + # NULL means Main. The business layer validates ownership / + # archived state and raises `BusinessProcessingError` on mismatch. + posted_topic_id = raw.get('topic_id') + if posted_topic_id is not None: + try: + posted_topic_id = int(posted_topic_id) + except (TypeError, ValueError): + return response_api_error('Invalid topic_id') slash = parse_slash(body) if slash is not None: @@ -446,18 +557,48 @@ def post_chat(war_room_id): wants_thread = ref_type == '__set_thread_title__' if wants_thread: ref_type = None + # `/topic <name>` uses a symmetrical sentinel — we create + # the topic first, drop the message on it (so the "Opened + # topic #X" system row is anchored under it), and echo the + # new topic id back so the SPA can auto-switch its view. + wants_topic = ref_type == '__create_topic__' + created_topic = None + if wants_topic: + ref_type = None + # The topic name is the tail of the resolved body — we + # parsed it into the "Opened topic #<name>" template. + topic_name = body.split('#', 1)[1] if '#' in body else body + try: + created_topic = create_topic( + war_room_id, topic_name, iris_current_user.id + ) + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + # Slash-command system rows normally sit on the posted topic + # (defaulting to the current view). `/topic` anchors its + # system row on the newly-created topic instead so the + # "Opened topic #X" line is the first row in the new view. + slash_topic_id = ( + created_topic.topic_id if created_topic is not None + else posted_topic_id + ) try: msg = create_message( war_room_id, iris_current_user.id, body, kind=kind, ref_type=ref_type, ref_id=ref_id, - ref_case_id=ref_case_id, + ref_case_id=ref_case_id, topic_id=slash_topic_id, ) if wants_thread: set_thread_title(war_room_id, msg.message_id, body) except BusinessProcessingError as e: return response_api_error(e.get_message()) _emit_socket(war_room_id, 'message:new', {'message_id': msg.message_id}) - return response_api_created({'message_id': msg.message_id, 'kind': msg.kind}) + payload = {'message_id': msg.message_id, 'kind': msg.kind} + if created_topic is not None: + payload['topic'] = _serialize_topic(created_topic) + _emit_socket(war_room_id, 'topic:new', + {'topic_id': created_topic.topic_id}) + return response_api_created(payload) # Unknown command. Returning an explicit 400 — instead of # falling through to `create_message(body)` and storing the @@ -470,7 +611,9 @@ def post_chat(war_room_id): ) try: - msg = create_message(war_room_id, iris_current_user.id, body) + msg = create_message(war_room_id, iris_current_user.id, body, + topic_id=posted_topic_id, + file_ids=file_ids) except BusinessProcessingError as e: return response_api_error(e.get_message()) @@ -515,6 +658,99 @@ def remove_chat(war_room_id, message_id): return response_api_deleted() +@war_rooms_chat_blueprint.patch('/<int:message_id>/pin') +@ac_api_requires() +def pin_chat(war_room_id, message_id): + """Toggle the sticky-pin flag on a chat message. + + Body: `{"is_pinned": bool}`. War-room write required — pinning + isn't destructive so we don't gate to the author (unlike + edit/delete). Emits a `message:pin` socket event so other + clients in the room flip the badge without a full stream reload. + """ + err = require_war_room_write(war_room_id) + if err is not None: + return err + raw = request.get_json() + if not isinstance(raw, dict) or 'is_pinned' not in raw: + return response_api_error('is_pinned (bool) is required') + is_admin = ac_current_user_has_permission(Permissions.server_administrator) + try: + set_message_pin(war_room_id, message_id, bool(raw['is_pinned']), + iris_current_user.id, is_admin) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + _emit_socket(war_room_id, 'message:pin', + {'message_id': message_id, + 'is_pinned': bool(raw['is_pinned'])}) + return response_api_success({'message_id': message_id, + 'is_pinned': bool(raw['is_pinned'])}) + + +# ----- Topics -------------------------------------------------------------- + + +@war_rooms_chat_blueprint.get('/topics') +@ac_api_requires() +def list_topics_route(war_room_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + rows = list_topics(war_room_id, include_archived=True) + return response_api_success(data=[_serialize_topic(r) for r in rows]) + + +@war_rooms_chat_blueprint.post('/topics') +@ac_api_requires() +def create_topic_route(war_room_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + raw = request.get_json() + if not isinstance(raw, dict): + return response_api_error('Invalid request') + try: + row = create_topic(war_room_id, raw.get('name'), iris_current_user.id) + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + _emit_socket(war_room_id, 'topic:new', {'topic_id': row.topic_id}) + return response_api_created(_serialize_topic(row)) + + +@war_rooms_chat_blueprint.post('/topics/<int:topic_id>/archive') +@ac_api_requires() +def archive_topic_route(war_room_id, topic_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + row = archive_topic(war_room_id, topic_id) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + _emit_socket(war_room_id, 'topic:archive', {'topic_id': topic_id}) + return response_api_success(_serialize_topic(row)) + + +@war_rooms_chat_blueprint.post('/topics/<int:topic_id>/unarchive') +@ac_api_requires() +def unarchive_topic_route(war_room_id, topic_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + row = unarchive_topic(war_room_id, topic_id) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + _emit_socket(war_room_id, 'topic:unarchive', {'topic_id': topic_id}) + return response_api_success(_serialize_topic(row)) + + # ----- Threads ------------------------------------------------------------- @@ -573,7 +809,9 @@ def list_trace(war_room_id): return err limit = request.args.get('limit', type=int) rows = list_trace_log(war_room_id, limit=limit) - return response_api_success(data=[_serialize(r) for r in rows]) + return response_api_success( + data=[_serialize(r, viewer_id=iris_current_user.id) for r in rows] + ) @war_rooms_chat_blueprint.get('/<int:message_id>/replies') @@ -588,8 +826,9 @@ def list_message_replies(war_room_id, message_id): except ObjectNotFoundError: return response_api_not_found() reactions = list_reactions([r.message_id for r in rows]) + viewer_id = iris_current_user.id return response_api_success( - data=[_serialize(r, reactions.get(r.message_id)) for r in rows] + data=[_serialize(r, reactions.get(r.message_id), viewer_id) for r in rows] ) @@ -727,3 +966,110 @@ def post_reaction(war_room_id, message_id): 'emoji': emoji, 'added': added, }) return response_api_success({'message_id': message_id, 'added': added}) + + +# ----- Polls --------------------------------------------------------------- +# +# A poll is posted inline in the stream as a `kind='poll'` chat +# message with `ref_type='chat_poll'` + `ref_id=<poll_id>`. Voting +# and closing are separate endpoints under `/polls/<id>` to keep the +# blueprint's action surface obvious; poll creation reuses the +# `POST /` message endpoint's `message:new` broadcast so subscribed +# clients fold the new poll into the stream without a separate +# poll:created listener path — the extra `poll:created` event is +# fired anyway for clients that specifically care. + + +@war_rooms_chat_blueprint.post('/polls') +@ac_api_requires() +def create_poll_route(war_room_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + raw = request.get_json() + if not isinstance(raw, dict): + return response_api_error('Invalid request') + try: + msg, poll = create_poll( + war_room_id, + author_id=iris_current_user.id, + question=raw.get('question'), + options=raw.get('options') or [], + is_multi_select=bool(raw.get('is_multi_select', False)), + is_anonymous=bool(raw.get('is_anonymous', False)), + closes_at=raw.get('closes_at'), + ) + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + # Two broadcasts: the standard `message:new` so subscribed + # streams append the row without extra logic, and a dedicated + # `poll:created` so clients that specifically track polls (e.g. + # for a future "open polls" widget) don't have to filter on + # `message:new` payloads themselves. + _emit_socket(war_room_id, 'message:new', {'message_id': msg.message_id}) + _emit_socket(war_room_id, 'poll:created', { + 'poll_id': poll.poll_id, 'message_id': msg.message_id, + }) + return response_api_created({ + 'message_id': msg.message_id, + 'poll_id': poll.poll_id, + }) + + +@war_rooms_chat_blueprint.get('/polls/<int:poll_id>') +@ac_api_requires() +def get_poll_route(war_room_id, poll_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + try: + state = get_poll_state(war_room_id, poll_id, iris_current_user.id) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(state) + + +@war_rooms_chat_blueprint.post('/polls/<int:poll_id>/vote') +@ac_api_requires() +def post_poll_vote(war_room_id, poll_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + raw = request.get_json() + if not isinstance(raw, dict): + return response_api_error('Invalid request') + try: + vote_on_poll(war_room_id, poll_id, iris_current_user.id, + raw.get('option_ids') or []) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + _emit_socket(war_room_id, 'poll:voted', { + 'poll_id': poll_id, 'user_id': iris_current_user.id, + }) + # Return the fresh state so the caller can update its UI + # atomically without a follow-up GET. Other viewers rely on the + # socket broadcast to trigger their own refetch. + return response_api_success( + get_poll_state(war_room_id, poll_id, iris_current_user.id) + ) + + +@war_rooms_chat_blueprint.post('/polls/<int:poll_id>/close') +@ac_api_requires() +def post_poll_close(war_room_id, poll_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + is_admin = ac_current_user_has_permission(Permissions.server_administrator) + try: + close_poll(war_room_id, poll_id, iris_current_user.id, is_admin) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + _emit_socket(war_room_id, 'poll:closed', {'poll_id': poll_id}) + return response_api_success( + get_poll_state(war_room_id, poll_id, iris_current_user.id) + ) diff --git a/source/app/blueprints/rest/v2/war_rooms/linked_case_timelines.py b/source/app/blueprints/rest/v2/war_rooms/linked_case_timelines.py new file mode 100644 index 000000000..287e5c6dd --- /dev/null +++ b/source/app/blueprints/rest/v2/war_rooms/linked_case_timelines.py @@ -0,0 +1,69 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""REST routes for the linked-case-timelines projection. + +Read-only surface — see `app.business.war_room_linked_case_timelines` +for the rationale. Writes to case timelines still go through the +case-side timelines endpoints; this module only projects case events +into the war-room view so the frontend can render them alongside +native `WarRoomTimelineEvent` rows. +""" + +from flask import Blueprint, request + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.iris_user import iris_current_user +from app.blueprints.rest.endpoints import response_api_error +from app.blueprints.rest.endpoints import response_api_success +from app.blueprints.rest.v2.war_rooms.access import require_war_room_read +from app.business.war_room_linked_case_timelines import ( + list_linked_case_events, + list_linked_case_timelines, +) + + +war_rooms_linked_case_timelines_blueprint = Blueprint( + 'war_rooms_linked_case_timelines_rest_v2', __name__, + url_prefix='/<int:war_room_id>/linked-case-timelines', +) + + +@war_rooms_linked_case_timelines_blueprint.get('') +@ac_api_requires() +def list_available(war_room_id): + """Nested `case -> timelines` tree the sidebar renders as toggle + sources. Cases the caller can't read are silently dropped by the + business layer so we don't leak their existence.""" + err = require_war_room_read(war_room_id) + if err is not None: + return err + tree = list_linked_case_timelines(war_room_id, iris_current_user.id) + return response_api_success(data=tree) + + +@war_rooms_linked_case_timelines_blueprint.get('/events') +@ac_api_requires() +def list_events(war_room_id): + """Return case events for the requested timelines, shaped like + native war-room events. `case_timeline_ids` is CSV in the query + string — matches the shape of `/timelines/events?timeline_ids=` so + the frontend can build the URL the same way.""" + err = require_war_room_read(war_room_id) + if err is not None: + return err + + raw = request.args.get('case_timeline_ids', type=str) + if not raw: + # No timelines requested → no events. Matches the "empty + # selection = no case events" default state the sidebar + # ships with. + return response_api_success(data=[]) + try: + ids = [int(x) for x in raw.split(',') if x.strip()] + except ValueError: + return response_api_error('Invalid case_timeline_ids') + + events = list_linked_case_events(war_room_id, iris_current_user.id, ids) + return response_api_success(data=events) diff --git a/source/app/blueprints/rest/v2/war_rooms/notes.py b/source/app/blueprints/rest/v2/war_rooms/notes.py index 16c084865..1703f9f60 100644 --- a/source/app/blueprints/rest/v2/war_rooms/notes.py +++ b/source/app/blueprints/rest/v2/war_rooms/notes.py @@ -2,7 +2,14 @@ # Copyright (C) 2026 - DFIR-IRIS # contact@dfir-iris.org -"""War-room notes REST routes.""" +"""War-room notes REST routes. + +Notes live in a folder tree owned by the war room (see +`war_rooms_notes_folders_blueprint` in `notes_folders.py`). This module +covers the note CRUD + revision-history endpoints. Folder CRUD is next +door so the URL surface stays flat: everything mounts under +`/api/v2/war-rooms/{id}/`. +""" from flask import Blueprint, request @@ -16,13 +23,16 @@ from app.blueprints.rest.v2.war_rooms.access import require_war_room_read from app.blueprints.rest.v2.war_rooms.access import require_war_room_write from app.business.war_room_chat import emit_system_event -from app.business.war_room_notes import ( - war_room_note_create, - war_room_note_delete, - war_room_note_get, - war_room_note_list, - war_room_note_update, -) +from app.business.war_room_notes import _UNSET +from app.business.war_room_notes import war_room_note_create +from app.business.war_room_notes import war_room_note_delete +from app.business.war_room_notes import war_room_note_delete_revision +from app.business.war_room_notes import war_room_note_get +from app.business.war_room_notes import war_room_note_get_revision +from app.business.war_room_notes import war_room_note_list +from app.business.war_room_notes import war_room_note_list_revisions +from app.business.war_room_notes import war_room_note_restore_revision +from app.business.war_room_notes import war_room_note_update from app.models.errors import BusinessProcessingError from app.models.errors import ObjectNotFoundError @@ -36,6 +46,7 @@ def _serialize(n): return { 'note_id': n.note_id, 'war_room_id': n.war_room_id, + 'folder_id': n.folder_id, 'title': n.title, 'content': n.content, 'created_at': n.created_at.isoformat() if n.created_at else None, @@ -45,6 +56,26 @@ def _serialize(n): } +def _serialize_revision_row(row): + """Rows come from `list_revisions` as named tuples of + (revision_number, revised_at, user_name).""" + return { + 'revision_number': row.revision_number, + 'revised_at': row.revised_at.isoformat() if row.revised_at else None, + 'user_name': row.user_name, + } + + +def _serialize_revision(rev): + return { + 'revision_number': rev.revision_number, + 'title': rev.title, + 'content': rev.content, + 'revised_at': rev.revised_at.isoformat() if rev.revised_at else None, + 'revised_by_id': rev.revised_by_id, + } + + @war_rooms_notes_blueprint.get('') @ac_api_requires() def list_notes(war_room_id): @@ -65,8 +96,11 @@ def create_note(war_room_id): return response_api_error('Invalid request') try: note = war_room_note_create( - war_room_id, title=raw.get('title'), - content=raw.get('content'), created_by_id=iris_current_user.id, + war_room_id, + title=raw.get('title'), + content=raw.get('content'), + folder_id=raw.get('folder_id'), + created_by_id=iris_current_user.id, ) except BusinessProcessingError as e: return response_api_error(e.get_message()) @@ -101,11 +135,16 @@ def update_note(war_room_id, note_id): raw = request.get_json() if not isinstance(raw, dict): return response_api_error('Invalid request') + # `folder_id` in the body is meaningful even when set to `null` (move + # to root). Only pass it through to the business layer if the caller + # explicitly included the key; otherwise leave the folder alone. + folder_kwarg = raw['folder_id'] if 'folder_id' in raw else _UNSET try: note = war_room_note_update( war_room_id, note_id, title=raw.get('title'), content=raw.get('content'), + folder_id=folder_kwarg, updated_by_id=iris_current_user.id, ) except ObjectNotFoundError: @@ -126,3 +165,66 @@ def delete_note(war_room_id, note_id): except ObjectNotFoundError: return response_api_not_found() return response_api_deleted() + + +# --------------------------------------------------------------------------- +# Revision history +# --------------------------------------------------------------------------- + +@war_rooms_notes_blueprint.get('/<int:note_id>/revisions') +@ac_api_requires() +def list_revisions(war_room_id, note_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + try: + rows = war_room_note_list_revisions(war_room_id, note_id) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(data=[_serialize_revision_row(r) for r in rows]) + + +@war_rooms_notes_blueprint.get('/<int:note_id>/revisions/<int:revision_number>') +@ac_api_requires() +def get_revision(war_room_id, note_id, revision_number): + err = require_war_room_read(war_room_id) + if err is not None: + return err + try: + revision = war_room_note_get_revision(war_room_id, note_id, revision_number) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(_serialize_revision(revision)) + + +@war_rooms_notes_blueprint.delete('/<int:note_id>/revisions/<int:revision_number>') +@ac_api_requires() +def delete_revision(war_room_id, note_id, revision_number): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + war_room_note_delete_revision(war_room_id, note_id, revision_number) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + return response_api_deleted() + + +@war_rooms_notes_blueprint.post('/<int:note_id>/revisions/<int:revision_number>/restore') +@ac_api_requires() +def restore_revision(war_room_id, note_id, revision_number): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + note = war_room_note_restore_revision( + war_room_id, note_id, revision_number, + updated_by_id=iris_current_user.id, + ) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + return response_api_success(_serialize(note)) diff --git a/source/app/blueprints/rest/v2/war_rooms/notes_folders.py b/source/app/blueprints/rest/v2/war_rooms/notes_folders.py new file mode 100644 index 000000000..e2e53fec8 --- /dev/null +++ b/source/app/blueprints/rest/v2/war_rooms/notes_folders.py @@ -0,0 +1,155 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""War-room notes folder REST routes. + +Companion to `notes.py` — this owns the folder tree. Same URL prefix +convention (`/<war_room_id>/notes-folders`) as the case-notes +`/notes-directories` endpoints, but "folder" is the term the frontend +uses so we prefer it here (case-notes still says "directories" for +legacy reasons). + +Every endpoint is gated by the war-room ACL helpers — NOT the case +ACL — because war rooms live in their own permission model +(`WarRoomAccessLevel`). +""" + +from flask import Blueprint, request + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.rest.endpoints import response_api_created +from app.blueprints.rest.endpoints import response_api_deleted +from app.blueprints.rest.endpoints import response_api_error +from app.blueprints.rest.endpoints import response_api_not_found +from app.blueprints.rest.endpoints import response_api_success +from app.blueprints.rest.v2.war_rooms.access import require_war_room_read +from app.blueprints.rest.v2.war_rooms.access import require_war_room_write +from app.business.war_room_note_folders import verify_parent_folder +from app.business.war_room_note_folders import war_room_note_folders_create +from app.business.war_room_note_folders import war_room_note_folders_delete +from app.business.war_room_note_folders import war_room_note_folders_get +from app.business.war_room_note_folders import war_room_note_folders_update +from app.datamgmt.war_rooms.war_room_notes_db import list_folders +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.models.war_rooms import WarRoomNoteFolder + + +war_rooms_notes_folders_blueprint = Blueprint( + 'war_rooms_notes_folders_rest_v2', __name__, + url_prefix='/<int:war_room_id>/notes-folders', +) + + +def _serialize(folder: WarRoomNoteFolder) -> dict: + return { + 'id': folder.id, + 'name': folder.name, + 'war_room_id': folder.war_room_id, + 'parent_id': folder.parent_id, + 'created_at': folder.created_at.isoformat() if folder.created_at else None, + 'updated_at': folder.updated_at.isoformat() if folder.updated_at else None, + } + + +def _validate_name(raw) -> str: + if not isinstance(raw, str) or not raw.strip(): + raise BusinessProcessingError('Folder name is required') + return raw.strip()[:512] + + +@war_rooms_notes_folders_blueprint.get('') +@ac_api_requires() +def list_notes_folders(war_room_id): + """Flat list of every folder in the war room. The frontend hydrates + the tree client-side from `parent_id` — same pattern as case notes.""" + err = require_war_room_read(war_room_id) + if err is not None: + return err + return response_api_success( + data=[_serialize(f) for f in list_folders(war_room_id)] + ) + + +@war_rooms_notes_folders_blueprint.post('') +@ac_api_requires() +def create_notes_folder(war_room_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + raw = request.get_json() + if not isinstance(raw, dict): + return response_api_error('Invalid request') + try: + name = _validate_name(raw.get('name')) + parent_id = verify_parent_folder( + raw.get('parent_id'), war_room_id, current_id=None, + ) + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + folder = WarRoomNoteFolder( + name=name, war_room_id=war_room_id, parent_id=parent_id, + ) + folder = war_room_note_folders_create(folder) + return response_api_created(_serialize(folder)) + + +@war_rooms_notes_folders_blueprint.get('/<int:folder_id>') +@ac_api_requires() +def get_notes_folder(war_room_id, folder_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + try: + folder = war_room_note_folders_get(folder_id) + except ObjectNotFoundError: + return response_api_not_found() + if folder.war_room_id != war_room_id: + # 404 rather than 403 so folder ids can't be enumerated across war rooms. + return response_api_not_found() + return response_api_success(_serialize(folder)) + + +@war_rooms_notes_folders_blueprint.put('/<int:folder_id>') +@ac_api_requires() +def update_notes_folder(war_room_id, folder_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + folder = war_room_note_folders_get(folder_id) + except ObjectNotFoundError: + return response_api_not_found() + if folder.war_room_id != war_room_id: + return response_api_not_found() + raw = request.get_json() + if not isinstance(raw, dict): + return response_api_error('Invalid request') + try: + if 'name' in raw: + folder.name = _validate_name(raw['name']) + if 'parent_id' in raw: + folder.parent_id = verify_parent_folder( + raw['parent_id'], war_room_id, current_id=folder.id, + ) + folder = war_room_note_folders_update(folder) + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + return response_api_success(_serialize(folder)) + + +@war_rooms_notes_folders_blueprint.delete('/<int:folder_id>') +@ac_api_requires() +def delete_notes_folder(war_room_id, folder_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + folder = war_room_note_folders_get(folder_id) + except ObjectNotFoundError: + return response_api_not_found() + if folder.war_room_id != war_room_id: + return response_api_not_found() + war_room_note_folders_delete(folder) + return response_api_deleted() diff --git a/source/app/blueprints/rest/v2/war_rooms/root.py b/source/app/blueprints/rest/v2/war_rooms/root.py index 3cfaa9e77..5852e83fa 100644 --- a/source/app/blueprints/rest/v2/war_rooms/root.py +++ b/source/app/blueprints/rest/v2/war_rooms/root.py @@ -69,9 +69,12 @@ from app.blueprints.rest.v2.war_rooms.chat import war_rooms_chat_blueprint from app.blueprints.rest.v2.war_rooms.datastore import war_rooms_datastore_blueprint +from app.blueprints.rest.v2.war_rooms.linked_case_timelines import war_rooms_linked_case_timelines_blueprint from app.blueprints.rest.v2.war_rooms.notes import war_rooms_notes_blueprint +from app.blueprints.rest.v2.war_rooms.notes_folders import war_rooms_notes_folders_blueprint from app.blueprints.rest.v2.war_rooms.sitreps import war_rooms_sitreps_blueprint from app.blueprints.rest.v2.war_rooms.tasks import war_rooms_tasks_blueprint +from app.blueprints.rest.v2.war_rooms.teams import war_rooms_teams_blueprint from app.blueprints.rest.v2.war_rooms.timelines import war_rooms_timelines_blueprint @@ -81,9 +84,12 @@ war_rooms_blueprint.register_blueprint(war_rooms_chat_blueprint) war_rooms_blueprint.register_blueprint(war_rooms_tasks_blueprint) war_rooms_blueprint.register_blueprint(war_rooms_notes_blueprint) +war_rooms_blueprint.register_blueprint(war_rooms_notes_folders_blueprint) +war_rooms_blueprint.register_blueprint(war_rooms_linked_case_timelines_blueprint) war_rooms_blueprint.register_blueprint(war_rooms_timelines_blueprint) war_rooms_blueprint.register_blueprint(war_rooms_sitreps_blueprint) war_rooms_blueprint.register_blueprint(war_rooms_datastore_blueprint) +war_rooms_blueprint.register_blueprint(war_rooms_teams_blueprint) def _is_admin(): diff --git a/source/app/blueprints/rest/v2/war_rooms/tasks.py b/source/app/blueprints/rest/v2/war_rooms/tasks.py index 4708918ea..ab2c4b7c8 100644 --- a/source/app/blueprints/rest/v2/war_rooms/tasks.py +++ b/source/app/blueprints/rest/v2/war_rooms/tasks.py @@ -26,6 +26,7 @@ war_room_task_list, war_room_task_reopen, war_room_task_update, + war_room_task_used_tags, ) from app.models.errors import BusinessProcessingError from app.models.errors import ObjectNotFoundError @@ -52,6 +53,38 @@ def _parse_due(raw): raise BusinessProcessingError('due_at must be an ISO date string') +def _parse_int_list(raw): + """Parse a repeatable query-string int param into a list. + + Accepts `?status_id=1&status_id=2` or `?status_id=1,2`. Silently + drops non-integer entries so a bad tag doesn't 400 the whole list + call. + """ + if raw is None: + return [] + if isinstance(raw, list): + parts = raw + else: + parts = [p for p in str(raw).split(',') if p] + out = [] + for p in parts: + try: + out.append(int(p)) + except (TypeError, ValueError): + continue + return out + + +def _parse_str_list(raw): + if raw is None: + return [] + if isinstance(raw, list): + parts = raw + else: + parts = str(raw).split(',') + return [p.strip() for p in parts if p and p.strip()] + + def _serialize_row(row): return { 'task_id': row.task_id, @@ -59,6 +92,8 @@ def _serialize_row(row): 'title': row.title, 'description': row.description, 'status_id': row.status_id, + 'status_name': getattr(row, 'status_name', None), + 'status_bscolor': getattr(row, 'status_bscolor', None), 'assignee_id': row.assignee_id, 'assignee_login': row.assignee_login, 'assignee_name': row.assignee_name, @@ -76,36 +111,173 @@ def _serialize_row(row): 'closed_by_login': getattr(row, 'closed_by_login', None), 'closed_by_name': getattr(row, 'closed_by_name', None), 'tags': row.tags, + 'parent_task_id': getattr(row, 'parent_task_id', None), } def _serialize_obj(task): + """Serialize a plain `WarRoomTask` row (no join info). + + Used by the mutation endpoints. Status name / actor logins are + resolved via a light relationship lookup so the SPA doesn't have + to re-request the row after every write. + """ + status_name = task.status.status_name if task.status else None + status_bscolor = task.status.status_bscolor if task.status else None + assignee = task.assignee + creator = task.created_by + closer = task.closed_by return { 'task_id': task.task_id, 'war_room_id': task.war_room_id, 'title': task.title, 'description': task.description, 'status_id': task.status_id, + 'status_name': status_name, + 'status_bscolor': status_bscolor, 'assignee_id': task.assignee_id, + 'assignee_login': assignee.user if assignee else None, + 'assignee_name': assignee.name if assignee else None, 'due_at': task.due_at.isoformat() if task.due_at else None, 'source_case_id': task.source_case_id, 'source_case_task_id': task.source_case_task_id, 'created_at': task.created_at.isoformat() if task.created_at else None, 'created_by_id': task.created_by_id, + 'created_by_login': creator.user if creator else None, + 'created_by_name': creator.name if creator else None, 'closed_at': task.closed_at.isoformat() if task.closed_at else None, 'closed_by_id': task.closed_by_id, + 'closed_by_login': closer.user if closer else None, + 'closed_by_name': closer.name if closer else None, 'tags': task.tags, + 'parent_task_id': getattr(task, 'parent_task_id', None), } +def _parse_bool(raw, default=True): + if raw is None: + return default + return str(raw).strip().lower() not in ('0', 'false', 'no', 'off') + + +def _parse_date_arg(raw): + """Parse an ISO date/datetime string; returns None if empty/invalid. + + Kept lenient — a bad value silently drops the endpoint rather + than 400-ing the whole list. That matches the tolerance of the + other filter parsers on this endpoint. + """ + if not raw: + return None + s = str(raw).strip() + if not s: + return None + try: + if len(s) == 10: + return datetime.fromisoformat(s) + return datetime.fromisoformat(s.replace('Z', '+00:00')) + except ValueError: + return None + + @war_rooms_tasks_blueprint.get('') @ac_api_requires() def list_tasks(war_room_id): err = require_war_room_read(war_room_id) if err is not None: return err - rows = war_room_task_list(war_room_id) - return response_api_success(data=[_serialize_row(r) for r in rows]) + q = request.args.get('q') or None + status_ids = _parse_int_list(request.args.getlist('status_id') + or request.args.get('status_id')) + assignee_ids_raw = (request.args.getlist('assignee_id') + or request.args.get('assignee_id')) + # Accept `0` to mean Unassigned; parse ints then reinject the flag. + assignee_ids = [] + if assignee_ids_raw: + if isinstance(assignee_ids_raw, list): + parts = assignee_ids_raw + else: + parts = str(assignee_ids_raw).split(',') + for p in parts: + p = str(p).strip().lower() + if p in ('0', 'unassigned', 'null', 'none'): + assignee_ids.append(0) + continue + try: + assignee_ids.append(int(p)) + except ValueError: + continue + tags = _parse_str_list(request.args.getlist('tag') + or request.args.get('tag')) + parent = request.args.get('parent_task_id') + if parent is None: + parent_task_id = -1 + else: + p = str(parent).strip().lower() + if p in ('', 'null', 'none', 'top', 'root'): + parent_task_id = 0 + else: + try: + parent_task_id = int(p) + except ValueError: + parent_task_id = -1 + include_closed = _parse_bool(request.args.get('include_closed'), True) + due_from = _parse_date_arg(request.args.get('due_from')) + due_to = _parse_date_arg(request.args.get('due_to')) + include_no_due = _parse_bool(request.args.get('include_no_due'), True) + + # Pagination is opt-in: `page` present → return the envelope; + # absent → return the raw array (back-compat with earlier callers). + page_raw = request.args.get('page') + if page_raw is None: + rows = war_room_task_list( + war_room_id, + q=q, + status_ids=status_ids or None, + tags=tags or None, + assignee_ids=assignee_ids or None, + parent_task_id=parent_task_id, + due_from=due_from, + due_to=due_to, + include_no_due=include_no_due, + include_closed=include_closed, + ) + return response_api_success(data=[_serialize_row(r) for r in rows]) + + try: + page = int(page_raw) + except (TypeError, ValueError): + page = 1 + try: + per_page = int(request.args.get('per_page', 25)) + except (TypeError, ValueError): + per_page = 25 + envelope = war_room_task_list( + war_room_id, + q=q, + status_ids=status_ids or None, + tags=tags or None, + assignee_ids=assignee_ids or None, + parent_task_id=parent_task_id, + due_from=due_from, + due_to=due_to, + include_no_due=include_no_due, + include_closed=include_closed, + page=page, + per_page=per_page, + ) + envelope['data'] = [_serialize_row(r) for r in envelope['data']] + return response_api_success(data=envelope) + + +@war_rooms_tasks_blueprint.get('/tags') +@ac_api_requires() +def list_used_tags(war_room_id): + """Distinct tag values already in use in this war room.""" + err = require_war_room_read(war_room_id) + if err is not None: + return err + return response_api_success(data=war_room_task_used_tags(war_room_id)) @war_rooms_tasks_blueprint.post('') @@ -129,6 +301,7 @@ def create_task(war_room_id): source_case_id=raw.get('source_case_id'), source_case_task_id=raw.get('source_case_task_id'), tags=raw.get('tags'), + parent_task_id=raw.get('parent_task_id'), created_by_id=iris_current_user.id, ) except BusinessProcessingError as e: @@ -154,9 +327,11 @@ def update_task(war_room_id, task_id): try: fields = {k: raw[k] for k in raw if k in ('title', 'description', 'status_id', 'assignee_id', - 'source_case_id', 'source_case_task_id', 'tags')} + 'source_case_id', 'source_case_task_id', 'tags', + 'parent_task_id')} if 'due_at' in raw: fields['due_at'] = _parse_due(raw['due_at']) + fields['updated_by_id'] = iris_current_user.id task = war_room_task_update(war_room_id, task_id, **fields) except ObjectNotFoundError: return response_api_not_found() diff --git a/source/app/blueprints/rest/v2/war_rooms/teams.py b/source/app/blueprints/rest/v2/war_rooms/teams.py new file mode 100644 index 000000000..301a99795 --- /dev/null +++ b/source/app/blueprints/rest/v2/war_rooms/teams.py @@ -0,0 +1,223 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. + +"""v2 REST routes for war-room teams. + +Teams are per-war-room groupings used as @-mention targets. The URL +layout mirrors members: + + GET /<war_room_id>/teams list teams + POST /<war_room_id>/teams create a team + GET /<war_room_id>/teams/<tid> get a team + its members + PATCH /<war_room_id>/teams/<tid> rename / recolor + DELETE /<war_room_id>/teams/<tid> remove + POST /<war_room_id>/teams/<tid>/members add a user + DELETE /<war_room_id>/teams/<tid>/members/<uid> remove +""" + +from flask import Blueprint, request + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.iris_user import iris_current_user +from app.blueprints.rest.endpoints import response_api_created +from app.blueprints.rest.endpoints import response_api_deleted +from app.blueprints.rest.endpoints import response_api_error +from app.blueprints.rest.endpoints import response_api_not_found +from app.blueprints.rest.endpoints import response_api_success +from app.blueprints.rest.v2.war_rooms.access import require_war_room_read +from app.blueprints.rest.v2.war_rooms.access import require_war_room_write +from app.business.war_room_teams import war_room_team_create +from app.business.war_room_teams import war_room_team_delete +from app.business.war_room_teams import war_room_team_get +from app.business.war_room_teams import war_room_team_list +from app.business.war_room_teams import war_room_team_member_add +from app.business.war_room_teams import war_room_team_member_remove +from app.business.war_room_teams import war_room_team_members_list +from app.business.war_room_teams import war_room_team_update +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError + + +war_rooms_teams_blueprint = Blueprint( + 'war_rooms_teams_rest_v2', __name__, url_prefix='/<int:war_room_id>/teams' +) + + +def _serialize_team(team, member_ids=None): + return { + 'team_id': team.team_id, + 'war_room_id': team.war_room_id, + 'name': team.name, + 'description': team.description, + 'color': team.color, + 'created_at': team.created_at.isoformat() if team.created_at else None, + 'created_by_id': team.created_by_id, + 'member_ids': sorted(member_ids) if member_ids is not None else None, + } + + +def _serialize_member(row): + return { + 'team_id': row.team_id, + 'user_id': row.user_id, + 'added_at': row.added_at.isoformat() if row.added_at else None, + 'added_by_id': row.added_by_id, + } + + +def _team_member_ids(team_id): + """User IDs for a team — skips the war-room scope check because the + caller has already resolved the team belongs to this war room.""" + from app.db import db + from app.models.war_rooms import WarRoomTeamMember + rows = ( + db.session.query(WarRoomTeamMember.user_id) + .filter(WarRoomTeamMember.team_id == team_id) + .all() + ) + return {r.user_id for r in rows} + + +@war_rooms_teams_blueprint.get('') +@ac_api_requires() +def list_teams(war_room_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + teams = war_room_team_list(war_room_id) + data = [ + _serialize_team(t, _team_member_ids(t.team_id)) + for t in teams + ] + return response_api_success(data=data) + + +@war_rooms_teams_blueprint.post('') +@ac_api_requires() +def create_team(war_room_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + raw = request.get_json() + if not isinstance(raw, dict): + return response_api_error('Invalid request') + try: + team = war_room_team_create( + war_room_id, + name=raw.get('name'), + description=raw.get('description'), + color=raw.get('color'), + created_by_id=iris_current_user.id, + ) + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + return response_api_created(_serialize_team(team, set())) + + +@war_rooms_teams_blueprint.get('/<int:team_id>') +@ac_api_requires() +def get_team(war_room_id, team_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + try: + team = war_room_team_get(war_room_id, team_id) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(_serialize_team(team, _team_member_ids(team_id))) + + +@war_rooms_teams_blueprint.patch('/<int:team_id>') +@ac_api_requires() +def update_team(war_room_id, team_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + raw = request.get_json() + if not isinstance(raw, dict): + return response_api_error('Invalid request') + try: + team = war_room_team_update( + war_room_id, team_id, + name=raw.get('name'), + description=raw.get('description'), + color=raw.get('color'), + ) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + return response_api_success(_serialize_team(team, _team_member_ids(team_id))) + + +@war_rooms_teams_blueprint.delete('/<int:team_id>') +@ac_api_requires() +def delete_team(war_room_id, team_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + war_room_team_delete(war_room_id, team_id) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_deleted() + + +# --- Team members ---------------------------------------------------------- + +@war_rooms_teams_blueprint.get('/<int:team_id>/members') +@ac_api_requires() +def list_team_members(war_room_id, team_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + try: + rows = war_room_team_members_list(war_room_id, team_id) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(data=[_serialize_member(r) for r in rows]) + + +@war_rooms_teams_blueprint.post('/<int:team_id>/members') +@ac_api_requires() +def add_team_member(war_room_id, team_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + raw = request.get_json() + if not isinstance(raw, dict): + return response_api_error('Invalid request') + try: + row, auto_added_room_member = war_room_team_member_add( + war_room_id, team_id, + user_id=raw.get('user_id'), + added_by_id=iris_current_user.id, + ) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + payload = _serialize_member(row) + # Flag surfaced so the SPA can show "also added to the war room as + # responder" without a follow-up GET on the members list. + payload['auto_added_room_member'] = bool(auto_added_room_member) + return response_api_created(payload) + + +@war_rooms_teams_blueprint.delete('/<int:team_id>/members/<int:user_id>') +@ac_api_requires() +def remove_team_member(war_room_id, team_id, user_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + war_room_team_member_remove(war_room_id, team_id, user_id) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_deleted() diff --git a/source/app/blueprints/rest/v2/war_rooms/timelines.py b/source/app/blueprints/rest/v2/war_rooms/timelines.py index c4a71dcc4..734d5d38e 100644 --- a/source/app/blueprints/rest/v2/war_rooms/timelines.py +++ b/source/app/blueprints/rest/v2/war_rooms/timelines.py @@ -22,9 +22,16 @@ create_timeline_event, delete_timeline, delete_timeline_event, + duplicate_event, + event_asset_ids, + event_children_count, + event_ioc_ids, get_timeline, list_timeline_events, list_timelines, + set_event_assets, + set_event_iocs, + toggle_event_flag, update_timeline, update_timeline_event, ) @@ -52,20 +59,42 @@ def _serialize_timeline(t): def _serialize_event(e): + """Wire shape for a native war-room event. + + Matches the case-timeline event card's expectations — the card + reads `uuid`, `source`, `raw`, `tags`, `is_flagged`, plus the + hydrated `assets` / `iocs` id lists and the `children_count` badge. + `war_room_source` is intentionally omitted here (native events); + the projection layer sets it to `'case'` on case-sourced rows so + the frontend can discriminate. Per-event comments aren't part of + the war-room event surface — war-room chat plays that role. + """ return { 'id': e.id, + # `uuid` may be absent on databases predating the column — + # `getattr` fallback lets the route survive a boot where the + # migration hasn't been run yet. Same for the other new fields. + 'uuid': str(getattr(e, 'uuid', '') or ''), 'timeline_id': e.timeline_id, + 'parent_id': getattr(e, 'parent_id', None), 'case_id': e.case_id, 'event_id': e.event_id, 'title': e.title, 'content': e.content, + 'raw': getattr(e, 'raw', None), + 'source': getattr(e, 'source', None), + 'tags': getattr(e, 'tags', None), + 'is_flagged': bool(getattr(e, 'is_flagged', False)), 'event_date': e.event_date.isoformat() if e.event_date else None, 'event_tz': e.event_tz, 'color': e.color, - # `category` may be absent on databases predating the column — - # tolerate that so the route still returns valid JSON when the - # migration hasn't been run yet. 'category': getattr(e, 'category', None), + 'modification_history': getattr(e, 'modification_history', None), + # Hydrated read-side extras. Kept as arrays / ints so the + # frontend's derived-state code can spot changes cheaply. + 'assets': event_asset_ids(e.id), + 'iocs': event_ioc_ids(e.id), + 'children_count': event_children_count(e.id), 'created_at': e.created_at.isoformat() if e.created_at else None, 'created_by_id': e.created_by_id, } @@ -189,6 +218,13 @@ def add_event(war_room_id, timeline_id): category=raw.get('category'), case_id=raw.get('case_id'), event_id=raw.get('event_id'), + source=raw.get('source'), + raw=raw.get('raw'), + tags=raw.get('tags'), + is_flagged=bool(raw.get('is_flagged', False)), + parent_id=raw.get('parent_id'), + asset_ids=raw.get('asset_ids'), + ioc_ids=raw.get('ioc_ids'), created_by_id=iris_current_user.id, ) except ObjectNotFoundError: @@ -214,8 +250,13 @@ def patch_event(war_room_id, event_id): if not isinstance(raw, dict): return response_api_error('Invalid request') kwargs = {} + # Only fields the client actually included are forwarded — the + # business layer's sentinel-based partial-update contract needs + # "omitted" to be distinguishable from "cleared". `is_flagged` + # accepts either a bool or something coerceable to one. for key in ('title', 'content', 'event_tz', 'color', 'category', - 'timeline_id'): + 'timeline_id', 'source', 'raw', 'tags', 'is_flagged', + 'parent_id', 'asset_ids', 'ioc_ids'): if key in raw: kwargs[key] = raw[key] if 'event_date' in raw: @@ -243,3 +284,71 @@ def remove_event(war_room_id, event_id): except ObjectNotFoundError: return response_api_not_found() return response_api_deleted() + + +@war_rooms_timelines_blueprint.post('/events/<int:event_id>/flag') +@ac_api_requires() +def flag_event(war_room_id, event_id): + """Toggle the triage flag on an event. No body — one click, server + flips the boolean and returns the fresh row.""" + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + row = toggle_event_flag(war_room_id, event_id) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(_serialize_event(row)) + + +@war_rooms_timelines_blueprint.post('/events/<int:event_id>/duplicate') +@ac_api_requires() +def duplicate_event_route(war_room_id, event_id): + """Shallow-copy an event onto the same timeline. Convenience for the + three-dot menu; the frontend could POST the fields itself but this + keeps the "copy" affordance a one-click operation.""" + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + row = duplicate_event(war_room_id, event_id, + created_by_id=iris_current_user.id) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_created(_serialize_event(row)) + + +@war_rooms_timelines_blueprint.put('/events/<int:event_id>/assets') +@ac_api_requires() +def replace_event_assets(war_room_id, event_id): + """Replace the event's asset associations with the given id list. + Passing `[]` detaches everything; missing / non-list body → 400.""" + err = require_war_room_write(war_room_id) + if err is not None: + return err + raw = request.get_json() + ids = raw.get('asset_ids') if isinstance(raw, dict) else None + if not isinstance(ids, list): + return response_api_error('asset_ids must be a list of integers') + try: + set_event_assets(war_room_id, event_id, ids) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success({'asset_ids': event_asset_ids(event_id)}) + + +@war_rooms_timelines_blueprint.put('/events/<int:event_id>/iocs') +@ac_api_requires() +def replace_event_iocs(war_room_id, event_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + raw = request.get_json() + ids = raw.get('ioc_ids') if isinstance(raw, dict) else None + if not isinstance(ids, list): + return response_api_error('ioc_ids must be a list of integers') + try: + set_event_iocs(war_room_id, event_id, ids) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success({'ioc_ids': event_ioc_ids(event_id)}) diff --git a/source/app/business/alerts.py b/source/app/business/alerts.py index 54768d92b..3f9d272df 100644 --- a/source/app/business/alerts.py +++ b/source/app/business/alerts.py @@ -24,8 +24,11 @@ from app.db import db from app import socket_io from app.models.alerts import Alert +from app.models.alerts import AlertStatus +from app.models.cases import Cases from app.models.iocs import Ioc from app.models.assets import CaseAssets +from app.blueprints.iris_user import iris_current_user from app.datamgmt.alerts.alerts_db import cache_similar_alert from app.datamgmt.alerts.alerts_db import delete_similar_alert_cache from app.datamgmt.alerts.alerts_db import delete_related_alerts_cache @@ -35,9 +38,16 @@ from app.datamgmt.alerts.alerts_db import get_related_alerts_details from app.datamgmt.alerts.alerts_db import get_assets_with_cases from app.datamgmt.alerts.alerts_db import get_iocs_with_cases +from app.datamgmt.alerts.alerts_db import create_case_from_alert +from app.datamgmt.alerts.alerts_db import create_case_from_alerts +from app.datamgmt.alerts.alerts_db import merge_alert_in_case +from app.datamgmt.alerts.alerts_db import unmerge_alert_from_case +from app.datamgmt.case.case_db import get_case +from app.iris_engine.access_control.utils import ac_set_new_case_access from app.iris_engine.module_handler.module_handler import call_modules_hook from app.iris_engine.utils.tracker import track_activity from app.util import add_obj_history_entry +from app.models.errors import BusinessProcessingError from app.models.errors import ObjectNotFoundError @@ -364,3 +374,182 @@ def alerts_delete(alert: Alert): call_modules_hook('on_postload_alert_delete', alert.alert_id) track_activity(f'delete alert #{alert.alert_id}', ctx_less=True) + + +def _resolve_alert_status_id(status_name: str) -> Optional[int]: + row = AlertStatus.query.filter_by(status_name=status_name).first() + return row.status_id if row else None + + +def alerts_escalate(alert: Alert, iocs_import_list: Optional[list] = None, + assets_import_list: Optional[list] = None, note: Optional[str] = None, + import_as_event: bool = False, case_tags: Optional[str] = None, + case_title: Optional[str] = None, + case_template_id: Optional[int] = None) -> Cases: + escalated_id = _resolve_alert_status_id('Escalated') + if escalated_id is not None: + alert.alert_status_id = escalated_id + db.session.commit() + + case = create_case_from_alert( + alert, + iocs_list=iocs_import_list, + assets_list=assets_import_list, + note=note, + import_as_event=import_as_event, + case_tags=case_tags, + case_title=case_title, + template_id=case_template_id, + ) + if not case: + raise BusinessProcessingError('Failed to create case from alert') + + ac_set_new_case_access(iris_current_user, case.case_id, case.client_id) + case = call_modules_hook('on_postload_case_create', case) + + add_obj_history_entry(case, 'created') + track_activity(f'new case {case.name} created from alert', ctx_less=True) + add_obj_history_entry(alert, f'Alert escalated to case #{case.case_id}') + call_modules_hook('on_postload_alert_escalate', alert) + + return case + + +def alerts_merge(alert: Alert, target_case_id: int, + iocs_import_list: Optional[list] = None, + assets_import_list: Optional[list] = None, note: Optional[str] = None, + import_as_event: bool = False, + case_tags: Optional[str] = None) -> Cases: + case = get_case(target_case_id) + if case is None: + raise ObjectNotFoundError() + + merged_id = _resolve_alert_status_id('Merged') + if merged_id is not None: + alert.alert_status_id = merged_id + db.session.commit() + + merge_alert_in_case( + alert, + case, + iocs_list=iocs_import_list, + assets_list=assets_import_list, + note=note, + import_as_event=import_as_event, + case_tags=case_tags, + ) + call_modules_hook('on_postload_alert_merge', alert, caseid=target_case_id) + + track_activity(f'merge alert #{alert.alert_id} into existing case #{target_case_id}', + caseid=target_case_id) + add_obj_history_entry(alert, f'Alert merged into existing case #{target_case_id}') + return case + + +def alerts_unmerge(alert: Alert, target_case_id: int) -> tuple[Alert, str]: + case = get_case(target_case_id) + if case is None: + raise ObjectNotFoundError() + + success, message = unmerge_alert_from_case(alert, case) + if not success: + raise BusinessProcessingError(message) + + track_activity(f'unmerge alert #{alert.alert_id} from case #{target_case_id}', + caseid=target_case_id) + add_obj_history_entry(alert, f'Alert unmerged from case #{target_case_id}') + call_modules_hook('on_postload_alert_unmerge', alert) + return alert, message + + +def alerts_batch_merge(alert_ids: list, target_case_id: int, + iocs_import_list: Optional[list] = None, + assets_import_list: Optional[list] = None, note: Optional[str] = None, + import_as_event: bool = False, + case_tags: Optional[str] = None) -> Cases: + case = get_case(target_case_id) + if case is None: + raise ObjectNotFoundError() + + merged_id = _resolve_alert_status_id('Merged') + + for alert_id in alert_ids: + alert = get_alert_by_id(alert_id) + if not alert: + continue + + if merged_id is not None: + alert.alert_status_id = merged_id + db.session.commit() + + merge_alert_in_case( + alert, + case, + iocs_list=iocs_import_list, + assets_list=assets_import_list, + note=None, + import_as_event=import_as_event, + case_tags=case_tags, + ) + add_obj_history_entry(alert, f'Alert merged into existing case #{target_case_id}') + call_modules_hook('on_postload_alert_merge', alert) + + if note: + case.description += ( + f"\n\n### Escalation note\n\n{note}\n\n" + if case.description else f"\n\n{note}\n\n" + ) + db.session.commit() + + track_activity(f'batched merge alerts {alert_ids} into existing case #{target_case_id}', + caseid=target_case_id) + return case + + +def alerts_batch_escalate(alert_ids: list, + iocs_import_list: Optional[list] = None, + assets_import_list: Optional[list] = None, + note: Optional[str] = None, import_as_event: bool = False, + case_tags: Optional[str] = None, + case_title: Optional[str] = None, + case_template_id: Optional[int] = None) -> Cases: + # NOTE: the legacy route marked alerts as "Merged" here (see + # alerts_routes.py:906). Preserved verbatim to avoid altering behavior + # in this migration; if it should be "Escalated", that's a separate fix. + merged_id = _resolve_alert_status_id('Merged') + + alerts_list = [] + for alert_id in alert_ids: + alert = get_alert_by_id(alert_id) + if not alert: + continue + + if merged_id is not None: + alert.alert_status_id = merged_id + db.session.commit() + alert = call_modules_hook('on_postload_alert_escalate', alert) + alerts_list.append(alert) + + case = create_case_from_alerts( + alerts_list, + iocs_import_list, + assets_import_list, + case_title, + note, + import_as_event, + case_tags, + case_template_id, + ) + if not case: + raise BusinessProcessingError('Failed to create case from alerts') + + ac_set_new_case_access(iris_current_user, case.case_id, case.client_id) + case = call_modules_hook('on_postload_case_create', case) + + add_obj_history_entry(case, 'created') + track_activity(f'new case {case.name} created from alerts', caseid=case.case_id) + + for alert in alerts_list: + add_obj_history_entry(alert, f'Alert escalated into new case #{case.case_id}') + + return case diff --git a/source/app/business/collab.py b/source/app/business/collab.py index e9966b088..f558210be 100644 --- a/source/app/business/collab.py +++ b/source/app/business/collab.py @@ -45,6 +45,7 @@ from app.models.cases import Cases from app.models.collab import CollabDoc from app.models.models import Notes +from app.models.war_rooms import WarRoom from app.models.war_rooms import WarRoomNote from app.models.war_rooms import WarRoomSitRep @@ -70,7 +71,7 @@ class DocResolutionError(Exception): # Doc kinds we recognise. Any doc_name whose prefix isn't in this map is # rejected up-front — keeps the socket handler from spawning arbitrary # rooms based on client input. -_DOC_KINDS = {'note', 'case-summary', 'war-room-note', 'sitrep'} +_DOC_KINDS = {'note', 'case-summary', 'war-room-note', 'war-room-summary', 'sitrep'} def _parse_doc_name(doc_name): @@ -169,6 +170,23 @@ def resolve_doc(doc_name, user_id): 'can_write': write_level is not None, 'current_content': wrn.content} + if kind == 'war-room-summary': + room = WarRoom.query.filter_by(war_room_id=obj_id).first() + if room is None: + return {'kind': kind, 'id': obj_id, 'exists': False, + 'can_read': False, 'can_write': False, + 'current_content': None} + write_level = _war_room_access_check( + user_id, room.war_room_id, [WarRoomAccessLevel.full_access], + ) + read_level = write_level or _war_room_access_check( + user_id, room.war_room_id, [WarRoomAccessLevel.read_only], + ) + return {'kind': kind, 'id': obj_id, 'exists': True, + 'can_read': read_level is not None, + 'can_write': write_level is not None, + 'current_content': room.description} + if kind == 'sitrep': sit = WarRoomSitRep.query.filter_by(sitrep_id=obj_id).first() if sit is None: @@ -372,6 +390,16 @@ def flush_to_source(doc_name): war_room_id=wrn.war_room_id) return + if kind == 'war-room-summary': + room = WarRoom.query.filter_by(war_room_id=obj_id).first() + if room is None or room.description == new_content: + return + room.description = new_content + db.session.commit() + track_activity(f'updated war room summary', + war_room_id=room.war_room_id) + return + if kind == 'sitrep': sit = WarRoomSitRep.query.filter_by(sitrep_id=obj_id).first() # Never flush into a published sitrep — the ACL layer already diff --git a/source/app/business/war_room_chat.py b/source/app/business/war_room_chat.py index aa7029ca8..c87ef6949 100644 --- a/source/app/business/war_room_chat.py +++ b/source/app/business/war_room_chat.py @@ -26,8 +26,12 @@ from app.models.errors import BusinessProcessingError from app.models.errors import ObjectNotFoundError from app.models.war_rooms import WarRoomChatMessage +from app.models.war_rooms import WarRoomChatPoll +from app.models.war_rooms import WarRoomChatPollOption +from app.models.war_rooms import WarRoomChatPollVote from app.models.war_rooms import WarRoomChatReaction from app.models.war_rooms import WarRoomThreadFollower +from app.models.war_rooms import WarRoomTopic _BODY_MAX_LEN = 16_384 @@ -98,6 +102,79 @@ def _threads_supported(): return supported +_PIN_SUPPORTED = None + + +def _pin_supported(): + """Probe whether the `is_pinned` column exists on this DB. + + Same rolling-upgrade rationale as `_threads_supported` — an install + that hasn't run the pin migration still gets a working chat stream; + pin features just go dark until the migration lands. + """ + global _PIN_SUPPORTED + if _PIN_SUPPORTED is True: + return True + try: + from sqlalchemy import text as _text + with db.engine.connect() as conn: + conn.execute( + _text('SELECT is_pinned FROM war_room_chat_message LIMIT 0') + ) + supported = True + except Exception as e: + from app.logger import logger + pgcode = getattr(getattr(e, 'orig', None), 'pgcode', None) + if pgcode == '42703': + logger.info('Pin support disabled: is_pinned column missing') + else: + logger.exception( + 'Pin support probe failed unexpectedly (pgcode=%s)', pgcode + ) + return False + if supported: + _PIN_SUPPORTED = True + return supported + + +_TOPICS_SUPPORTED = None + + +def _topics_supported(): + """Probe whether the topics schema exists on this DB. + + Same rolling-upgrade rationale as `_threads_supported` — an install + that hasn't run the topics migration still gets a working chat + stream; topic features just go dark until the migration lands. + """ + global _TOPICS_SUPPORTED + if _TOPICS_SUPPORTED is True: + return True + try: + from sqlalchemy import text as _text + with db.engine.connect() as conn: + conn.execute( + _text('SELECT topic_id FROM war_room_chat_message LIMIT 0') + ) + conn.execute( + _text('SELECT topic_id FROM war_room_topic LIMIT 0') + ) + supported = True + except Exception as e: + from app.logger import logger + pgcode = getattr(getattr(e, 'orig', None), 'pgcode', None) + if pgcode in ('42703', '42P01'): + logger.info('Topics disabled: schema not migrated yet') + else: + logger.exception( + 'Topics support probe failed unexpectedly (pgcode=%s)', pgcode + ) + return False + if supported: + _TOPICS_SUPPORTED = True + return supported + + _VALID_KINDS = { 'message', 'system', 'task_assigned', 'task_completed', @@ -111,6 +188,11 @@ def _threads_supported(): # `priority` flags a banner-style row stamped when the operator flips # the war room into a hotter posture via `/priority` or `/state`. 'priority', + # `poll` hosts an inline poll (question + options + votes). The + # poll body lives in `WarRoomChatPoll`; the chat row acts as the + # anchor in the stream so the row's `created_at` and thread + # placement stay consistent with every other kind. + 'poll', } @@ -309,7 +391,7 @@ def _fetch_live_case_activities(war_room_id, before_dt, limit, def list_messages(war_room_id, before=None, limit=None, kinds=None, - case_ids=None, search=None): + case_ids=None, search=None, topic_ids=None): """Return the next page of the war-room stream, newest first. Two sources are merged at read time: @@ -385,6 +467,8 @@ def list_messages(war_room_id, before=None, limit=None, kinds=None, # before any row could be returned. We probe the schema once and # cache the result. threads_on = _threads_supported() + pin_on = _pin_supported() + topics_on = _topics_supported() columns = [ WarRoomChatMessage.message_id, WarRoomChatMessage.war_room_id, @@ -400,6 +484,10 @@ def list_messages(war_room_id, before=None, limit=None, kinds=None, WarRoomChatMessage.parent_message_id, WarRoomChatMessage.thread_title, ] + if pin_on: + columns.append(WarRoomChatMessage.is_pinned) + if topics_on: + columns.append(WarRoomChatMessage.topic_id) columns += [ WarRoomChatMessage.created_at, WarRoomChatMessage.edited_at, @@ -431,6 +519,26 @@ def list_messages(war_room_id, before=None, limit=None, kinds=None, q = q.filter(WarRoomChatMessage.kind.in_(list(kinds))) if case_ids: q = q.filter(WarRoomChatMessage.ref_case_id.in_(list(case_ids))) + if topics_on and topic_ids is not None: + # Empty list means "no topics selected" — return nothing rather + # than an unfiltered stream, which would be a confusing UX. + if not topic_ids: + return [] + # Selecting Main also matches messages with NULL topic_id (which + # are implicitly on Main). Non-Main topics only match by id. + main = ( + WarRoomTopic.query + .filter_by(war_room_id=war_room_id, is_main=True) + .with_entities(WarRoomTopic.topic_id) + .first() + ) + main_id = main.topic_id if main else None + wants_main = main_id is not None and main_id in topic_ids + from sqlalchemy import or_ as _or + clauses = [WarRoomChatMessage.topic_id.in_(list(topic_ids))] + if wants_main: + clauses.append(WarRoomChatMessage.topic_id.is_(None)) + q = q.filter(_or(*clauses)) # Free-text filter: ILIKE against the message body. Soft-deleted # rows drop out here too, because their body is nulled at delete # time and NULL doesn't match `LIKE`. Overfetch is fine — the merge @@ -461,10 +569,84 @@ def list_messages(war_room_id, before=None, limit=None, kinds=None, return merged[:limit] +def _resolve_attachments(war_room_id, file_ids): + """Turn a raw list of `file_id`s from the client into the JSONB + payload we store on the chat message. + + Every `file_id` must resolve to a `WarRoomDatastoreFile` row in the + same war room — cross-room file references are rejected. Order is + preserved so the UI can render the attachments in the order the + operator dropped them. + """ + if not file_ids: + return None + if not isinstance(file_ids, list): + raise BusinessProcessingError('file_ids must be a list') + normalised = [] + for raw in file_ids: + try: + normalised.append(int(raw)) + except (TypeError, ValueError): + raise BusinessProcessingError('file_ids must be integers') + if not normalised: + return None + + from app.models.war_rooms import WarRoomDatastoreFile + rows = ( + WarRoomDatastoreFile.query + .filter(WarRoomDatastoreFile.war_room_id == war_room_id) + .filter(WarRoomDatastoreFile.file_id.in_(normalised)) + .all() + ) + by_id = {r.file_id: r for r in rows} + payload = [] + for fid in normalised: + row = by_id.get(fid) + if row is None: + raise BusinessProcessingError( + f'File #{fid} does not belong to this war room' + ) + payload.append({ + 'file_id': row.file_id, + 'filename': row.filename, + 'mime_type': row.mime_type, + 'size_bytes': int(row.size_bytes) if row.size_bytes is not None else 0, + }) + return payload + + def create_message(war_room_id, author_id, body, kind=None, - ref_type=None, ref_id=None, ref_case_id=None): + ref_type=None, ref_id=None, ref_case_id=None, + topic_id=None, file_ids=None): kind = _validate_kind(kind) - body = _validate_body(body, kind) + attachments = _resolve_attachments(war_room_id, file_ids) + # Body is only required when there are no attachments — an + # attachment-only message is a valid post (drop an image, hit send). + if kind == 'message' and attachments and not (isinstance(body, str) and body.strip()): + # Skip the strict "message body required" check by validating + # with `system` semantics; the row still ends up `kind='message'` + # but the body may be blank because the payload is in the file. + body = _validate_body(body, 'system') + else: + body = _validate_body(body, kind) + + # Only trust `topic_id` when the schema supports it AND the topic + # belongs to this war room and isn't archived. NULL falls through + # to Main. + resolved_topic_id = None + if topic_id is not None and _topics_supported(): + topic = ( + WarRoomTopic.query + .filter_by(war_room_id=war_room_id, topic_id=topic_id) + .first() + ) + if topic is None: + raise BusinessProcessingError('Unknown topic') + if topic.archived_at is not None: + raise BusinessProcessingError( + 'Cannot post to an archived topic' + ) + resolved_topic_id = topic.topic_id msg = WarRoomChatMessage() msg.war_room_id = war_room_id @@ -474,6 +656,16 @@ def create_message(war_room_id, author_id, body, kind=None, msg.ref_type = ref_type msg.ref_id = ref_id msg.ref_case_id = ref_case_id + if resolved_topic_id is not None: + msg.topic_id = resolved_topic_id + if attachments is not None: + # Guard against a database that hasn't applied the attachments + # migration yet — silently drop the payload rather than 500 on + # `AttributeError` from the ORM. + try: + msg.attachments = attachments + except Exception: + pass db.session.add(msg) db.session.commit() @@ -526,6 +718,37 @@ def delete_message(war_room_id, message_id, author_id, is_admin=False): {'war_room_id': war_room_id, 'message_id': message_id}) +def set_message_pin(war_room_id, message_id, is_pinned, actor_id, is_admin=False): + """Toggle the sticky-pin flag on a chat message. + + Anyone with war-room write access can pin/unpin — pinning isn't a + destructive act (delete/edit are author-only) so we don't gate to + the author. If we later grow a per-war-room role model that + distinguishes 'member' from 'moderator' this is the place to + tighten the check. `actor_id`/`is_admin` are threaded through for + future permission work and for the `track_activity` bookkeeping. + """ + msg = get_message(war_room_id, message_id) + if msg.deleted_at is not None: + raise BusinessProcessingError('Cannot pin a deleted message') + if msg.kind not in ('message', 'pin', 'decision', 'note'): + # System rows (task_assigned, case_attached, sitrep_published, + # poll, …) aren't pinnable — they're already elevated via + # `kind` and cluttering the pin list with them would defeat + # the point. + raise BusinessProcessingError( + f'Messages of kind {msg.kind!r} cannot be pinned' + ) + msg.is_pinned = bool(is_pinned) + db.session.commit() + call_modules_hook('on_postload_war_room_message_pin', + {'war_room_id': war_room_id, + 'message_id': message_id, + 'is_pinned': msg.is_pinned, + 'actor_id': actor_id}) + return msg + + # ----- Reactions ----------------------------------------------------------- def toggle_reaction(war_room_id, message_id, user_id, emoji): @@ -761,24 +984,28 @@ def list_replies(war_room_id, root_message_id, limit=None): limit = _PAGE_DEFAULT limit = min(int(limit), _PAGE_MAX) root = _get_root_message(war_room_id, root_message_id) + pin_on = _pin_supported() + columns = [ + WarRoomChatMessage.message_id, + WarRoomChatMessage.war_room_id, + WarRoomChatMessage.author_id, + WarRoomChatMessage.body, + WarRoomChatMessage.kind, + WarRoomChatMessage.ref_type, + WarRoomChatMessage.ref_id, + WarRoomChatMessage.ref_case_id, + WarRoomChatMessage.parent_message_id, + WarRoomChatMessage.thread_title, + WarRoomChatMessage.created_at, + WarRoomChatMessage.edited_at, + WarRoomChatMessage.deleted_at, + User.user.label('author_login'), + User.name.label('author_name'), + ] + if pin_on: + columns.append(WarRoomChatMessage.is_pinned) q = ( - db.session.query( - WarRoomChatMessage.message_id, - WarRoomChatMessage.war_room_id, - WarRoomChatMessage.author_id, - WarRoomChatMessage.body, - WarRoomChatMessage.kind, - WarRoomChatMessage.ref_type, - WarRoomChatMessage.ref_id, - WarRoomChatMessage.ref_case_id, - WarRoomChatMessage.parent_message_id, - WarRoomChatMessage.thread_title, - WarRoomChatMessage.created_at, - WarRoomChatMessage.edited_at, - WarRoomChatMessage.deleted_at, - User.user.label('author_login'), - User.name.label('author_name'), - ) + db.session.query(*columns) .outerjoin(User, User.id == WarRoomChatMessage.author_id) .filter(WarRoomChatMessage.war_room_id == war_room_id) .filter(WarRoomChatMessage.parent_message_id == root.message_id) @@ -807,6 +1034,8 @@ def list_trace_log(war_room_id, limit=None): limit = min(int(limit), _PAGE_MAX) threads_on = _threads_supported() + pin_on = _pin_supported() + topics_on = _topics_supported() columns = [ WarRoomChatMessage.message_id, WarRoomChatMessage.war_room_id, @@ -828,13 +1057,37 @@ def list_trace_log(war_room_id, limit=None): # trace-worthy message is by definition a top-level one anyway. if threads_on: columns.append(WarRoomChatMessage.parent_message_id) + if pin_on: + columns.append(WarRoomChatMessage.is_pinned) + # Trace entries carry their topic so the SPA can auto-switch the + # stream filter when the operator clicks the sidebar entry — + # otherwise a decision posted into a topic the operator isn't + # currently viewing looks like a dead click. + if topics_on: + columns.append(WarRoomChatMessage.topic_id) + + from sqlalchemy import or_ + filters = [ + WarRoomChatMessage.war_room_id == war_room_id, + WarRoomChatMessage.deleted_at.is_(None), + ] + if pin_on: + # Trace-worthy = system rows we already flagged as "keep me" + # (decisions/pins/notes) OR any regular message an analyst + # explicitly pinned. Union rather than two queries to keep + # the ORDER BY LIMIT correct across both sources. + filters.append(or_( + WarRoomChatMessage.kind.in_(_TRACE_KINDS), + WarRoomChatMessage.is_pinned.is_(True), + )) + else: + # Pre-migration DBs: only the system-row kinds count as trace-worthy. + filters.append(WarRoomChatMessage.kind.in_(_TRACE_KINDS)) q = ( db.session.query(*columns) .outerjoin(User, User.id == WarRoomChatMessage.author_id) - .filter(WarRoomChatMessage.war_room_id == war_room_id) - .filter(WarRoomChatMessage.kind.in_(_TRACE_KINDS)) - .filter(WarRoomChatMessage.deleted_at.is_(None)) + .filter(*filters) .order_by(desc(WarRoomChatMessage.created_at), desc(WarRoomChatMessage.message_id)) .limit(limit) ) @@ -998,11 +1251,11 @@ def _fire_message_notifications(msg): replies (see `_fire_reply_notifications`). """ try: - from app.iris_engine.notifications.mentions import extract_mentioned_user_ids + from app.iris_engine.notifications.mentions import resolve_mentions_to_user_ids from app.iris_engine.notifications.service import notify_many from app.models.war_rooms import WarRoomMember - mentioned = extract_mentioned_user_ids(msg.body) + mentioned = resolve_mentions_to_user_ids(msg.body, msg.war_room_id) if not mentioned: return @@ -1040,7 +1293,7 @@ def _fire_message_notifications(msg): def _fire_reply_notifications(msg, root_message_id): """Notify thread followers + mentions on a new reply.""" try: - from app.iris_engine.notifications.mentions import extract_mentioned_user_ids + from app.iris_engine.notifications.mentions import resolve_mentions_to_user_ids from app.iris_engine.notifications.service import notify_many # 1. Thread followers (excluding the author) @@ -1065,7 +1318,7 @@ def _fire_reply_notifications(msg, root_message_id): # 2. Mentions inside the reply (independent from follow — # mentioning a non-follower still pings them). - mentioned = extract_mentioned_user_ids(msg.body) + mentioned = resolve_mentions_to_user_ids(msg.body, msg.war_room_id) if mentioned: from app.models.war_rooms import WarRoomMember member_ids = { @@ -1096,6 +1349,492 @@ def _fire_reply_notifications(msg, root_message_id): 'war-room reply notification failed') +# ----- Polls --------------------------------------------------------------- + +_POLL_QUESTION_MAX = 512 +_POLL_OPTION_MAX_LEN = 256 +_POLL_OPTIONS_MIN = 2 +_POLL_OPTIONS_MAX = 20 + + +def _validate_poll_options(options): + """Normalise + validate the option-label list from the client. + + Raises `BusinessProcessingError` on shape violations; returns the + trimmed list otherwise. Ordering is preserved — the caller + persists options with `sort_order` matching their index in the + returned list.""" + if not isinstance(options, list): + raise BusinessProcessingError('options must be a list of strings') + if not (_POLL_OPTIONS_MIN <= len(options) <= _POLL_OPTIONS_MAX): + raise BusinessProcessingError( + f'A poll must have between {_POLL_OPTIONS_MIN} ' + f'and {_POLL_OPTIONS_MAX} options' + ) + cleaned = [] + for opt in options: + if not isinstance(opt, str): + raise BusinessProcessingError('Each option must be a string') + s = opt.strip() + if not s: + raise BusinessProcessingError('Option labels cannot be empty') + if len(s) > _POLL_OPTION_MAX_LEN: + raise BusinessProcessingError( + f'Option labels must be at most {_POLL_OPTION_MAX_LEN} characters' + ) + cleaned.append(s) + return cleaned + + +def _parse_closes_at(raw): + """Parse a client-supplied `closes_at` deadline string. + + None / empty string → no deadline. Otherwise expects ISO-8601. + Naive datetimes are treated as UTC (matches the rest of the + codebase's `datetime.utcnow` usage).""" + if raw is None or raw == '': + return None + if isinstance(raw, datetime.datetime): + return raw + if not isinstance(raw, str): + raise BusinessProcessingError('closes_at must be an ISO date string') + try: + return datetime.datetime.fromisoformat(raw.replace('Z', '+00:00')) + except ValueError: + raise BusinessProcessingError('closes_at must be an ISO date string') + + +def poll_is_closed(poll: WarRoomChatPoll) -> bool: + """A poll is closed if it was manually closed OR its deadline has + passed. Called from every vote path so the truth is centralised.""" + if poll.closed_at is not None: + return True + if poll.closes_at is not None and poll.closes_at <= datetime.datetime.utcnow(): + return True + return False + + +def _get_poll(war_room_id: int, poll_id: int) -> WarRoomChatPoll: + poll = ( + WarRoomChatPoll.query + .filter_by(poll_id=poll_id, war_room_id=war_room_id) + .first() + ) + if poll is None: + raise ObjectNotFoundError() + return poll + + +def create_poll(war_room_id, author_id, question, options, + is_multi_select=False, is_anonymous=False, closes_at=None): + """Create a poll + its companion chat message in one commit. + + The chat message is `kind='poll'` with `ref_type='chat_poll'` and + `ref_id=<poll_id>`. The route layer emits `message:new` after this + returns so the stream broadcast fires through the existing path — + no separate `poll:created` event is strictly required, but we + also emit `poll:created` for clients that want to react to poll + creation specifically (e.g. jump to it, seed a local cache).""" + if not isinstance(question, str) or not question.strip(): + raise BusinessProcessingError('Poll question is required') + question = question.strip() + if len(question) > _POLL_QUESTION_MAX: + raise BusinessProcessingError( + f'Poll question must be at most {_POLL_QUESTION_MAX} characters' + ) + labels = _validate_poll_options(options) + parsed_closes_at = _parse_closes_at(closes_at) + if parsed_closes_at is not None and parsed_closes_at <= datetime.datetime.utcnow(): + raise BusinessProcessingError('closes_at must be in the future') + + poll = WarRoomChatPoll() + poll.war_room_id = war_room_id + poll.author_id = author_id + poll.question = question + poll.is_multi_select = bool(is_multi_select) + poll.is_anonymous = bool(is_anonymous) + poll.closes_at = parsed_closes_at + db.session.add(poll) + db.session.flush() # get poll.poll_id before inserting options + message + + for idx, label in enumerate(labels): + opt = WarRoomChatPollOption() + opt.poll_id = poll.poll_id + opt.label = label + opt.sort_order = idx + db.session.add(opt) + + # Companion chat message. `body` intentionally left empty — the + # frontend renders the poll card from the poll payload, not from + # the message body. Keeping `body` NULL means the stream fallback + # renderer produces nothing awkward if the poll card doesn't load. + msg = WarRoomChatMessage() + msg.war_room_id = war_room_id + msg.author_id = author_id + msg.kind = 'poll' + msg.body = None + msg.ref_type = 'chat_poll' + db.session.add(msg) + db.session.flush() + msg.ref_id = poll.poll_id + poll.chat_message_id = msg.message_id + db.session.commit() + + call_modules_hook('on_postload_war_room_poll_create', + {'war_room_id': war_room_id, + 'poll_id': poll.poll_id, + 'message_id': msg.message_id}) + return msg, poll + + +def vote_on_poll(war_room_id, poll_id, user_id, option_ids): + """Cast/replace a user's votes on a poll. + + For single-select polls, `option_ids` must be exactly one — the + existing vote (if any) is atomically replaced by the new one. + For multi-select polls, `option_ids` is the FULL desired set — + votes not in the list are removed, votes in the list are added + (idempotent). Passing `[]` clears the user's votes entirely. + + Rejects if the poll is closed or if any id doesn't belong to it. + """ + if not isinstance(option_ids, list): + raise BusinessProcessingError('option_ids must be a list of integers') + + poll = _get_poll(war_room_id, poll_id) + if poll_is_closed(poll): + raise BusinessProcessingError('Poll is closed for voting') + + # Coerce + dedupe. Ints only; non-numeric silently dropped so a + # client bug doesn't 400 the whole request. + wanted: set[int] = set() + for raw in option_ids: + try: + wanted.add(int(raw)) + except (TypeError, ValueError): + continue + + if not poll.is_multi_select and len(wanted) > 1: + raise BusinessProcessingError( + 'This is a single-select poll; only one option may be chosen' + ) + + # Validate every requested option belongs to this poll — cheap + # single-query check, keeps us from ballot-stuffing across polls. + valid_option_ids = { + row.option_id for row in + WarRoomChatPollOption.query + .filter(WarRoomChatPollOption.poll_id == poll.poll_id, + WarRoomChatPollOption.option_id.in_(wanted)) + .with_entities(WarRoomChatPollOption.option_id) + .all() + } if wanted else set() + if wanted != valid_option_ids: + raise BusinessProcessingError('One or more option ids are invalid for this poll') + + # Snapshot the user's current votes across ANY option in this + # poll. Anything not in `wanted` gets removed; anything in + # `wanted` and not already present gets added. + existing_option_ids = { + row.option_id for row in + db.session.query(WarRoomChatPollVote.option_id) + .join(WarRoomChatPollOption, + WarRoomChatPollOption.option_id == WarRoomChatPollVote.option_id) + .filter(WarRoomChatPollOption.poll_id == poll.poll_id, + WarRoomChatPollVote.user_id == user_id) + .all() + } + + to_remove = existing_option_ids - wanted + to_add = wanted - existing_option_ids + + if to_remove: + WarRoomChatPollVote.query.filter( + WarRoomChatPollVote.option_id.in_(to_remove), + WarRoomChatPollVote.user_id == user_id, + ).delete(synchronize_session=False) + + for opt_id in to_add: + vote = WarRoomChatPollVote() + vote.option_id = opt_id + vote.user_id = user_id + db.session.add(vote) + + db.session.commit() + call_modules_hook('on_postload_war_room_poll_vote', + {'war_room_id': war_room_id, + 'poll_id': poll.poll_id, + 'user_id': user_id, + 'added': list(to_add), 'removed': list(to_remove)}) + return poll + + +def close_poll(war_room_id, poll_id, user_id, is_admin=False): + """Manually close a poll. Author or admin only. Idempotent.""" + poll = _get_poll(war_room_id, poll_id) + if poll.author_id != user_id and not is_admin: + raise BusinessProcessingError( + 'Only the poll author or an admin can close a poll' + ) + if poll.closed_at is None: + poll.closed_at = datetime.datetime.utcnow() + db.session.commit() + call_modules_hook('on_postload_war_room_poll_close', + {'war_room_id': war_room_id, + 'poll_id': poll.poll_id, + 'closed_by': user_id}) + return poll + + +def get_poll_state(war_room_id, poll_id, viewer_id): + """Return the poll + option tallies for a viewer. + + Voter identity is stripped when `poll.is_anonymous=true`; only + the aggregate `vote_count` is returned per option. Non-anonymous + polls return per-option `voters: [{user_id, name}]` so the UI + can render "voted by …" chips. + + `my_votes` is always the viewer's own option ids — even on + anonymous polls the viewer sees their own selections.""" + poll = _get_poll(war_room_id, poll_id) + + # One join per option to fetch its votes + voter identity in a + # single query. Cheap because polls have at most 20 options each. + from sqlalchemy import func + counts = dict( + db.session.query( + WarRoomChatPollOption.option_id, + func.count(WarRoomChatPollVote.option_id), + ) + .outerjoin(WarRoomChatPollVote, + WarRoomChatPollVote.option_id == WarRoomChatPollOption.option_id) + .filter(WarRoomChatPollOption.poll_id == poll.poll_id) + .group_by(WarRoomChatPollOption.option_id) + .all() + ) + + # Viewer's own selections — anonymous polls still show these + # (a user always knows what they clicked). + my_votes = [ + row.option_id for row in + db.session.query(WarRoomChatPollVote.option_id) + .join(WarRoomChatPollOption, + WarRoomChatPollOption.option_id == WarRoomChatPollVote.option_id) + .filter(WarRoomChatPollOption.poll_id == poll.poll_id, + WarRoomChatPollVote.user_id == viewer_id) + .all() + ] + + voters_by_option: dict[int, list] = {} + if not poll.is_anonymous: + rows = ( + db.session.query( + WarRoomChatPollVote.option_id, + User.id, User.user, User.name, + ) + .join(WarRoomChatPollOption, + WarRoomChatPollOption.option_id == WarRoomChatPollVote.option_id) + .join(User, User.id == WarRoomChatPollVote.user_id) + .filter(WarRoomChatPollOption.poll_id == poll.poll_id) + .all() + ) + for opt_id, uid, login, name in rows: + voters_by_option.setdefault(opt_id, []).append({ + 'user_id': uid, + 'user_login': login, + 'user_name': name, + }) + + options_out = [] + for opt in poll.options: + entry = { + 'option_id': opt.option_id, + 'label': opt.label, + 'sort_order': opt.sort_order, + 'vote_count': int(counts.get(opt.option_id, 0)), + } + if not poll.is_anonymous: + entry['voters'] = voters_by_option.get(opt.option_id, []) + options_out.append(entry) + + return { + 'poll_id': poll.poll_id, + 'war_room_id': poll.war_room_id, + 'author_id': poll.author_id, + 'question': poll.question, + 'is_multi_select': poll.is_multi_select, + 'is_anonymous': poll.is_anonymous, + 'closes_at': poll.closes_at.isoformat() if poll.closes_at else None, + 'closed_at': poll.closed_at.isoformat() if poll.closed_at else None, + 'is_closed': poll_is_closed(poll), + 'chat_message_id': poll.chat_message_id, + 'created_at': poll.created_at.isoformat() if poll.created_at else None, + 'my_votes': my_votes, + 'options': options_out, + } + + +def get_poll_by_message_id(war_room_id, message_id): + """Look up the poll hosted by a `kind='poll'` chat message. Used + by the list-messages serializer to inline the poll payload so the + SPA doesn't need a second RPC per poll on stream load.""" + return ( + WarRoomChatPoll.query + .filter_by(war_room_id=war_room_id, chat_message_id=message_id) + .first() + ) + + +# ----- Topics -------------------------------------------------------------- + +_TOPIC_NAME_MAX_LEN = 80 +_MAIN_TOPIC_NAME = 'Main' + + +def _validate_topic_name(name): + if not isinstance(name, str): + raise BusinessProcessingError('Topic name must be a string') + stripped = name.strip() + if not stripped: + raise BusinessProcessingError('Topic name is required') + if len(stripped) > _TOPIC_NAME_MAX_LEN: + raise BusinessProcessingError( + f'Topic name must be at most {_TOPIC_NAME_MAX_LEN} characters' + ) + return stripped + + +def get_or_create_main_topic(war_room_id): + """Return the war-room's Main topic, materialising it on first use. + + Main is created lazily so freshly-migrated rooms don't need a + backfill. All non-topic messages (`topic_id IS NULL`) are treated + as Main by the read path even before this row exists. + """ + if not _topics_supported(): + return None + row = ( + WarRoomTopic.query + .filter_by(war_room_id=war_room_id, is_main=True) + .first() + ) + if row is not None: + return row + row = WarRoomTopic() + row.war_room_id = war_room_id + row.name = _MAIN_TOPIC_NAME + row.is_main = True + db.session.add(row) + try: + db.session.commit() + except Exception: + # A concurrent request may have created it first — surface + # whatever the DB now holds. + db.session.rollback() + row = ( + WarRoomTopic.query + .filter_by(war_room_id=war_room_id, is_main=True) + .first() + ) + return row + + +def list_topics(war_room_id, include_archived=True): + """Return every topic on the war room, Main first, then live, then + archived. Non-archived first within each group, ordered by name. + """ + if not _topics_supported(): + return [] + # Ensure Main exists so the SPA sidebar always has a landing lane. + get_or_create_main_topic(war_room_id) + rows = ( + WarRoomTopic.query + .filter_by(war_room_id=war_room_id) + .order_by( + desc(WarRoomTopic.is_main), + WarRoomTopic.archived_at.isnot(None), + WarRoomTopic.name, + ) + .all() + ) + if not include_archived: + rows = [r for r in rows if r.archived_at is None] + return rows + + +def create_topic(war_room_id, name, user_id): + """Create a new (non-main) topic — or return the existing one if the + name (case-insensitive) already exists. Archived rows with the same + name are unarchived and reused. + """ + if not _topics_supported(): + raise BusinessProcessingError( + 'Topics are not enabled on this server yet — ' + 'apply the latest migrations.' + ) + name = _validate_topic_name(name) + # Case-insensitive collision check — the DB unique index is + # case-sensitive, so we normalise here. + existing = ( + WarRoomTopic.query + .filter(WarRoomTopic.war_room_id == war_room_id) + .filter(WarRoomTopic.name.ilike(name)) + .first() + ) + if existing is not None: + if existing.archived_at is not None: + existing.archived_at = None + db.session.commit() + return existing + # Make sure Main is materialised — cheap and keeps the sidebar + # ordering consistent when the operator creates their first + # extra topic. + get_or_create_main_topic(war_room_id) + row = WarRoomTopic() + row.war_room_id = war_room_id + row.name = name + row.is_main = False + row.created_by_id = user_id + db.session.add(row) + db.session.commit() + return row + + +def get_topic(war_room_id, topic_id): + row = ( + WarRoomTopic.query + .filter_by(war_room_id=war_room_id, topic_id=topic_id) + .first() + ) + if row is None: + raise ObjectNotFoundError() + return row + + +def archive_topic(war_room_id, topic_id): + """Soft-archive a topic. Main can never be archived. Idempotent.""" + row = get_topic(war_room_id, topic_id) + if row.is_main: + raise BusinessProcessingError('The Main topic cannot be archived') + if row.archived_at is None: + row.archived_at = datetime.datetime.utcnow() + db.session.commit() + return row + + +def unarchive_topic(war_room_id, topic_id): + """Restore an archived topic. Idempotent — a no-op if the topic is + already live.""" + row = get_topic(war_room_id, topic_id) + if row.is_main: + # Main is never archived; nothing to do. + return row + if row.archived_at is not None: + row.archived_at = None + db.session.commit() + return row + + # ----- Activity ingest ----------------------------------------------------- def ingest_case_activity(case_id, activity_text, ref_activity_id=None): diff --git a/source/app/business/war_room_linked_case_timelines.py b/source/app/business/war_room_linked_case_timelines.py new file mode 100644 index 000000000..62253e885 --- /dev/null +++ b/source/app/business/war_room_linked_case_timelines.py @@ -0,0 +1,301 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Projection layer that surfaces case-side timelines inside a war room. + +A war room aggregates multiple cases (`WarRoomCase`), and every case has +its own timelines (`CaseTimeline` + `CaseEventTimeline`). Historically +the war-room timeline view only rendered its own native +`WarRoomTimelineEvent` rows — analysts had to copy case events over one +by one if they wanted them here. This module turns that around: instead +of copying, we compute the projection on the fly so the war-room +timeline can UNION its native events with a live view of any case +timelines the user has opted in to. + +The projection is deliberately read-only. Writes (create/edit/delete) +still go through the case-side timeline endpoints — the case remains +the source of truth for its own events. This keeps the data model +clean (no drift between projected copies and the originals) and lets +edits/deletes on the case propagate immediately to every war room the +case is attached to. + +ACL is layered on top of the war-room ACL: even if you can read the +war room, you only see the case timelines / events for cases you also +have case-level read access to. Cases you can't reach are silently +elided so the sidebar doesn't advertise their existence. +""" + +from typing import Iterable +from typing import List +from typing import Optional +from typing import Set + +from sqlalchemy import func + +from app.business.case_timelines import case_timeline_list +from app.db import db +from app.models.authorization import CaseAccessLevel +from app.models.cases import CaseEventTimeline +from app.models.cases import CaseTimeline +from app.models.cases import Cases +from app.models.cases import CasesEvent +from app.models.war_rooms import WarRoomCase + + +def _accessible_linked_case_ids(war_room_id: int, user_id: int) -> Set[int]: + """Every case linked to `war_room_id` that the caller can read. + + Kept private and used by both entry points below so a caller who + can't reach Case #12 sees the same "as if it isn't linked" view on + the timelines sidebar AND on the events endpoint (i.e., can't + smuggle case events by guessing timeline ids). + + Late import of the case-ACL helper matches the pattern in + `app.business.collab._war_room_access_check` — importing at module + top would trip a circular import through `app.blueprints`.""" + from app.blueprints.access_controls import ac_fast_check_user_has_case_access + + linked_case_ids = [ + row.case_id + for row in WarRoomCase.query + .filter(WarRoomCase.war_room_id == war_room_id) + .with_entities(WarRoomCase.case_id) + .all() + ] + if not linked_case_ids: + return set() + + reachable: Set[int] = set() + for case_id in linked_case_ids: + level = ac_fast_check_user_has_case_access( + user_id, case_id, + [CaseAccessLevel.read_only, CaseAccessLevel.full_access], + ) + if level is not None: + reachable.add(case_id) + return reachable + + +def list_linked_case_timelines(war_room_id: int, user_id: int) -> list: + """Return a nested tree of case timelines available to the caller. + + Shape: + [ + { + 'case_id': int, + 'case_name': str, + 'timelines': [ + {'timeline_id': int, 'name': str, + 'color': str | None, 'is_default': bool}, + ... + ], + }, + ... + ] + + Cases without any timelines are still included (as an empty list) + — the sidebar can show "no timelines yet" affordance without a + separate probe. Ordering: cases by attach time (via `WarRoomCase`), + timelines using `case_timeline_list`'s existing "defaults first, + then chronological" order. + """ + accessible = _accessible_linked_case_ids(war_room_id, user_id) + if not accessible: + return [] + + # Preserve WarRoomCase.attached_at ordering — this is what the + # linked-cases page uses, so the timelines sidebar reads in the + # same order. + attach_rows = ( + WarRoomCase.query + .filter(WarRoomCase.war_room_id == war_room_id, + WarRoomCase.case_id.in_(accessible)) + .order_by(WarRoomCase.attached_at.asc()) + .with_entities(WarRoomCase.case_id, WarRoomCase.attached_at) + .all() + ) + ordered_case_ids = [row.case_id for row in attach_rows] + + case_names = dict( + Cases.query + .filter(Cases.case_id.in_(accessible)) + .with_entities(Cases.case_id, Cases.name) + .all() + ) + + result = [] + for case_id in ordered_case_ids: + timelines = case_timeline_list(case_id) + result.append({ + 'case_id': case_id, + 'case_name': case_names.get(case_id) or f'Case #{case_id}', + 'timelines': [ + { + 'timeline_id': t.timeline_id, + 'name': t.name, + 'color': t.color, + 'is_default': bool(t.is_default), + } + for t in timelines + ], + }) + return result + + +def list_linked_case_events(war_room_id: int, user_id: int, + case_timeline_ids: Iterable[int]) -> list: + """Return case events shaped like `WarRoomTimelineEvent` rows. + + The frontend merges these with the native war-room events and sorts + both by `event_date` client-side. Field names are aligned with + `WarRoomTimelineEvent`'s serializer so the timeline card component + doesn't need per-source branching: + + { + 'id': 'case:<case_id>:<event_id>', # synthetic; won't collide with native ids + 'war_room_source': 'case', # discriminator for the client + 'timeline_id': <case timeline id>, + 'case_id': int, + 'event_id': int, + 'title': str, + 'content': str | None, + 'event_date': ISO8601 | None, + 'event_tz': str | None, + 'color': str | None, + 'category': None, # case events don't carry a category today + 'created_at': ISO8601 | None, # maps from `event_added` + 'created_by_id': int | None, # maps from `user_id` + } + + Empty / unknown / inaccessible timeline ids are silently dropped — + the goal is that a stale preference blob (e.g., the analyst + unlinked a case) degrades gracefully to "no events from that + source" rather than a 500. + """ + if not case_timeline_ids: + return [] + + # Normalise + dedupe. The route already CSV-parses, but callers + # from other code paths might pass strings mixed with ints. + wanted_ids: Set[int] = set() + for raw in case_timeline_ids: + try: + wanted_ids.add(int(raw)) + except (TypeError, ValueError): + continue + if not wanted_ids: + return [] + + accessible_case_ids = _accessible_linked_case_ids(war_room_id, user_id) + if not accessible_case_ids: + return [] + + # Filter to timelines that (a) exist, (b) belong to a linked + + # accessible case. A single JOIN keeps the trip to the DB cheap + # and enforces the tenancy check server-side even if the client + # supplied ids from a case they can't see. + valid_pairs = ( + CaseTimeline.query + .filter(CaseTimeline.timeline_id.in_(wanted_ids), + CaseTimeline.case_id.in_(accessible_case_ids)) + .with_entities(CaseTimeline.timeline_id, CaseTimeline.case_id) + .all() + ) + if not valid_pairs: + return [] + valid_timeline_ids = {row.timeline_id for row in valid_pairs} + + rows = ( + CasesEvent.query + .join(CaseEventTimeline, + CaseEventTimeline.event_id == CasesEvent.event_id) + .filter(CaseEventTimeline.timeline_id.in_(valid_timeline_ids)) + .add_columns(CaseEventTimeline.timeline_id) + .order_by(CasesEvent.event_date.asc().nullslast(), + CasesEvent.event_id.asc()) + .all() + ) + + return [_serialize_case_event(event, timeline_id) + for event, timeline_id in rows] + + +def _serialize_case_event(event: CasesEvent, source_timeline_id: int) -> dict: + """Case event → war-room-event-shaped dict. Kept out of the query so + it stays trivial to reuse from tests and any future socket-driven + push path that needs the same shape. + + Every field the war-room event card renders is populated here from + its case-side equivalent — `event_source` / `event_raw` / + `event_tags` / `event_is_flagged` map straight across. Assets / + IOCs / children_count are hydrated too so the frontend doesn't + have to branch on `war_room_source` when reading. Projected events + remain read-only regardless of what these fields contain — the + frontend gates edit affordances on `war_room_source === 'case'`. + Per-event comments aren't part of the war-room event surface. + """ + from app.models.models import CaseEventsAssets + from app.models.models import CaseEventsIoc + + # Cheap targeted lookups. Bulk-batching these into a single N-way + # join would be micro-optimisation; the projection is called with + # a bounded set of events (per selected case timeline) and each + # scalar count is a single index lookup. + asset_ids = [ + r.asset_id for r in + CaseEventsAssets.query + .filter_by(event_id=event.event_id) + .with_entities(CaseEventsAssets.asset_id).all() + ] + ioc_ids = [ + r.ioc_id for r in + CaseEventsIoc.query + .filter_by(event_id=event.event_id) + .with_entities(CaseEventsIoc.ioc_id).all() + ] + children_count = ( + db.session.query(func.count(CasesEvent.event_id)) + .filter(CasesEvent.parent_event_id == event.event_id) + .scalar() or 0 + ) + + return { + # Synthetic id — never a bare integer so it won't collide with + # native `WarRoomTimelineEvent.id`. The frontend uses this as + # its `key` and drag-drop identity. + 'id': f'case:{event.case_id}:{event.event_id}', + 'war_room_source': 'case', + # Reuse the case event's UUID so the "copy share link" affordance + # produces a URL that also works when opened from the source case. + 'uuid': str(event.event_uuid) if event.event_uuid else '', + 'timeline_id': source_timeline_id, + # Parent traversal not projected across the boundary — a case's + # parent event might not be on the timelines the user selected, + # so leaving `parent_id=None` here means the war-room tree view + # shows projected events as roots. Reasonable default. + 'parent_id': None, + 'case_id': event.case_id, + 'event_id': event.event_id, + 'title': event.event_title, + 'content': event.event_content, + 'raw': event.event_raw, + 'source': event.event_source, + 'tags': event.event_tags, + 'is_flagged': bool(event.event_is_flagged), + 'event_date': event.event_date.isoformat() if event.event_date else None, + 'event_tz': event.event_tz, + 'color': event.event_color, + # Case events use a M2M `case_events_category` join rather than a + # column — surface as `None` here so the card component's + # existing `category` handling still works. If we ever want the + # category badge on projected events we can enrich this later + # without changing the shape. + 'category': None, + 'modification_history': event.modification_history, + 'assets': asset_ids, + 'iocs': ioc_ids, + 'children_count': children_count, + 'created_at': event.event_added.isoformat() if event.event_added else None, + 'created_by_id': event.user_id, + } diff --git a/source/app/business/war_room_note_folders.py b/source/app/business/war_room_note_folders.py new file mode 100644 index 000000000..165fc2332 --- /dev/null +++ b/source/app/business/war_room_note_folders.py @@ -0,0 +1,117 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Business layer for the war-room notes folder tree. + +Mirror of `app/business/notes_directories.py`. Recursive delete lives +in `datamgmt/war_rooms/war_room_notes_db.py`; this file is a thin +CRUD/activity wrapper. Ancestry-walking cycle detection is provided so +the schema validator can reject move-into-descendant before any commit. +""" + +from datetime import datetime +from typing import Optional + +from app.datamgmt.war_rooms.war_room_notes_db import delete_folder +from app.datamgmt.war_rooms.war_room_notes_db import get_folder +from app.datamgmt.war_rooms.war_room_notes_db import paginate_folders +from app.db import db +from app.iris_engine.utils.tracker import track_activity +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.models.pagination_parameters import PaginationParameters +from app.models.war_rooms import WarRoomNoteFolder + + +def war_room_note_folders_filter(war_room_id: int, + pagination_parameters: PaginationParameters): + return paginate_folders(war_room_id, pagination_parameters) + + +def war_room_note_folders_get(identifier: int) -> WarRoomNoteFolder: + folder = get_folder(identifier) + if folder is None: + raise ObjectNotFoundError() + return folder + + +def war_room_note_folders_create(folder: WarRoomNoteFolder) -> WarRoomNoteFolder: + db.session.add(folder) + db.session.commit() + track_activity( + f'created war-room note folder "{folder.name}"', + war_room_id=folder.war_room_id, + ) + return folder + + +def war_room_note_folders_update(folder: WarRoomNoteFolder) -> WarRoomNoteFolder: + folder.updated_at = datetime.utcnow() + db.session.commit() + track_activity( + f'updated war-room note folder "{folder.name}"', + war_room_id=folder.war_room_id, + ) + return folder + + +def war_room_note_folders_delete(folder: WarRoomNoteFolder) -> None: + war_room_id = folder.war_room_id + name = folder.name + delete_folder(folder) + db.session.commit() + track_activity( + f'deleted war-room note folder "{name}"', + war_room_id=war_room_id, + ) + + +def verify_parent_folder(parent_id: Optional[int], war_room_id: int, + current_id: Optional[int] = None) -> Optional[int]: + """Validate a `parent_id` for a folder that lives in `war_room_id`. + + Returns the parent id unchanged when valid, raises + `BusinessProcessingError` otherwise. + + * `parent_id=None` → root-level (always fine). + * `current_id=None` → creating a fresh folder (only the parent's + war-room scope needs checking). + * `current_id` set → rename/move; walk the parent's ancestry to + make sure we're not moving the folder under one of its own + descendants (that's the cycle the case-notes validator misses). + + This helper is deliberately loud on failure — schema validators + catch `BusinessProcessingError` and turn it into a 400. + """ + if parent_id is None: + return None + + parent = WarRoomNoteFolder.query.filter_by(id=parent_id).first() + if parent is None or parent.war_room_id != war_room_id: + raise BusinessProcessingError('Invalid parent folder id') + + if current_id is None: + return parent_id + + if int(parent_id) == int(current_id): + raise BusinessProcessingError('Folder cannot be its own parent') + + # Walk parent -> grandparent -> ... up to root; if we hit the folder + # we're editing at any depth, the move would create a cycle. + cursor = parent + seen = set() + while cursor is not None: + if cursor.id in seen: + # Defensive: shouldn't happen given the FK cycle check, but + # if the DB is somehow inconsistent we don't want an infinite + # loop under a validation call. + raise BusinessProcessingError('Cycle detected in folder tree') + seen.add(cursor.id) + if cursor.id == int(current_id): + raise BusinessProcessingError( + 'Folder cannot be moved into one of its own descendants' + ) + cursor = cursor.parent + + return parent_id diff --git a/source/app/business/war_room_notes.py b/source/app/business/war_room_notes.py index 0bc677061..5437ac92c 100644 --- a/source/app/business/war_room_notes.py +++ b/source/app/business/war_room_notes.py @@ -6,12 +6,18 @@ import datetime +from app.datamgmt.war_rooms.war_room_notes_db import count_revisions +from app.datamgmt.war_rooms.war_room_notes_db import get_folder +from app.datamgmt.war_rooms.war_room_notes_db import get_revision +from app.datamgmt.war_rooms.war_room_notes_db import list_revisions +from app.datamgmt.war_rooms.war_room_notes_db import write_revision from app.db import db from app.iris_engine.module_handler.module_handler import call_modules_hook from app.iris_engine.utils.tracker import track_activity from app.models.errors import BusinessProcessingError from app.models.errors import ObjectNotFoundError from app.models.war_rooms import WarRoomNote +from app.models.war_rooms import WarRoomNoteRevision def _validate_title(title): @@ -20,6 +26,59 @@ def _validate_title(title): return title.strip()[:512] +def _fire_mention_notifications(note, actor_id, is_update): + """Notify war-room members mentioned in a note. + + Silent on failure — a broken notification pipeline must not fail a + note write.""" + try: + from app.iris_engine.notifications.mentions import resolve_mentions_to_user_ids + from app.iris_engine.notifications.service import notify_many + from app.models.war_rooms import WarRoomMember + + mentioned = resolve_mentions_to_user_ids(note.content, note.war_room_id) + if not mentioned: + return + + member_ids = { + row.user_id for row in + WarRoomMember.query + .filter(WarRoomMember.war_room_id == note.war_room_id) + .filter(WarRoomMember.user_id.in_(mentioned)) + .all() + } + if not member_ids: + return + + verb = 'updated' if is_update else 'created' + notify_many( + user_ids=list(member_ids), + event_type='mention', + title=f'You were mentioned in a war-room note', + body=f'{verb}: {note.title}', + link=f'/war-rooms/{note.war_room_id}/notes?note={note.note_id}', + source_type='war_room_note', + source_id=note.note_id, + exclude_user_ids=[actor_id] if actor_id else [], + ) + except Exception: + import logging + logging.getLogger(__name__).exception( + 'war-room note mention notification failed') + + +def _resolve_folder_id(war_room_id, folder_id): + """Coerce a folder-id payload to `None | int` and enforce that the + folder (if given) belongs to the same war room. Keeps notes from + leaking across war rooms via a spoofed `folder_id`.""" + if folder_id is None: + return None + folder = get_folder(int(folder_id)) + if folder is None or folder.war_room_id != war_room_id: + raise BusinessProcessingError('Invalid folder id for this war room') + return folder.id + + def war_room_note_list(war_room_id): return ( WarRoomNote.query @@ -38,32 +97,57 @@ def war_room_note_get(war_room_id, note_id): return row -def war_room_note_create(war_room_id, title, content=None, created_by_id=None): +def war_room_note_create(war_room_id, title, content=None, + folder_id=None, created_by_id=None): title = _validate_title(title) + resolved_folder = _resolve_folder_id(war_room_id, folder_id) note = WarRoomNote() note.war_room_id = war_room_id note.title = title note.content = content + note.folder_id = resolved_folder note.created_by_id = created_by_id note.updated_by_id = created_by_id db.session.add(note) + db.session.flush() + # Seed revision #1 so history is complete from the first save. Any + # later dedup skip on empty edits is fine — we've captured the baseline. + write_revision(created_by_id, note) db.session.commit() track_activity(f'created war room note "{note.title}"', war_room_id=war_room_id) + _fire_mention_notifications(note, created_by_id, is_update=False) note = call_modules_hook('on_postload_war_room_note_create', note) return note +_UNSET = object() + + def war_room_note_update(war_room_id, note_id, title=None, content=None, - updated_by_id=None): + folder_id=_UNSET, updated_by_id=None): + """Update a note. `folder_id` uses a sentinel default so callers can + distinguish "don't change the folder" (omit) from "move to root" + (pass `None` explicitly).""" note = war_room_note_get(war_room_id, note_id) + prior_content = note.content if title is not None: note.title = _validate_title(title) if content is not None: note.content = content + if folder_id is not _UNSET: + note.folder_id = _resolve_folder_id(war_room_id, folder_id) note.updated_at = datetime.datetime.utcnow() note.updated_by_id = updated_by_id + # Snapshot before commit — the dedup inside `write_revision` skips + # no-op saves so pure metadata edits (folder move) don't create + # revisions with identical title+content. + write_revision(updated_by_id, note) db.session.commit() track_activity(f'updated war room note "{note.title}"', war_room_id=war_room_id) + # Only fire when the body actually changed — pure title / folder edits + # shouldn't re-page every mentioned user. + if content is not None and content != prior_content: + _fire_mention_notifications(note, updated_by_id, is_update=True) note = call_modules_hook('on_postload_war_room_note_update', note) return note @@ -76,3 +160,62 @@ def war_room_note_delete(war_room_id, note_id): track_activity(f'deleted war room note "{title}"', war_room_id=war_room_id) call_modules_hook('on_postload_war_room_note_delete', {'war_room_id': war_room_id, 'note_id': note_id}) + + +def war_room_note_list_revisions(war_room_id, note_id): + """Return revision-row tuples for the note. Access is gated at the + route layer via `require_war_room_read`; we still re-check the note + belongs to the war room so a caller can't enumerate revisions + across war rooms by note id.""" + war_room_note_get(war_room_id, note_id) # raises if cross-tenant + return list_revisions(note_id) + + +def war_room_note_get_revision(war_room_id, note_id, + revision_number) -> WarRoomNoteRevision: + war_room_note_get(war_room_id, note_id) + revision = get_revision(note_id, revision_number) + if revision is None: + raise ObjectNotFoundError() + return revision + + +def war_room_note_delete_revision(war_room_id, note_id, revision_number): + war_room_note_get(war_room_id, note_id) + revision = get_revision(note_id, revision_number) + if revision is None: + raise ObjectNotFoundError() + # Keep at least one revision so history never shows "no versions + # ever existed" for a note that was clearly created and edited. + if count_revisions(note_id) <= 1: + raise BusinessProcessingError('Cannot delete the only revision of a note') + db.session.delete(revision) + db.session.commit() + track_activity( + f'deleted revision #{revision_number} of note {note_id}', + war_room_id=war_room_id, + ) + + +def war_room_note_restore_revision(war_room_id, note_id, revision_number, + updated_by_id=None) -> WarRoomNote: + """Snapshot the current note state as a NEW revision, then overwrite + the note with the target revision. Restore is undoable — the state + just before restore is preserved as the newest revision.""" + note = war_room_note_get(war_room_id, note_id) + revision = get_revision(note_id, revision_number) + if revision is None: + raise ObjectNotFoundError() + + write_revision(updated_by_id, note) + + note.title = revision.title + note.content = revision.content + note.updated_at = datetime.datetime.utcnow() + note.updated_by_id = updated_by_id + db.session.commit() + track_activity( + f'restored revision #{revision_number} of note "{note.title}"', + war_room_id=war_room_id, + ) + return note diff --git a/source/app/business/war_room_tasks.py b/source/app/business/war_room_tasks.py index acaddb765..119ab2444 100644 --- a/source/app/business/war_room_tasks.py +++ b/source/app/business/war_room_tasks.py @@ -8,10 +8,18 @@ optionally point at a source case (and a source case task) so the operator can promote a per-case task into a war-room-level coordination item without losing the link. + +Task management extensions (subtasks, status via task_status, tags, +search): parents can have children but children cannot; the tags +column is free-form comma-separated to match the rest of Iris; the +status column reuses the shared `task_status` taxonomy. """ import datetime +from sqlalchemy import func, or_ +from sqlalchemy.orm import aliased + from app.db import db from app.iris_engine.module_handler.module_handler import call_modules_hook from app.iris_engine.utils.tracker import track_activity @@ -23,6 +31,42 @@ _TITLE_MAX_LEN = 1024 +# Cache for the once-per-process check of whether the `parent_task_id` +# column exists on the live database. Same rolling-upgrade rationale +# as `_threads_supported` in war_room_chat: an install that hasn't +# applied the subtasks migration should still be able to render the +# tasks page; subtasks features just go dark until the migration +# lands. +_SUBTASKS_SUPPORTED = None + + +def _subtasks_supported(): + global _SUBTASKS_SUPPORTED + if _SUBTASKS_SUPPORTED is True: + return True + try: + from sqlalchemy import text as _text + with db.engine.connect() as conn: + conn.execute( + _text('SELECT parent_task_id FROM war_room_task LIMIT 0') + ) + supported = True + except Exception as e: + from app.logger import logger + pgcode = getattr(getattr(e, 'orig', None), 'pgcode', None) + if pgcode == '42703': + logger.info('Subtasks disabled: parent_task_id column missing') + else: + logger.exception( + 'Subtasks support probe failed unexpectedly (pgcode=%s)', + pgcode, + ) + return False + if supported: + _SUBTASKS_SUPPORTED = True + return supported + + def _validate_title(title): if not isinstance(title, str): raise BusinessProcessingError('Task title must be a string') @@ -36,51 +80,197 @@ def _validate_title(title): return stripped -def war_room_task_list(war_room_id): - """List tasks on a war room with assignee / creator / closer joined. +def _normalize_tags(tags): + """Trim + de-duplicate a comma-separated tag string. + + Accepts either a list or a comma-separated string; always returns + a comma-separated string (or None if empty). Preserves user order + on the first occurrence of each tag. + """ + if tags is None: + return None + if isinstance(tags, list): + items = tags + elif isinstance(tags, str): + items = tags.split(',') + else: + raise BusinessProcessingError('tags must be a string or list') + seen = set() + out = [] + for raw in items: + if not isinstance(raw, str): + continue + t = raw.strip() + if not t: + continue + key = t.lower() + if key in seen: + continue + seen.add(key) + out.append(t) + return ','.join(out) if out else None + + +def _base_task_query(war_room_id): + """Build the joined-row query used by list/get-with-actors. Three independent outer-joins on `User` (aliased) so a single row - carries the display name for every actor — the SPA shows them as - "<assignee> · created by <creator>" without a per-row roundtrip. + carries the display name for every actor. Also joins `TaskStatus` + so the SPA can render the status pill without a per-row lookup. """ from app.models.authorization import User - from sqlalchemy.orm import aliased + from app.models.models import TaskStatus Assignee = aliased(User) Creator = aliased(User) Closer = aliased(User) - rows = ( - db.session.query( - WarRoomTask.task_id, - WarRoomTask.war_room_id, - WarRoomTask.title, - WarRoomTask.description, - WarRoomTask.status_id, - WarRoomTask.assignee_id, - WarRoomTask.due_at, - WarRoomTask.source_case_id, - WarRoomTask.source_case_task_id, - WarRoomTask.created_at, - WarRoomTask.created_by_id, - WarRoomTask.closed_at, - WarRoomTask.closed_by_id, - WarRoomTask.tags, - Assignee.user.label('assignee_login'), - Assignee.name.label('assignee_name'), - Creator.user.label('created_by_login'), - Creator.name.label('created_by_name'), - Closer.user.label('closed_by_login'), - Closer.name.label('closed_by_name'), - ) + columns = [ + WarRoomTask.task_id, + WarRoomTask.war_room_id, + WarRoomTask.title, + WarRoomTask.description, + WarRoomTask.status_id, + WarRoomTask.assignee_id, + WarRoomTask.due_at, + WarRoomTask.source_case_id, + WarRoomTask.source_case_task_id, + WarRoomTask.created_at, + WarRoomTask.created_by_id, + WarRoomTask.closed_at, + WarRoomTask.closed_by_id, + WarRoomTask.tags, + Assignee.user.label('assignee_login'), + Assignee.name.label('assignee_name'), + Creator.user.label('created_by_login'), + Creator.name.label('created_by_name'), + Closer.user.label('closed_by_login'), + Closer.name.label('closed_by_name'), + TaskStatus.status_name.label('status_name'), + TaskStatus.status_bscolor.label('status_bscolor'), + ] + # parent_task_id is only exposed if the migration has landed; on + # pre-migration DBs we return NULL so downstream serialisers see + # a consistent shape. + if _subtasks_supported(): + columns.append(WarRoomTask.parent_task_id.label('parent_task_id')) + + q = ( + db.session.query(*columns) .outerjoin(Assignee, Assignee.id == WarRoomTask.assignee_id) .outerjoin(Creator, Creator.id == WarRoomTask.created_by_id) .outerjoin(Closer, Closer.id == WarRoomTask.closed_by_id) + .outerjoin(TaskStatus, TaskStatus.id == WarRoomTask.status_id) .filter(WarRoomTask.war_room_id == war_room_id) - .order_by(WarRoomTask.created_at.desc()) - .all() ) - return rows + return q + + +def war_room_task_list(war_room_id, q=None, status_ids=None, tags=None, + assignee_ids=None, parent_task_id=None, + due_from=None, due_to=None, include_no_due=True, + include_closed=True, page=None, per_page=None): + """List tasks with optional search + filters. + + All filters are AND-combined. `q` matches title or description + case-insensitively. `tags` is a list of tag strings; a task matches + if any of its comma-separated tags matches (case-insensitive + substring on the CSV, bracketed by commas so "foo" doesn't match + "foobar"). `assignee_ids` can include `0` to mean Unassigned. + `parent_task_id`: pass `0`/`None` for top-level only via the flag + on the REST layer — this helper simply forwards the value; use + `-1` to include everything (no parent filter). + + Pagination: when `page` is provided, return a dict envelope with + `total`, `data`, `last_page`, `current_page`, `next_page`. When + `page` is None, return the raw row list (back-compat for callers + that want everything at once). + + Due-date filter semantics: rows are kept if their `due_at` falls + within `[due_from, due_to]` (either endpoint may be None to make + that side open-ended). Rows with no due date are kept when + `include_no_due=True`, so a filter like "due this week" doesn't + silently drop the untriaged backlog. + """ + query = _base_task_query(war_room_id) + + if q: + needle = f'%{q.strip().lower()}%' + query = query.filter(or_( + func.lower(WarRoomTask.title).like(needle), + func.lower(func.coalesce(WarRoomTask.description, '')).like(needle), + )) + + if status_ids: + query = query.filter(WarRoomTask.status_id.in_(status_ids)) + + if assignee_ids: + conds = [] + real_ids = [aid for aid in assignee_ids if aid and aid != 0] + if 0 in assignee_ids or None in assignee_ids: + conds.append(WarRoomTask.assignee_id.is_(None)) + if real_ids: + conds.append(WarRoomTask.assignee_id.in_(real_ids)) + if conds: + query = query.filter(or_(*conds)) + + if tags: + tag_conds = [] + # Match whole-tag: bracket the CSV with commas so the needle + # ",foo," can't match a substring of ",foobar,". Portable + # across Postgres via `func.concat`. + tag_expr = func.lower( + func.concat(',', func.coalesce(WarRoomTask.tags, ''), ',') + ) + for t in tags: + if not isinstance(t, str) or not t.strip(): + continue + needle = f'%,{t.strip().lower()},%' + tag_conds.append(tag_expr.like(needle)) + if tag_conds: + query = query.filter(or_(*tag_conds)) + + if parent_task_id is not None and parent_task_id != -1: + if parent_task_id == 0 and _subtasks_supported(): + query = query.filter(WarRoomTask.parent_task_id.is_(None)) + elif _subtasks_supported(): + query = query.filter( + WarRoomTask.parent_task_id == parent_task_id + ) + + if due_from is not None or due_to is not None: + range_conds = [] + if due_from is not None and due_to is not None: + range_conds.append(WarRoomTask.due_at.between(due_from, due_to)) + elif due_from is not None: + range_conds.append(WarRoomTask.due_at >= due_from) + else: + range_conds.append(WarRoomTask.due_at <= due_to) + if include_no_due: + range_conds.append(WarRoomTask.due_at.is_(None)) + query = query.filter(or_(*range_conds)) + + if not include_closed: + query = query.filter(WarRoomTask.closed_at.is_(None)) + + query = query.order_by(WarRoomTask.created_at.desc()) + + if page is None: + return query.all() + + per_page = max(1, min(int(per_page or 25), 200)) + page = max(1, int(page)) + total = query.count() + rows = query.offset((page - 1) * per_page).limit(per_page).all() + last_page = max(1, (total + per_page - 1) // per_page) + next_page = page + 1 if page < last_page else None + return { + 'total': total, + 'data': rows, + 'last_page': last_page, + 'current_page': page, + 'next_page': next_page, + } def war_room_task_get(war_room_id, task_id): @@ -92,11 +282,84 @@ def war_room_task_get(war_room_id, task_id): return row +def _resolve_parent(war_room_id, parent_task_id): + """Fetch + validate a candidate parent task. + + A parent must (1) exist, (2) live in the same war room, and + (3) not itself be a subtask (single-level tree). Returns the + parent row or raises BusinessProcessingError. + """ + if parent_task_id is None: + return None + if not _subtasks_supported(): + raise BusinessProcessingError( + 'Subtasks are not available on this database yet' + ) + parent = WarRoomTask.query.filter_by( + war_room_id=war_room_id, task_id=parent_task_id + ).first() + if parent is None: + raise BusinessProcessingError('Parent task not found') + if parent.parent_task_id is not None: + raise BusinessProcessingError( + 'Cannot nest subtasks more than one level deep' + ) + return parent + + +def _fire_mention_notifications(task, actor_id, is_update): + """Notify war-room members mentioned in a task's title or description. + + Silent on failure — a broken notification pipeline must not fail a + task write. + """ + try: + from app.iris_engine.notifications.mentions import resolve_mentions_to_user_ids + from app.iris_engine.notifications.service import notify_many + from app.models.war_rooms import WarRoomMember + + # Mentions in either the title or the body — description carries + # the TipTap HTML for the mention span; title is plain text but + # extract works safely on either (yields empty set for pure text). + combined = ' '.join(filter(None, [task.title, task.description])) + mentioned = resolve_mentions_to_user_ids(combined, task.war_room_id) + if not mentioned: + return + + member_ids = { + row.user_id for row in + WarRoomMember.query + .filter(WarRoomMember.war_room_id == task.war_room_id) + .filter(WarRoomMember.user_id.in_(mentioned)) + .all() + } + if not member_ids: + return + + verb = 'updated' if is_update else 'created' + notify_many( + user_ids=list(member_ids), + event_type='mention', + title=f'You were mentioned in a war-room task', + body=f'{verb}: {task.title}', + link=f'/war-rooms/{task.war_room_id}/tasks?task={task.task_id}', + source_type='war_room_task', + source_id=task.task_id, + exclude_user_ids=[actor_id] if actor_id else [], + ) + except Exception: + import logging + logging.getLogger(__name__).exception( + 'war-room task mention notification failed') + + def war_room_task_create(war_room_id, title, description=None, status_id=None, assignee_id=None, due_at=None, source_case_id=None, source_case_task_id=None, - tags=None, created_by_id=None): + tags=None, parent_task_id=None, + created_by_id=None): title = _validate_title(title) + _resolve_parent(war_room_id, parent_task_id) task = WarRoomTask() task.war_room_id = war_room_id task.title = title @@ -106,25 +369,58 @@ def war_room_task_create(war_room_id, title, description=None, task.due_at = due_at task.source_case_id = source_case_id task.source_case_task_id = source_case_task_id - task.tags = tags + task.tags = _normalize_tags(tags) task.created_by_id = created_by_id + if _subtasks_supported(): + task.parent_task_id = parent_task_id db.session.add(task) db.session.commit() track_activity(f'created war room task "{task.title}"', war_room_id=war_room_id) + _fire_mention_notifications(task, created_by_id, is_update=False) task = call_modules_hook('on_postload_war_room_task_create', task) return task def war_room_task_update(war_room_id, task_id, **fields): + # `updated_by_id` is metadata about the actor, not a column on the + # task row. Pop it up front so the setattr loop below doesn't try + # to write it back to the DB. + updated_by_id = fields.pop('updated_by_id', None) task = war_room_task_get(war_room_id, task_id) + prior_title = task.title + prior_description = task.description if 'title' in fields and fields['title'] is not None: task.title = _validate_title(fields['title']) + if 'parent_task_id' in fields: + new_parent_id = fields.pop('parent_task_id') + if new_parent_id == task.task_id: + raise BusinessProcessingError('A task cannot be its own parent') + if new_parent_id is not None and _subtasks_supported(): + # If this task already has subtasks, it cannot itself + # become a child — that would break the single-level rule. + has_children = db.session.query( + WarRoomTask.query.filter_by( + parent_task_id=task.task_id + ).exists() + ).scalar() + if has_children: + raise BusinessProcessingError( + 'Cannot demote a task with subtasks into a subtask' + ) + _resolve_parent(war_room_id, new_parent_id) + if _subtasks_supported(): + task.parent_task_id = new_parent_id for f in ('description', 'status_id', 'assignee_id', 'due_at', - 'source_case_id', 'source_case_task_id', 'tags'): + 'source_case_id', 'source_case_task_id'): if f in fields: setattr(task, f, fields[f]) + if 'tags' in fields: + task.tags = _normalize_tags(fields['tags']) db.session.commit() track_activity(f'updated war room task "{task.title}"', war_room_id=war_room_id) + # Fire only when the mention-carrying fields changed. + if task.title != prior_title or task.description != prior_description: + _fire_mention_notifications(task, updated_by_id, is_update=True) task = call_modules_hook('on_postload_war_room_task_update', task) return task @@ -157,3 +453,30 @@ def war_room_task_delete(war_room_id, task_id): track_activity(f'deleted war room task "{title}"', war_room_id=war_room_id) call_modules_hook('on_postload_war_room_task_delete', {'war_room_id': war_room_id, 'task_id': task_id}) + + +def war_room_task_used_tags(war_room_id): + """Return the distinct set of tags already used on this room's tasks. + + Powers autocomplete on the tag input. Case is preserved on first + occurrence. + """ + rows = ( + db.session.query(WarRoomTask.tags) + .filter( + WarRoomTask.war_room_id == war_room_id, + WarRoomTask.tags.isnot(None), + WarRoomTask.tags != '', + ) + .all() + ) + seen = {} + for (raw,) in rows: + for t in (raw or '').split(','): + t = t.strip() + if not t: + continue + key = t.lower() + if key not in seen: + seen[key] = t + return sorted(seen.values(), key=lambda s: s.lower()) diff --git a/source/app/business/war_room_teams.py b/source/app/business/war_room_teams.py new file mode 100644 index 000000000..fb1a590a0 --- /dev/null +++ b/source/app/business/war_room_teams.py @@ -0,0 +1,241 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. + +"""Business layer for per-war-room teams. + +Teams are named groupings of war-room members used as @-mention +targets in chat, threads, notes, and tasks. Team scope is the war room: +name uniqueness, membership add/remove, and cascade delete all key off +`war_room_id`. +""" + +import re + +from app.db import db +from app.iris_engine.utils.tracker import track_activity +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.models.war_rooms import ( + WarRoomMember, + WarRoomTeam, + WarRoomTeamMember, +) + + +_NAME_MAX_LEN = 80 +_HEX_COLOR_RE = re.compile(r'^#[0-9a-fA-F]{6}$') + + +def _validate_name(name): + if not isinstance(name, str): + raise BusinessProcessingError('Team name must be a string') + stripped = name.strip() + if not stripped: + raise BusinessProcessingError('Team name is required') + if len(stripped) > _NAME_MAX_LEN: + raise BusinessProcessingError( + f'Team name must be at most {_NAME_MAX_LEN} characters' + ) + return stripped + + +def _validate_color(color): + if color is None or color == '': + return None + if not isinstance(color, str) or not _HEX_COLOR_RE.match(color): + raise BusinessProcessingError('Color must be a hex string like #RRGGBB') + return color + + +def war_room_team_list(war_room_id): + return ( + WarRoomTeam.query + .filter(WarRoomTeam.war_room_id == war_room_id) + .order_by(WarRoomTeam.name.asc()) + .all() + ) + + +def war_room_team_get(war_room_id, team_id): + team = ( + WarRoomTeam.query + .filter(WarRoomTeam.war_room_id == war_room_id) + .filter(WarRoomTeam.team_id == team_id) + .first() + ) + if team is None: + raise ObjectNotFoundError('Team not found') + return team + + +def war_room_team_create(war_room_id, name, description=None, color=None, + created_by_id=None): + name = _validate_name(name) + color = _validate_color(color) + + existing = ( + WarRoomTeam.query + .filter(WarRoomTeam.war_room_id == war_room_id) + .filter(WarRoomTeam.name == name) + .first() + ) + if existing is not None: + raise BusinessProcessingError('A team with that name already exists') + + team = WarRoomTeam( + war_room_id=war_room_id, + name=name, + description=(description or None), + color=color, + created_by_id=created_by_id, + ) + db.session.add(team) + db.session.commit() + track_activity( + f'Created war-room team "{team.name}"', + user_input=False, + ) + return team + + +def war_room_team_update(war_room_id, team_id, name=None, description=None, + color=None): + team = war_room_team_get(war_room_id, team_id) + + if name is not None: + new_name = _validate_name(name) + if new_name != team.name: + collision = ( + WarRoomTeam.query + .filter(WarRoomTeam.war_room_id == war_room_id) + .filter(WarRoomTeam.name == new_name) + .filter(WarRoomTeam.team_id != team_id) + .first() + ) + if collision is not None: + raise BusinessProcessingError('A team with that name already exists') + team.name = new_name + + if description is not None: + team.description = description or None + + if color is not None: + team.color = _validate_color(color) + + db.session.commit() + return team + + +def war_room_team_delete(war_room_id, team_id): + team = war_room_team_get(war_room_id, team_id) + db.session.delete(team) + db.session.commit() + + +def war_room_team_members_list(war_room_id, team_id): + # Validate scope: team must belong to this war room. + war_room_team_get(war_room_id, team_id) + return ( + WarRoomTeamMember.query + .filter(WarRoomTeamMember.team_id == team_id) + .all() + ) + + +def war_room_team_member_add(war_room_id, team_id, user_id, added_by_id=None): + """Add a user to a team; auto-add them to the war room if needed. + + A user who isn't a `WarRoomMember` of this room gets added as + `responder` with `full_access` first, then joined to the team. + Same transaction envelope as the pure team-add path. + + Returns a `(row, auto_added_as_room_member)` tuple so the API layer + can echo "user was also added to the war room as responder" back to + the caller. + """ + war_room_team_get(war_room_id, team_id) + + if not isinstance(user_id, int): + raise BusinessProcessingError('user_id is required') + + # Auto-provision war-room membership if missing. This keeps the + # invariant that every team member is also a room member — the + # CASCADE on WarRoomTeamMember.user_id still cleans up if the user + # is later removed from the room. + room_member = ( + WarRoomMember.query + .filter(WarRoomMember.war_room_id == war_room_id) + .filter(WarRoomMember.user_id == user_id) + .first() + ) + auto_added_room_member = False + if room_member is None: + # Local import to avoid a module-load import loop: + # war_rooms → war_rooms_access → (already loaded), and this + # module is imported by REST init, which happens before + # war_rooms in some test paths. + from app.business.war_rooms import war_room_add_member + war_room_add_member( + war_room_id, user_id, + role='responder', + added_by_id=added_by_id, + ) + auto_added_room_member = True + + existing = ( + WarRoomTeamMember.query + .filter(WarRoomTeamMember.team_id == team_id) + .filter(WarRoomTeamMember.user_id == user_id) + .first() + ) + if existing is not None: + return existing, auto_added_room_member + + row = WarRoomTeamMember( + team_id=team_id, + user_id=user_id, + added_by_id=added_by_id, + ) + db.session.add(row) + db.session.commit() + return row, auto_added_room_member + + +def war_room_team_member_remove(war_room_id, team_id, user_id): + war_room_team_get(war_room_id, team_id) + row = ( + WarRoomTeamMember.query + .filter(WarRoomTeamMember.team_id == team_id) + .filter(WarRoomTeamMember.user_id == user_id) + .first() + ) + if row is None: + raise ObjectNotFoundError('Team member not found') + db.session.delete(row) + db.session.commit() + + +def war_room_team_member_user_ids(war_room_id, team_ids): + """Return the union of `user_id`s across the given teams. + + Used by the notification pipeline to expand a set of @-team mentions + into the set of users to notify. Teams outside the given war room + are silently ignored — a scope leak here would notify people who + can't read the source content. + """ + if not team_ids: + return set() + rows = ( + db.session.query(WarRoomTeamMember.user_id) + .join(WarRoomTeam, WarRoomTeam.team_id == WarRoomTeamMember.team_id) + .filter(WarRoomTeam.war_room_id == war_room_id) + .filter(WarRoomTeam.team_id.in_(list(team_ids))) + .all() + ) + return {r.user_id for r in rows} diff --git a/source/app/business/war_room_timelines.py b/source/app/business/war_room_timelines.py index 7e521a1b4..76d36f95e 100644 --- a/source/app/business/war_room_timelines.py +++ b/source/app/business/war_room_timelines.py @@ -11,14 +11,23 @@ """ import re +from typing import Iterable +from typing import List +from typing import Optional + +from sqlalchemy import func from app.db import db from app.iris_engine.module_handler.module_handler import call_modules_hook from app.iris_engine.utils.tracker import track_activity +from app.models.assets import CaseAssets from app.models.errors import BusinessProcessingError from app.models.errors import ObjectNotFoundError +from app.models.iocs import Ioc from app.models.war_rooms import WarRoomTimeline from app.models.war_rooms import WarRoomTimelineEvent +from app.models.war_rooms import WarRoomTimelineEventAsset +from app.models.war_rooms import WarRoomTimelineEventIoc _NAME_MAX_LEN = 128 @@ -151,9 +160,24 @@ def list_timeline_events(war_room_id, timeline_ids=None): WarRoomTimelineEvent.id.asc()).all() +def _validate_category(category): + if category is None or category == '': + return None + if not isinstance(category, str): + raise BusinessProcessingError('Category must be a string') + stripped = category.strip() + if len(stripped) > _CATEGORY_MAX_LEN: + raise BusinessProcessingError( + f'Category must be at most {_CATEGORY_MAX_LEN} characters' + ) + return stripped or None + + def create_timeline_event(war_room_id, timeline_id, title=None, content=None, event_date=None, event_tz=None, color=None, category=None, case_id=None, event_id=None, + source=None, raw=None, tags=None, is_flagged=False, + parent_id=None, asset_ids=None, ioc_ids=None, created_by_id=None): timeline = get_timeline(war_room_id, timeline_id) if (case_id is None) != (event_id is None): @@ -165,15 +189,8 @@ def create_timeline_event(war_room_id, timeline_id, title=None, content=None, 'Provide either a case event reference or a title/content' ) color = _validate_color(color) - if category is not None: - if not isinstance(category, str): - raise BusinessProcessingError('Category must be a string') - stripped = category.strip() - if len(stripped) > _CATEGORY_MAX_LEN: - raise BusinessProcessingError( - f'Category must be at most {_CATEGORY_MAX_LEN} characters' - ) - category = stripped or None + category = _validate_category(category) + row = WarRoomTimelineEvent() row.timeline_id = timeline.timeline_id row.case_id = case_id @@ -184,8 +201,24 @@ def create_timeline_event(war_room_id, timeline_id, title=None, content=None, row.event_tz = event_tz row.color = color row.category = category + row.source = source + row.raw = raw + row.tags = tags + row.is_flagged = bool(is_flagged) + if parent_id is not None: + # Parent must belong to the same war room — enforce here so a + # cross-tenant parent id can't sneak in via the create payload. + _get_event(war_room_id, parent_id) + row.parent_id = parent_id row.created_by_id = created_by_id db.session.add(row) + db.session.flush() # obtain row.id before hooking up joins + + if asset_ids: + _set_asset_links(row.id, asset_ids) + if ioc_ids: + _set_ioc_links(row.id, ioc_ids) + db.session.commit() label = row.title or (f'case event #{event_id}' if event_id else 'event') track_activity(f'added timeline event "{label}"', war_room_id=war_room_id) @@ -209,12 +242,18 @@ def _get_event(war_room_id, event_id): def update_timeline_event(war_room_id, event_id, *, title=_UNSET, content=_UNSET, event_date=_UNSET, event_tz=_UNSET, color=_UNSET, - category=_UNSET, timeline_id=_UNSET): + category=_UNSET, timeline_id=_UNSET, + source=_UNSET, raw=_UNSET, tags=_UNSET, + is_flagged=_UNSET, parent_id=_UNSET, + asset_ids=_UNSET, ioc_ids=_UNSET): """Partial update for a war-room timeline event. Sentinel-based: a field passed as `_UNSET` is left untouched, while explicit `None` clears it. `timeline_id` is the drag-between-timelines - knob — validates the target belongs to the same war room. + knob — validates the target belongs to the same war room. The + `asset_ids` / `ioc_ids` lists (when passed) fully replace the row's + current asset / IOC associations — pass `[]` to detach everything, + omit to keep the existing set. """ row = _get_event(war_room_id, event_id) if timeline_id is not _UNSET and timeline_id != row.timeline_id: @@ -231,17 +270,27 @@ def update_timeline_event(war_room_id, event_id, *, title=_UNSET, content=_UNSET if color is not _UNSET: row.color = _validate_color(color) if category is not _UNSET: - if category is None or category == '': - row.category = None + row.category = _validate_category(category) + if source is not _UNSET: + row.source = source + if raw is not _UNSET: + row.raw = raw + if tags is not _UNSET: + row.tags = tags + if is_flagged is not _UNSET: + row.is_flagged = bool(is_flagged) + if parent_id is not _UNSET: + if parent_id is None: + row.parent_id = None else: - if not isinstance(category, str): - raise BusinessProcessingError('Category must be a string') - stripped = category.strip() - if len(stripped) > _CATEGORY_MAX_LEN: - raise BusinessProcessingError( - f'Category must be at most {_CATEGORY_MAX_LEN} characters' - ) - row.category = stripped or None + if parent_id == row.id: + raise BusinessProcessingError('An event cannot be its own parent') + _get_event(war_room_id, parent_id) # cross-tenant guard + row.parent_id = parent_id + if asset_ids is not _UNSET: + _set_asset_links(row.id, asset_ids or []) + if ioc_ids is not _UNSET: + _set_ioc_links(row.id, ioc_ids or []) db.session.commit() label = row.title or (f'case event #{row.event_id}' if row.event_id else f'event #{row.id}') track_activity(f'updated timeline event "{label}"', war_room_id=war_room_id) @@ -249,6 +298,149 @@ def update_timeline_event(war_room_id, event_id, *, title=_UNSET, content=_UNSET return row +def toggle_event_flag(war_room_id, event_id): + """Flip `is_flagged` on the event and return the new value. Kept as + its own endpoint so the frontend flag button doesn't need to know + the current state — one click, server toggles.""" + row = _get_event(war_room_id, event_id) + row.is_flagged = not row.is_flagged + db.session.commit() + label = row.title or f'event #{row.id}' + track_activity( + f'{"flagged" if row.is_flagged else "unflagged"} timeline event "{label}"', + war_room_id=war_room_id, + ) + return row + + +def duplicate_event(war_room_id, event_id, created_by_id=None): + """Shallow-copy an event onto the same timeline. Asset and IOC + associations are copied; comments intentionally aren't — a new + event starts its own conversation.""" + src = _get_event(war_room_id, event_id) + dup = WarRoomTimelineEvent() + dup.timeline_id = src.timeline_id + dup.case_id = src.case_id + dup.event_id = src.event_id + dup.title = f'{src.title} (copy)' if src.title else 'Untitled (copy)' + dup.content = src.content + dup.raw = src.raw + dup.source = src.source + dup.tags = src.tags + dup.is_flagged = src.is_flagged + dup.event_date = src.event_date + dup.event_tz = src.event_tz + dup.color = src.color + dup.category = src.category + dup.parent_id = src.parent_id + dup.created_by_id = created_by_id + db.session.add(dup) + db.session.flush() + + # Copy asset/IOC links so the duplicate feels like a "start-from-here" + # rather than a stripped skeleton. Comments intentionally left off. + src_asset_ids = [ + r.asset_id for r in + WarRoomTimelineEventAsset.query.filter_by(event_id=src.id).all() + ] + if src_asset_ids: + _set_asset_links(dup.id, src_asset_ids) + src_ioc_ids = [ + r.ioc_id for r in + WarRoomTimelineEventIoc.query.filter_by(event_id=src.id).all() + ] + if src_ioc_ids: + _set_ioc_links(dup.id, src_ioc_ids) + + db.session.commit() + track_activity( + f'duplicated timeline event "{src.title or f"#{src.id}"}"', + war_room_id=war_room_id, + ) + return dup + + +def _set_asset_links(event_id: int, asset_ids: Iterable[int]) -> None: + """Replace the event's asset associations. Idempotent — dedupes ids + on the way in and doesn't crash if the client passed a non-existent + asset id (silently skipped after the FK-integrity check).""" + wanted = {int(a) for a in asset_ids if a is not None} + WarRoomTimelineEventAsset.query.filter_by(event_id=event_id).delete() + if not wanted: + return + existing = { + row.asset_id for row in + CaseAssets.query.filter(CaseAssets.asset_id.in_(wanted)) + .with_entities(CaseAssets.asset_id).all() + } + for aid in wanted & existing: + db.session.add(WarRoomTimelineEventAsset( + event_id=event_id, asset_id=aid, + )) + + +def _set_ioc_links(event_id: int, ioc_ids: Iterable[int]) -> None: + """Replace the event's IOC associations. Same idempotent semantics + as `_set_asset_links`.""" + wanted = {int(i) for i in ioc_ids if i is not None} + WarRoomTimelineEventIoc.query.filter_by(event_id=event_id).delete() + if not wanted: + return + existing = { + row.ioc_id for row in + Ioc.query.filter(Ioc.ioc_id.in_(wanted)) + .with_entities(Ioc.ioc_id).all() + } + for iid in wanted & existing: + db.session.add(WarRoomTimelineEventIoc( + event_id=event_id, ioc_id=iid, + )) + + +def set_event_assets(war_room_id, event_id, asset_ids): + """Public wrapper — cross-war-room-check + replace-and-commit.""" + _get_event(war_room_id, event_id) + _set_asset_links(event_id, asset_ids or []) + db.session.commit() + + +def set_event_iocs(war_room_id, event_id, ioc_ids): + _get_event(war_room_id, event_id) + _set_ioc_links(event_id, ioc_ids or []) + db.session.commit() + + +# Read-side hydration ------------------------------------------------------ + +def event_asset_ids(event_id: int) -> List[int]: + return [ + r.asset_id for r in + WarRoomTimelineEventAsset.query + .filter_by(event_id=event_id) + .with_entities(WarRoomTimelineEventAsset.asset_id) + .all() + ] + + +def event_ioc_ids(event_id: int) -> List[int]: + return [ + r.ioc_id for r in + WarRoomTimelineEventIoc.query + .filter_by(event_id=event_id) + .with_entities(WarRoomTimelineEventIoc.ioc_id) + .all() + ] + + +def event_children_count(event_id: int) -> int: + return ( + db.session.query(func.count(WarRoomTimelineEvent.id)) + .filter(WarRoomTimelineEvent.parent_id == event_id) + .scalar() + or 0 + ) + + def delete_timeline_event(war_room_id, event_id): row = _get_event(war_room_id, event_id) label = row.title or (f'case event #{row.event_id}' if row.event_id else f'event #{row.id}') diff --git a/source/app/datamgmt/war_rooms/__init__.py b/source/app/datamgmt/war_rooms/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/source/app/datamgmt/war_rooms/war_room_notes_db.py b/source/app/datamgmt/war_rooms/war_room_notes_db.py new file mode 100644 index 000000000..2e03b5407 --- /dev/null +++ b/source/app/datamgmt/war_rooms/war_room_notes_db.py @@ -0,0 +1,134 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Persistence helpers for the war-room notes folder tree. + +Kept thin — the business layer wraps these with activity tracking and +module hooks. This module owns the recursive-delete walk and the +revision-write dedup logic, mirroring `app/datamgmt/case/case_notes_db.py` +so the two systems evolve in parallel.""" + +from datetime import datetime +from typing import List + +from app.datamgmt.db_operations import db_create +from app.datamgmt.db_operations import db_delete +from app.datamgmt.filtering import paginate +from app.db import db +from app.models.pagination_parameters import PaginationParameters +from app.models.war_rooms import WarRoomNote +from app.models.war_rooms import WarRoomNoteFolder +from app.models.war_rooms import WarRoomNoteRevision + + +def get_note(war_room_id: int, note_id: int) -> WarRoomNote: + return WarRoomNote.query.filter_by( + war_room_id=war_room_id, note_id=note_id + ).first() + + +def get_folder(folder_id: int) -> WarRoomNoteFolder: + return WarRoomNoteFolder.query.filter_by(id=folder_id).first() + + +def paginate_folders(war_room_id: int, + pagination_parameters: PaginationParameters): + query = WarRoomNoteFolder.query.filter_by(war_room_id=war_room_id) + return paginate(WarRoomNoteFolder, pagination_parameters, query) + + +def list_folders(war_room_id: int) -> List[WarRoomNoteFolder]: + """Every folder for the war room, flat. The frontend hydrates the tree + from `parent_id` client-side (same pattern as case notes).""" + return WarRoomNoteFolder.query.filter_by( + war_room_id=war_room_id + ).order_by(WarRoomNoteFolder.name.asc()).all() + + +def delete_folder(folder: WarRoomNoteFolder) -> bool: + """Recursively delete a folder + every note inside it + every subfolder. + + The DB has `ondelete='CASCADE'` on `war_room_note_folder.parent_id`, + so a simple `db_delete(folder)` would already reap subfolders. But + `WarRoomNote.folder_id` has NO cascade — we want the business layer + to explicitly walk the subtree so revision history and any future + per-note module hooks get a chance to fire. This mirrors + `case_notes_db.delete_directory()`.""" + if not folder: + return False + for note in list(folder.notes): + delete_note(note.note_id) + for subfolder in list(folder.subfolders): + delete_folder(subfolder) + db_delete(folder) + return True + + +def delete_note(note_id: int) -> None: + """Delete a note and its revision history. The `versions` + relationship on `WarRoomNote` uses `cascade='all, delete-orphan'`, + so an ORM delete tears down `war_room_note_revision` rows with it.""" + note = WarRoomNote.query.filter(WarRoomNote.note_id == note_id).first() + if note is None: + return + db.session.delete(note) + + +def write_revision(user_id: int, note: WarRoomNote) -> bool: + """Snapshot the current note state as a new revision, unless the + latest revision already has identical title+content. Returns True + when a row was actually written. + + Called on create (first revision) and on every content-changing + update — the dedup skip means back-to-back saves with no diff + don't spam the history.""" + latest = ( + db.session.query(WarRoomNoteRevision) + .filter_by(note_id=note.note_id) + .order_by(WarRoomNoteRevision.revision_number.desc()) + .first() + ) + revision_number = 1 if latest is None else latest.revision_number + 1 + if (revision_number > 1 + and latest.title == note.title + and latest.content == note.content): + return False + revision = WarRoomNoteRevision( + note_id=note.note_id, + revision_number=revision_number, + title=note.title, + content=note.content, + revised_by_id=user_id, + revised_at=datetime.utcnow(), + ) + db_create(revision) + return True + + +def list_revisions(note_id: int): + """Revision rows shaped for the frontend history dialog. Joins the + `user` table so the caller doesn't have to make N+1 lookups just to + render the author name.""" + from app.models.authorization import User + return ( + db.session.query( + WarRoomNoteRevision.revision_number, + WarRoomNoteRevision.revised_at, + User.user.label('user_name'), + ) + .outerjoin(User, User.id == WarRoomNoteRevision.revised_by_id) + .filter(WarRoomNoteRevision.note_id == note_id) + .order_by(WarRoomNoteRevision.revision_number.desc()) + .all() + ) + + +def get_revision(note_id: int, revision_number: int) -> WarRoomNoteRevision: + return WarRoomNoteRevision.query.filter_by( + note_id=note_id, revision_number=revision_number + ).first() + + +def count_revisions(note_id: int) -> int: + return WarRoomNoteRevision.query.filter_by(note_id=note_id).count() diff --git a/source/app/iris_engine/collab/render.py b/source/app/iris_engine/collab/render.py index ae8a9e22b..e571d001d 100644 --- a/source/app/iris_engine/collab/render.py +++ b/source/app/iris_engine/collab/render.py @@ -100,7 +100,13 @@ def markdown_to_ydoc_update(md: str) -> bytes: # sees that as one paragraph and never emits table tokens. Un-flatten # those single-liners into proper multi-line tables before parsing — # cheap heuristic, safe for well-formed input. - tokens = md_parser.parse(_unflatten_pipe_tables(md or '')) + # + # `_rewrite_legacy_case_urls` runs FIRST so old IRIS v2.4.29 URLs + # (`/case/iocs?cid=X&ioc_id=Y`, `/case?cid=X`, …) get rewritten to the + # new SvelteKit path form before the tokens are frozen into the Y.Doc. + # Without this the raw column keeps the old URLs forever after the + # first collab open, since the Y.Doc becomes the source of truth. + tokens = md_parser.parse(_unflatten_pipe_tables(_rewrite_legacy_case_urls(md or ''))) with doc.transaction(): _build_blocks_into(frag, tokens) @@ -108,6 +114,60 @@ def markdown_to_ydoc_update(md: str) -> bytes: return doc.get_update() +_LEGACY_CASE_SUBPATH_ID_PARAM = { + 'iocs': 'ioc_id', + 'assets': 'asset_id', + 'tasks': 'id', # v2.4 tasks used `id=`, not `task_id=` + 'notes': 'note_id', + 'evidences': 'evidence_id', +} + +_LEGACY_CASE_SUBPATH_RE = re.compile( + r'/case/(iocs|assets|tasks|notes|evidences|timeline)(\?[^)\s"\'<>]*)' +) +_LEGACY_CASE_BARE_RE = re.compile(r'/case(\?[^)\s"\'<>]*)') +_LEGACY_CID_RE = re.compile(r'[?&]cid=(\d+)') + + +def _rewrite_legacy_case_urls(md: str) -> str: + """Rewrite IRIS v2.4.29 flat-query case URLs to the SvelteKit v2 tree. + + Mirrors `iris-frontend/src/lib/components/common/MarkDown/legacy-content.ts` + — kept intentionally byte-identical in behaviour so a document rewritten + server-side matches what the frontend normalizer would produce. Runs + before the tokens are seeded into the Y.Doc so old links don't get + locked into collaborative state. + """ + if '/case' not in md or 'cid=' not in md: + return md + + def _subpath(match: re.Match) -> str: + section = match.group(1) + query = match.group(2) + cid_match = _LEGACY_CID_RE.search(query) + if not cid_match: + return match.group(0) + cid = cid_match.group(1) + if section == 'timeline': + return f'/case/{cid}/timeline' + id_param = _LEGACY_CASE_SUBPATH_ID_PARAM.get(section) + if not id_param: + return match.group(0) + id_match = re.search(rf'[?&]{id_param}=(\d+)', query) + if not id_match: + return f'/case/{cid}/{section}' + return f'/case/{cid}/{section}/{id_match.group(1)}' + + def _bare(match: re.Match) -> str: + cid_match = _LEGACY_CID_RE.search(match.group(1)) + if not cid_match: + return match.group(0) + return f'/case/{cid_match.group(1)}' + + out = _LEGACY_CASE_SUBPATH_RE.sub(_subpath, md) + return _LEGACY_CASE_BARE_RE.sub(_bare, out) + + def _unflatten_pipe_tables(md: str) -> str: """Repair single-line GFM pipe tables produced by legacy exporters. diff --git a/source/app/iris_engine/notifications/mentions.py b/source/app/iris_engine/notifications/mentions.py index 1e7f881ad..766ca650c 100644 --- a/source/app/iris_engine/notifications/mentions.py +++ b/source/app/iris_engine/notifications/mentions.py @@ -47,6 +47,17 @@ re.IGNORECASE, ) +# Team-kind mention span. Same shape as the user variant but keyed on +# `data-kind="team"`. Team IDs are war-room-scoped, so callers MUST +# resolve them via `war_room_team_member_user_ids(war_room_id, ...)` to +# avoid leaking notifications across rooms. +_TEAM_MENTION_SPAN_RE = re.compile( + r"<span\b(?=[^>]*\bdata-mention\b)" + r"(?=[^>]*\bdata-kind=[\"']team[\"'])" + r"[^>]*?\bdata-id=[\"'](?P<id>\d+)[\"']", + re.IGNORECASE, +) + # Legacy `@handle` matcher. Only used when the content contains NO # mention spans (i.e. pre-mention-node notes) — otherwise a chip like # `@John Doe` would double-count via both parsers. The character class @@ -127,6 +138,81 @@ def resolve_user_handles(handles: Iterable[str]) -> Set[int]: return {r.id for r in rows} +def extract_mentioned_team_ids(content: Optional[str]) -> Set[int]: + """Return the set of team IDs mentioned in `content`. + + Team IDs are scoped to their war room; the caller is responsible + for scoping the resolved team IDs to a specific war room when + expanding them to user IDs. + """ + if not content: + return set() + ids: Set[int] = set() + for m in _TEAM_MENTION_SPAN_RE.finditer(content): + try: + ids.add(int(m.group('id'))) + except (TypeError, ValueError): + continue + return ids + + +def _resolve_team_names_to_ids(names: Iterable[str], war_room_id: int) -> Set[int]: + """Resolve `@team-name` plaintext tokens against `WarRoomTeam.name`. + + Case-insensitive, war-room-scoped. Unknown names silently drop out. + Used only from the plaintext fallback path in `resolve_mentions_to_user_ids` + where the chat composer emits `@name` rather than a structured span. + """ + normalised = {n.strip().lower() for n in names if n and n.strip()} + if not normalised: + return set() + from app.models.war_rooms import WarRoomTeam + rows = ( + db.session.query(WarRoomTeam.team_id) + .filter(WarRoomTeam.war_room_id == war_room_id) + .filter(func.lower(WarRoomTeam.name).in_(normalised)) + .all() + ) + return {r.team_id for r in rows} + + +def resolve_mentions_to_user_ids(content: Optional[str], + war_room_id: int) -> Set[int]: + """Union of user IDs from both user-mentions and team-mention expansion. + + Team IDs found in `content` are scoped to `war_room_id` — team IDs + that don't belong to this war room contribute nobody. Direct user + mentions are returned as-is; membership scoping is the caller's job + (see `_fire_message_notifications` for the war-room-member filter). + + Plaintext `@name` fallback also resolves team names for war-room + content, which is where the chat composer emits plain `@name` tokens + rather than structured spans. + """ + if not content: + return set() + + user_ids = extract_mentioned_user_ids(content) + team_ids = extract_mentioned_team_ids(content) + + # Plaintext fallback: only run when the content had NO structured + # user spans (same short-circuit as `extract_mentioned_user_ids`). + # For plain `@handle` tokens, try user resolution first, then fall + # through to team-name resolution for any unresolved handles. + has_user_spans = bool(_MENTION_SPAN_RE.search(content)) + if not has_user_spans: + handles = {m.group('handle') + for m in _LEGACY_MENTION_RE.finditer(content)} + if handles: + team_ids = team_ids | _resolve_team_names_to_ids(handles, war_room_id) + + if team_ids: + # Local import to avoid a business-layer import loop at module load. + from app.business.war_room_teams import war_room_team_member_user_ids + user_ids = user_ids | war_room_team_member_user_ids(war_room_id, team_ids) + return user_ids + + def resolve_user_handle(handle: str) -> Optional[int]: """Single-handle lookup returning `user_id` or None. diff --git a/source/app/models/war_rooms.py b/source/app/models/war_rooms.py index 6d219a39b..5fccf40f0 100644 --- a/source/app/models/war_rooms.py +++ b/source/app/models/war_rooms.py @@ -150,6 +150,56 @@ class WarRoomMember(db.Model): added_by = relationship('User', foreign_keys=[added_by_id]) +class WarRoomTeam(db.Model): + """Named team scoped to a single war room. + + Teams are per-war-room groupings used for @-mentions. Any war-room + member can see and @ any team; membership in the team drives the + notification fan-out. Deleting the room cascades the teams. + """ + __tablename__ = 'war_room_team' + __table_args__ = ( + UniqueConstraint('war_room_id', 'name', name='uq_war_room_team_name'), + ) + + team_id = Column(BigInteger, primary_key=True) + war_room_id = Column(BigInteger, + ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + nullable=False, index=True) + name = Column(String(80), nullable=False) + description = Column(Text, nullable=True) + color = Column(String(7), nullable=True) + created_at = Column(DateTime, nullable=False, server_default=text('now()')) + created_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + + war_room = relationship('WarRoom') + created_by = relationship('User') + + +class WarRoomTeamMember(db.Model): + """Membership of a war-room team. + + A user must be a WarRoomMember of the same war room to be added to + a team — enforced at the business layer. + """ + __tablename__ = 'war_room_team_member' + __table_args__ = ( + UniqueConstraint('team_id', 'user_id', name='uq_war_room_team_member'), + ) + + team_id = Column(BigInteger, + ForeignKey('war_room_team.team_id', ondelete='CASCADE'), + primary_key=True, nullable=False) + user_id = Column(BigInteger, ForeignKey('user.id', ondelete='CASCADE'), + primary_key=True, nullable=False) + added_at = Column(DateTime, nullable=False, server_default=text('now()')) + added_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + + team = relationship('WarRoomTeam', backref='members') + user = relationship('User', foreign_keys=[user_id]) + added_by = relationship('User', foreign_keys=[added_by_id]) + + class WarRoomTimeline(db.Model): """Named timeline on a war room. @@ -186,6 +236,13 @@ class WarRoomTimelineEvent(db.Model): free-form entry the operator authored inline in the war room. The `case_id`/`event_id` pair is nullable for that reason; when both are null, `title` and `event_date` are the source of truth. + + Feature parity with `CasesEvent`: this row can carry a source label, + a machine-raw payload, comma-separated tags, a triage flag, and a + self-referencing parent for tree rendering. Assets and IOCs attach + through the `war_room_timeline_event_assets` / `..._iocs` M2M + tables. Comments hang off the row via a nullable FK on the shared + `Comments` table (see `models/comments.py`). """ __tablename__ = 'war_room_timeline_event' __table_args__ = ( @@ -197,9 +254,20 @@ class WarRoomTimelineEvent(db.Model): ) id = Column(BigInteger, primary_key=True) + # Public identifier used by share links. Case events grew one for + # the same reason — a stable-across-renames external URL that + # doesn't require the client to know the numeric id. + uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, nullable=False, + server_default=text('gen_random_uuid()'), unique=True) timeline_id = Column(BigInteger, ForeignKey('war_room_timeline.timeline_id', ondelete='CASCADE'), nullable=False, index=True) + # Self-ref for parent/child tree rendering. `SET NULL` on parent + # delete so an orphaned child stays in the timeline rather than + # cascading away — user's decision to promote it later. + parent_id = Column(BigInteger, + ForeignKey('war_room_timeline_event.id', ondelete='SET NULL'), + nullable=True) case_id = Column(BigInteger, ForeignKey('cases.case_id', ondelete='SET NULL'), nullable=True) @@ -208,10 +276,28 @@ class WarRoomTimelineEvent(db.Model): nullable=True) title = Column(Text, nullable=True) content = Column(Text, nullable=True) + # Machine-original payload for evidence trail — kept alongside the + # human-readable `content` so an analyst can always drop back to + # the untouched source. Matches `CasesEvent.event_raw`. + raw = Column(Text, nullable=True) + # Free-text source label (e.g. "Suricata", "Firewall"). Rendered as + # an uppercase caption in the event card metadata row. + source = Column(Text, nullable=True) + # Comma-separated tags. Same wire shape as `CasesEvent.event_tags`; + # the frontend splits on `,` before rendering as `#foo` pills. + tags = Column(Text, nullable=True) + # Triage flag — red flag icon in the metadata row when set. No + # semantics beyond "someone marked this important". + is_flagged = Column(Boolean, nullable=False, default=False, + server_default=text('false')) event_date = Column(DateTime, nullable=True) event_tz = Column(String(16), nullable=True) color = Column(String(7), nullable=True) category = Column(String(64), nullable=True) + # JSONB audit trail written by `add_obj_history_entry`. Same shape + # as the case-notes / alert-cluster modification history so the + # existing history dialog can render both without branching. + modification_history = Column(JSONB, nullable=True) created_at = Column(DateTime, nullable=False, server_default=text('now()')) created_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) @@ -219,6 +305,92 @@ class WarRoomTimelineEvent(db.Model): case = relationship('Cases') event = relationship('CasesEvent') created_by = relationship('User') + parent = relationship('WarRoomTimelineEvent', + remote_side=[id], backref='children') + + +class WarRoomTimelineEventAsset(db.Model): + """M2M — pin an asset from any case to a war-room timeline event. + + Mirrors `CaseEventsAssets`. War-room events aren't case-scoped, so + the `case_id` column present on the case-side row is dropped here; + the linked case is inferable from `case_assets.case_id` if callers + need it. Cascading DELETE on `event_id` cleans up automatically + when the parent event goes away. + """ + __tablename__ = 'war_room_timeline_event_assets' + __table_args__ = ( + UniqueConstraint('event_id', 'asset_id', + name='uq_war_room_timeline_event_asset'), + ) + + id = Column(BigInteger, primary_key=True) + event_id = Column(BigInteger, + ForeignKey('war_room_timeline_event.id', ondelete='CASCADE'), + nullable=False, index=True) + asset_id = Column(BigInteger, + ForeignKey('case_assets.asset_id', ondelete='CASCADE'), + nullable=False) + + event = relationship('WarRoomTimelineEvent', backref='asset_links') + asset = relationship('CaseAssets') + + +class WarRoomTimelineEventIoc(db.Model): + """M2M — pin an IOC from any case to a war-room timeline event. + + Mirrors `CaseEventsIoc` with the same rationale as + `WarRoomTimelineEventAsset` — no redundant `case_id`, cascading + delete on event removal.""" + __tablename__ = 'war_room_timeline_event_iocs' + __table_args__ = ( + UniqueConstraint('event_id', 'ioc_id', + name='uq_war_room_timeline_event_ioc'), + ) + + id = Column(BigInteger, primary_key=True) + event_id = Column(BigInteger, + ForeignKey('war_room_timeline_event.id', ondelete='CASCADE'), + nullable=False, index=True) + ioc_id = Column(BigInteger, + ForeignKey('ioc.ioc_id', ondelete='CASCADE'), + nullable=False) + + event = relationship('WarRoomTimelineEvent', backref='ioc_links') + ioc = relationship('Ioc') + + +class WarRoomTopic(db.Model): + """A named partition of the chat stream — a Slack-channel-like slice. + + Every war room has exactly one `is_main=True` topic that cannot be + renamed or archived; it's the default landing lane and the fallback + for messages whose topic was archived. All other topics are user- + created via the `+` button or `/topic <name>` slash command. + + Archiving is soft (`archived_at`): archived topics stay visible in + a separate sidebar section and remain readable, but the composer + refuses new messages targeting them and rename is blocked. + """ + __tablename__ = 'war_room_topic' + __table_args__ = ( + UniqueConstraint('war_room_id', 'name', + name='uq_war_room_topic_name'), + ) + + topic_id = Column(BigInteger, primary_key=True) + war_room_id = Column(BigInteger, + ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + nullable=False, index=True) + name = Column(String(80), nullable=False) + is_main = Column(Boolean, nullable=False, default=False, + server_default=text('false')) + created_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + created_at = Column(DateTime, nullable=False, server_default=text('now()')) + archived_at = Column(DateTime, nullable=True) + + war_room = relationship('WarRoom') + created_by = relationship('User') class WarRoomChatMessage(db.Model): @@ -250,6 +422,13 @@ class WarRoomChatMessage(db.Model): ref_id = Column(BigInteger, nullable=True) ref_case_id = Column(BigInteger, ForeignKey('cases.case_id', ondelete='SET NULL'), nullable=True, index=True) + # Topic partition. NULL means the message belongs to the war room's + # Main topic — we don't require a row on Main during transitional + # deployments, and the read path treats NULL and Main-id + # equivalently. Non-NULL points at a `war_room_topic` row. + topic_id = Column(BigInteger, + ForeignKey('war_room_topic.topic_id', ondelete='SET NULL'), + nullable=True, index=True) # Threading. `parent_message_id` is nullable: a row with NULL parent # is a top-level stream message; a row pointing at another message # is a reply hanging off that root. Two-level only — replies can @@ -275,6 +454,21 @@ class WarRoomChatMessage(db.Model): created_at = Column(DateTime, nullable=False, server_default=text('now()')) edited_at = Column(DateTime, nullable=True) deleted_at = Column(DateTime, nullable=True) + # Analyst-toggled sticky flag. Pinned messages surface in the + # sidebar "Decisions & Pins" list next to the existing pin-kind + # system rows and get a small pin badge inline in the stream. + # Kept as a Boolean column rather than a separate table because + # pin state is a per-message single-bit toggle and shows up in the + # message serializer every read — a join would be gratuitous. + is_pinned = Column(Boolean, nullable=False, default=False, + server_default=text('false')) + # Inline file attachments — list of `{file_id, filename, mime_type, + # size_bytes}` referencing `war_room_datastore_file` rows in the same + # war room. NULL / empty list = no attachments. Denormalised (rather + # than a join table) because the render path always fetches the + # message + its attachments together and messages carry at most a + # handful of files. + attachments = Column(JSONB, nullable=True) war_room = relationship('WarRoom') author = relationship('User') @@ -322,6 +516,105 @@ class WarRoomChatReaction(db.Model): created_at = Column(DateTime, nullable=False, server_default=text('now()')) +class WarRoomChatPoll(db.Model): + """A poll posted inline in the war-room chat stream. + + Every poll has a companion `WarRoomChatMessage` with `kind='poll'` + that hosts it in the stream (via `chat_message_id`); deleting the + message soft-deletes the stream entry but keeps the poll audit + trail via `ON DELETE SET NULL`. The poll row itself is only + removed when the war room is deleted (CASCADE on `war_room_id`). + + `is_anonymous` is a display-side toggle — vote rows still carry + `user_id` so the app can enforce "one vote per user in a + single-select poll" and "user can retract their own vote". The + REST serializer strips voter identity on read for anonymous + polls; a separate admin-audit endpoint can still surface it if a + war-room admin needs to investigate ballot-stuffing. + """ + __tablename__ = 'war_room_chat_poll' + + poll_id = Column(BigInteger, primary_key=True) + war_room_id = Column(BigInteger, + ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + nullable=False, index=True) + author_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + question = Column(Text, nullable=False) + is_multi_select = Column(Boolean, nullable=False, default=False, + server_default=text('false')) + is_anonymous = Column(Boolean, nullable=False, default=False, + server_default=text('false')) + # Optional deadline. NULL means "no auto-close"; a value in the + # past means the poll is closed for new votes (business layer + # rejects new votes past the deadline). + closes_at = Column(DateTime, nullable=True) + # Set once the poll is manually closed by author/admin or a vote + # attempt observed the deadline had passed and lazy-closed it. + closed_at = Column(DateTime, nullable=True) + # Backref to the stream message that hosts the poll UI. `SET NULL` + # so soft-deleting the chat row leaves the poll audit trail intact. + chat_message_id = Column(BigInteger, + ForeignKey('war_room_chat_message.message_id', + ondelete='SET NULL'), + nullable=True) + created_at = Column(DateTime, nullable=False, server_default=text('now()')) + + war_room = relationship('WarRoom') + author = relationship('User') + chat_message = relationship('WarRoomChatMessage', foreign_keys=[chat_message_id]) + options = relationship('WarRoomChatPollOption', + back_populates='poll', + cascade='all, delete-orphan', + order_by='WarRoomChatPollOption.sort_order') + + +class WarRoomChatPollOption(db.Model): + """One selectable answer on a poll. + + `sort_order` is client-controlled so the composer's drag-reorder + UX has a stable representation; the business layer only enforces + uniqueness of the option's parent poll (via cascading FK) and + doesn't police the numeric range. + """ + __tablename__ = 'war_room_chat_poll_option' + + option_id = Column(BigInteger, primary_key=True) + poll_id = Column(BigInteger, + ForeignKey('war_room_chat_poll.poll_id', ondelete='CASCADE'), + nullable=False, index=True) + label = Column(Text, nullable=False) + sort_order = Column(Integer, nullable=False, + default=0, server_default=text('0')) + + poll = relationship('WarRoomChatPoll', back_populates='options') + votes = relationship('WarRoomChatPollVote', + back_populates='option', + cascade='all, delete-orphan') + + +class WarRoomChatPollVote(db.Model): + """A single user's vote for a single option. + + Composite PK `(option_id, user_id)` — same user can NOT vote for + the same option twice (idempotent toggle instead), but multi-select + polls allow N rows per user across different options in the same + poll. Single-select is enforced in the business layer by + delete-existing-then-insert on vote. + """ + __tablename__ = 'war_room_chat_poll_vote' + + option_id = Column(BigInteger, + ForeignKey('war_room_chat_poll_option.option_id', + ondelete='CASCADE'), + primary_key=True, nullable=False) + user_id = Column(BigInteger, ForeignKey('user.id', ondelete='CASCADE'), + primary_key=True, nullable=False) + voted_at = Column(DateTime, nullable=False, server_default=text('now()')) + + option = relationship('WarRoomChatPollOption', back_populates='votes') + user = relationship('User') + + class WarRoomTask(db.Model): """Task tracked at the war-room level. @@ -352,6 +645,13 @@ class WarRoomTask(db.Model): closed_at = Column(DateTime, nullable=True) closed_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) tags = Column(Text, nullable=True) + # Single-level subtasks: a task may point at another task in the + # same war room. "No grand-children" is enforced by the business + # layer; the FK stays simple. + parent_task_id = Column(BigInteger, + ForeignKey('war_room_task.task_id', + ondelete='CASCADE'), + nullable=True) custom_attributes = Column(JSONB, nullable=True) war_room = relationship('WarRoom') @@ -360,6 +660,40 @@ class WarRoomTask(db.Model): created_by = relationship('User', foreign_keys=[created_by_id]) closed_by = relationship('User', foreign_keys=[closed_by_id]) source_case = relationship('Cases') + parent = relationship('WarRoomTask', remote_side=[task_id], + backref='subtasks') + + +class WarRoomNoteFolder(db.Model): + """Folder in a war-room notes tree. + + Self-referencing adjacency-list tree scoped to a single war room — + same shape as `NoteDirectory` for case notes, but with the friendlier + "folder" name (case-notes inherited "directory" from the legacy + `notes_group` table; this is greenfield so we pick the term the UI + actually uses). + + Deletion is CASCADE at the DB level for `war_room_id` and + `parent_id`, but the business layer walks the subtree explicitly so + it can also purge the child notes (whose `folder_id` FK has no + cascade — see `WarRoomNote.folder_id` below). + """ + __tablename__ = 'war_room_note_folder' + + id = Column(BigInteger, primary_key=True) + name = Column(Text, nullable=False) + war_room_id = Column(BigInteger, + ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + nullable=False, index=True) + parent_id = Column(BigInteger, + ForeignKey('war_room_note_folder.id', ondelete='CASCADE'), + nullable=True) + created_at = Column(DateTime, nullable=False, server_default=text('now()')) + updated_at = Column(DateTime, nullable=False, server_default=text('now()')) + + parent = relationship('WarRoomNoteFolder', + remote_side=[id], backref='subfolders') + war_room = relationship('WarRoom') class WarRoomNote(db.Model): @@ -377,14 +711,48 @@ class WarRoomNote(db.Model): nullable=False, index=True) title = Column(Text, nullable=False) content = Column(Text, nullable=True) + # Nullable so root-level notes (no folder) are valid. No cascade — + # `war_room_notes_db.delete_folder()` walks the subtree and deletes + # child notes explicitly so revision history is torn down with them. + folder_id = Column(BigInteger, + ForeignKey('war_room_note_folder.id'), nullable=True) created_at = Column(DateTime, nullable=False, server_default=text('now()')) updated_at = Column(DateTime, nullable=False, server_default=text('now()')) created_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) updated_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) war_room = relationship('WarRoom') + folder = relationship('WarRoomNoteFolder', backref='notes') created_by = relationship('User', foreign_keys=[created_by_id]) updated_by = relationship('User', foreign_keys=[updated_by_id]) + versions = relationship('WarRoomNoteRevision', + back_populates='note', + cascade='all, delete-orphan') + + +class WarRoomNoteRevision(db.Model): + """Immutable snapshot of a war-room note. + + Written by the business layer on every content-changing update + (dedup: skipped when title+content match the latest revision), plus + once on create as revision #1. `restore_revision` snapshots the + current state as a new revision before overwriting, so restore is + itself undoable. Mirrors `NoteRevisions` for case notes. + """ + __tablename__ = 'war_room_note_revision' + + revision_id = Column(BigInteger, primary_key=True) + note_id = Column(BigInteger, + ForeignKey('war_room_note.note_id', ondelete='CASCADE'), + nullable=False, index=True) + revision_number = Column(Integer, nullable=False) + title = Column(Text, nullable=True) + content = Column(Text, nullable=True) + revised_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + revised_at = Column(DateTime, nullable=False, server_default=text('now()')) + + note = relationship('WarRoomNote', back_populates='versions') + revised_by = relationship('User', foreign_keys=[revised_by_id]) class WarRoomSitRep(db.Model): diff --git a/source/app/post_init.py b/source/app/post_init.py index d5b8ca9f5..36c400e6c 100644 --- a/source/app/post_init.py +++ b/source/app/post_init.py @@ -624,6 +624,14 @@ def create_safe_hooks(): hook_description='Triggered on war room thread reply creation, after commit in DB') create_safe(db.session, IrisHook, hook_name='on_postload_war_room_reaction_toggle', hook_description='Triggered on war room chat reaction toggle, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_message_pin', + hook_description='Triggered on war room chat message pin/unpin, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_poll_create', + hook_description='Triggered on war room chat poll creation, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_poll_vote', + hook_description='Triggered on war room chat poll vote, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_poll_close', + hook_description='Triggered on war room chat poll close, after commit in DB') # --- War Room timelines create_safe(db.session, IrisHook, hook_name='on_postload_war_room_timeline_create', diff --git a/tests/tests_rest_alerts_filters.py b/tests/tests_rest_alerts_filters.py index 8fb0c1f31..cb6034236 100644 --- a/tests/tests_rest_alerts_filters.py +++ b/tests/tests_rest_alerts_filters.py @@ -56,7 +56,7 @@ def test_create_alert_filter_should_return_201(self): } } - response = self._subject.create('/api/v2/alerts-filters', body) + response = self._subject.create('/api/v2/alerts/filters', body) self.assertEqual(201, response.status_code) def test_create_alert_filter_should_return_400_when_filter_data_is_missing(self): @@ -66,7 +66,7 @@ def test_create_alert_filter_should_return_400_when_filter_data_is_missing(self) 'filter_name': 'filter name', 'filter_description': 'filter description', } - response = self._subject.create('/api/v2/alerts-filters', body) + response = self._subject.create('/api/v2/alerts/filters', body) self.assertEqual(400, response.status_code) def test_create_alert_filter_should_return_filter_type(self): @@ -96,7 +96,7 @@ def test_create_alert_filter_should_return_filter_type(self): } } - response = self._subject.create('/api/v2/alerts-filters', body).json() + response = self._subject.create('/api/v2/alerts/filters', body).json() self.assertEqual(filter_type, response['filter_type']) def test_create_alert_filter_should_return_filter_name(self): @@ -126,7 +126,7 @@ def test_create_alert_filter_should_return_filter_name(self): } } - response = self._subject.create('/api/v2/alerts-filters', body).json() + response = self._subject.create('/api/v2/alerts/filters', body).json() self.assertEqual(filter_name, response['filter_name']) def test_create_alert_filter_should_return_in_filter_data_alert_title(self): @@ -156,7 +156,7 @@ def test_create_alert_filter_should_return_in_filter_data_alert_title(self): } } - response = self._subject.create('/api/v2/alerts-filters', body).json() + response = self._subject.create('/api/v2/alerts/filters', body).json() self.assertEqual(alert_title, response['filter_data']['alert_title']) def test_get_alert_filter_should_return_200(self): @@ -186,9 +186,9 @@ def test_get_alert_filter_should_return_200(self): } } - response = self._subject.create('/api/v2/alerts-filters', body).json() + response = self._subject.create('/api/v2/alerts/filters', body).json() identifier = response['filter_id'] - response = self._subject.get(f'/api/v2/alerts-filters/{identifier}') + response = self._subject.get(f'/api/v2/alerts/filters/{identifier}') self.assertEqual(200, response.status_code) def test_get_alert_filter_should_return_filter_name(self): @@ -219,13 +219,13 @@ def test_get_alert_filter_should_return_filter_name(self): } } - response = self._subject.create('/api/v2/alerts-filters', body).json() + response = self._subject.create('/api/v2/alerts/filters', body).json() identifier = response['filter_id'] - response = self._subject.get(f'/api/v2/alerts-filters/{identifier}').json() + response = self._subject.get(f'/api/v2/alerts/filters/{identifier}').json() self.assertEqual(filter_name, response['filter_name']) def test_get_alert_filter_should_return_404_when_alert_filter_not_found(self): - response = self._subject.get(f'/api/v2/alerts-filters/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}') + response = self._subject.get(f'/api/v2/alerts/filters/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}') self.assertEqual(404, response.status_code) def test_get_alert_filter_should_return_404_when_user_has_not_created_alert_filter(self): @@ -256,9 +256,9 @@ def test_get_alert_filter_should_return_404_when_user_has_not_created_alert_filt } } - response = self._subject.create('/api/v2/alerts-filters', body).json() + response = self._subject.create('/api/v2/alerts/filters', body).json() identifier = response['filter_id'] - response = user.get(f'/api/v2/alerts-filters/{identifier}') + response = user.get(f'/api/v2/alerts/filters/{identifier}') self.assertEqual(404, response.status_code) def test_update_alert_filter_should_return_200(self): @@ -288,12 +288,12 @@ def test_update_alert_filter_should_return_200(self): } } - response = self._subject.create('/api/v2/alerts-filters', body).json() + response = self._subject.create('/api/v2/alerts/filters', body).json() identifier = response['filter_id'] body = { 'filter_name': 'filter name', } - response = self._subject.update(f'/api/v2/alerts-filters/{identifier}', body) + response = self._subject.update(f'/api/v2/alerts/filters/{identifier}', body) self.assertEqual(200, response.status_code) def test_update_alert_filter_should_return_filter_name(self): @@ -324,12 +324,12 @@ def test_update_alert_filter_should_return_filter_name(self): } } - response = self._subject.create('/api/v2/alerts-filters', body).json() + response = self._subject.create('/api/v2/alerts/filters', body).json() identifier = response['filter_id'] body = { 'filter_name': filter_name, } - response = self._subject.update(f'/api/v2/alerts-filters/{identifier}', body).json() + response = self._subject.update(f'/api/v2/alerts/filters/{identifier}', body).json() self.assertEqual(filter_name, response['filter_name']) def test_update_alert_filter_should_return_filter_description(self): @@ -360,12 +360,12 @@ def test_update_alert_filter_should_return_filter_description(self): } } - response = self._subject.create('/api/v2/alerts-filters', body).json() + response = self._subject.create('/api/v2/alerts/filters', body).json() identifier = response['filter_id'] body = { 'filter_description': filter_description, } - response = self._subject.update(f'/api/v2/alerts-filters/{identifier}', body).json() + response = self._subject.update(f'/api/v2/alerts/filters/{identifier}', body).json() self.assertEqual(filter_description, response['filter_description']) def test_update_alert_filter_should_return_filter_type(self): @@ -396,12 +396,12 @@ def test_update_alert_filter_should_return_filter_type(self): } } - response = self._subject.create('/api/v2/alerts-filters', body).json() + response = self._subject.create('/api/v2/alerts/filters', body).json() identifier = response['filter_id'] body = { 'filter_type': filter_type, } - response = self._subject.update(f'/api/v2/alerts-filters/{identifier}', body).json() + response = self._subject.update(f'/api/v2/alerts/filters/{identifier}', body).json() self.assertEqual(filter_type, response['filter_type']) def test_update_alert_filter_should_return_filter_data_alert_title(self): @@ -432,12 +432,12 @@ def test_update_alert_filter_should_return_filter_data_alert_title(self): } } - response = self._subject.create('/api/v2/alerts-filters', body).json() + response = self._subject.create('/api/v2/alerts/filters', body).json() identifier = response['filter_id'] body = { 'filter_data': {'alert_title': alert_title}, } - response = self._subject.update(f'/api/v2/alerts-filters/{identifier}', body).json() + response = self._subject.update(f'/api/v2/alerts/filters/{identifier}', body).json() self.assertEqual(alert_title, response['filter_data']['alert_title']) def test_update_alert_filter_should_return_404_when_alert_filter_is_not_found(self): @@ -466,11 +466,11 @@ def test_update_alert_filter_should_return_404_when_alert_filter_is_not_found(se } } - response = self._subject.create('/api/v2/alerts-filters', body).json() + response = self._subject.create('/api/v2/alerts/filters', body).json() body = { 'filter_data': {'alert_title': 'alert_title'}, } - response = self._subject.update(f'/api/v2/alerts-filters/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}', body) + response = self._subject.update(f'/api/v2/alerts/filters/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}', body) self.assertEqual(404, response.status_code) def test_delete_alert_filter_should_return_204(self): @@ -500,13 +500,13 @@ def test_delete_alert_filter_should_return_204(self): } } - response = self._subject.create('/api/v2/alerts-filters', body).json() + response = self._subject.create('/api/v2/alerts/filters', body).json() identifier = response['filter_id'] - response = self._subject.delete(f'/api/v2/alerts-filters/{identifier}') + response = self._subject.delete(f'/api/v2/alerts/filters/{identifier}') self.assertEqual(204, response.status_code) def test_delete_alert_filter_should_return_404_when_alert_not_found(self): - response = self._subject.delete(f'/api/v2/alerts-filters/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}') + response = self._subject.delete(f'/api/v2/alerts/filters/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}') self.assertEqual(404, response.status_code) def test_get_alert_filter_should_return_404_after_delete_alert_filter(self): @@ -536,10 +536,10 @@ def test_get_alert_filter_should_return_404_after_delete_alert_filter(self): } } - response = self._subject.create('/api/v2/alerts-filters', body).json() + response = self._subject.create('/api/v2/alerts/filters', body).json() identifier = response['filter_id'] - self._subject.delete(f'/api/v2/alerts-filters/{identifier}') - response = self._subject.get(f'/api/v2/alerts-filters/{identifier}') + self._subject.delete(f'/api/v2/alerts/filters/{identifier}') + response = self._subject.get(f'/api/v2/alerts/filters/{identifier}') self.assertEqual(404, response.status_code) def test_update_alert_filter_should_return_400(self): @@ -569,10 +569,10 @@ def test_update_alert_filter_should_return_400(self): } } - response = self._subject.create('/api/v2/alerts-filters', body).json() + response = self._subject.create('/api/v2/alerts/filters', body).json() identifier = response['filter_id'] body = { 'filter_name': 1, } - response = self._subject.update(f'/api/v2/alerts-filters/{identifier}', body) + response = self._subject.update(f'/api/v2/alerts/filters/{identifier}', body) self.assertEqual(400, response.status_code)