diff --git a/.env.model b/.env.model index d21eb283b..267cb8fd9 100644 --- a/.env.model +++ b/.env.model @@ -1,13 +1,31 @@ # -- COMMON LOG_LEVEL=info +# -- HOSTNAME (single source of truth) +# When set, IRIS_HOSTNAME provides fallback defaults for SERVER_NAME +# (nginx), PUBLIC_EXTERNAL_API_URL / ORIGIN (SvelteKit), and +# VITE_ALLOWED_HOSTS (Vite dev). Explicit values still take precedence +# for split-hostname deployments (e.g. API on a different host than UI). +# Existing .env files that set the four vars individually keep working. +#IRIS_HOSTNAME=iris.app.dev + # -- NGINX NGINX_IMAGE_NAME=ghcr.io/dfir-iris/iriswebapp_nginx SERVER_NAME=iris.app.dev +# When both KEY_FILENAME and CERT_FILENAME are unset AND certbot's +# standard fullchain.pem + privkey.pem pair exist under +# ./certificates/web_certificates/, the nginx entrypoint autodetects +# them — useful for Let's Encrypt setups. See +# scripts/certbot-deploy-hook.sh for zero-copy renewals. KEY_FILENAME=iris_dev_key.pem CERT_FILENAME=iris_dev_cert.pem +# Nginx polls the cert file's mtime every N seconds and reloads on +# change so certbot renewals apply without a container restart. Set +# to 0 to disable if your deployment reloads nginx externally. +#IRIS_CERT_RELOAD_INTERVAL=60 + # -- DATABASE DB_IMAGE_NAME=ghcr.io/dfir-iris/iriswebapp_db @@ -88,6 +106,23 @@ IRIS_AUTHENTICATION_TYPE=local # optional mapping for usergroup-names # OIDC_MAPPING_USERGROUP= "iris-roles" #Group field in OIDC Token # OIDC_MAPPING_ROLES= '{"iris-admin": "1", "iris-analyst": "2"}' +# -- CUSTOM CA CERTIFICATES (Keycloak, LDAPS, webhooks, ...) +# The recommended way to trust internal CAs is to drop one or more +# *.crt / *.pem files into ./certificates/ca-bundle/ on the host — +# they are mounted at /etc/iris-ca/ inside the app and worker +# containers, and the entrypoint installs them into the OS trust +# store (update-ca-certificates) on every boot. This is ADDITIVE: +# system roots stay trusted, so no manual concatenation is needed. +# +# The legacy single-file bundle at +# ./certificates/ca-bundle/extra-ca.crt is still honoured for +# backward compatibility, as are the REQUESTS_CA_BUNDLE / +# SSL_CERT_FILE / TLS_ROOT_CA env vars (which REPLACE the default +# trust store rather than augmenting it — prefer the drop-in dir +# unless you have a reason to pin the exact bundle). +#REQUESTS_CA_BUNDLE=/etc/iris-ca/extra-ca.crt +#SSL_CERT_FILE=/etc/iris-ca/extra-ca.crt + # -- LISTENING PORT INTERFACE_HTTPS_PORT=443 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3363b95fb..58aaf8b88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,43 +110,6 @@ jobs: name: iriswebapp_app path: ${{ runner.temp }}/iriswebapp_app.tar - build-graphql-documentation: - name: Generate graphQL documentation - runs-on: ubuntu-22.04 - needs: - - build-docker-db - - build-docker-nginx - - build-docker-app - steps: - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - pattern: iriswebapp_* - path: ${{ runner.temp }} - merge-multiple: true - - name: Load docker images - run: | - docker load --input ${{ runner.temp }}/iriswebapp_db.tar - docker load --input ${{ runner.temp }}/iriswebapp_nginx.tar - docker load --input ${{ runner.temp }}/iriswebapp_app.tar - - name: Check out iris - uses: actions/checkout@v4 - - name: Start development server - run: | - cp .env.tests.model .env - docker compose --file docker-compose.dev.yml up --detach --wait - - name: Generate GraphQL documentation - run: | - npx spectaql@^3.0.2 source/spectaql/config.yml - - name: Stop development server - run: | - docker compose down - - uses: actions/upload-artifact@v4 - with: - name: GraphQL DFIR-IRIS documentation - path: public - if-no-files-found: error - test-api: name: Test API runs-on: ubuntu-22.04 diff --git a/.gitignore b/.gitignore index 268153c69..890b45514 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,5 @@ certificates/web_certificates/*.pem certificates/web_certificates/*.key !certificates/web_certificates/iris_dev_* local_dev/ -.serena/ \ No newline at end of file +.serena/ +.tokensave diff --git a/docker-compose.base.yml b/docker-compose.base.yml index 15533a342..c41f91aae 100644 --- a/docker-compose.base.yml +++ b/docker-compose.base.yml @@ -30,6 +30,13 @@ services: - POSTGRES_ADMIN_USER - POSTGRES_ADMIN_PASSWORD - POSTGRES_DB + # PG18's Docker image refuses to use a directory mounted directly + # as PGDATA (it expects the version-prefixed layout from + # pg_ctlcluster). Pointing PGDATA at a sub-path inside the + # mounted volume sidesteps that check and keeps the on-disk + # layout the same across the project's bind mounts / named + # volumes regardless of host. + - PGDATA=/var/lib/postgresql/data/pgdata networks: - iris_backend volumes: @@ -41,6 +48,25 @@ services: volumes: - ./certificates/:/home/iris/certificates/:ro - ./certificates/ldap/:/iriswebapp/certificates/ldap/:ro + # Operator-supplied CA bundle for outbound TLS from the Python + # processes (Keycloak discovery, OIDC token endpoint, LDAPS, + # webhook integrations, ...). + # + # IMPORTANT: REQUESTS_CA_BUNDLE / SSL_CERT_FILE replace the + # default trust store rather than augmenting it, so the file + # mounted at /etc/iris-ca/extra-ca.crt MUST be a full bundle: + # your internal CA chain CONCATENATED with the system root + # bundle (e.g. /etc/ssl/certs/ca-certificates.crt on the host). + # + # On the host: + # mkdir -p iris-web/certificates/ca-bundle + # cat /etc/ssl/certs/ca-certificates.crt \ + # /path/to/internal-ca-chain.pem \ + # > iris-web/certificates/ca-bundle/extra-ca.crt + # + # Leave it absent / empty to keep the container's default trust + # store (Python will then refuse certs signed by private CAs). + - ./certificates/ca-bundle/:/etc/iris-ca/:ro - iris-downloads:/home/iris/downloads - user_templates:/home/iris/user_templates - server_data:/home/iris/server_data @@ -59,6 +85,18 @@ services: - POSTGRES_PORT - IRIS_SECRET_KEY - IRIS_SECURITY_PASSWORD_SALT + # Point Python's `requests` library AND the stdlib `ssl` module + # at an operator-supplied bundle so it trusts chains for + # internal services (Keycloak, LDAPS, etc.). The bundle must + # include the system roots too — these env vars REPLACE the + # default trust store, not add to it. + # + # Unset by default — only opt in by exporting REQUESTS_CA_BUNDLE + # in iris-web/.env, e.g.: + # REQUESTS_CA_BUNDLE=/etc/iris-ca/extra-ca.crt + # SSL_CERT_FILE=/etc/iris-ca/extra-ca.crt + - REQUESTS_CA_BUNDLE + - SSL_CERT_FILE networks: - iris_backend - iris_frontend @@ -75,6 +113,7 @@ services: volumes: - ./certificates/:/home/iris/certificates/:ro - ./certificates/ldap/:/iriswebapp/certificates/ldap/:ro + - ./certificates/ca-bundle/:/etc/iris-ca/:ro - iris-downloads:/home/iris/downloads - user_templates:/home/iris/user_templates - server_data:/home/iris/server_data @@ -95,6 +134,10 @@ services: - IRIS_SECURITY_PASSWORD_SALT - IRIS_WORKER - LOG_LEVEL + # See the `app` service: REPLACES the default trust store. Set + # in iris-web/.env to opt in. + - REQUESTS_CA_BUNDLE + - SSL_CERT_FILE networks: - iris_backend @@ -106,10 +149,19 @@ services: - IRIS_FRONTEND_SERVER - IRIS_FRONTEND_PORT - INTERFACE_HTTPS_PORT - - SERVER_NAME + # SERVER_NAME still takes precedence when set. If unset, the + # entrypoint falls back to IRIS_HOSTNAME so operators only + # declare the hostname once. + - SERVER_NAME=${SERVER_NAME:-${IRIS_HOSTNAME:-}} + # CERT_FILENAME / KEY_FILENAME still take precedence when set. + # When unset, the entrypoint autodetects certbot's standard + # fullchain.pem / privkey.pem pair under /www/certs/. - CERT_FILENAME - KEY_FILENAME - IRIS_AUTHENTICATION_TYPE + # Poll interval (seconds) for the cert-reload watcher. Set to 0 + # to disable auto-reload on cert change. + - IRIS_CERT_RELOAD_INTERVAL=${IRIS_CERT_RELOAD_INTERVAL:-60} networks: - iris_frontend ports: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 89f33f81a..72a748af4 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -108,13 +108,22 @@ services: working_dir: /app environment: - IRIS_SVELTEKIT_FRONTEND_DIR=${IRIS_SVELTEKIT_FRONTEND_DIR:-../iris-frontend} - - PUBLIC_EXTERNAL_API_URL=${PUBLIC_EXTERNAL_API_URL:-https://127.0.0.1} + # PUBLIC_EXTERNAL_API_URL, ORIGIN, and VITE_ALLOWED_HOSTS all + # fall back to IRIS_HOSTNAME so operators only declare the + # external hostname once (in iris-web/.env). Explicit values + # still take precedence for split-hostname deployments. + - PUBLIC_EXTERNAL_API_URL=${PUBLIC_EXTERNAL_API_URL:-https://${IRIS_HOSTNAME:-127.0.0.1}} - PUBLIC_INTERNAL_API_URL=http://${IRIS_UPSTREAM_SERVER}:${IRIS_UPSTREAM_PORT} - PUBLIC_USE_MOCK_API_DATA=false - - ORIGIN=https://127.0.0.1 + - ORIGIN=${ORIGIN:-https://${IRIS_HOSTNAME:-127.0.0.1}} - PROTOCOL_HEADER=x-forwarded-proto - HOST_HEADER=x-forwarded-host - NODE_ENV=development + # Whitelist hostnames Vite will serve the dev SPA to. Without + # this, hitting the container behind nginx via a real hostname + # gets blocked by Vite's host-header check. Falls back to + # IRIS_HOSTNAME when unset; use `all` to disable the check. + - VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS:-${IRIS_HOSTNAME:-}} volumes: - ${IRIS_SVELTEKIT_FRONTEND_DIR:-../iris-frontend}:/app # Map the frontend directory dynamically - /app/node_modules # Ensure `node_modules` is preserved inside the container diff --git a/docker-compose.new-ui.yml b/docker-compose.new-ui.yml new file mode 100644 index 000000000..0f2988c36 --- /dev/null +++ b/docker-compose.new-ui.yml @@ -0,0 +1,155 @@ +# IRIS Source Code +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# Production-style stack for the new SvelteKit UI. +# +# Mirrors docker-compose.dev.yml, but the frontend is a compiled +# SvelteKit Node bundle (no Vite, no HMR, no bind mount). Use this +# when you want the new UI behind nginx without running the Vite dev +# server. +# +# Usage: +# docker compose -f docker-compose.new-ui.yml --profile new-ui up -d --build +# +# Required env (iris-web/.env): +# ORIGIN=https://your.deployment.host # SvelteKit CSRF gate +# IRIS_SVELTEKIT_FRONTEND_DIR=../iris-frontend +# NGINX_CONF_FILE=nginx-newui.conf + +services: + rabbitmq: + extends: + file: docker-compose.base.yml + service: rabbitmq + user: rabbitmq + healthcheck: + test: rabbitmq-diagnostics check_port_connectivity || exit 1 + start_period: 60s + start_interval: 1s + + db: + extends: + file: docker-compose.base.yml + service: db + build: + context: docker/db + image: iriswebapp_db:newui + ports: + - "127.0.0.1:5432:5432" + healthcheck: + test: pg_isready -U postgres -d iris_db || exit 1 + start_period: 60s + start_interval: 1s + + app: + extends: + file: docker-compose.base.yml + service: app + build: + context: . + dockerfile: docker/webApp/Dockerfile + image: iriswebapp_app:newui + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + rabbitmq: + condition: service_healthy + healthcheck: + test: curl --head --fail http://localhost:8000 || exit 1 + start_period: 60s + start_interval: 1s + + worker: + extends: + file: docker-compose.base.yml + service: worker + build: + context: . + dockerfile: docker/webApp/Dockerfile + image: iriswebapp_app:newui + depends_on: + app: + condition: service_healthy + db: + condition: service_healthy + rabbitmq: + condition: service_healthy + healthcheck: + test: celery -A app.celery inspect ping || exit 1 + start_period: 60s + start_interval: 1s + + nginx: + extends: + file: docker-compose.base.yml + service: nginx + build: + context: ./docker/nginx + args: + NGINX_CONF_GID: 1234 + NGINX_CONF_FILE: ${NGINX_CONF_FILE:-nginx-newui.conf} + image: iriswebapp_nginx:newui + depends_on: + app: + condition: service_healthy + worker: + condition: service_healthy + + frontend: + # Compiled SvelteKit Node bundle. No source bind-mount, no `npm + # 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} + image: iris_frontend:newui + profiles: ["new-ui"] + container_name: iris_sveltekit_frontend + environment: + # Both fall back to IRIS_HOSTNAME so operators only declare the + # external hostname once (in iris-web/.env). Explicit + # PUBLIC_EXTERNAL_API_URL / ORIGIN still take precedence — useful + # when the API is served from a different host than the UI. + - PUBLIC_EXTERNAL_API_URL=${PUBLIC_EXTERNAL_API_URL:-https://${IRIS_HOSTNAME:-127.0.0.1}} + - PUBLIC_INTERNAL_API_URL=http://${IRIS_UPSTREAM_SERVER}:${IRIS_UPSTREAM_PORT} + - PUBLIC_USE_MOCK_API_DATA=false + # ORIGIN gates SvelteKit's CSRF check in production (see + # svelte.config.js → kit.csrf.checkOrigin). Must match the + # external URL the user hits. + - ORIGIN=${ORIGIN:-https://${IRIS_HOSTNAME:-127.0.0.1}} + - PROTOCOL_HEADER=x-forwarded-proto + - HOST_HEADER=x-forwarded-host + - NODE_ENV=production + # Disable adapter-node's 512K default so avatar (multi-MB) and + # datastore (multi-GB) uploads aren't truncated mid-stream — the + # per-endpoint caps live in nginx (client_max_body_size). + - BODY_SIZE_LIMIT=Infinity + ports: + - "5173:5173" + networks: + - iris_backend + - iris_frontend + restart: always + +volumes: + iris-downloads: + user_templates: + server_data: + db_data: + +networks: + iris_backend: + name: iris_backend + iris_frontend: + name: iris_frontend diff --git a/docker/db/Dockerfile b/docker/db/Dockerfile index 26968c6b0..bf986f2ef 100644 --- a/docker/db/Dockerfile +++ b/docker/db/Dockerfile @@ -17,6 +17,6 @@ # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -FROM postgres:12-alpine +FROM postgres:18-alpine COPY create_user.sh /docker-entrypoint-initdb.d/10-create_user.sh \ No newline at end of file diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile new file mode 100644 index 000000000..dc4ce036b --- /dev/null +++ b/docker/frontend/Dockerfile @@ -0,0 +1,61 @@ +# 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/Dockerfile b/docker/nginx/Dockerfile index 5c229ffb0..462a52661 100644 --- a/docker/nginx/Dockerfile +++ b/docker/nginx/Dockerfile @@ -19,7 +19,19 @@ FROM nginx:1.30 -RUN apt-get update && apt-get install -y curl +# Pull in current Debian security updates (libxml2 CVE-2024-56171 / +# CVE-2025-49794 / CVE-2025-49796 and similar). `apt-get upgrade` is safe +# inside the image because apt retrieves from Debian's configured archive +# mirrors and the updates are binary-compatible within the suite. +RUN apt-get update \ + && apt-get upgrade -y --no-install-recommends \ + && apt-get install -y --no-install-recommends curl libcap2-bin \ + && rm -rf /var/lib/apt/lists/* + +# nginx runs as non-root www-data (see USER below), which by default +# cannot bind to privileged ports (<1024) such as 443. Grant the one +# capability it needs instead of running as root. +RUN setcap 'cap_net_bind_service=+ep' /usr/sbin/nginx # Used to pass protected files to the container through volumes ARG NGINX_CONF_GID diff --git a/docker/nginx/entrypoint.sh b/docker/nginx/entrypoint.sh index 58c79278c..2b8591f6d 100644 --- a/docker/nginx/entrypoint.sh +++ b/docker/nginx/entrypoint.sh @@ -20,10 +20,70 @@ set -e +CERTS_DIR="/www/certs" + +# Autodetect certbot's standard filenames when the operator hasn't +# pinned CERT_FILENAME / KEY_FILENAME explicitly. Keeping the explicit +# env vars authoritative preserves 100% backward compatibility for +# deployments that already ship custom filenames via .env. +if [ -z "${CERT_FILENAME:-}" ] && [ -f "${CERTS_DIR}/fullchain.pem" ]; then + export CERT_FILENAME="fullchain.pem" + echo "[iris-nginx] CERT_FILENAME autodetected: fullchain.pem" +fi +if [ -z "${KEY_FILENAME:-}" ] && [ -f "${CERTS_DIR}/privkey.pem" ]; then + export KEY_FILENAME="privkey.pem" + echo "[iris-nginx] KEY_FILENAME autodetected: privkey.pem" +fi + +# Strip scheme from SERVER_NAME so operators can drop a full URL in +# .env (e.g. SERVER_NAME=https://iris.lab) without breaking nginx's +# server_name directive. Historical .env files with a bare hostname +# pass through unchanged. +if [ -n "${SERVER_NAME:-}" ]; then + SERVER_NAME="${SERVER_NAME#http://}" + SERVER_NAME="${SERVER_NAME#https://}" + SERVER_NAME="${SERVER_NAME%%/*}" + export SERVER_NAME +fi + # envsubst will make a substitution on every $variable in a file, since the nginx file contains nginx variable like $host, we have to limit the substitution to this set # otherwise, each nginx variable will be replaced by an empty string envsubst '${INTERFACE_HTTPS_PORT} ${IRIS_UPSTREAM_SERVER} ${IRIS_UPSTREAM_PORT} ${SERVER_NAME} ${KEY_FILENAME} ${CERT_FILENAME} ${IRIS_FRONTEND_SERVER} ${IRIS_FRONTEND_PORT}' < /etc/nginx/nginx.conf > /tmp/nginx.conf cp /tmp/nginx.conf /etc/nginx/nginx.conf rm /tmp/nginx.conf +# Background cert-reload watcher. When certbot (or any external +# renewal) rewrites the cert file on disk, `nginx -s reload` picks up +# the new material without a container restart. Poll interval is +# controlled by IRIS_CERT_RELOAD_INTERVAL (seconds); set to 0 to +# disable — deployments that manage nginx reloads externally are +# unaffected. +reload_interval="${IRIS_CERT_RELOAD_INTERVAL:-60}" +if [ "${reload_interval}" -gt 0 ] 2>/dev/null && [ -n "${CERT_FILENAME:-}" ]; then + cert_path="${CERTS_DIR}/${CERT_FILENAME}" + ( + # Give nginx master a moment to bind before the first stat so + # we don't race the exec below. + sleep "${reload_interval}" + last_mtime="" + if [ -f "${cert_path}" ]; then + last_mtime=$(stat -c %Y "${cert_path}" 2>/dev/null || echo "") + fi + while true; do + sleep "${reload_interval}" + if [ ! -f "${cert_path}" ]; then + continue + fi + current_mtime=$(stat -c %Y "${cert_path}" 2>/dev/null || echo "") + if [ -n "${current_mtime}" ] && [ "${current_mtime}" != "${last_mtime}" ]; then + if [ -n "${last_mtime}" ]; then + echo "[iris-nginx] cert mtime changed, reloading nginx" + nginx -s reload || echo "[iris-nginx] nginx -s reload failed" + fi + last_mtime="${current_mtime}" + fi + done + ) & +fi + exec nginx -g "daemon off;" diff --git a/docker/nginx/nginx-newui.conf b/docker/nginx/nginx-newui.conf index a46479aa9..a7f778e3b 100644 --- a/docker/nginx/nginx-newui.conf +++ b/docker/nginx/nginx-newui.conf @@ -26,7 +26,9 @@ events { http { map $request_uri $csp_header { - default "default-src 'self' https://analytics.dfir-iris.org https://127.0.0.1; script-src 'self' 'unsafe-inline' https://analytics.dfir-iris.org; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:;"; + # `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:;"; } include /etc/nginx/mime.types; @@ -50,8 +52,14 @@ http { proxy_headers_hash_max_size 2048; proxy_headers_hash_bucket_size 128; proxy_buffering on; - proxy_buffers 8 16k; - proxy_buffer_size 4k; + # SvelteKit's Node server can emit response headers that exceed + # nginx's default 4k header buffer (large `Set-Cookie` chains, CSP + # nonces, etc.) — bump the header buffer to 32k and widen the body + # buffers in proportion so we don't stall on the first proxied + # response with "upstream sent too big header". + proxy_buffers 16 32k; + proxy_buffer_size 32k; + proxy_busy_buffers_size 64k; client_header_buffer_size 2k; large_client_header_buffers 8 64k; @@ -128,6 +136,13 @@ http { proxy_cache_bypass $http_upgrade; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; + # SvelteKit's adapter-node reads X-Forwarded-Host (see + # HOST_HEADER=x-forwarded-host in the frontend env) to + # rebuild the URL it uses for the CSRF origin check. Without + # this header it sees `localhost:5173` and rejects any POST + # from the real hostname as "Cross-site POST form submissions + # are forbidden". + proxy_set_header X-Forwarded-Host $host; proxy_set_header Origin $http_origin; } diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index b28c41638..42e400927 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -26,7 +26,9 @@ events { http { map $request_uri $csp_header { - default "default-src 'self' https://analytics.dfir-iris.org; script-src 'self' 'unsafe-inline' https://analytics.dfir-iris.org; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:;"; + # `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; 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; @@ -50,8 +52,14 @@ http { proxy_headers_hash_max_size 2048; proxy_headers_hash_bucket_size 128; proxy_buffering on; - proxy_buffers 8 16k; - proxy_buffer_size 4k; + # SvelteKit's Node server can emit response headers that exceed + # nginx's default 4k header buffer (large `Set-Cookie` chains, CSP + # nonces, etc.) — bump the header buffer to 32k and widen the body + # buffers in proportion so we don't stall on the first proxied + # response with "upstream sent too big header". + proxy_buffers 16 32k; + proxy_buffer_size 32k; + proxy_busy_buffers_size 64k; client_header_buffer_size 2k; large_client_header_buffers 8 64k; @@ -124,16 +132,15 @@ http { proxy_cache_bypass 1; location /static { - # Enable Caching (Overrides server "off" settings) for static - # ---------------------------------------------------- + # Enable caching (overrides server "off" settings) for static + # assets, with security headers re-applied since `add_header` + # in a location block fully replaces inherited ones. expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; - # Re-enable ETags and Last-Modified so browsers can check for updates etag on; if_modified_since exact; - # Re-apply Security Headers add_header X-XSS-Protection "1; mode=block"; add_header X-Frame-Options DENY; add_header X-Content-Type-Options nosniff; @@ -141,8 +148,6 @@ http { add_header Front-End-Https on; add_header Content-Security-Policy $csp_header; - - # Standard Proxy Config proxy_pass http://${IRIS_UPSTREAM_SERVER}:${IRIS_UPSTREAM_PORT}; proxy_no_cache 0; @@ -150,6 +155,15 @@ http { } location /collab/ { + # Resolve at request time (Docker's embedded DNS) instead of at + # nginx startup: the collab service is a BV-only optional + # feature and isn't present in every compose stack (e.g. the + # upstream-derived dev/CI stack). A static proxy_pass hostname + # would make nginx refuse to start entirely when "collab" can't + # be resolved; this way only /collab/ requests fail if it's absent. + resolver 127.0.0.11 valid=30s; + set $collab_upstream collab; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -161,7 +175,7 @@ http { proxy_send_timeout 1h; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; - proxy_pass http://collab:5001; + proxy_pass http://$collab_upstream:5001; } location / { diff --git a/docker/webApp/Dockerfile b/docker/webApp/Dockerfile index 52f5722c1..50039fe21 100644 --- a/docker/webApp/Dockerfile +++ b/docker/webApp/Dockerfile @@ -34,7 +34,10 @@ RUN npm run build # COMPILE IMAGE # ################# FROM python:3.12 AS compile-image -RUN apt-get update +RUN apt-get update \ + && apt-get upgrade -y --no-install-recommends \ + && (apt-get purge -y --auto-remove 'imagemagick*' 'libmagickcore-*' 'libmagickwand-*' || true) \ + && rm -rf /var/lib/apt/lists/* RUN python -m venv /opt/venv # Make sure we use the virtualenv: @@ -61,8 +64,11 @@ ENV PATH="/opt/venv/bin:$PATH" # Define specific admin password at creation #ENV IRIS_ADM_PASSWORD="MySuperFirstPasswordIWant" -RUN apt update -RUN apt install -y p7zip-full pgp rsync postgresql-client +RUN apt-get update \ + && apt-get upgrade -y --no-install-recommends \ + && apt-get install -y --no-install-recommends p7zip-full pgp rsync postgresql-client \ + && (apt-get purge -y --auto-remove 'imagemagick*' 'libmagickcore-*' 'libmagickwand-*' || true) \ + && rm -rf /var/lib/apt/lists/* RUN mkdir /iriswebapp/ RUN mkdir -p /home/iris/certificates/web_certificates @@ -82,10 +88,6 @@ COPY ./source . COPY --from=compile-js-image /ui/dist/ /iriswebapp/static/ -# Add execution right to binaries needed by evtx2splunk for iris_evtx module -RUN chmod +x /iriswebapp/dependencies/evtxdump_binaries/linux/x64/fd -RUN chmod +x /iriswebapp/dependencies/evtxdump_binaries/linux/x64/evtx_dump - RUN chmod +x iris-entrypoint.sh RUN chmod +x wait-for-iriswebapp.sh #ENTRYPOINT [ "./iris-entrypoint.sh" ] diff --git a/docker/webApp/iris-entrypoint.sh b/docker/webApp/iris-entrypoint.sh index d7b5d9232..20c343672 100755 --- a/docker/webApp/iris-entrypoint.sh +++ b/docker/webApp/iris-entrypoint.sh @@ -27,6 +27,88 @@ if [[ -z $LOG_LEVEL ]]; then LOG_LEVEL='info' fi +# ---------------------------------------------------------------------- +# Operator-supplied CA bundle install. +# +# Two supported layouts under /etc/iris-ca/ (bind-mounted from +# ./certificates/ca-bundle/ per docker-compose.base.yml): +# +# 1. Legacy — a single concatenated bundle at +# /etc/iris-ca/extra-ca.crt. Preserved as-is for backward +# compatibility with existing deployments. +# +# 2. Drop-in — one or more *.crt / *.pem files placed anywhere +# under /etc/iris-ca/. Each is installed into the OS trust store +# individually. No manual concatenation with the system roots +# required — update-ca-certificates ADDS to the store, it does +# not replace it (unlike REQUESTS_CA_BUNDLE / SSL_CERT_FILE). +# +# Registering CAs at the OS level makes *every* TLS library inside +# the image trust them (requests, urllib3, the oic library, ldap, +# ...). Without this, setting REQUESTS_CA_BUNDLE only fixes the +# `requests` calls — the `oic` library opens its own session that +# ignores the env var and fails on internal CAs at the token +# endpoint. +# +# Idempotent: safe on every boot, tolerates the directory being +# missing or the operator not opting in. +_iris_ca_installed_any=0 + +if [[ -s /etc/iris-ca/extra-ca.crt ]]; then + if [[ ! -f /usr/local/share/ca-certificates/iris-extra-ca.crt ]] \ + || ! cmp -s /etc/iris-ca/extra-ca.crt /usr/local/share/ca-certificates/iris-extra-ca.crt; then + printf "Installing operator CA bundle into system trust store...\n" + cp /etc/iris-ca/extra-ca.crt /usr/local/share/ca-certificates/iris-extra-ca.crt + _iris_ca_installed_any=1 + fi +fi + +if [[ -d /etc/iris-ca ]]; then + while IFS= read -r -d '' ca_src; do + # Skip the legacy single-file bundle — already handled above. + [[ "${ca_src}" == "/etc/iris-ca/extra-ca.crt" ]] && continue + # Flatten nested paths into a unique filename so multiple CAs + # from subdirectories don't collide in the destination dir. + rel="${ca_src#/etc/iris-ca/}" + safe_name="iris-dropin-${rel//\//_}" + # update-ca-certificates only picks up files ending in .crt. + [[ "${safe_name}" == *.crt ]] || safe_name="${safe_name}.crt" + dst="/usr/local/share/ca-certificates/${safe_name}" + if [[ ! -f "${dst}" ]] || ! cmp -s "${ca_src}" "${dst}"; then + printf "Installing drop-in CA: %s\n" "${rel}" + cp "${ca_src}" "${dst}" + _iris_ca_installed_any=1 + fi + done < <(find /etc/iris-ca -maxdepth 4 -type f \( -name '*.crt' -o -name '*.pem' \) -print0 2>/dev/null) +fi + +if [[ "${_iris_ca_installed_any}" -eq 1 ]]; then + update-ca-certificates --fresh >/dev/null 2>&1 || \ + printf "WARN: update-ca-certificates failed (CAs not registered)\n" +fi + +# ---------------------------------------------------------------------- +# Point Python's HTTPS libraries at the OS trust store. +# +# `requests`, `urllib3`, and any library that goes through them (the +# `oic` OIDC client, webhook integrations, ...) default to certifi's +# bundle (/opt/venv/.../certifi/cacert.pem) — a hard-coded copy of +# Mozilla's public roots that does NOT include any CA we just +# installed. Setting REQUESTS_CA_BUNDLE / SSL_CERT_FILE to Debian's +# consolidated bundle (which update-ca-certificates rebuilt above) +# makes every Python HTTPS call trust the OS store, so the drop-in +# CAs are effective. +# +# Only set when the operator hasn't pinned these env vars themselves, +# so existing deployments that point at a bespoke bundle continue to +# work unchanged. +if [[ -z "${REQUESTS_CA_BUNDLE:-}" ]] && [[ -f /etc/ssl/certs/ca-certificates.crt ]]; then + export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt +fi +if [[ -z "${SSL_CERT_FILE:-}" ]] && [[ -f /etc/ssl/certs/ca-certificates.crt ]]; then + export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt +fi + printf "Running ${target} ...\n" trap 'pkill "^(celery|gunicorn)"; exit 0' TERM INT diff --git a/scripts/certbot-deploy-hook.sh b/scripts/certbot-deploy-hook.sh new file mode 100755 index 000000000..685f99259 --- /dev/null +++ b/scripts/certbot-deploy-hook.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# +# Certbot deploy-hook for IRIS. +# +# Wire this into certbot so renewed certificates land in the bind +# volume IRIS's nginx already reads from (./certificates/web_certificates/). +# The nginx entrypoint watches that directory for cert mtime changes +# and issues `nginx -s reload` on its own — no container restart, no +# manual copy step. +# +# Install (host, one-time): +# sudo cp iris-web/scripts/certbot-deploy-hook.sh /etc/letsencrypt/renewal-hooks/deploy/iris.sh +# sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/iris.sh +# # Point IRIS_WEB_CERT_DIR at your absolute path to certificates/web_certificates/ +# sudo sed -i 's|__IRIS_WEB_CERT_DIR__|/opt/iris/iris-web/certificates/web_certificates|' \ +# /etc/letsencrypt/renewal-hooks/deploy/iris.sh +# +# Certbot will invoke this after every successful renewal. Set +# RENEWED_LINEAGE (certbot does this automatically) or pass it in +# manually to test. + +set -eu + +# Absolute path to iris-web/certificates/web_certificates/ on the host. +# Override on the command line or edit this default. +IRIS_WEB_CERT_DIR="${IRIS_WEB_CERT_DIR:-__IRIS_WEB_CERT_DIR__}" + +if [ -z "${RENEWED_LINEAGE:-}" ]; then + echo "certbot-deploy-hook: RENEWED_LINEAGE unset — invoke via certbot --deploy-hook" >&2 + exit 1 +fi + +if [ ! -d "${IRIS_WEB_CERT_DIR}" ]; then + echo "certbot-deploy-hook: ${IRIS_WEB_CERT_DIR} does not exist" >&2 + exit 1 +fi + +# Install with permissions that let the nginx container (uid mapped to +# www-data) read the material while denying world access to the key. +install -m 0644 "${RENEWED_LINEAGE}/fullchain.pem" "${IRIS_WEB_CERT_DIR}/fullchain.pem" +install -m 0640 "${RENEWED_LINEAGE}/privkey.pem" "${IRIS_WEB_CERT_DIR}/privkey.pem" + +echo "certbot-deploy-hook: refreshed certs in ${IRIS_WEB_CERT_DIR}" +# The nginx container's watcher will notice the mtime change and +# reload on its next tick (default 60s). diff --git a/source/app/__init__.py b/source/app/__init__.py index 840d6b081..2f8b1e830 100644 --- a/source/app/__init__.py +++ b/source/app/__init__.py @@ -187,7 +187,15 @@ def after_request(response): response.headers.add('Access-Control-Allow-Origin', app.config['IRIS_ALLOW_ORIGIN']) response.headers.add('Access-Control-Allow-Credentials', 'true') response.headers.add('Access-Control-Allow-Headers', 'Content-Type, Authorization') - response.headers.add('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + # PATCH was missing — preflight for `PATCH /api/v2/war-rooms/` + # (and any other v2 PATCH endpoint we add) failed the CORS check + # because this list didn't include the verb. Browsers cache failed + # preflights for a few seconds, so a stale tab may keep failing + # briefly after this lands. + response.headers.add( + 'Access-Control-Allow-Methods', + 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + ) return response @@ -208,7 +216,11 @@ def after_request(response): from app.blueprints.socket_io_event_handlers.case_event_handlers import register_case_event_handlers from app.blueprints.socket_io_event_handlers.case_notes_event_handlers import register_notes_event_handlers from app.blueprints.socket_io_event_handlers.update_event_handlers import register_update_event_handlers +from app.blueprints.socket_io_event_handlers.notification_event_handlers import register_notification_socket_handlers +from app.blueprints.socket_io_event_handlers.collab_event_handlers import register_collab_socket_handlers register_case_event_handlers() register_notes_event_handlers() register_update_event_handlers() +register_notification_socket_handlers() +register_collab_socket_handlers() diff --git a/source/app/alembic/alembic_utils.py b/source/app/alembic/alembic_utils.py index e066bd3ba..5e6bf568b 100644 --- a/source/app/alembic/alembic_utils.py +++ b/source/app/alembic/alembic_utils.py @@ -1,43 +1,54 @@ from alembic import op -from sqlalchemy import engine_from_config -from sqlalchemy.engine import reflection - - +from sqlalchemy import inspect from sqlalchemy import text -def _table_has_column(table, column): - config = op.get_context().config - engine = engine_from_config( - config.get_section(config.config_ini_section), prefix='sqlalchemy.') - connection = engine.connect() - try: - result = connection.execute(text(f"SELECT * FROM \"{table}\" LIMIT 1")) - columns = result.keys() - except Exception: +# All three helpers below now reuse the bind that alembic already +# opened for the current upgrade transaction (`op.get_bind()`). +# +# The previous implementation called `engine_from_config(...)` per +# call which spun up a brand new SQLAlchemy engine — and never +# disposed it. Each leaked engine kept a connection pool of 5 + 10 +# overflow connections alive against Postgres. A single migration +# run that hits these helpers a handful of times can therefore +# exhaust `max_connections`, and the app start-up sequence loops +# this every time the container restarts. The end-user-visible +# symptom is `FATAL: sorry, too many clients already` shortly after +# boot. +# +# `op.get_bind()` returns the connection alembic is *already* using +# for the migration, so we ride on top of it instead of opening a +# parallel one. It's also faster — no extra TCP/SSL handshake per +# helper call. + + +def _table_has_column(table_name, column_name): + """Return True when `table_name.column_name` exists in the DB.""" + bind = op.get_bind() + inspector = inspect(bind) + if table_name not in inspector.get_table_names(): return False - finally: - connection.close() - - has_column = column in columns - return has_column + columns = {c['name'] for c in inspector.get_columns(table_name)} + return column_name in columns def _has_table(table_name): - config = op.get_context().config - engine = engine_from_config( - config.get_section(config.config_ini_section), prefix="sqlalchemy." - ) - inspector = reflection.Inspector.from_engine(engine) - tables = inspector.get_table_names() - return table_name in tables + """Return True when `table_name` exists in the current schema.""" + bind = op.get_bind() + inspector = inspect(bind) + return table_name in inspector.get_table_names() def index_exists(table_name, index_name): - config = op.get_context().config - engine = engine_from_config( - config.get_section(config.config_ini_section), prefix="sqlalchemy." - ) - inspector = reflection.Inspector.from_engine(engine) + """Return True when an index named `index_name` exists on `table_name`.""" + bind = op.get_bind() + inspector = inspect(bind) + if table_name not in inspector.get_table_names(): + return False indexes = inspector.get_indexes(table_name) return any(index['name'] == index_name for index in indexes) + + +# Kept around because a handful of migrations still import `text` from +# here transitively. Re-export to avoid breaking those. +__all__ = ['_has_table', '_table_has_column', 'index_exists', 'text'] diff --git a/source/app/alembic/env.py b/source/app/alembic/env.py index b83b99ffe..54ede5402 100644 --- a/source/app/alembic/env.py +++ b/source/app/alembic/env.py @@ -72,8 +72,19 @@ def run_migrations_online(): connection=connection, target_metadata=target_metadata ) - #with context.begin_transaction(): -- Fixes stuck transaction. Need more info on that - context.run_migrations() + # `context.begin_transaction()` wraps the migrations in a + # transaction that alembic commits (updating alembic_version) + # on success. Without this block, "Will assume transactional + # DDL" still applies — Postgres wraps every DDL statement in + # a transaction — but nothing commits it, so the whole + # upgrade rolls back when the connection closes. The + # observable symptom is a log line saying "Running upgrade + # X -> Y" followed by no schema change and no + # alembic_version update. Old migrations that survived in + # this repo did so under an earlier env.py that had this + # wrapper. + with context.begin_transaction(): + context.run_migrations() if context.is_offline_mode(): diff --git a/source/app/alembic/versions/3dd1c1b644fb_merge_upstream_2026_07_collab_doc_head.py b/source/app/alembic/versions/3dd1c1b644fb_merge_upstream_2026_07_collab_doc_head.py new file mode 100644 index 000000000..867a23a37 --- /dev/null +++ b/source/app/alembic/versions/3dd1c1b644fb_merge_upstream_2026_07_collab_doc_head.py @@ -0,0 +1,28 @@ +"""Merge upstream 2026-07 collab_doc head + +Upstream pushed 5 more commits after our initial sync point (86a05892), +including e2f3g4h5i6j7 (add_collab_doc) chained off d1e2f3a4b5c6 — the +same parent our earlier merge migration (cc42df8caf1b) already used, +so pulling them in re-forked the alembic head into two. + +Revision ID: 3dd1c1b644fb +Revises: cc42df8caf1b, e2f3g4h5i6j7 +Create Date: 2026-07-07 00:00:00.000000 + +""" + +# revision identifiers, used by Alembic. +revision = '3dd1c1b644fb' +down_revision = ('cc42df8caf1b', 'e2f3g4h5i6j7') +branch_labels = None +depends_on = None + + +def upgrade(): + # No schema changes — merge point. Both branches are additive and + # schema-compatible up to this point. + pass + + +def downgrade(): + pass diff --git a/source/app/alembic/versions/a1b2c3d4e5f6_add_war_room_timeline_event_category.py b/source/app/alembic/versions/a1b2c3d4e5f6_add_war_room_timeline_event_category.py new file mode 100644 index 000000000..97e9fd68f --- /dev/null +++ b/source/app/alembic/versions/a1b2c3d4e5f6_add_war_room_timeline_event_category.py @@ -0,0 +1,43 @@ +"""Add `category` column to war_room_timeline_event. + +War-room timelines now expose a free-form category badge on events, +mirroring the case timeline's `event_category` taxonomy — but kept as +a plain string rather than a FK so war rooms don't need to share the +global category table. + +Idempotent via `_table_has_column` so re-running on an environment +that already has the column is a no-op. + +Revision ID: a1b2c3d4e5f6 +Revises: f6c213d80b41 +Create Date: 2026-06-28 12:00:00.000000 +""" +import sqlalchemy as sa +from alembic import op + +from app.alembic.alembic_utils import _has_table, _table_has_column + + +revision = 'a1b2c3d4e5f6' +down_revision = 'f6c213d80b41' +branch_labels = None +depends_on = None + + +def upgrade(): + if not _has_table('war_room_timeline_event'): + return + if _table_has_column('war_room_timeline_event', 'category'): + return + op.add_column( + 'war_room_timeline_event', + sa.Column('category', sa.String(length=64), nullable=True), + ) + + +def downgrade(): + if not _has_table('war_room_timeline_event'): + return + if not _table_has_column('war_room_timeline_event', 'category'): + return + op.drop_column('war_room_timeline_event', 'category') diff --git a/source/app/alembic/versions/a1c7d92f4b10_add_user_followed_case.py b/source/app/alembic/versions/a1c7d92f4b10_add_user_followed_case.py new file mode 100644 index 000000000..7f11dab68 --- /dev/null +++ b/source/app/alembic/versions/a1c7d92f4b10_add_user_followed_case.py @@ -0,0 +1,52 @@ +"""Add user_followed_case join table. + +Per-user "follow case" preference: each row links a user to a case the +user wants surfaced on their dashboard regardless of ownership. The +table is intentionally narrow — just the FK pair, a timestamp, and a +unique constraint — because follower-only metadata (notification +preferences, mute, etc.) is intentionally out of scope. + +ON DELETE CASCADE on both FKs so that deleting a user or a case +doesn't leave dangling follow rows pointing nowhere. + +Revision ID: a1c7d92f4b10 +Revises: e9b5f3d20a44 +Create Date: 2026-06-27 09: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 index_exists + + +revision = 'a1c7d92f4b10' +down_revision = 'e9b5f3d20a44' +branch_labels = None +depends_on = None + + +def upgrade(): + if not _has_table('user_followed_case'): + op.create_table( + 'user_followed_case', + sa.Column('user_id', sa.BigInteger(), nullable=False), + sa.Column('case_id', sa.BigInteger(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['case_id'], ['cases.case_id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id', 'case_id'), + sa.UniqueConstraint('user_id', 'case_id', + name='uq_user_followed_case_user_case'), + ) + # Helps the "followers of this case" query (case detail page count). + if not index_exists('user_followed_case', 'ix_user_followed_case_case_id'): + op.create_index('ix_user_followed_case_case_id', 'user_followed_case', ['case_id']) + + +def downgrade(): + if index_exists('user_followed_case', 'ix_user_followed_case_case_id'): + op.drop_index('ix_user_followed_case_case_id', table_name='user_followed_case') + if _has_table('user_followed_case'): + op.drop_table('user_followed_case') diff --git a/source/app/alembic/versions/a1c9f7b2e3d4_add_incidents_rules_and_investigation_flows.py b/source/app/alembic/versions/a1c9f7b2e3d4_add_incidents_rules_and_investigation_flows.py new file mode 100644 index 000000000..715059b80 --- /dev/null +++ b/source/app/alembic/versions/a1c9f7b2e3d4_add_incidents_rules_and_investigation_flows.py @@ -0,0 +1,214 @@ +"""Add incidents, incident rules, and investigation flows. + +Creates the tables backing the alert-stacking + guided-triage features: + + * `incident_status` — lookup (Open / Investigating / Dismissed / Escalated) + * `incidents` — the alert-container entity + * `alert_incident_association` — N:N alerts↔incidents + * `incident_rules` — flexible auto-stacking + auto-flow-attach rules + * `investigation_flows` — named checklist definitions + * `investigation_flow_steps` — ordered steps per flow + * `alert_investigation_progress` — per-alert step check-offs + * `alerts.alert_investigation_flow_id` — cached flow attachment on an alert + +Every step is guarded by `_has_table` / `_table_has_column` so the migration +is idempotent — safe to re-run on partially-upgraded environments. Permission +flags are enum bitmasks on `Group.group_permissions` (no DB rows to seed). +The four IncidentStatus rows are seeded by `post_init.create_safe_incident_status()` +at boot, not by the migration, to match how AlertStatus / AlertResolutionStatus +are handled. + +Revision ID: a1c9f7b2e3d4 +Revises: f2b6a4d19e3c +Create Date: 2026-07-02 10: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 = 'a1c9f7b2e3d4' +down_revision = 'f2b6a4d19e3c' +branch_labels = None +depends_on = None + + +def _create_incident_status(): + if _has_table('incident_status'): + return + op.create_table( + 'incident_status', + sa.Column('status_id', sa.Integer(), primary_key=True), + sa.Column('status_name', sa.Text(), nullable=False), + sa.Column('status_description', sa.Text(), nullable=True), + sa.UniqueConstraint('status_name', name='uq_incident_status_name'), + ) + + +def _create_incident_rules(): + if _has_table('incident_rules'): + return + op.create_table( + 'incident_rules', + sa.Column('rule_id', sa.BigInteger(), primary_key=True), + sa.Column('rule_uuid', sa.dialects.postgresql.UUID(as_uuid=True), + nullable=False, server_default=sa.text('gen_random_uuid()')), + sa.Column('rule_name', sa.Text(), nullable=False), + sa.Column('rule_description', sa.Text(), nullable=True), + sa.Column('rule_is_active', sa.Boolean(), nullable=False, + server_default=sa.text('true')), + sa.Column('rule_priority', sa.Integer(), nullable=False, + server_default=sa.text('100')), + sa.Column('rule_customer_scope', sa.dialects.postgresql.JSONB(), nullable=True), + sa.Column('rule_conditions', sa.dialects.postgresql.JSONB(), nullable=False), + sa.Column('rule_action_type', sa.Text(), nullable=False), + sa.Column('rule_action_config', sa.dialects.postgresql.JSONB(), + nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column('rule_created_by', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True), + sa.Column('rule_created_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.Column('rule_updated_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.UniqueConstraint('rule_uuid', name='uq_incident_rules_uuid'), + ) + + +def _create_investigation_flows(): + if _has_table('investigation_flows'): + return + op.create_table( + 'investigation_flows', + sa.Column('flow_id', sa.BigInteger(), primary_key=True), + sa.Column('flow_uuid', sa.dialects.postgresql.UUID(as_uuid=True), + nullable=False, server_default=sa.text('gen_random_uuid()')), + sa.Column('flow_name', sa.Text(), nullable=False), + sa.Column('flow_description', sa.Text(), nullable=True), + sa.Column('flow_is_active', sa.Boolean(), nullable=False, + server_default=sa.text('true')), + sa.Column('flow_customer_scope', sa.dialects.postgresql.JSONB(), nullable=True), + sa.Column('flow_created_by', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True), + sa.Column('flow_created_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.Column('flow_updated_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.UniqueConstraint('flow_uuid', name='uq_investigation_flows_uuid'), + ) + + +def _create_investigation_flow_steps(): + if _has_table('investigation_flow_steps'): + return + op.create_table( + 'investigation_flow_steps', + sa.Column('step_id', sa.BigInteger(), primary_key=True), + sa.Column('flow_id', sa.BigInteger(), + sa.ForeignKey('investigation_flows.flow_id', ondelete='CASCADE'), + nullable=False, index=True), + sa.Column('step_order', sa.Integer(), nullable=False), + sa.Column('step_title', sa.Text(), nullable=False), + sa.Column('step_description', sa.Text(), nullable=True), + sa.Column('step_is_required', sa.Boolean(), nullable=False, + server_default=sa.text('false')), + ) + + +def _add_alert_investigation_flow_column(): + if _table_has_column('alerts', 'alert_investigation_flow_id'): + return + op.add_column( + 'alerts', + sa.Column('alert_investigation_flow_id', sa.BigInteger(), + sa.ForeignKey('investigation_flows.flow_id'), nullable=True), + ) + + +def _create_incidents(): + if _has_table('incidents'): + return + op.create_table( + 'incidents', + sa.Column('incident_id', sa.BigInteger(), primary_key=True), + sa.Column('incident_uuid', sa.dialects.postgresql.UUID(as_uuid=True), + nullable=False, server_default=sa.text('gen_random_uuid()')), + sa.Column('incident_title', sa.Text(), nullable=False), + sa.Column('incident_description', sa.Text(), nullable=True), + sa.Column('incident_status_id', sa.Integer(), + sa.ForeignKey('incident_status.status_id'), nullable=False), + sa.Column('incident_severity_id', sa.Integer(), + sa.ForeignKey('severities.severity_id'), nullable=True), + sa.Column('incident_customer_id', sa.BigInteger(), + sa.ForeignKey('client.client_id'), nullable=False), + sa.Column('incident_owner_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True), + sa.Column('incident_creation_time', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.Column('incident_source_rule_id', sa.BigInteger(), + sa.ForeignKey('incident_rules.rule_id'), nullable=True), + sa.Column('incident_case_id', sa.BigInteger(), + sa.ForeignKey('cases.case_id'), nullable=True), + sa.Column('incident_dedupe_key', sa.Text(), nullable=True, index=True), + sa.Column('modification_history', sa.dialects.postgresql.JSON(), nullable=True), + sa.UniqueConstraint('incident_uuid', name='uq_incidents_uuid'), + ) + + +def _create_alert_incident_association(): + if _has_table('alert_incident_association'): + return + op.create_table( + 'alert_incident_association', + sa.Column('alert_id', sa.BigInteger(), + sa.ForeignKey('alerts.alert_id'), primary_key=True, nullable=False), + sa.Column('incident_id', sa.BigInteger(), + sa.ForeignKey('incidents.incident_id'), primary_key=True, + nullable=False, index=True), + ) + + +def _create_alert_investigation_progress(): + if _has_table('alert_investigation_progress'): + return + op.create_table( + 'alert_investigation_progress', + sa.Column('id', sa.BigInteger(), primary_key=True), + sa.Column('alert_id', sa.BigInteger(), + sa.ForeignKey('alerts.alert_id', ondelete='CASCADE'), + nullable=False, index=True), + sa.Column('step_id', sa.BigInteger(), + sa.ForeignKey('investigation_flow_steps.step_id', ondelete='CASCADE'), + nullable=False), + sa.Column('completed_by_user_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=False), + sa.Column('completed_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.Column('note', sa.Text(), nullable=True), + sa.UniqueConstraint('alert_id', 'step_id', + name='uq_alert_investigation_progress_alert_step'), + ) + + +def upgrade(): + _create_incident_status() + _create_incident_rules() + _create_investigation_flows() + _create_investigation_flow_steps() + _add_alert_investigation_flow_column() + _create_incidents() + _create_alert_incident_association() + _create_alert_investigation_progress() + + +def downgrade(): + op.drop_table('alert_investigation_progress') + op.drop_table('alert_incident_association') + op.drop_table('incidents') + if _table_has_column('alerts', 'alert_investigation_flow_id'): + op.drop_column('alerts', 'alert_investigation_flow_id') + op.drop_table('investigation_flow_steps') + op.drop_table('investigation_flows') + op.drop_table('incident_rules') + op.drop_table('incident_status') diff --git a/source/app/alembic/versions/b2c3d4e5f7a8_add_war_room_chat_threads.py b/source/app/alembic/versions/b2c3d4e5f7a8_add_war_room_chat_threads.py new file mode 100644 index 000000000..26f748115 --- /dev/null +++ b/source/app/alembic/versions/b2c3d4e5f7a8_add_war_room_chat_threads.py @@ -0,0 +1,110 @@ +"""Add threading to war_room_chat_message + follower table. + +Threads are two-level: a top-level message can have any number of +replies pointing at it via `parent_message_id`, but replies cannot +themselves be parents (enforced at the business layer). A thread root +that has been promoted to a named topic via `/thread ` carries +its label in `thread_title`. + +`war_room_thread_follower` is the per-user follow flag for a thread +root — used by the UI today and the notification pipeline later. + +Idempotent via `_has_table` / `_table_has_column` so re-running on +environments that already applied the migration is a no-op. + +Revision ID: b2c3d4e5f7a8 +Revises: a1b2c3d4e5f6 +Create Date: 2026-06-28 15:00:00.000000 +""" +import sqlalchemy as sa +from alembic import op + +from app.alembic.alembic_utils import _has_table, _table_has_column + + +revision = 'b2c3d4e5f7a8' +down_revision = 'a1b2c3d4e5f6' +branch_labels = None +depends_on = None + + +def upgrade(): + if _has_table('war_room_chat_message'): + if not _table_has_column('war_room_chat_message', 'parent_message_id'): + op.add_column( + 'war_room_chat_message', + sa.Column( + 'parent_message_id', sa.BigInteger(), + sa.ForeignKey( + 'war_room_chat_message.message_id', ondelete='CASCADE' + ), + nullable=True, + ), + ) + op.create_index( + 'ix_war_room_chat_message_parent_message_id', + 'war_room_chat_message', + ['parent_message_id'], + ) + if not _table_has_column('war_room_chat_message', 'thread_title'): + op.add_column( + 'war_room_chat_message', + sa.Column('thread_title', sa.String(length=160), nullable=True), + ) + + if not _has_table('war_room_thread_follower'): + op.create_table( + 'war_room_thread_follower', + sa.Column('id', sa.BigInteger(), primary_key=True), + sa.Column( + 'message_id', sa.BigInteger(), + sa.ForeignKey( + 'war_room_chat_message.message_id', ondelete='CASCADE' + ), + nullable=False, + ), + sa.Column( + 'user_id', sa.BigInteger(), + sa.ForeignKey('user.id', ondelete='CASCADE'), + nullable=False, + ), + sa.Column( + 'created_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()'), + ), + sa.UniqueConstraint('message_id', 'user_id', + name='uq_war_room_thread_follower'), + ) + op.create_index( + 'ix_war_room_thread_follower_message_id', + 'war_room_thread_follower', + ['message_id'], + ) + op.create_index( + 'ix_war_room_thread_follower_user_id', + 'war_room_thread_follower', + ['user_id'], + ) + + +def downgrade(): + if _has_table('war_room_thread_follower'): + op.drop_index( + 'ix_war_room_thread_follower_user_id', + table_name='war_room_thread_follower', + ) + op.drop_index( + 'ix_war_room_thread_follower_message_id', + table_name='war_room_thread_follower', + ) + op.drop_table('war_room_thread_follower') + + if _has_table('war_room_chat_message'): + if _table_has_column('war_room_chat_message', 'thread_title'): + op.drop_column('war_room_chat_message', 'thread_title') + if _table_has_column('war_room_chat_message', 'parent_message_id'): + op.drop_index( + 'ix_war_room_chat_message_parent_message_id', + table_name='war_room_chat_message', + ) + op.drop_column('war_room_chat_message', 'parent_message_id') diff --git a/source/app/alembic/versions/b2d0e8a9f4c1_decouple_flows_from_rules_add_flow_conditions.py b/source/app/alembic/versions/b2d0e8a9f4c1_decouple_flows_from_rules_add_flow_conditions.py new file mode 100644 index 000000000..016e01a98 --- /dev/null +++ b/source/app/alembic/versions/b2d0e8a9f4c1_decouple_flows_from_rules_add_flow_conditions.py @@ -0,0 +1,104 @@ +"""Decouple investigation flows from incident rules. + +Flows now own their own matching conditions and can attach to alerts +and/or incidents on their own — the `attach_flow` rule action is +retired (rules only stack alerts into incidents). This migration adds: + + * `investigation_flows.flow_target` — 'alert' / 'incident' / 'both' + * `investigation_flows.flow_conditions` — JSONB, same DSL as rules + * `investigation_flows.flow_priority` — tie-breaker + * `incidents.incident_investigation_flow_id` — cached FK + * `incident_investigation_progress` — per-incident check-off table + +Every step is guarded by `_has_table`/`_table_has_column` so the +migration is idempotent — safe to re-run. + +Revision ID: b2d0e8a9f4c1 +Revises: a1c9f7b2e3d4 +Create Date: 2026-07-02 14: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 = 'b2d0e8a9f4c1' +down_revision = 'a1c9f7b2e3d4' +branch_labels = None +depends_on = None + + +def _add_flow_columns(): + if not _table_has_column('investigation_flows', 'flow_target'): + op.add_column( + 'investigation_flows', + sa.Column('flow_target', sa.Text(), nullable=False, + server_default=sa.text("'alert'")), + ) + if not _table_has_column('investigation_flows', 'flow_conditions'): + op.add_column( + 'investigation_flows', + sa.Column( + 'flow_conditions', sa.dialects.postgresql.JSONB(), + nullable=False, + server_default=sa.text("'{\"logic\":\"and\",\"conditions\":[]}'::jsonb"), + ), + ) + if not _table_has_column('investigation_flows', 'flow_priority'): + op.add_column( + 'investigation_flows', + sa.Column('flow_priority', sa.Integer(), nullable=False, + server_default=sa.text('100')), + ) + + +def _add_incident_flow_column(): + if _table_has_column('incidents', 'incident_investigation_flow_id'): + return + op.add_column( + 'incidents', + sa.Column('incident_investigation_flow_id', sa.BigInteger(), + sa.ForeignKey('investigation_flows.flow_id'), nullable=True), + ) + + +def _create_incident_investigation_progress(): + if _has_table('incident_investigation_progress'): + return + op.create_table( + 'incident_investigation_progress', + sa.Column('id', sa.BigInteger(), primary_key=True), + sa.Column('incident_id', sa.BigInteger(), + sa.ForeignKey('incidents.incident_id', ondelete='CASCADE'), + nullable=False, index=True), + sa.Column('step_id', sa.BigInteger(), + sa.ForeignKey('investigation_flow_steps.step_id', ondelete='CASCADE'), + nullable=False), + sa.Column('completed_by_user_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=False), + sa.Column('completed_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.Column('note', sa.Text(), nullable=True), + sa.UniqueConstraint('incident_id', 'step_id', + name='uq_incident_investigation_progress_incident_step'), + ) + + +def upgrade(): + _add_flow_columns() + _add_incident_flow_column() + _create_incident_investigation_progress() + + +def downgrade(): + op.drop_table('incident_investigation_progress') + if _table_has_column('incidents', 'incident_investigation_flow_id'): + op.drop_column('incidents', 'incident_investigation_flow_id') + if _table_has_column('investigation_flows', 'flow_priority'): + op.drop_column('investigation_flows', 'flow_priority') + if _table_has_column('investigation_flows', 'flow_conditions'): + op.drop_column('investigation_flows', 'flow_conditions') + if _table_has_column('investigation_flows', 'flow_target'): + op.drop_column('investigation_flows', 'flow_target') diff --git a/source/app/alembic/versions/b2d8e91f5c20_add_case_timelines.py b/source/app/alembic/versions/b2d8e91f5c20_add_case_timelines.py new file mode 100644 index 000000000..5224ac39c --- /dev/null +++ b/source/app/alembic/versions/b2d8e91f5c20_add_case_timelines.py @@ -0,0 +1,101 @@ +"""Add named timelines per case + event<->timeline junction. + +Introduces `case_timelines` and `case_event_timelines` so a single +case can host multiple named timelines (e.g. "Attacker activity", +"Network", "Forensic") and the same event can appear on more than +one. The SPA's timeline view will render a multi-select sidebar over +these. + +Backfill: for each existing case, create a single "Main" timeline +(is_default=True) and attach every existing event to it. After this +migration the user-visible behaviour is unchanged — all events stay +on one timeline labeled "Main" — but the data model is in place for +the SPA to expose the multi-timeline UX. + +Revision ID: b2d8e91f5c20 +Revises: a1c7d92f4b10 +Create Date: 2026-06-27 09:30:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +from app.alembic.alembic_utils import _has_table + + +revision = 'b2d8e91f5c20' +down_revision = 'a1c7d92f4b10' +branch_labels = None +depends_on = None + + +def upgrade(): + if not _has_table('case_timelines'): + op.create_table( + 'case_timelines', + sa.Column('timeline_id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('case_id', sa.BigInteger(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column('is_default', sa.Boolean(), nullable=False, + server_default=sa.text('false')), + sa.Column('created_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.Column('created_by_id', sa.BigInteger(), nullable=True), + sa.ForeignKeyConstraint(['case_id'], ['cases.case_id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['created_by_id'], ['user.id']), + sa.PrimaryKeyConstraint('timeline_id'), + sa.UniqueConstraint('case_id', 'name', name='uq_case_timelines_case_name'), + ) + op.create_index('ix_case_timelines_case_id', 'case_timelines', ['case_id']) + + if not _has_table('case_event_timelines'): + op.create_table( + 'case_event_timelines', + sa.Column('event_id', sa.BigInteger(), nullable=False), + sa.Column('timeline_id', sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint(['event_id'], ['cases_events.event_id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['timeline_id'], ['case_timelines.timeline_id'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('event_id', 'timeline_id'), + ) + op.create_index('ix_case_event_timelines_timeline_id', + 'case_event_timelines', ['timeline_id']) + + # Backfill: one "Main" timeline per existing case, all current + # events linked to it. We run this as raw SQL so it works on + # databases that already have user data — no per-row Python loop. + bind = op.get_bind() + bind.execute(sa.text( + """ + INSERT INTO case_timelines (case_id, name, is_default) + SELECT c.case_id, 'Main', true + FROM cases c + WHERE NOT EXISTS ( + SELECT 1 FROM case_timelines t + WHERE t.case_id = c.case_id AND t.is_default = true + ) + """ + )) + bind.execute(sa.text( + """ + INSERT INTO case_event_timelines (event_id, timeline_id) + SELECT e.event_id, t.timeline_id + FROM cases_events e + JOIN case_timelines t ON t.case_id = e.case_id AND t.is_default = true + WHERE NOT EXISTS ( + SELECT 1 FROM case_event_timelines x + WHERE x.event_id = e.event_id AND x.timeline_id = t.timeline_id + ) + """ + )) + + +def downgrade(): + if _has_table('case_event_timelines'): + op.drop_index('ix_case_event_timelines_timeline_id', + table_name='case_event_timelines') + op.drop_table('case_event_timelines') + if _has_table('case_timelines'): + op.drop_index('ix_case_timelines_case_id', table_name='case_timelines') + op.drop_table('case_timelines') diff --git a/source/app/alembic/versions/c3e9fa0e6d31_backfill_events_to_default_timeline.py b/source/app/alembic/versions/c3e9fa0e6d31_backfill_events_to_default_timeline.py new file mode 100644 index 000000000..8af4f0194 --- /dev/null +++ b/source/app/alembic/versions/c3e9fa0e6d31_backfill_events_to_default_timeline.py @@ -0,0 +1,73 @@ +"""Re-run the event<->default-timeline backfill. + +The previous migration (`b2d8e91f5c20_add_case_timelines`) shipped the +backfill SQL alongside the schema change, but a class of dev / staging +environments ended up with the schema applied while leaving the +backfill rows missing — either because the migration was run on a +partial intermediate revision of this branch, or because new events +created via the legacy `/case/timeline/events/...` endpoint (which +predate the timeline-aware event handlers) landed without an entry in +`case_event_timelines`. + +This migration is a safety net: for every case that has a default +timeline AND events that are not yet linked to ANY timeline, it +attaches those events to the default. Idempotent — if everything is +already wired, it inserts nothing. + +Revision ID: c3e9fa0e6d31 +Revises: b2d8e91f5c20 +Create Date: 2026-06-27 10:30:00.000000 +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'c3e9fa0e6d31' +down_revision = 'b2d8e91f5c20' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + + # First make sure every case has a Main default timeline. This + # duplicates the previous migration's guarantee but costs nothing + # when the row already exists. + bind.execute(sa.text( + """ + INSERT INTO case_timelines (case_id, name, is_default) + SELECT c.case_id, 'Main', true + FROM cases c + WHERE NOT EXISTS ( + SELECT 1 FROM case_timelines t + WHERE t.case_id = c.case_id AND t.is_default = true + ) + """ + )) + + # Attach every event that lives on no timeline at all to its case's + # default timeline. The `NOT EXISTS` against any timeline membership + # (not just the default) means events the user explicitly removed + # from every timeline get re-attached — but the "no timelines" state + # only happens when the row was never backfilled in the first place, + # so this is safe. + bind.execute(sa.text( + """ + INSERT INTO case_event_timelines (event_id, timeline_id) + SELECT e.event_id, t.timeline_id + FROM cases_events e + JOIN case_timelines t ON t.case_id = e.case_id AND t.is_default = true + WHERE NOT EXISTS ( + SELECT 1 FROM case_event_timelines x + WHERE x.event_id = e.event_id + ) + """ + )) + + +def downgrade(): + # Backfill is data-only — no schema to revert. A downgrade is a + # no-op rather than blowing away user data that may include + # legitimate explicit attachments made after the upgrade. + pass diff --git a/source/app/alembic/versions/c4d1a2b7f503_add_war_room_archive.py b/source/app/alembic/versions/c4d1a2b7f503_add_war_room_archive.py new file mode 100644 index 000000000..001968080 --- /dev/null +++ b/source/app/alembic/versions/c4d1a2b7f503_add_war_room_archive.py @@ -0,0 +1,69 @@ +"""Add archive columns to war_room. + +Archive is a filing decision independent from the operational state +(`open` / `active` / `standby` / `closed`). A war room can be archived +regardless of its state — the point is to move it out of the default +sight line once the operator no longer needs to see it, while keeping +it fully readable. + +Two nullable columns are added: + + * `archived_at` — timestamp; NULL when the room is live. + * `archived_by_id` — FK to `user.id`; who filed the room away. + +Idempotent via `_has_table` / `_table_has_column` so re-running is a +no-op. + +Revision ID: c4d1a2b7f503 +Revises: b2c3d4e5f7a8 +Create Date: 2026-07-01 12:00:00.000000 +""" +import sqlalchemy as sa +from alembic import op + +from app.alembic.alembic_utils import _has_table, _table_has_column + + +revision = 'c4d1a2b7f503' +down_revision = 'b2c3d4e5f7a8' +branch_labels = None +depends_on = None + + +def upgrade(): + if not _has_table('war_room'): + return + + if not _table_has_column('war_room', 'archived_at'): + op.add_column( + 'war_room', + sa.Column('archived_at', sa.DateTime(), nullable=True), + ) + op.create_index( + 'ix_war_room_archived_at', + 'war_room', + ['archived_at'], + ) + + if not _table_has_column('war_room', 'archived_by_id'): + op.add_column( + 'war_room', + sa.Column( + 'archived_by_id', sa.BigInteger(), + sa.ForeignKey('user.id'), + nullable=True, + ), + ) + + +def downgrade(): + if not _has_table('war_room'): + return + if _table_has_column('war_room', 'archived_by_id'): + op.drop_column('war_room', 'archived_by_id') + if _table_has_column('war_room', 'archived_at'): + try: + op.drop_index('ix_war_room_archived_at', table_name='war_room') + except Exception: + pass + op.drop_column('war_room', 'archived_at') diff --git a/source/app/alembic/versions/c7f1e2a4d810_add_custom_dashboards.py b/source/app/alembic/versions/c7f1e2a4d810_add_custom_dashboards.py new file mode 100644 index 000000000..42018e1a9 --- /dev/null +++ b/source/app/alembic/versions/c7f1e2a4d810_add_custom_dashboards.py @@ -0,0 +1,228 @@ +"""Add custom dashboards tables and seed Statistics dashboard + +Revision ID: c7f1e2a4d810 +Revises: afcff5ebcf7c +Create Date: 2026-06-25 10:00:00.000000 + +""" +import json + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from sqlalchemy.sql import table, column + +from app.alembic.alembic_utils import _has_table, _table_has_column + + +revision = 'c7f1e2a4d810' +down_revision = 'afcff5ebcf7c' +branch_labels = None +depends_on = None + + +STATISTICS_DASHBOARD_UUID = '00000000-0000-4000-8000-000000000001' + + +def _statistics_definition(): + return { + 'name': 'Statistics', + 'description': 'Built-in IRIS statistics dashboard. Clone to customise.', + 'is_shared': True, + 'is_system': True, + 'filters_schema': [ + {'key': 'start', 'label': 'Start date', 'type': 'date'}, + {'key': 'end', 'label': 'End date', 'type': 'date'}, + {'key': 'customer_id', 'label': 'Customer', 'type': 'reference', 'table': 'client', 'column': 'client_id'}, + {'key': 'severity_id', 'label': 'Severity', 'type': 'reference', 'table': 'severities', 'column': 'severity_id'}, + {'key': 'case_status_id', 'label': 'Case status', 'type': 'reference', 'table': 'case_state', 'column': 'state_id'} + ], + 'sections': [ + { + 'id': 'section-kpis', + 'title': 'Key indicators', + 'description': 'Headline volumes and response times.', + 'show_divider': False, + 'widgets': [ + { + 'name': 'Total alerts', + 'chart_type': 'number', + 'fields': [{'table': 'alerts', 'column': 'alert_id', 'aggregation': 'count', 'alias': 'total_alerts'}], + 'layout': {'widget_size': 'kpi'} + }, + { + 'name': 'Total cases', + 'chart_type': 'number', + 'fields': [{'table': 'cases', 'column': 'case_id', 'aggregation': 'count', 'alias': 'total_cases'}], + 'layout': {'widget_size': 'kpi'} + }, + { + 'name': 'Mean time to detect', + 'chart_type': 'number', + 'fields': [{'table': 'computed', 'column': 'mttd_seconds', 'alias': 'mttd_seconds'}], + 'options': {'value_format': 'duration'}, + 'layout': {'widget_size': 'kpi'} + }, + { + 'name': 'Mean time to resolve', + 'chart_type': 'number', + 'fields': [{'table': 'computed', 'column': 'mttr_seconds', 'alias': 'mttr_seconds'}], + 'options': {'value_format': 'duration'}, + 'layout': {'widget_size': 'kpi'} + } + ] + }, + { + 'id': 'section-charts', + 'title': 'Distributions', + 'description': 'How alerts, cases and evidence break down across the selected window.', + 'show_divider': True, + 'widgets': [ + { + 'name': 'Alerts by severity', + 'chart_type': 'pie', + 'fields': [ + {'table': 'alerts', 'column': 'alert_id', 'aggregation': 'count', 'alias': 'total'} + ], + 'group_by': ['severities.severity_name'], + 'layout': {'widget_size': 'half'} + }, + { + 'name': 'Cases by classification', + 'chart_type': 'bar', + 'fields': [ + {'table': 'cases', 'column': 'case_id', 'aggregation': 'count', 'alias': 'total'} + ], + 'group_by': ['case_classification.name_expanded'], + 'layout': {'widget_size': 'half'} + }, + { + 'name': 'Evidence by type', + 'chart_type': 'bar', + 'fields': [ + {'table': 'case_assets', 'column': 'asset_id', 'aggregation': 'count', 'alias': 'total'} + ], + 'group_by': ['case_asset_types.asset_name'], + 'layout': {'widget_size': 'full'} + } + ] + } + ] + } + + +def upgrade(): + if not _has_table('custom_dashboard'): + op.create_table( + 'custom_dashboard', + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('dashboard_uuid', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False, unique=True), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text, nullable=True), + sa.Column('owner_id', sa.Integer, sa.ForeignKey('user.id'), nullable=True), + sa.Column('is_shared', sa.Boolean, nullable=False, server_default=sa.text('false')), + sa.Column('is_system', sa.Boolean, nullable=False, server_default=sa.text('false')), + sa.Column('definition', postgresql.JSONB, nullable=True), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), nullable=False), + sa.Column('updated_at', sa.DateTime, server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False) + ) + + # Reconcile pre-existing custom_dashboard table (e.g. created by a prior + # alembic head or by SQLAlchemy metadata) with the schema this migration + # expects. Add is_system if missing, relax owner_id NOT NULL so system + # rows can be seeded. + if _has_table('custom_dashboard'): + if not _table_has_column('custom_dashboard', 'is_system'): + op.add_column( + 'custom_dashboard', + sa.Column('is_system', sa.Boolean, nullable=False, server_default=sa.text('false')), + ) + op.execute(sa.text('ALTER TABLE custom_dashboard ALTER COLUMN owner_id DROP NOT NULL')) + + if not _has_table('custom_dashboard_widget'): + op.create_table( + 'custom_dashboard_widget', + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('widget_uuid', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False, unique=True), + sa.Column('dashboard_id', sa.Integer, sa.ForeignKey('custom_dashboard.id', ondelete='CASCADE'), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('chart_type', sa.String(length=64), nullable=False), + sa.Column('definition', postgresql.JSONB, nullable=False), + sa.Column('position', sa.Integer, nullable=False, server_default=sa.text('0')), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), nullable=False), + sa.Column('updated_at', sa.DateTime, server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False) + ) + op.create_index('ix_custom_dashboard_widget_dashboard_id', 'custom_dashboard_widget', ['dashboard_id']) + + dashboard_table = table( + 'custom_dashboard', + column('dashboard_uuid', postgresql.UUID(as_uuid=True)), + column('name', sa.String), + column('description', sa.Text), + column('owner_id', sa.Integer), + column('is_shared', sa.Boolean), + column('is_system', sa.Boolean), + column('definition', postgresql.JSONB) + ) + widget_table = table( + 'custom_dashboard_widget', + column('dashboard_id', sa.Integer), + column('name', sa.String), + column('chart_type', sa.String), + column('definition', postgresql.JSONB), + column('position', sa.Integer) + ) + + bind = op.get_bind() + existing = bind.execute( + sa.text('SELECT id FROM custom_dashboard WHERE dashboard_uuid = :u'), + {'u': STATISTICS_DASHBOARD_UUID} + ).fetchone() + if existing is not None: + return + + definition = _statistics_definition() + op.bulk_insert(dashboard_table, [{ + 'dashboard_uuid': STATISTICS_DASHBOARD_UUID, + 'name': definition['name'], + 'description': definition['description'], + 'owner_id': None, + 'is_shared': True, + 'is_system': True, + 'definition': definition + }]) + + inserted = bind.execute( + sa.text('SELECT id FROM custom_dashboard WHERE dashboard_uuid = :u'), + {'u': STATISTICS_DASHBOARD_UUID} + ).fetchone() + if inserted is None: + return + dashboard_id = inserted[0] + + widgets = [] + position = 0 + for section in definition.get('sections', []): + for widget in section.get('widgets', []): + widget_payload = dict(widget) + widget_payload.setdefault('layout', {})['section_id'] = section.get('id') + widget_payload['layout']['section_title'] = section.get('title') + widgets.append({ + 'dashboard_id': dashboard_id, + 'name': widget['name'], + 'chart_type': widget['chart_type'], + 'definition': widget_payload, + 'position': position + }) + position += 1 + if widgets: + op.bulk_insert(widget_table, widgets) + + +def downgrade(): + if _has_table('custom_dashboard_widget'): + op.drop_index('ix_custom_dashboard_widget_dashboard_id', table_name='custom_dashboard_widget') + op.drop_table('custom_dashboard_widget') + + if _has_table('custom_dashboard'): + op.drop_table('custom_dashboard') diff --git a/source/app/alembic/versions/c8f3a2d47b19_add_incident_id_in_comments.py b/source/app/alembic/versions/c8f3a2d47b19_add_incident_id_in_comments.py new file mode 100644 index 000000000..7a0d35f8d --- /dev/null +++ b/source/app/alembic/versions/c8f3a2d47b19_add_incident_id_in_comments.py @@ -0,0 +1,38 @@ +"""Add incident scope to comments. + +Mirrors the alert path (`comment_alert_id`) so incidents can carry a +first-class analyst comment stream, feeding the incident detail +Activity tab. + +Revision ID: c8f3a2d47b19 +Revises: b2d0e8a9f4c1 +Create Date: 2026-07-02 12:00:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +from app.alembic.alembic_utils import _table_has_column + + +revision = 'c8f3a2d47b19' +down_revision = 'b2d0e8a9f4c1' +branch_labels = None +depends_on = None + + +def upgrade(): + if not _table_has_column('comments', 'comment_incident_id'): + op.add_column( + 'comments', + sa.Column('comment_incident_id', sa.BigInteger(), nullable=True), + ) + op.create_foreign_key( + 'fk_comments_incident_id', + 'comments', 'incidents', + ['comment_incident_id'], ['incident_id'], + ) + + +def downgrade(): + op.drop_constraint('fk_comments_incident_id', 'comments', type_='foreignkey') + op.drop_column('comments', 'comment_incident_id') diff --git a/source/app/alembic/versions/cc42df8caf1b_merge_upstream_2026_07_sync_heads.py b/source/app/alembic/versions/cc42df8caf1b_merge_upstream_2026_07_sync_heads.py new file mode 100644 index 000000000..28c8d8746 --- /dev/null +++ b/source/app/alembic/versions/cc42df8caf1b_merge_upstream_2026_07_sync_heads.py @@ -0,0 +1,28 @@ +"""Merge upstream 2026-07 sync heads + +Syncing upstream/develop (through 86a05892, 2026-07-03) into bv-develop +introduced a second alembic head: d1e2f3a4b5c6 (add_war_room_id_to_user_activity), +built on top of upstream's own migration chain, diverging from bv-develop's +prior merge point 2b9a1f8c3d47. + +Revision ID: cc42df8caf1b +Revises: 2b9a1f8c3d47, d1e2f3a4b5c6 +Create Date: 2026-07-07 00:00:00.000000 + +""" + +# revision identifiers, used by Alembic. +revision = 'cc42df8caf1b' +down_revision = ('2b9a1f8c3d47', 'd1e2f3a4b5c6') +branch_labels = None +depends_on = None + + +def upgrade(): + # No schema changes — merge point. Both branches are additive and + # schema-compatible up to this point. + pass + + +def downgrade(): + pass diff --git a/source/app/alembic/versions/d1e2f3a4b5c6_add_war_room_id_to_user_activity.py b/source/app/alembic/versions/d1e2f3a4b5c6_add_war_room_id_to_user_activity.py new file mode 100644 index 000000000..b7abeb881 --- /dev/null +++ b/source/app/alembic/versions/d1e2f3a4b5c6_add_war_room_id_to_user_activity.py @@ -0,0 +1,56 @@ +"""Add war_room_id to user_activity. + +The UserActivity audit trail was scoped only to cases, so anything that +happens on a war room (create, member changes, tasks, notes, sitreps, +timelines, datastore, access) had no home in the Activities tab. Adding +a nullable FK to war_room lets the tracker log war-room-scoped events +without inventing a separate table, and lets the listing endpoint +filter by war room. + +Idempotent via `_has_table` / `_table_has_column` — safe to re-run. + +Revision ID: d1e2f3a4b5c6 +Revises: c8f3a2d47b19 +Create Date: 2026-07-03 12:00:00.000000 +""" +import sqlalchemy as sa +from alembic import op + +from app.alembic.alembic_utils import _has_table, _table_has_column + + +revision = 'd1e2f3a4b5c6' +down_revision = 'c8f3a2d47b19' +branch_labels = None +depends_on = None + + +def upgrade(): + if not _has_table('user_activity'): + return + + if not _table_has_column('user_activity', 'war_room_id'): + op.add_column( + 'user_activity', + sa.Column( + 'war_room_id', sa.BigInteger(), + sa.ForeignKey('war_room.war_room_id'), + nullable=True, + ), + ) + op.create_index( + 'ix_user_activity_war_room_id', + 'user_activity', + ['war_room_id'], + ) + + +def downgrade(): + if not _has_table('user_activity'): + return + if _table_has_column('user_activity', 'war_room_id'): + try: + op.drop_index('ix_user_activity_war_room_id', table_name='user_activity') + except Exception: + pass + op.drop_column('user_activity', 'war_room_id') diff --git a/source/app/alembic/versions/d4f0a23c8e51_add_war_rooms.py b/source/app/alembic/versions/d4f0a23c8e51_add_war_rooms.py new file mode 100644 index 000000000..9aee63e0c --- /dev/null +++ b/source/app/alembic/versions/d4f0a23c8e51_add_war_rooms.py @@ -0,0 +1,480 @@ +"""Add the war rooms feature. + +Creates every table behind the war-room workspace: + + * `war_room` — top-level workspace row + * `war_room_case` — N:N to attached cases + * `war_room_member` — roster + * `war_room_timeline` / `war_room_timeline_event` — per-war-room timelines + * `war_room_chat_message` / `war_room_chat_reaction` — chat stream + * `war_room_task` — war-room-level tasks + * `war_room_note` — sticky notes / collab markdown + * `war_room_sitrep` — versioned situational reports + * `war_room_graph_node` / `war_room_graph_edge` — cases-as-graph board + * `war_room_datastore_file` — files attached directly to the war room + * `user_war_room_access`, `group_war_room_access`, + `user_war_room_effective_access` — ACL precedence chain + +Every step is guarded by `_has_table`/`_table_has_column`/`index_exists` +so the migration is idempotent on any existing IRIS deployment — older +versions, partial-upgrade environments, and re-runs after a failed +attempt all converge on the same schema without raising on existing +objects. + +Three new permission flags (`war_rooms_read`/_write/_create) are +defined as enum values rather than DB rows, so no permission table +seeding is needed — the bitmask values land on `Group.group_permissions` +the first time an admin toggles them on. + +Revision ID: d4f0a23c8e51 +Revises: c3e9fa0e6d31 +Create Date: 2026-06-27 11: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 index_exists + + +revision = 'd4f0a23c8e51' +down_revision = 'c3e9fa0e6d31' +branch_labels = None +depends_on = None + + +def _create_war_room(): + if _has_table('war_room'): + return + op.create_table( + 'war_room', + sa.Column('war_room_id', sa.BigInteger(), primary_key=True), + sa.Column('war_room_uuid', sa.dialects.postgresql.UUID(as_uuid=True), + nullable=False, server_default=sa.text('gen_random_uuid()')), + sa.Column('name', sa.String(256), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('state', sa.String(16), nullable=False, + server_default=sa.text("'open'")), + sa.Column('severity_id', sa.BigInteger(), + sa.ForeignKey('severities.severity_id'), nullable=True), + sa.Column('color', sa.String(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.Column('closed_at', sa.DateTime(), nullable=True), + sa.Column('closed_by_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True), + sa.Column('custom_attributes', sa.dialects.postgresql.JSONB(), + nullable=True), + sa.UniqueConstraint('war_room_uuid', name='uq_war_room_uuid'), + ) + + +def _create_war_room_case(): + if _has_table('war_room_case'): + return + op.create_table( + 'war_room_case', + sa.Column('war_room_id', sa.BigInteger(), nullable=False), + sa.Column('case_id', sa.BigInteger(), nullable=False), + sa.Column('attached_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.Column('attached_by_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True), + sa.Column('note', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['war_room_id'], ['war_room.war_room_id'], + ondelete='CASCADE'), + sa.ForeignKeyConstraint(['case_id'], ['cases.case_id'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('war_room_id', 'case_id'), + sa.UniqueConstraint('war_room_id', 'case_id', name='uq_war_room_case'), + ) + if not index_exists('war_room_case', 'ix_war_room_case_case_id'): + op.create_index('ix_war_room_case_case_id', 'war_room_case', ['case_id']) + + +def _create_war_room_member(): + if _has_table('war_room_member'): + return + op.create_table( + 'war_room_member', + sa.Column('war_room_id', sa.BigInteger(), nullable=False), + sa.Column('user_id', sa.BigInteger(), nullable=False), + sa.Column('role', sa.String(16), nullable=False, + server_default=sa.text("'responder'")), + 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.ForeignKeyConstraint(['war_room_id'], ['war_room.war_room_id'], + ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('war_room_id', 'user_id'), + sa.UniqueConstraint('war_room_id', 'user_id', name='uq_war_room_member'), + ) + + +def _create_war_room_timelines(): + if not _has_table('war_room_timeline'): + op.create_table( + 'war_room_timeline', + sa.Column('timeline_id', sa.BigInteger(), primary_key=True), + sa.Column('war_room_id', sa.BigInteger(), nullable=False), + sa.Column('name', sa.String(128), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(7), nullable=True), + sa.Column('is_default', sa.Boolean(), nullable=False, + server_default=sa.text('false')), + 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.ForeignKeyConstraint(['war_room_id'], ['war_room.war_room_id'], + ondelete='CASCADE'), + sa.UniqueConstraint('war_room_id', 'name', + name='uq_war_room_timeline_name'), + ) + if not index_exists('war_room_timeline', 'ix_war_room_timeline_war_room_id'): + op.create_index('ix_war_room_timeline_war_room_id', + 'war_room_timeline', ['war_room_id']) + + if not _has_table('war_room_timeline_event'): + op.create_table( + 'war_room_timeline_event', + sa.Column('id', sa.BigInteger(), primary_key=True), + sa.Column('timeline_id', sa.BigInteger(), nullable=False), + sa.Column('case_id', sa.BigInteger(), nullable=True), + sa.Column('event_id', sa.BigInteger(), nullable=True), + sa.Column('title', sa.Text(), nullable=True), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('event_date', sa.DateTime(), nullable=True), + sa.Column('event_tz', sa.String(16), nullable=True), + sa.Column('color', sa.String(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.ForeignKeyConstraint(['timeline_id'], + ['war_room_timeline.timeline_id'], + ondelete='CASCADE'), + sa.ForeignKeyConstraint(['case_id'], ['cases.case_id'], + ondelete='SET NULL'), + sa.ForeignKeyConstraint(['event_id'], ['cases_events.event_id'], + ondelete='CASCADE'), + sa.CheckConstraint( + "(case_id IS NULL AND event_id IS NULL) OR " + "(case_id IS NOT NULL AND event_id IS NOT NULL)", + name='ck_war_room_timeline_event_case_event_pair' + ), + ) + if not index_exists('war_room_timeline_event', + 'ix_war_room_timeline_event_timeline_id'): + op.create_index('ix_war_room_timeline_event_timeline_id', + 'war_room_timeline_event', ['timeline_id']) + + +def _create_war_room_chat(): + if not _has_table('war_room_chat_message'): + op.create_table( + 'war_room_chat_message', + sa.Column('message_id', sa.BigInteger(), primary_key=True), + sa.Column('war_room_id', sa.BigInteger(), nullable=False), + sa.Column('author_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True), + sa.Column('body', sa.Text(), nullable=True), + sa.Column('kind', sa.String(32), nullable=False, + server_default=sa.text("'message'")), + sa.Column('ref_type', sa.String(32), nullable=True), + sa.Column('ref_id', sa.BigInteger(), nullable=True), + sa.Column('ref_case_id', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.Column('edited_at', sa.DateTime(), nullable=True), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['war_room_id'], ['war_room.war_room_id'], + ondelete='CASCADE'), + sa.ForeignKeyConstraint(['ref_case_id'], ['cases.case_id'], + ondelete='SET NULL'), + ) + for idx, cols in ( + ('ix_war_room_chat_message_war_room_id', ['war_room_id']), + ('ix_war_room_chat_message_war_room_id_created_at', + ['war_room_id', 'created_at']), + ('ix_war_room_chat_message_ref_case_id', ['ref_case_id']), + ): + if not index_exists('war_room_chat_message', idx): + op.create_index(idx, 'war_room_chat_message', cols) + + if not _has_table('war_room_chat_reaction'): + op.create_table( + 'war_room_chat_reaction', + sa.Column('id', sa.BigInteger(), primary_key=True), + sa.Column('message_id', sa.BigInteger(), nullable=False), + sa.Column('user_id', sa.BigInteger(), nullable=False), + sa.Column('emoji', sa.String(32), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.ForeignKeyConstraint(['message_id'], + ['war_room_chat_message.message_id'], + ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.UniqueConstraint('message_id', 'user_id', 'emoji', + name='uq_war_room_chat_reaction'), + ) + if not index_exists('war_room_chat_reaction', + 'ix_war_room_chat_reaction_message_id'): + op.create_index('ix_war_room_chat_reaction_message_id', + 'war_room_chat_reaction', ['message_id']) + + +def _create_war_room_tasks(): + if _has_table('war_room_task'): + return + op.create_table( + 'war_room_task', + sa.Column('task_id', sa.BigInteger(), primary_key=True), + sa.Column('war_room_id', sa.BigInteger(), nullable=False), + sa.Column('title', sa.Text(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status_id', sa.Integer(), + sa.ForeignKey('task_status.id'), nullable=True), + sa.Column('assignee_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True), + sa.Column('due_at', sa.DateTime(), nullable=True), + sa.Column('source_case_id', sa.BigInteger(), nullable=True), + sa.Column('source_case_task_id', sa.BigInteger(), 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.Column('closed_at', sa.DateTime(), nullable=True), + sa.Column('closed_by_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True), + sa.Column('tags', sa.Text(), nullable=True), + sa.Column('custom_attributes', sa.dialects.postgresql.JSONB(), + nullable=True), + sa.ForeignKeyConstraint(['war_room_id'], ['war_room.war_room_id'], + ondelete='CASCADE'), + sa.ForeignKeyConstraint(['source_case_id'], ['cases.case_id'], + ondelete='SET NULL'), + sa.ForeignKeyConstraint(['source_case_task_id'], ['case_tasks.id'], + ondelete='SET NULL'), + ) + if not index_exists('war_room_task', 'ix_war_room_task_war_room_id'): + op.create_index('ix_war_room_task_war_room_id', + 'war_room_task', ['war_room_id']) + + +def _create_war_room_note(): + if _has_table('war_room_note'): + return + op.create_table( + 'war_room_note', + sa.Column('note_id', sa.BigInteger(), primary_key=True), + sa.Column('war_room_id', sa.BigInteger(), nullable=False), + sa.Column('title', sa.Text(), nullable=False), + sa.Column('content', sa.Text(), 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()')), + sa.Column('created_by_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True), + sa.Column('updated_by_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True), + sa.ForeignKeyConstraint(['war_room_id'], ['war_room.war_room_id'], + ondelete='CASCADE'), + ) + if not index_exists('war_room_note', 'ix_war_room_note_war_room_id'): + op.create_index('ix_war_room_note_war_room_id', + 'war_room_note', ['war_room_id']) + + +def _create_war_room_sitrep(): + if _has_table('war_room_sitrep'): + return + op.create_table( + 'war_room_sitrep', + sa.Column('sitrep_id', sa.BigInteger(), primary_key=True), + sa.Column('war_room_id', sa.BigInteger(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('title', sa.Text(), nullable=False), + sa.Column('body_md', sa.Text(), nullable=False), + sa.Column('snapshot_json', sa.dialects.postgresql.JSONB(), + nullable=True), + sa.Column('authored_by_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True), + sa.Column('authored_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.Column('published', sa.Boolean(), nullable=False, + server_default=sa.text('false')), + sa.ForeignKeyConstraint(['war_room_id'], ['war_room.war_room_id'], + ondelete='CASCADE'), + sa.UniqueConstraint('war_room_id', 'version', + name='uq_war_room_sitrep_version'), + ) + if not index_exists('war_room_sitrep', 'ix_war_room_sitrep_war_room_id'): + op.create_index('ix_war_room_sitrep_war_room_id', + 'war_room_sitrep', ['war_room_id']) + + +def _create_war_room_graph(): + if not _has_table('war_room_graph_node'): + op.create_table( + 'war_room_graph_node', + sa.Column('node_id', sa.BigInteger(), primary_key=True), + sa.Column('war_room_id', sa.BigInteger(), nullable=False), + sa.Column('kind', sa.String(16), nullable=False), + sa.Column('ref_id', sa.BigInteger(), nullable=True), + sa.Column('label', sa.Text(), nullable=True), + sa.Column('note_md', sa.Text(), nullable=True), + sa.Column('color', sa.String(7), nullable=True), + sa.Column('x', sa.Float(), nullable=False, server_default=sa.text('0')), + sa.Column('y', sa.Float(), nullable=False, server_default=sa.text('0')), + sa.ForeignKeyConstraint(['war_room_id'], ['war_room.war_room_id'], + ondelete='CASCADE'), + ) + if not index_exists('war_room_graph_node', + 'ix_war_room_graph_node_war_room_id'): + op.create_index('ix_war_room_graph_node_war_room_id', + 'war_room_graph_node', ['war_room_id']) + + if not _has_table('war_room_graph_edge'): + op.create_table( + 'war_room_graph_edge', + sa.Column('edge_id', sa.BigInteger(), primary_key=True), + sa.Column('war_room_id', sa.BigInteger(), nullable=False), + sa.Column('from_node_id', sa.BigInteger(), nullable=False), + sa.Column('to_node_id', sa.BigInteger(), nullable=False), + sa.Column('label', sa.Text(), nullable=True), + sa.Column('note_md', sa.Text(), nullable=True), + sa.Column('style', sa.String(16), nullable=True), + sa.ForeignKeyConstraint(['war_room_id'], ['war_room.war_room_id'], + ondelete='CASCADE'), + sa.ForeignKeyConstraint(['from_node_id'], + ['war_room_graph_node.node_id'], + ondelete='CASCADE'), + sa.ForeignKeyConstraint(['to_node_id'], + ['war_room_graph_node.node_id'], + ondelete='CASCADE'), + ) + if not index_exists('war_room_graph_edge', + 'ix_war_room_graph_edge_war_room_id'): + op.create_index('ix_war_room_graph_edge_war_room_id', + 'war_room_graph_edge', ['war_room_id']) + + +def _create_war_room_datastore(): + if _has_table('war_room_datastore_file'): + return + op.create_table( + 'war_room_datastore_file', + sa.Column('file_id', sa.BigInteger(), primary_key=True), + sa.Column('war_room_id', sa.BigInteger(), nullable=False), + sa.Column('filename', sa.Text(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('storage_path', sa.Text(), nullable=False), + sa.Column('size_bytes', sa.BigInteger(), nullable=False), + sa.Column('mime_type', sa.String(128), nullable=True), + sa.Column('sha256', sa.String(64), nullable=True), + sa.Column('uploaded_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.Column('uploaded_by_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True), + sa.Column('tags', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['war_room_id'], ['war_room.war_room_id'], + ondelete='CASCADE'), + ) + if not index_exists('war_room_datastore_file', + 'ix_war_room_datastore_file_war_room_id'): + op.create_index('ix_war_room_datastore_file_war_room_id', + 'war_room_datastore_file', ['war_room_id']) + + +def _create_war_room_acl(): + if not _has_table('user_war_room_access'): + op.create_table( + 'user_war_room_access', + sa.Column('id', sa.BigInteger(), primary_key=True), + sa.Column('user_id', sa.BigInteger(), nullable=False), + sa.Column('war_room_id', sa.BigInteger(), nullable=False), + sa.Column('access_level', sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['war_room_id'], ['war_room.war_room_id'], + ondelete='CASCADE'), + sa.UniqueConstraint('war_room_id', 'user_id', + name='uq_user_war_room_access_room_user'), + ) + + if not _has_table('group_war_room_access'): + op.create_table( + 'group_war_room_access', + sa.Column('id', sa.BigInteger(), primary_key=True), + sa.Column('group_id', sa.BigInteger(), nullable=False), + sa.Column('war_room_id', sa.BigInteger(), nullable=False), + sa.Column('access_level', sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint(['group_id'], ['groups.group_id'], + ondelete='CASCADE'), + sa.ForeignKeyConstraint(['war_room_id'], ['war_room.war_room_id'], + ondelete='CASCADE'), + sa.UniqueConstraint('war_room_id', 'group_id', + name='uq_group_war_room_access_room_group'), + ) + + if not _has_table('user_war_room_effective_access'): + op.create_table( + 'user_war_room_effective_access', + sa.Column('id', sa.BigInteger(), primary_key=True), + sa.Column('user_id', sa.BigInteger(), nullable=False), + sa.Column('war_room_id', sa.BigInteger(), nullable=False), + sa.Column('access_level', sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['war_room_id'], ['war_room.war_room_id'], + ondelete='CASCADE'), + sa.UniqueConstraint('war_room_id', 'user_id', + name='uq_user_war_room_effective_access_room_user'), + ) + if not index_exists('user_war_room_effective_access', + 'ix_user_war_room_effective_access_user_id'): + op.create_index('ix_user_war_room_effective_access_user_id', + 'user_war_room_effective_access', ['user_id']) + + +def upgrade(): + _create_war_room() + _create_war_room_case() + _create_war_room_member() + _create_war_room_timelines() + _create_war_room_chat() + _create_war_room_tasks() + _create_war_room_note() + _create_war_room_sitrep() + _create_war_room_graph() + _create_war_room_datastore() + _create_war_room_acl() + + +def downgrade(): + # Drop in reverse dependency order. Each step is guarded so a + # partial downgrade re-run on a partial schema doesn't blow up. + for table in ( + 'user_war_room_effective_access', + 'group_war_room_access', + 'user_war_room_access', + 'war_room_datastore_file', + 'war_room_graph_edge', + 'war_room_graph_node', + 'war_room_sitrep', + 'war_room_note', + 'war_room_task', + 'war_room_chat_reaction', + 'war_room_chat_message', + 'war_room_timeline_event', + 'war_room_timeline', + 'war_room_member', + 'war_room_case', + 'war_room', + ): + if _has_table(table): + op.drop_table(table) diff --git a/source/app/alembic/versions/d8a2f6e91c12_ensure_saved_filters_table.py b/source/app/alembic/versions/d8a2f6e91c12_ensure_saved_filters_table.py new file mode 100644 index 000000000..858ec6eeb --- /dev/null +++ b/source/app/alembic/versions/d8a2f6e91c12_ensure_saved_filters_table.py @@ -0,0 +1,56 @@ +"""Ensure the saved_filters table exists. + +`SavedFilter` is referenced by both the alerts saved-filters endpoints +(historical) and the new cases saved-filters endpoints, but the table +was previously only created by `db.create_all()` at first boot. Long- +lived deployments that never ran `create_all` against an empty database +(or skipped it after a partial restore) end up without the table, and +the very first POST against `/api/v2/alerts-filters` or +`/api/v2/cases-filters` then 500s with `relation "saved_filters" does +not exist`. + +This migration creates the table idempotently — `_has_table` short- +circuits the create when the table already exists, so deployments +where `db.create_all()` already provisioned it just get a no-op +upgrade. + +Revision ID: d8a2f6e91c12 +Revises: c7f1e2a4d810 +Create Date: 2026-06-26 09:00:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +from app.alembic.alembic_utils import _has_table + + +revision = 'd8a2f6e91c12' +down_revision = 'c7f1e2a4d810' +branch_labels = None +depends_on = None + + +def upgrade(): + if _has_table('saved_filters'): + return + + op.create_table( + 'saved_filters', + sa.Column('filter_id', sa.BigInteger(), primary_key=True), + sa.Column('created_by', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=False), + sa.Column('filter_name', sa.Text(), nullable=False), + sa.Column('filter_description', sa.Text(), nullable=True), + sa.Column('filter_data', sa.JSON(), nullable=False), + sa.Column('filter_is_private', sa.Boolean(), nullable=False), + sa.Column('filter_type', sa.Text(), nullable=False), + ) + + +def downgrade(): + # Intentional no-op: dropping the table would lose every saved + # filter the user has built up. The table also predates this + # migration (it was created by `db.create_all()` on fresh + # installs), so dropping it on downgrade would punish anyone who + # was already using the alerts saved-filters feature. + return diff --git a/source/app/alembic/versions/d8e3f1a90c17_add_user_preferences.py b/source/app/alembic/versions/d8e3f1a90c17_add_user_preferences.py new file mode 100644 index 000000000..fb2f95537 --- /dev/null +++ b/source/app/alembic/versions/d8e3f1a90c17_add_user_preferences.py @@ -0,0 +1,41 @@ +"""Add a generic per-user preferences JSONB column. + +A single `user.preferences` JSONB column lets the SPA persist small +per-user settings without a new table per feature. Callers agree on +top-level keys — e.g. `war_room_stream` for the chat sidebar filter +selection — and read/write with `jsonb_set`-style updates. + +Idempotent via `_has_table` / `_table_has_column`. + +Revision ID: d8e3f1a90c17 +Revises: c4d1a2b7f503 +Create Date: 2026-07-01 12:30:00.000000 +""" +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB +from alembic import op + +from app.alembic.alembic_utils import _has_table, _table_has_column + + +revision = 'd8e3f1a90c17' +down_revision = 'c4d1a2b7f503' +branch_labels = None +depends_on = None + + +def upgrade(): + if not _has_table('user'): + return + if not _table_has_column('user', 'preferences'): + op.add_column( + 'user', + sa.Column('preferences', JSONB, nullable=True), + ) + + +def downgrade(): + if not _has_table('user'): + return + if _table_has_column('user', 'preferences'): + op.drop_column('user', 'preferences') diff --git a/source/app/alembic/versions/db034de157b9_war_room_id_fkey_set_null_on_delete.py b/source/app/alembic/versions/db034de157b9_war_room_id_fkey_set_null_on_delete.py new file mode 100644 index 000000000..a4cbe68bc --- /dev/null +++ b/source/app/alembic/versions/db034de157b9_war_room_id_fkey_set_null_on_delete.py @@ -0,0 +1,41 @@ +"""war_room_id fkey set null on delete + +`war_room_delete()` (business/war_rooms.py) intentionally deletes the +row directly rather than nulling out every activity log entry that +references it, on the assumption that the FK would just go NULL. But +the FK had no ON DELETE behaviour, so Postgres blocked the delete with +a ForeignKeyViolation the moment any activity (even the war room's own +creation) had been logged against it — i.e. every real war room. + +Revision ID: db034de157b9 +Revises: 3dd1c1b644fb +Create Date: 2026-07-07 00:00:00.000000 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = 'db034de157b9' +down_revision = '3dd1c1b644fb' +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_constraint('user_activity_war_room_id_fkey', 'user_activity', type_='foreignkey') + op.create_foreign_key( + 'user_activity_war_room_id_fkey', + 'user_activity', 'war_room', + ['war_room_id'], ['war_room_id'], + ondelete='SET NULL', + ) + + +def downgrade(): + op.drop_constraint('user_activity_war_room_id_fkey', 'user_activity', type_='foreignkey') + op.create_foreign_key( + 'user_activity_war_room_id_fkey', + 'user_activity', 'war_room', + ['war_room_id'], ['war_room_id'], + ) diff --git a/source/app/alembic/versions/e2f3g4h5i6j7_add_collab_doc.py b/source/app/alembic/versions/e2f3g4h5i6j7_add_collab_doc.py new file mode 100644 index 000000000..ce03025e9 --- /dev/null +++ b/source/app/alembic/versions/e2f3g4h5i6j7_add_collab_doc.py @@ -0,0 +1,46 @@ +"""Add collab_doc table for Yjs snapshots. + +Backs the real-time collaborative editor. One row per document +(case note, case summary, war-room note, sitrep); `doc_name` is a +stable string key of the form `<kind>:<id>` — see +`app.models.collab.CollabDoc` for the schema motivation. + +Idempotent via `_has_table`. + +Revision ID: e2f3g4h5i6j7 +Revises: d1e2f3a4b5c6 +Create Date: 2026-07-03 15:00:00.000000 +""" +import sqlalchemy as sa +from alembic import op + +from app.alembic.alembic_utils import _has_table + + +revision = 'e2f3g4h5i6j7' +down_revision = 'd1e2f3a4b5c6' +branch_labels = None +depends_on = None + + +def upgrade(): + if _has_table('collab_doc'): + return + + op.create_table( + 'collab_doc', + sa.Column('doc_name', sa.Text(), nullable=False), + sa.Column('y_state', sa.LargeBinary(), nullable=True), + sa.Column('content_md', sa.Text(), nullable=True), + sa.Column('last_flushed_at', sa.DateTime(), nullable=True), + sa.Column( + 'updated_by_id', sa.BigInteger(), + sa.ForeignKey('user.id'), nullable=True, + ), + sa.PrimaryKeyConstraint('doc_name'), + ) + + +def downgrade(): + if _has_table('collab_doc'): + op.drop_table('collab_doc') diff --git a/source/app/alembic/versions/e5a1b46c7d92_add_war_room_chat_activity_type.py b/source/app/alembic/versions/e5a1b46c7d92_add_war_room_chat_activity_type.py new file mode 100644 index 000000000..40e7fece6 --- /dev/null +++ b/source/app/alembic/versions/e5a1b46c7d92_add_war_room_chat_activity_type.py @@ -0,0 +1,68 @@ +"""Add `activity_type` column to `war_room_chat_message`. + +A finer-grained classifier for case-activity rows on the war-room +stream. Where the existing `kind` column already says "this is a case +activity, this is a task assignment, this is a SitRep publish", the +new `activity_type` says *what kind* of case activity it is — note +created vs. IOC updated vs. asset deleted, etc. — so the stream's +filter pane can offer per-case, per-type checkboxes without +re-parsing the activity description on the client. + +Nullable: most existing rows (operator messages, sytem rows that +predate this migration) carry no type. The ingest hook starts +stamping new rows on the next request after the migration applies. + +Idempotent — guarded by `_table_has_column` so re-running on an +already-upgraded environment is a no-op. + +Revision ID: e5a1b46c7d92 +Revises: d4f0a23c8e51 +Create Date: 2026-06-27 14: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 +from app.alembic.alembic_utils import index_exists + + +revision = 'e5a1b46c7d92' +down_revision = 'd4f0a23c8e51' +branch_labels = None +depends_on = None + + +def upgrade(): + if not _has_table('war_room_chat_message'): + # The base war-room migration must run first; bail quietly if + # the parent table is somehow missing. + return + + if not _table_has_column('war_room_chat_message', 'activity_type'): + op.add_column( + 'war_room_chat_message', + sa.Column('activity_type', sa.String(48), nullable=True), + ) + + if not index_exists( + 'war_room_chat_message', 'ix_war_room_chat_message_activity_type' + ): + op.create_index( + 'ix_war_room_chat_message_activity_type', + 'war_room_chat_message', + ['activity_type'], + ) + + +def downgrade(): + if _has_table('war_room_chat_message'): + if index_exists( + 'war_room_chat_message', 'ix_war_room_chat_message_activity_type' + ): + op.drop_index( + 'ix_war_room_chat_message_activity_type', + table_name='war_room_chat_message', + ) + if _table_has_column('war_room_chat_message', 'activity_type'): + op.drop_column('war_room_chat_message', 'activity_type') diff --git a/source/app/alembic/versions/e5d79b8c4a55_add_custom_dashboards_tables.py b/source/app/alembic/versions/e5d79b8c4a55_add_custom_dashboards_tables.py index 36ff70353..e8457fb68 100644 --- a/source/app/alembic/versions/e5d79b8c4a55_add_custom_dashboards_tables.py +++ b/source/app/alembic/versions/e5d79b8c4a55_add_custom_dashboards_tables.py @@ -49,7 +49,6 @@ def upgrade(): op.create_index('ix_custom_dashboard_widget_dashboard_id', 'custom_dashboard_widget', ['dashboard_id']) - def downgrade(): if _has_table('custom_dashboard_widget'): op.drop_index('ix_custom_dashboard_widget_dashboard_id', table_name='custom_dashboard_widget') diff --git a/source/app/alembic/versions/e7b1f4a8c920_add_notifications.py b/source/app/alembic/versions/e7b1f4a8c920_add_notifications.py new file mode 100644 index 000000000..1b0eba37e --- /dev/null +++ b/source/app/alembic/versions/e7b1f4a8c920_add_notifications.py @@ -0,0 +1,102 @@ +"""Add notification + notification_setting tables. + +`notification` holds materialised per-user events (title/body/link +rendered at fire time so they survive deletion of the source object). + +`notification_setting` implements the two-tier config: rows with +`user_id IS NULL` are the org-wide admin default, per-user rows +override. A partial unique index enforces at most one admin row per +(event_type, channel) since Postgres treats NULLs as distinct in the +plain uniqueness constraint on (user_id, event_type, channel). + +Idempotent via `_has_table` / `index_exists`. + +Revision ID: e7b1f4a8c920 +Revises: d8e3f1a90c17 +Create Date: 2026-07-02 09:00:00.000000 +""" +import sqlalchemy as sa +from alembic import op + +from app.alembic.alembic_utils import _has_table +from app.alembic.alembic_utils import index_exists + + +revision = 'e7b1f4a8c920' +down_revision = 'd8e3f1a90c17' +branch_labels = None +depends_on = None + + +def upgrade(): + if not _has_table('notification'): + op.create_table( + 'notification', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.BigInteger(), nullable=False), + sa.Column('event_type', sa.String(length=64), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('body', sa.Text(), nullable=True), + sa.Column('link', sa.String(length=1024), nullable=True), + sa.Column('source_type', sa.String(length=64), nullable=True), + sa.Column('source_id', sa.BigInteger(), nullable=True), + sa.Column('read_at', sa.DateTime(), nullable=True), + sa.Column('emailed_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + + if not index_exists('notification', 'ix_notification_user_id'): + op.create_index('ix_notification_user_id', 'notification', ['user_id']) + if not index_exists('notification', 'ix_notification_user_unread'): + op.create_index('ix_notification_user_unread', 'notification', + ['user_id', 'read_at', 'created_at']) + + if not _has_table('notification_setting'): + op.create_table( + 'notification_setting', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.BigInteger(), nullable=True), + sa.Column('event_type', sa.String(length=64), nullable=False), + sa.Column('channel', sa.String(length=16), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False, + server_default=sa.text('true')), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'event_type', 'channel', + name='uq_notification_setting_scope'), + ) + + # Partial unique index so at most ONE admin default row exists per + # (event_type, channel). Postgres's plain uniqueness treats NULL + # user_ids as distinct, which would otherwise let duplicates slip in + # via concurrent inserts. The `WHERE user_id IS NULL` clause is what + # makes this a single-tier constraint. + if not index_exists('notification_setting', + 'uq_notification_setting_admin_default'): + op.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS " + "uq_notification_setting_admin_default " + "ON notification_setting (event_type, channel) " + "WHERE user_id IS NULL" + ) + + +def downgrade(): + if index_exists('notification_setting', + 'uq_notification_setting_admin_default'): + op.execute( + "DROP INDEX IF EXISTS uq_notification_setting_admin_default" + ) + if _has_table('notification_setting'): + op.drop_table('notification_setting') + + if index_exists('notification', 'ix_notification_user_unread'): + op.drop_index('ix_notification_user_unread', table_name='notification') + if index_exists('notification', 'ix_notification_user_id'): + op.drop_index('ix_notification_user_id', table_name='notification') + if _has_table('notification'): + op.drop_table('notification') diff --git a/source/app/alembic/versions/e9b5f3d20a44_add_user_avatar.py b/source/app/alembic/versions/e9b5f3d20a44_add_user_avatar.py new file mode 100644 index 000000000..ec5f5f46a --- /dev/null +++ b/source/app/alembic/versions/e9b5f3d20a44_add_user_avatar.py @@ -0,0 +1,60 @@ +"""Add per-user avatar columns to the user table. + +Stores the uploaded profile picture as a bytes blob on the user row. +We picked the LargeBinary column over a filesystem path so that: + +* a single Postgres backup captures the avatar with the user that owns + it (no second restore step, no race between row & file); +* a non-existent file can't desynchronise with a stale DB pointer; +* the bytes are gated by the same auth pipeline as everything else — + there is no `/static/avatars` directory to leak through a misconfig. + +Uploads are normalised to a 256×256 PNG by the route handler before +hitting the column, so the row growth is bounded (~30–80 KB per +avatar) and the schema doesn't need to track MIME type beyond a +sanity-check column. + +Revision ID: e9b5f3d20a44 +Revises: d8a2f6e91c12 +Create Date: 2026-06-26 11:00:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +from app.alembic.alembic_utils import _table_has_column + + +revision = 'e9b5f3d20a44' +down_revision = 'd8a2f6e91c12' +branch_labels = None +depends_on = None + + +def upgrade(): + # `_table_has_column` makes the upgrade re-runnable. Helpful for + # deployments that hand-applied the columns ahead of the migration + # being merged, and for downgrade → upgrade cycles during dev. + if not _table_has_column('user', 'avatar_blob'): + op.add_column('user', sa.Column('avatar_blob', sa.LargeBinary(), nullable=True)) + + if not _table_has_column('user', 'avatar_mime'): + op.add_column('user', sa.Column('avatar_mime', sa.String(length=64), nullable=True)) + + if not _table_has_column('user', 'avatar_updated_at'): + op.add_column( + 'user', + sa.Column('avatar_updated_at', sa.DateTime(), nullable=True), + ) + + +def downgrade(): + # Drop only what we added. Idempotent guards mirror the upgrade so + # a partial downgrade can be re-run cleanly. + if _table_has_column('user', 'avatar_updated_at'): + op.drop_column('user', 'avatar_updated_at') + + if _table_has_column('user', 'avatar_mime'): + op.drop_column('user', 'avatar_mime') + + if _table_has_column('user', 'avatar_blob'): + op.drop_column('user', 'avatar_blob') diff --git a/source/app/alembic/versions/f2b6a4d19e3c_add_mail_config_and_ingest.py b/source/app/alembic/versions/f2b6a4d19e3c_add_mail_config_and_ingest.py new file mode 100644 index 000000000..176d771a0 --- /dev/null +++ b/source/app/alembic/versions/f2b6a4d19e3c_add_mail_config_and_ingest.py @@ -0,0 +1,173 @@ +"""Mail feature — extend server_settings + add ingest rule/log tables. + +Outbound + inbound mail config lives on the singleton `server_settings` +row alongside proxy + password-policy config; there's only ever one +outbound relay and one inbound mailbox per deployment, so a dedicated +table would be pure overhead. Passwords (`mail_smtp_password`, +`mail_imap_password`) are stored ENCRYPTED using the app SECRET_KEY — +the columns hold ciphertext, never plaintext. See +`app/iris_engine/mail/secrets.py` for the wrap/unwrap helpers. + +`mail_ingest_rule` drives the inbound router: incoming messages are +matched against enabled rules in `priority` order (lowest first) and +the first hit decides `create_alert` / `create_case` / `drop`. + +`mail_ingest_log` is an audit trail keyed by `Message-ID` so re-polls +of the mailbox can dedupe cheaply. `outcome_object_id` points at the +alert or case the message became (or NULL for `drop` / `error`). + +Idempotent via `_has_table` / `_table_has_column`. + +Revision ID: f2b6a4d19e3c +Revises: e7b1f4a8c920 +Create Date: 2026-07-02 15:00:00.000000 +""" +import sqlalchemy as sa +from alembic import op + +from app.alembic.alembic_utils import _has_table +from app.alembic.alembic_utils import _table_has_column +from app.alembic.alembic_utils import index_exists + + +revision = 'f2b6a4d19e3c' +down_revision = 'e7b1f4a8c920' +branch_labels = None +depends_on = None + + +_MAIL_COLUMNS = [ + # ---- Outbound (SMTP) ---- + ('mail_smtp_enabled', sa.Boolean(), sa.text('false')), + ('mail_smtp_host', sa.String(length=255), None), + ('mail_smtp_port', sa.Integer(), None), + ('mail_smtp_user', sa.String(length=255), None), + # Encrypted at rest via Fernet(SECRET_KEY) — never plaintext. + ('mail_smtp_password', sa.Text(), None), + ('mail_smtp_use_tls', sa.Boolean(), sa.text('true')), + ('mail_smtp_use_ssl', sa.Boolean(), sa.text('false')), + ('mail_from_address', sa.String(length=255), None), + ('mail_from_name', sa.String(length=255), None), + # ---- Inbound (IMAP) ---- + ('mail_imap_enabled', sa.Boolean(), sa.text('false')), + ('mail_imap_host', sa.String(length=255), None), + ('mail_imap_port', sa.Integer(), None), + ('mail_imap_user', sa.String(length=255), None), + ('mail_imap_password', sa.Text(), None), + ('mail_imap_use_ssl', sa.Boolean(), sa.text('true')), + ('mail_imap_mailbox', sa.String(length=255), sa.text("'INBOX'")), + ('mail_imap_poll_interval_sec', sa.Integer(), sa.text('300')), + ('mail_imap_max_attachment_mb', sa.Integer(), sa.text('20')), +] + + +def upgrade(): + if _has_table('server_settings'): + for name, type_, default in _MAIL_COLUMNS: + if not _table_has_column('server_settings', name): + op.add_column( + 'server_settings', + sa.Column(name, type_, nullable=True, + server_default=default) if default is not None + else sa.Column(name, type_, nullable=True), + ) + + # ----------------------------------------------------------------- + # mail_ingest_rule + # ----------------------------------------------------------------- + if not _has_table('mail_ingest_rule'): + op.create_table( + 'mail_ingest_rule', + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + # Lower runs first. A rule with priority 100 is applied + # before priority 200. Idiomatic for ordered-rules systems. + sa.Column('priority', sa.Integer(), nullable=False, + server_default=sa.text('100')), + sa.Column('enabled', sa.Boolean(), nullable=False, + server_default=sa.text('true')), + # Match predicates — all nullable, all optional. A rule + # with no predicates matches every message (fallback rule). + sa.Column('match_subject_regex', sa.Text(), nullable=True), + sa.Column('match_from_regex', sa.Text(), nullable=True), + sa.Column('match_to_regex', sa.Text(), nullable=True), + # `create_alert` | `create_case` | `drop`. Free-form string + # (not a Postgres enum) so adding a new action doesn't need + # a migration. + sa.Column('action', sa.String(length=32), nullable=False, + server_default=sa.text("'create_alert'")), + sa.Column('customer_id', + sa.BigInteger(), nullable=True), + sa.Column('case_template_id', sa.Integer(), nullable=True), + sa.Column('severity_id', sa.Integer(), nullable=True), + sa.Column('assignee_user_id', sa.BigInteger(), nullable=True), + sa.Column('created_by_id', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['customer_id'], ['client.client_id'], + ondelete='SET NULL'), + sa.ForeignKeyConstraint(['case_template_id'], ['case_template.id'], + ondelete='SET NULL'), + sa.ForeignKeyConstraint(['severity_id'], ['severities.severity_id'], + ondelete='SET NULL'), + sa.ForeignKeyConstraint(['assignee_user_id'], ['user.id'], + ondelete='SET NULL'), + sa.ForeignKeyConstraint(['created_by_id'], ['user.id'], + ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + ) + if not index_exists('mail_ingest_rule', 'ix_mail_ingest_rule_priority'): + op.create_index('ix_mail_ingest_rule_priority', + 'mail_ingest_rule', ['priority', 'enabled']) + + # ----------------------------------------------------------------- + # mail_ingest_log + # ----------------------------------------------------------------- + if not _has_table('mail_ingest_log'): + op.create_table( + 'mail_ingest_log', + # `message_id` from the RFC-822 header (`Message-ID:` value, + # angle brackets stripped). Primary key: the same message + # arriving twice must be a no-op. Length 998 matches the + # RFC 5322 line-length ceiling. + sa.Column('message_id', sa.String(length=998), nullable=False), + sa.Column('received_at', sa.DateTime(), nullable=False, + server_default=sa.text('now()')), + # `alert_created` | `case_created` | `skipped_by_rule` + # | `no_rule_match` | `error`. Free-form to allow evolution. + sa.Column('outcome', sa.String(length=32), nullable=False), + sa.Column('outcome_object_id', sa.BigInteger(), nullable=True), + sa.Column('rule_id', sa.BigInteger(), nullable=True), + # `from_addr` + `subject` kept for the admin log UI so + # operators can eyeball what got in without opening every + # source object. + sa.Column('from_addr', sa.String(length=320), nullable=True), + sa.Column('subject', sa.Text(), nullable=True), + sa.Column('error', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['rule_id'], ['mail_ingest_rule.id'], + ondelete='SET NULL'), + sa.PrimaryKeyConstraint('message_id'), + ) + if not index_exists('mail_ingest_log', 'ix_mail_ingest_log_received_at'): + op.create_index('ix_mail_ingest_log_received_at', + 'mail_ingest_log', ['received_at']) + + +def downgrade(): + if index_exists('mail_ingest_log', 'ix_mail_ingest_log_received_at'): + op.drop_index('ix_mail_ingest_log_received_at', + table_name='mail_ingest_log') + if _has_table('mail_ingest_log'): + op.drop_table('mail_ingest_log') + + if index_exists('mail_ingest_rule', 'ix_mail_ingest_rule_priority'): + op.drop_index('ix_mail_ingest_rule_priority', + table_name='mail_ingest_rule') + if _has_table('mail_ingest_rule'): + op.drop_table('mail_ingest_rule') + + if _has_table('server_settings'): + for name, _type_, _default in _MAIL_COLUMNS: + if _table_has_column('server_settings', name): + op.drop_column('server_settings', name) diff --git a/source/app/alembic/versions/f6c213d80b41_drop_war_room_graph_tables.py b/source/app/alembic/versions/f6c213d80b41_drop_war_room_graph_tables.py new file mode 100644 index 000000000..b1d6260c6 --- /dev/null +++ b/source/app/alembic/versions/f6c213d80b41_drop_war_room_graph_tables.py @@ -0,0 +1,41 @@ +"""Drop the war-room graph tables. + +The cases-as-graph board was removed from the war-room workspace — +the operator-driven graph wasn't getting use during real incidents +and the data it stored (free-form node positions, annotation notes, +edges) was orphaned. Dropping the tables avoids leaving dead rows +hanging on every war room. + +Idempotent guards via `_has_table` so re-running on environments that +already removed the tables is a no-op. + +Revision ID: f6c213d80b41 +Revises: e5a1b46c7d92 +Create Date: 2026-06-28 09:00:00.000000 +""" +from alembic import op + +from app.alembic.alembic_utils import _has_table + + +revision = 'f6c213d80b41' +down_revision = 'e5a1b46c7d92' +branch_labels = None +depends_on = None + + +def upgrade(): + # Edges first — they FK into nodes. + if _has_table('war_room_graph_edge'): + op.drop_table('war_room_graph_edge') + if _has_table('war_room_graph_node'): + op.drop_table('war_room_graph_node') + + +def downgrade(): + # Intentionally not re-created. The feature is gone; reviving it + # would mean re-introducing the data model from the original + # `d4f0a23c8e51_add_war_rooms.py` migration. If the feature is + # ever brought back, that migration's `_create_war_room_graph` + # helper is the source of truth. + pass diff --git a/source/app/blueprints/access_controls.py b/source/app/blueprints/access_controls.py index d2c4942c0..ff121f3d6 100644 --- a/source/app/blueprints/access_controls.py +++ b/source/app/blueprints/access_controls.py @@ -542,7 +542,15 @@ def _local_authentication_process(incoming_request: Request): def _token_authentication_process(incoming_request: Request): """ - Process authentication using an Authorization header with Bearer token + Process authentication using an Authorization header with Bearer token. + + Refuses to admit a token whose carrier hasn't passed MFA when the server + policy requires it. Without this check, an attacker holding valid + username+password could call any `ac_api_requires`-decorated endpoint + immediately after the initial login (which issues a step-1 token with + `mfa_verified=False`) — bypassing the MFA challenge entirely. The flag + pair travels in the JWT itself so we don't need to consult the DB on + every request. """ auth_header = incoming_request.headers.get('Authorization', '') if not auth_header.startswith('Bearer '): @@ -558,6 +566,13 @@ def _token_authentication_process(incoming_request: Request): if not user_data: return False + if user_data.get('mfa_required') and not user_data.get('mfa_verified'): + # Don't populate g.auth_user — falling through to session auth + # would defeat the purpose. Returning False here lets the caller + # reject the request as unauthenticated, which is the correct + # surface for a step-1 token presented to a protected endpoint. + return False + # Store user data for later use g.auth_user = user_data g.auth_token_user_id = user_data['user_id'] @@ -591,6 +606,16 @@ def ac_fast_check_current_user_has_case_access(cid, access_level): return ac_fast_check_user_has_case_access(iris_current_user.id, cid, access_level) +def ac_fast_check_current_user_has_war_room_access(war_room_id, access_level): + # Late import: business.war_rooms_access imports several models that + # also pull this module transitively. Keeping it lazy avoids the + # circular-import trap and matches the pattern used elsewhere. + from app.business.war_rooms_access import ac_fast_check_user_has_war_room_access + return ac_fast_check_user_has_war_room_access( + iris_current_user.id, war_room_id, access_level + ) + + def _get_current_permissions_mask(): # Token-based authentication if hasattr(g, 'auth_token_user_id'): @@ -627,3 +652,14 @@ def ac_current_user_has_customer_access(customer_identifier): _get_current_permissions_mask(), customer_identifier ) + + +def ac_current_user_permissions_mask(): + """Public accessor for the caller's effective permission mask. + + Route handlers that need to pass the mask into a business-layer + helper — e.g. the incident-rules / investigation-flows scope + checks — should use this rather than importing the underscored + `_get_current_permissions_mask` directly (linters flag cross-module + private imports, and the underscore signals module-internal use).""" + return _get_current_permissions_mask() diff --git a/source/app/blueprints/graphql/cases.py b/source/app/blueprints/graphql/cases.py deleted file mode 100644 index 255b7e718..000000000 --- a/source/app/blueprints/graphql/cases.py +++ /dev/null @@ -1,201 +0,0 @@ -# IRIS Source Code -# Copyright (C) 2024 - 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. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -from graphene_sqlalchemy import SQLAlchemyObjectType -from graphene_sqlalchemy import SQLAlchemyConnectionField -from graphene.relay import Node -from graphene.relay import Connection -from graphene import Field -from graphene import Mutation -from graphene import NonNull -from graphene import Int -from graphene import Float -from graphene import String - -from app.blueprints.access_controls import ac_current_user_has_customer_access -from app.models.cases import Cases -from app.models.authorization import Permissions -from app.models.authorization import CaseAccessLevel - -from app.business.iocs import iocs_build_filter_query -from app.business.cases import cases_create -from app.business.cases import cases_delete -from app.business.cases import cases_update -from app.business.cases import cases_get_by_identifier -from app.models.errors import BusinessProcessingError -from app.blueprints.graphql.permissions import permissions_check_current_user_has_some_permission -from app.blueprints.graphql.permissions import permissions_check_current_user_has_some_case_access -from app.iris_engine.module_handler.module_handler import call_deprecated_on_preload_modules_hook -from app.schema.marshables import CaseSchema -from app.blueprints.iris_user import iris_current_user -from app.blueprints.graphql.iocs import IOCConnection - - -class CaseObject(SQLAlchemyObjectType): - class Meta: - model = Cases - interfaces = [Node] - - iocs = SQLAlchemyConnectionField(IOCConnection, ioc_id=Int(), ioc_uuid=String(), ioc_value=String(), ioc_type_id=Int(), - ioc_description=String(), ioc_tlp_id=Int(), ioc_tags=String(), ioc_misp=String(), - user_id=Float()) - - # TODO why is kwargs necessary? Should investigate and try to remove - @staticmethod - def resolve_iocs(root, info, ioc_id=None, ioc_uuid=None, ioc_value=None, ioc_type_id=None, ioc_description=None, ioc_tlp_id=None, ioc_tags=None, - ioc_misp=None, user_id=None, **kwargs): - - permissions_check_current_user_has_some_case_access(root.case_id, [CaseAccessLevel.full_access]) - - return iocs_build_filter_query(ioc_id=ioc_id, ioc_uuid=ioc_uuid, ioc_value=ioc_value, - ioc_type_id=ioc_type_id, ioc_description=ioc_description, - ioc_tlp_id=ioc_tlp_id, ioc_tags=ioc_tags, ioc_misp=ioc_misp, - user_id=user_id) - - @staticmethod - def resolve_case(root, info, case_id): - permissions_check_current_user_has_some_case_access(case_id, [CaseAccessLevel.full_access]) - return Cases.query.get(case_id) - - -class CaseConnection(Connection): - class Meta: - node = CaseObject - - total_count = Int() - - # TODO why is kwargs necessary? Should investigate and try to remove - @staticmethod - def resolve_total_count(root, info, **kwargs): - return root.length - - -class CaseCreate(Mutation): - - class Arguments: - name = NonNull(String) - description = NonNull(String) - client_id = NonNull(Int) - - soc_id = String() - classification_id = Int() - - case = Field(CaseObject) - - @staticmethod - def mutate(root, info, name, description, client_id, soc_id=None, classification_id=None): - request = { - 'case_name': name, - 'case_description': description, - 'case_customer': client_id, - 'case_soc_id': '' - } - if soc_id: - request['case_soc_id'] = soc_id - if classification_id: - request['classification_id'] = classification_id - - request_data = call_deprecated_on_preload_modules_hook('case_create', request) - schema = CaseSchema() - case = schema.load(request_data) - case_template_id = request_data.pop('case_template_id', None) - result = cases_create(iris_current_user, case, case_template_id) - return CaseCreate(case=result) - - -class CaseDelete(Mutation): - - class Arguments: - case_id = NonNull(Float) - - case = Field(CaseObject) - - @staticmethod - def mutate(root, info, case_id): - permissions_check_current_user_has_some_permission([Permissions.standard_user]) - permissions_check_current_user_has_some_case_access(case_id, [CaseAccessLevel.full_access]) - cases_delete(case_id) - - -class CaseUpdate(Mutation): - - class Arguments: - case_id = NonNull(Float) - - name = String() - description = String() - soc_id = String() - classification_id = Int() - severity_id = Int() - client_id = Int() - owner_id = Int() - state_id = Int() - review_status_id = Int() - reviewer_id = Int() - tags = String() - - case = Field(CaseObject) - - @staticmethod - def mutate(root, info, case_id, name=None, soc_id=None, classification_id=None, client_id=None, description=None, - severity_id=None, owner_id=None, state_id=None, reviewer_id=None, tags=None, review_status_id=None): - request = {} - if name: - request['case_name'] = name - if soc_id: - request['case_soc_id'] = soc_id - if classification_id: - request['classification_id'] = classification_id - if client_id: - request['case_customer'] = client_id - if description: - request['case_description'] = description - if severity_id: - request['severity_id'] = severity_id - if owner_id: - request['owner_id'] = owner_id - if state_id: - request['state_id'] = state_id - if reviewer_id: - request['reviewer_id'] = reviewer_id - if tags: - request['case_tags'] = tags - if review_status_id: - request['review_status_id'] = review_status_id - permissions_check_current_user_has_some_permission([Permissions.standard_user]) - permissions_check_current_user_has_some_case_access(case_id, [CaseAccessLevel.full_access]) - - case = cases_get_by_identifier(case_id) - - # If user tries to update the customer, check if the user has access to the new customer - if request.get('case_customer') and request.get('case_customer') != case.client_id: - if not ac_current_user_has_customer_access(request.get('case_customer')): - raise BusinessProcessingError('Invalid customer ID. Permission denied.') - - if 'case_name' in request: - short_case_name = request.get('case_name').replace(f'#{case.case_id} - ', '') - request['case_name'] = f'#{case.case_id} - {short_case_name}' - request['case_customer'] = case.client_id if not request.get('case_customer') else request.get('case_customer') - request['reviewer_id'] = None if request.get('reviewer_id') == '' else request.get('reviewer_id') - - add_case_schema = CaseSchema() - updated_case = add_case_schema.load(request, instance=case, partial=True) - protagonists = request.get('protagonists') - tags = request.get('case_tags') - case = cases_update(case, updated_case, protagonists, tags) - return CaseUpdate(case=case) diff --git a/source/app/blueprints/graphql/graphql_route.py b/source/app/blueprints/graphql/graphql_route.py deleted file mode 100644 index d63773bb0..000000000 --- a/source/app/blueprints/graphql/graphql_route.py +++ /dev/null @@ -1,131 +0,0 @@ -# IRIS Source Code -# Copyright (C) 2024 - 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. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -from functools import wraps - -from flask import request -from flask_wtf import FlaskForm -from flask import Blueprint - -from graphql_server.flask.views import GraphQLView -from graphene import ObjectType -from graphene import Schema -from graphene import Float -from graphene import Int -from graphene import Field -from graphene import String - -from graphene_sqlalchemy import SQLAlchemyConnectionField - -from app.datamgmt.manage.manage_cases_db import build_filter_case_query -from app.blueprints.access_controls import is_user_authenticated -from app.blueprints.responses import response_error - -from app.models.authorization import CaseAccessLevel - -from app.blueprints.graphql.cases import CaseObject -from app.blueprints.graphql.iocs import IOCObject -from app.blueprints.graphql.iocs import IOCCreate -from app.blueprints.graphql.iocs import IOCUpdate -from app.blueprints.graphql.iocs import IOCDelete -from app.blueprints.graphql.cases import CaseCreate -from app.blueprints.graphql.cases import CaseDelete -from app.blueprints.graphql.cases import CaseUpdate -from app.blueprints.graphql.cases import CaseConnection - -from app.business.cases import cases_get_by_identifier -from app.business.iocs import iocs_get -from app.blueprints.iris_user import iris_current_user -from app.blueprints.graphql.permissions import permissions_check_current_user_has_some_case_access -import warnings - -# Ignore all UserWarnings -warnings.filterwarnings('ignore', category=UserWarning) - - -class Query(ObjectType): - """This is the IRIS GraphQL queries documentation!""" - - cases = SQLAlchemyConnectionField(CaseConnection, classification_id=Float(), client_id=Float(), state_id=Int(), - owner_id=Float(), open_date=String(), name=String(), soc_id=String(), - severity_id=Int(), tags=String(), open_since=Int()) - case = Field(CaseObject, case_id=Float(), description='Retrieve a case by its identifier') - ioc = Field(IOCObject, ioc_id=Float(), description='Retrieve an ioc by its identifier') - - @staticmethod - def resolve_cases(root, info, classification_id=None, client_id=None, state_id=None, owner_id=None, open_date=None, name=None, soc_id=None, - severity_id=None, tags=None, open_since=None, **kwargs): - return build_filter_case_query(iris_current_user.id, start_open_date=open_date, end_open_date=None, case_customer_id=client_id, case_ids=None, - case_name=name, case_description=None, case_classification_id=classification_id, case_owner_id=owner_id, - case_opening_user_id=None, case_severity_id=severity_id, case_state_id=state_id, case_soc_id=soc_id, - case_tags=tags, case_open_since=open_since) - - @staticmethod - def resolve_case(root, info, case_id): - permissions_check_current_user_has_some_case_access(case_id, [CaseAccessLevel.read_only, CaseAccessLevel.full_access]) - return cases_get_by_identifier(case_id) - - @staticmethod - def resolve_ioc(root, info, ioc_id): - ioc = iocs_get(ioc_id) - permissions_check_current_user_has_some_case_access(ioc.case_id, [CaseAccessLevel.read_only, CaseAccessLevel.full_access]) - return ioc - - -class Mutation(ObjectType): - - ioc_create = IOCCreate.Field() - ioc_update = IOCUpdate.Field() - ioc_delete = IOCDelete.Field() - - case_create = CaseCreate.Field() - case_delete = CaseDelete.Field() - case_update = CaseUpdate.Field() - - -def _check_authentication_wrapper(f): - @wraps(f) - def wrap(*args, **kwargs): - if request.method == 'POST': - cookie_session = request.cookies.get('session') - if cookie_session: - form = FlaskForm() - if not form.validate(): - return response_error('Invalid CSRF token') - if request.is_json: - request.json.pop('csrf_token') - - if not is_user_authenticated(request): - return response_error('Authentication required', status=401) - - return f(*args, **kwargs) - return wrap - - -def _create_blueprint(): - schema = Schema(query=Query, mutation=Mutation) - graphql_view = GraphQLView.as_view('graphql', schema=schema.graphql_schema) - graphql_view_with_authentication = _check_authentication_wrapper(graphql_view) - - blueprint = Blueprint('graphql', __name__) - blueprint.add_url_rule('/graphql', view_func=graphql_view_with_authentication, methods=['POST']) - - return blueprint - - -graphql_blueprint = _create_blueprint() diff --git a/source/app/blueprints/graphql/iocs.py b/source/app/blueprints/graphql/iocs.py deleted file mode 100644 index 94561998f..000000000 --- a/source/app/blueprints/graphql/iocs.py +++ /dev/null @@ -1,158 +0,0 @@ -# IRIS Source Code -# Copyright (C) 2024 - 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. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -from graphene_sqlalchemy import SQLAlchemyObjectType -from graphene import Field -from graphene import Mutation -from graphene import NonNull -from graphene import Int -from graphene import Float -from graphene import String - -from app.blueprints.graphql.permissions import permissions_check_current_user_has_some_case_access -from app.blueprints.graphql.permissions import permissions_check_current_user_has_some_case_access_stricter -from app.models.authorization import CaseAccessLevel -from app.models.iocs import Ioc -from app.business.iocs import iocs_create -from app.business.iocs import iocs_get -from app.business.iocs import iocs_update -from app.business.iocs import iocs_delete -from app.iris_engine.module_handler.module_handler import call_deprecated_on_preload_modules_hook -from app.schema.marshables import IocSchema - -from graphene.relay import Connection - - -class IOCObject(SQLAlchemyObjectType): - class Meta: - model = Ioc - - -class IOCConnection(Connection): - class Meta: - node = IOCObject - - total_count = Int() - - @staticmethod - def resolve_total_count(root, info, **kwargs): - return root.length - - -class IOCCreate(Mutation): - - class Arguments: - # note: it seems really too difficult to work with IDs. - # I don't understand why graphql_relay.from_global_id does not seem to work... - # note: I prefer NonNull rather than the syntax required=True - case_id = NonNull(Float) - type_id = NonNull(Int) - tlp_id = NonNull(Int) - value = NonNull(String) - description = String() - tags = String() - - ioc = Field(IOCObject) - - @staticmethod - def mutate(root, info, case_id, type_id, tlp_id, value, description=None, tags=None): - request = { - 'ioc_type_id': type_id, - 'ioc_tlp_id': tlp_id, - 'ioc_value': value, - 'ioc_description': description, - 'ioc_tags': tags - } - permissions_check_current_user_has_some_case_access(case_id, [CaseAccessLevel.full_access]) - - request_data = call_deprecated_on_preload_modules_hook('ioc_create', request, case_id) - request_data['case_id'] = case_id - add_ioc_schema = IocSchema() - ioc = add_ioc_schema.load(request_data) - ioc = iocs_create(ioc) - return IOCCreate(ioc=ioc) - - -class IOCUpdate(Mutation): - - class Arguments: - ioc_id = NonNull(Float) - type_id = Int() - tlp_id = Int() - value = String() - description = String() - tags = String() - ioc_misp = String() - user_id = Float() - ioc_enrichment = String() - custom_attributes = String() - modification_history = String() - - ioc = Field(IOCObject) - - @staticmethod - def mutate(root, info, ioc_id, type_id=None, tlp_id=None, value=None, description=None, tags=None, - ioc_misp=None, user_id=None, ioc_enrichment=None, modification_history=None): - permissions_check_current_user_has_some_case_access_stricter([CaseAccessLevel.full_access]) - - request = {} - if type_id: - request['ioc_type_id'] = type_id - if tlp_id: - request['ioc_tlp_id'] = tlp_id - if value: - request['ioc_value'] = value - if description: - request['ioc_description'] = description - if tags: - request['ioc_tags'] = tags - if ioc_misp: - request['ioc_misp'] = ioc_misp - if user_id: - request['user_id'] = user_id - if ioc_enrichment: - request['ioc_enrichment'] = ioc_enrichment - if modification_history: - request['modification_history'] = modification_history - ioc = iocs_get(ioc_id) - - request_data = call_deprecated_on_preload_modules_hook('ioc_update', request, ioc.case_id) - - # validate before saving - ioc_schema = IocSchema() - request_data['ioc_id'] = ioc.ioc_id - request_data['case_id'] = ioc.case_id - ioc_sc = ioc_schema.load(request_data, instance=ioc, partial=True) - ioc = iocs_update(ioc, ioc_sc) - return IOCCreate(ioc=ioc) - - -class IOCDelete(Mutation): - - class Arguments: - ioc_id = NonNull(Float) - - message = String() - - @staticmethod - def mutate(root, info, ioc_id): - ioc = iocs_get(ioc_id) - permissions_check_current_user_has_some_case_access(ioc.case_id, [CaseAccessLevel.full_access]) - - message = iocs_delete(ioc) - return IOCDelete(message=message) diff --git a/source/app/blueprints/graphql/permissions.py b/source/app/blueprints/graphql/permissions.py deleted file mode 100644 index 49a2aa7b3..000000000 --- a/source/app/blueprints/graphql/permissions.py +++ /dev/null @@ -1,74 +0,0 @@ -# IRIS Source Code -# Copyright (C) 2024 - 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. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -import logging -from uuid import uuid4 - -from flask import session -from flask import request - -from app.blueprints.access_controls import get_case_access_from_api, ac_fast_check_current_user_has_case_access -from app.blueprints.iris_user import iris_current_user -from app.iris_engine.access_control.utils import ac_get_effective_permissions_of_user - - -class PermissionDeniedError(Exception): - pass - - -def _deny_permission(): - error_uuid = uuid4() - message = f'Permission denied (EID {error_uuid})' - logging.warning(message) - raise PermissionDeniedError(message) - - -# When moving down permission checks from the REST layer into the business layer, -# this method is used to replace manual calls to ac_fast_check_current_user_has_case_access -def permissions_check_current_user_has_some_case_access(case_identifier, access_levels): - if not ac_fast_check_current_user_has_case_access(case_identifier, access_levels): - _deny_permission() - - -# TODO: should remove this method and use permissions_check_current_user_has_some_case_access, only -# I am pretty sure the access checks are done with the wrong case identifier for graphql -# This one comes from ac_api_case_requires, whereas the other one comes from the way api_delete_case was written... -# When moving down permission checks from the REST layer into the business layer, -# this method is used to replace annotation ac_api_case_requires -def permissions_check_current_user_has_some_case_access_stricter(access_levels): - redir, caseid, has_access = get_case_access_from_api(request, access_levels) - - # TODO: do we really want to keep the details of the errors, when permission is denied => more work, more complex code? - if not caseid or redir: - _deny_permission() - - if not has_access: - _deny_permission() - - -# When moving down permission checks from the REST layer into the business layer, -# this method is used to replace annotation ac_api_requires -def permissions_check_current_user_has_some_permission(permissions): - if 'permissions' not in session: - session['permissions'] = ac_get_effective_permissions_of_user(iris_current_user) - - for permission in permissions: - if session['permissions'] & permission.value: - return - - _deny_permission() diff --git a/source/app/blueprints/graphql/sliced_result.py b/source/app/blueprints/graphql/sliced_result.py deleted file mode 100644 index bdd3933e0..000000000 --- a/source/app/blueprints/graphql/sliced_result.py +++ /dev/null @@ -1,31 +0,0 @@ -# IRIS Source Code -# Copyright (C) 2024 - 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. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -class SlicedResult: - def __init__(self, data, start_offset, total): - self._data = data - self._start = start_offset - self._total = total - - def __getitem__(self, index: slice) -> any: - x = (index.start - self._start) - y = (index.stop - self._start) - return self._data[x: y] - - def __len__(self) -> int: - return self._total diff --git a/source/app/blueprints/pages/login/login_routes.py b/source/app/blueprints/pages/login/login_routes.py index 10dbde2d1..51353a6a1 100644 --- a/source/app/blueprints/pages/login/login_routes.py +++ b/source/app/blueprints/pages/login/login_routes.py @@ -22,8 +22,8 @@ import qrcode import random import string -import json import time +import json from flask import Blueprint, flash from flask import redirect from flask import render_template @@ -139,6 +139,11 @@ def _authenticate_password(form, username, password): @login_blueprint.route("/login", methods=["GET", "POST"]) def login(): if iris_current_user.is_authenticated: + # If the old Jinja UI is the one receiving the post-OIDC + # redirect, the SPA-only JWT exchange marker never gets + # consumed. Drop it here so it can't linger and be + # redeemed later by a rogue XHR from the same browser. + session.pop("oidc_authenticated", None) return redirect(url_for("index.index")) if ( @@ -352,12 +357,27 @@ def oidc_authorise(): ] update_user_groups(user.id, new_user_group) - return wrap_login_user(user, is_oidc=True) + # Mark this session as OIDC-authenticated so the SPA can call + # /api/v2/auth/oidc-exchange exactly once to trade the session + # cookie for JWT tokens. Single-use: the exchange endpoint + # clears both this flag and the session immediately, so a + # stolen cookie can't be redeemed twice. + session["oidc_authenticated"] = True + + wrap_login_user(user, is_oidc=True) + + # Regardless of `next`, send the browser to the SPA's login + # page with an OIDC marker. The SPA's onMount detects the + # marker, calls oidc-exchange, populates the JWT store, and + # then navigates to `/`. Overriding wrap_login_user's redirect + # target here also blocks an open-redirect via ?next=... on + # the OIDC callback. + return redirect("/login?oidc=1") # MFA hardening constants. Values are deliberately conservative — a legitimate # user mistypes their token occasionally; an attacker brute-forcing 10^6 TOTP -# codes should not be able to linearly grind them. +# codes should not be able to linearly grind them. Backport of f596481b. _MFA_MAX_ATTEMPTS = 5 _MFA_LOCKOUT_SECONDS = 15 * 60 @@ -369,50 +389,50 @@ def _get_pre_mfa_user(): successful password (or LDAP) check. Any MFA handler that runs without it is being hit directly by an attacker and must be refused. """ - pre_mfa_user_id = session.get('pre_mfa_user_id') + pre_mfa_user_id = session.get("pre_mfa_user_id") if pre_mfa_user_id is None: return None - return get_user(pre_mfa_user_id, id_key='id') + return get_user(pre_mfa_user_id, id_key="id") def _clear_pre_mfa_state(preserve_lockout=False): """Clear the markers that prove this session just passed password auth. preserve_lockout: when True, keep the lockout timestamp and fail counter - so that an attacker cannot wipe a brute-force lockout simply by hitting - /login again. + so an attacker cannot wipe a brute-force lockout simply by hitting /login + again. """ - session.pop('pre_mfa_user_id', None) - session.pop('pending_mfa_secret', None) + session.pop("pre_mfa_user_id", None) + session.pop("pending_mfa_secret", None) if not preserve_lockout: - session.pop('mfa_fail_count', None) - session.pop('mfa_lockout_until', None) + session.pop("mfa_fail_count", None) + session.pop("mfa_lockout_until", None) def _mfa_is_locked_out(): - locked_until = session.get('mfa_lockout_until') + locked_until = session.get("mfa_lockout_until") if locked_until and locked_until > time.time(): return True if locked_until and locked_until <= time.time(): - # Lockout expired — reset the counter so the user gets a fresh window. - session.pop('mfa_lockout_until', None) - session['mfa_fail_count'] = 0 + session.pop("mfa_lockout_until", None) + session["mfa_fail_count"] = 0 return False def _register_mfa_failure(user, reason): - session['mfa_fail_count'] = session.get('mfa_fail_count', 0) + 1 + session["mfa_fail_count"] = session.get("mfa_fail_count", 0) + 1 track_activity( - f'Failed MFA {reason} for user {user.user} (attempt {session["mfa_fail_count"]}/{_MFA_MAX_ATTEMPTS})', - ctx_less=True, display_in_ui=False + f"Failed MFA {reason} for user {user.user} " + f"(attempt {session['mfa_fail_count']}/{_MFA_MAX_ATTEMPTS})", + ctx_less=True, display_in_ui=False, ) - if session['mfa_fail_count'] >= _MFA_MAX_ATTEMPTS: - session['mfa_lockout_until'] = time.time() + _MFA_LOCKOUT_SECONDS + if session["mfa_fail_count"] >= _MFA_MAX_ATTEMPTS: + session["mfa_lockout_until"] = time.time() + _MFA_LOCKOUT_SECONDS # Drop the pending-MFA marker so the attacker has to go back through # password auth before they get another burst of attempts. Preserve # the lockout timestamp so a fresh /login cannot wipe it. _clear_pre_mfa_state(preserve_lockout=True) - session.pop('username', None) + session.pop("username", None) @app.route("/auth/mfa-setup", methods=["GET", "POST"]) @@ -430,7 +450,7 @@ def mfa_setup(): return redirect(url_for("mfa_verify")) if _mfa_is_locked_out(): - flash('Too many attempts. Please try again later.', 'danger') + flash("Too many attempts. Please try again later.", "danger") return redirect(url_for("login.login")) form = MFASetupForm() @@ -442,9 +462,9 @@ def mfa_setup(): # The secret MUST come from the server-side session, never from the # submitted form. Trusting form.mfa_secret let an attacker enrol a # secret of their choosing and immediately log in. - mfa_secret = session.get('pending_mfa_secret') + mfa_secret = session.get("pending_mfa_secret") if not mfa_secret: - flash('MFA setup expired. Please restart the login flow.', 'danger') + flash("MFA setup expired. Please restart the login flow.", "danger") return redirect(url_for("login.login")) totp = pyotp.TOTP(mfa_secret) @@ -463,7 +483,7 @@ def mfa_setup(): has_valid_password = True if not has_valid_password: - _register_mfa_failure(user, 'setup (invalid password)') + _register_mfa_failure(user, "setup (invalid password)") flash("Invalid password. Please try again.", "danger") return render_template("mfa_setup.html", form=form) @@ -472,24 +492,23 @@ def mfa_setup(): db.session.commit() track_activity( f"MFA setup successful for user {user.user}", - ctx_less=True, - display_in_ui=False, + ctx_less=True, display_in_ui=False, ) + # Setup succeeded — promote this session to MFA-verified for this # user and clear the pre-MFA markers. Without this, wrap_login_user # would loop straight back to mfa_verify. - session['mfa_verified_for_user_id'] = user.id + session["mfa_verified_for_user_id"] = user.id _clear_pre_mfa_state() return wrap_login_user(user) - else: - _register_mfa_failure(user, 'setup (invalid token)') - flash("Invalid token or password. Please try again.", "danger") + _register_mfa_failure(user, "setup (invalid token)") + flash("Invalid token or password. Please try again.", "danger") # Generate a fresh secret on every GET and stash it in the session. The # client only sees the QR code and the base32 key for manual entry; it # never sends the secret back. temp_otp_secret = pyotp.random_base32() - session['pending_mfa_secret'] = temp_otp_secret + session["pending_mfa_secret"] = temp_otp_secret otp_uri = pyotp.TOTP(temp_otp_secret).provisioning_uri( user.email, issuer_name="IRIS" ) @@ -513,13 +532,12 @@ def mfa_verify(): if not user.mfa_secrets or not user.mfa_setup_complete: track_activity( f"MFA setup required for user {user.user}", - ctx_less=True, - display_in_ui=False, + ctx_less=True, display_in_ui=False, ) return redirect(url_for("mfa_setup")) if _mfa_is_locked_out(): - flash('Too many attempts. Please try again later.', 'danger') + flash("Too many attempts. Please try again later.", "danger") return redirect(url_for("login.login")) form = MFASetupForm() @@ -535,17 +553,15 @@ def mfa_verify(): if totp.verify(token): track_activity( f"MFA verification successful for user {user.user}", - ctx_less=True, - display_in_ui=False, + ctx_less=True, display_in_ui=False, ) # Bind the MFA-verified marker to this specific user id so that a # later login attempt for a different user on the same browser # session cannot reuse it. - session['mfa_verified_for_user_id'] = user.id + session["mfa_verified_for_user_id"] = user.id _clear_pre_mfa_state() return wrap_login_user(user) - else: - _register_mfa_failure(user, 'verification (invalid token)') - flash("Invalid token. Please try again.", "danger") + _register_mfa_failure(user, "verification (invalid token)") + flash("Invalid token. Please try again.", "danger") return render_template("mfa_verify.html", form=form) diff --git a/source/app/blueprints/rest/alerts_routes.py b/source/app/blueprints/rest/alerts_routes.py index 49dc9e793..8c454cd2e 100644 --- a/source/app/blueprints/rest/alerts_routes.py +++ b/source/app/blueprints/rest/alerts_routes.py @@ -66,15 +66,20 @@ alerts_rest_blueprint = Blueprint('alerts_rest', __name__) -# Fields that must be immutable on alert update. Allowing them via the API -# lets a user with write access to one customer re-attribute an alert to a -# customer they cannot see — planting fake alerts or (with an XSS vector) -# making another user move an alert into an attacker-controlled customer. -# See SBA-ADV-20260128-05 / CWE-863. +# Fields that must be immutable on alert update. The web UI never changes +# these, and allowing them via the API lets a user with write access to one +# customer re-attribute an alert to a customer they cannot see — either to +# plant fake alerts under another customer's name, or (combined with an XSS +# vector) to make another user move an alert into an attacker-controlled +# customer. See GHSA-8hwq-v6vm-9grr / SBA-ADV-20260128-05 / CWE-863. +# +# alert_id: primary key, must not be rewritten +# alert_customer_id: ownership, re-attribution bypasses customer-scoped ACL +# alert_creation_time: audit integrity; set once at creation _ALERT_UPDATE_READONLY_FIELDS = frozenset({ - 'alert_id', # primary key, must not be rewritten - 'alert_customer_id', # ownership — re-attribution bypasses customer ACL - 'alert_creation_time', # audit integrity; set once at creation + 'alert_id', + 'alert_customer_id', + 'alert_creation_time', }) @@ -350,8 +355,10 @@ def alerts_update_route(alert_id) -> Response: do_status_hook = False try: - # Load the JSON data from the request. Drop fields the caller must not - # be allowed to change (SBA-ADV-20260128-05 / CWE-863). + # Drop fields the caller must not be allowed to change on update + # (GHSA-8hwq-v6vm-9grr / SBA-ADV-20260128-05 / CWE-863). Done before + # any other processing so these values never reach the activity log + # or the ORM. data = _strip_readonly_update_fields(request.get_json()) activity_data = [] @@ -430,7 +437,10 @@ def alerts_batch_update_route() -> Response: # Load the JSON data from the request data = request.get_json() - # Get the list of alert IDs and updates from the request data + # Get the list of alert IDs and updates from the request data. Strip + # immutable fields from the batch payload so one API call can't silently + # re-attribute every selected alert to a different customer + # (GHSA-8hwq-v6vm-9grr / SBA-ADV-20260128-05 / CWE-863). alert_ids: List[int] = data.get('alert_ids', []) updates = _strip_readonly_update_fields(data.get('updates', {})) diff --git a/source/app/blueprints/rest/api_auth.py b/source/app/blueprints/rest/api_auth.py index ac0e99b5b..b8ff21658 100644 --- a/source/app/blueprints/rest/api_auth.py +++ b/source/app/blueprints/rest/api_auth.py @@ -5,9 +5,24 @@ from app import app from app.business.users import users_get_active +from app.models.errors import ObjectNotFoundError from app.blueprints.rest.endpoints import response_api_error +def _safe_get_active(user_id): + # The identifier on a token/session may point at a user that has since + # been deleted or deactivated. Callers translate the None into either + # "invalid" (JWT — where the caller *did* present a signed identity + # that we're rejecting) or a plain unauthenticated fallthrough for + # legacy/session paths. Without this we'd propagate + # ObjectNotFoundError up to the Flask exception handler and log a + # 500 for what is really just a stale credential. + try: + return users_get_active(user_id) + except ObjectNotFoundError: + return None + + def _jwt_user(): auth = request.headers.get("Authorization") if not auth or not auth.startswith("Bearer "): @@ -25,19 +40,29 @@ def _jwt_user(): if payload.get("type") != "access": return "invalid" - return users_get_active(payload["user_id"]) + # Step-1 tokens (password validated but MFA not yet verified) must not + # admit the bearer to protected endpoints. Treating them as `invalid` + # rather than `None` short-circuits the legacy/session fallthrough — a + # step-1 access token shouldn't quietly promote to a session login. + if payload.get("mfa_required") and not payload.get("mfa_verified"): + return "invalid" + + user = _safe_get_active(payload["user_id"]) + # Signed token for a user that no longer exists → reject outright, + # don't quietly fall through to legacy/session auth. + return user if user is not None else "invalid" def _legacy_token_user(): if not hasattr(g, "auth_user"): return None - return users_get_active(g.auth_user["user_id"]) + return _safe_get_active(g.auth_user["user_id"]) def _session_user(): if not current_user.is_authenticated: return None - return users_get_active(current_user.id) + return _safe_get_active(current_user.id) def api_auth(*, require_mfa: bool = False): diff --git a/source/app/blueprints/rest/api_v2_routes.py b/source/app/blueprints/rest/api_v2_routes.py index 467658d61..33cc0640a 100644 --- a/source/app/blueprints/rest/api_v2_routes.py +++ b/source/app/blueprints/rest/api_v2_routes.py @@ -18,6 +18,7 @@ from flask import Blueprint +from app.blueprints.rest.v2.activities import activities_blueprint from app.blueprints.rest.v2.alerts import alerts_blueprint from app.blueprints.rest.v2.assets import assets_blueprint from app.blueprints.rest.v2.events import events_blueprint @@ -25,14 +26,28 @@ from app.blueprints.rest.v2.notes import notes_blueprint from app.blueprints.rest.v2.auth import auth_blueprint from app.blueprints.rest.v2.cases import cases_blueprint +from app.blueprints.rest.v2.custom_dashboards import custom_dashboards_blueprint from app.blueprints.rest.v2.dashboard import dashboard_blueprint +from app.blueprints.rest.v2.dim_tasks import dim_tasks_blueprint from app.blueprints.rest.v2.global_tasks import global_tasks_blueprint from app.blueprints.rest.v2.iocs import iocs_blueprint from app.blueprints.rest.v2.manage import manage_v2_blueprint from app.blueprints.rest.v2.tags import tags_blueprint 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 +from app.blueprints.rest.v2.cases_filters import cases_filters_blueprint +from app.blueprints.rest.v2.war_rooms.root import war_rooms_blueprint +from app.blueprints.rest.v2.notifications import notifications_blueprint +from app.blueprints.rest.v2.notifications import admin_notifications_blueprint +from app.blueprints.rest.v2.mail import mail_blueprint +from app.blueprints.rest.v2.incidents import incidents_blueprint +from app.blueprints.rest.v2.incident_rules import incident_rules_blueprint +from app.blueprints.rest.v2.investigation_flows import investigation_flows_blueprint # Create root /api/v2 blueprint @@ -49,8 +64,23 @@ rest_v2_blueprint.register_blueprint(evidences_blueprint) rest_v2_blueprint.register_blueprint(notes_blueprint) rest_v2_blueprint.register_blueprint(alerts_blueprint) +rest_v2_blueprint.register_blueprint(custom_dashboards_blueprint) rest_v2_blueprint.register_blueprint(dashboard_blueprint) rest_v2_blueprint.register_blueprint(manage_v2_blueprint) 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) +rest_v2_blueprint.register_blueprint(admin_avatar_blueprint) +rest_v2_blueprint.register_blueprint(activities_blueprint) +rest_v2_blueprint.register_blueprint(dim_tasks_blueprint) +rest_v2_blueprint.register_blueprint(war_rooms_blueprint) +rest_v2_blueprint.register_blueprint(notifications_blueprint) +rest_v2_blueprint.register_blueprint(admin_notifications_blueprint) +rest_v2_blueprint.register_blueprint(mail_blueprint) +rest_v2_blueprint.register_blueprint(incidents_blueprint) +rest_v2_blueprint.register_blueprint(incident_rules_blueprint) +rest_v2_blueprint.register_blueprint(investigation_flows_blueprint) diff --git a/source/app/blueprints/rest/case/case_timeline_routes.py b/source/app/blueprints/rest/case/case_timeline_routes.py index 2c8a83cbc..907f5aaff 100644 --- a/source/app/blueprints/rest/case/case_timeline_routes.py +++ b/source/app/blueprints/rest/case/case_timeline_routes.py @@ -60,6 +60,7 @@ from app.models.authorization import CaseAccessLevel from app.models.authorization import User from app.models.cases import CasesEvent +from app.models.cases import CaseEventTimeline from app.models.models import CaseEventsAssets from app.models.models import CaseEventsIoc from app.models.models import EventCategory @@ -327,8 +328,8 @@ def case_gettimeline_api(asset_id, caseid): @case_timeline_rest_blueprint.route('/case/timeline/advanced-filter', methods=['GET']) -@ac_requires_case_identifier(CaseAccessLevel.read_only, CaseAccessLevel.full_access) @ac_api_requires() +@ac_requires_case_identifier(CaseAccessLevel.read_only, CaseAccessLevel.full_access) def case_filter_timeline(caseid): args = request.args.to_dict() query_filter = args.get('q') @@ -360,9 +361,97 @@ def case_filter_timeline(caseid): sources = filter_d.get('source') flag = filter_d.get('flag') + try: + page = max(1, int(args.get('page', 1))) + except (TypeError, ValueError): + page = 1 + + try: + per_page = int(args.get('per_page', 0)) + except (TypeError, ValueError): + per_page = 0 + if per_page < 0: + per_page = 0 + if per_page > 500: + per_page = 500 + cache, events_list, tim = _extract_timeline(assets, assets_id, caseid, categories, descriptions, end_date, event_ids, flag, iocs, iocs_id, raws, sources, start_date, tags, titles) + total = len(tim) + if per_page > 0: + last_page = max(1, (total + per_page - 1) // per_page) + if page > last_page: + page = last_page + start = (page - 1) * per_page + end = start + per_page + tim_page = tim[start:end] + next_page = page + 1 if page < last_page else None + + # Drag in descendants that live on later pages AND ancestors that + # live on earlier pages, so any event on this slice ships with its + # full lineage. Without this: + # - a child whose parent is on a later page would appear as a + # spurious root until pagination catches up (the frontend + # promotes events with unknown parent_event_id to roots). + # - a child whose parent is on the current page but whose own + # event_date falls into a later slice would be missing from + # the parent's children until later. + # Ancestors that arrived on a previous page are already cached + # client-side, but re-sending them is cheap and keeps each page + # self-consistent if loaded out of order. + all_by_id: dict[int, dict] = {event['event_id']: event for event in tim} + children_by_parent: dict[int, list[dict]] = {} + for event in tim: + parent_id = event.get('parent_event_id') + if parent_id is None: + continue + children_by_parent.setdefault(parent_id, []).append(event) + + page_ids = {event['event_id'] for event in tim_page} + + # Descendants + queue = list(page_ids) + while queue: + parent_id = queue.pop() + for child in children_by_parent.get(parent_id, ()): + cid = child['event_id'] + if cid in page_ids: + continue + page_ids.add(cid) + tim_page.append(child) + queue.append(cid) + + # Ancestors + queue = list(page_ids) + while queue: + event_id = queue.pop() + event = all_by_id.get(event_id) + if not event: + continue + parent_id = event.get('parent_event_id') + if parent_id is None or parent_id in page_ids: + continue + parent = all_by_id.get(parent_id) + if parent is None: + continue + page_ids.add(parent_id) + tim_page.append(parent) + queue.append(parent_id) + else: + last_page = 1 + page = 1 + tim_page = tim + next_page = None + + pagination = { + "total": total, + "per_page": per_page if per_page > 0 else total, + "current_page": page, + "last_page": last_page, + "next_page": next_page + } + if request.cookies.get('session'): iocs = Ioc.query.with_entities( @@ -379,18 +468,20 @@ def case_filter_timeline(caseid): events_comments_map.setdefault(k, []).append(v) resp = { - "tim": tim, + "tim": tim_page, "comments_map": events_comments_map, "assets": cache, "iocs": [ioc._asdict() for ioc in iocs], "categories": [cat.name for cat in get_events_categories()], - "state": get_timeline_state(caseid=caseid) + "state": get_timeline_state(caseid=caseid), + "pagination": pagination } else: resp = { - "timeline": tim, - "state": get_timeline_state(caseid=caseid) + "timeline": tim_page, + "state": get_timeline_state(caseid=caseid), + "pagination": pagination } return response_success("ok", data=resp) @@ -562,6 +653,22 @@ def _extract_timeline(assets: str | None, assets_id: str | None, caseid, categor if assets_map[event_id] == len_assets: assets_filter.append(event_id) + # Build {event_id -> [timeline_id, ...]} for every event we're + # about to ship so the SPA sidebar can filter on-the-fly without a + # second round-trip. Pre-computing once per request keeps this O(N) + # instead of one query per event in the hot loop below. + event_ids_in_scope = [row.event_id for row in timeline] + timelines_by_event: dict[int, list[int]] = {} + if event_ids_in_scope: + rows = ( + CaseEventTimeline.query + .with_entities(CaseEventTimeline.event_id, CaseEventTimeline.timeline_id) + .filter(CaseEventTimeline.event_id.in_(event_ids_in_scope)) + .all() + ) + for r in rows: + timelines_by_event.setdefault(r.event_id, []).append(r.timeline_id) + iocs_filter = [] if iocs: for ioc in iocs_cache: @@ -593,7 +700,10 @@ def _extract_timeline(assets: str | None, assets_id: str | None, caseid, categor if asset.event_id == ras['event_id']: alki.append( { + "id": asset.asset_id, "name": f"{asset.asset_name} ({asset.type})", + "asset_name": asset.asset_name, + "asset_type": asset.type, "ip": asset.asset_ip, "description": asset.asset_description, "compromised": asset.asset_compromise_status_id == CompromiseStatus.compromised.value @@ -609,12 +719,15 @@ def _extract_timeline(assets: str | None, assets_id: str | None, caseid, categor alki.append( { + "id": ioc.ioc_id, "name": f"{ioc.ioc_value}", + "ioc_value": ioc.ioc_value, "description": ioc.ioc_description } ) ras['iocs'] = alki + ras['timeline_ids'] = timelines_by_event.get(ras['event_id'], []) tim.append(ras) return cache, events_list, tim diff --git a/source/app/blueprints/rest/dashboard_routes.py b/source/app/blueprints/rest/dashboard_routes.py index 1eafa68ad..9b7e15f5b 100644 --- a/source/app/blueprints/rest/dashboard_routes.py +++ b/source/app/blueprints/rest/dashboard_routes.py @@ -68,8 +68,11 @@ ) -# Logout user -@dashboard_rest_blueprint.route('/logout') +# Logout user — POST-only to prevent CSRF. A plain <img src="/logout"> on a +# third-party page would otherwise log the user out of IRIS without consent +# (RFC 7231 §4.2.1: GET must be safe; CWE-650; GHSA-8hwq-v6vm-9grr; +# SBA-ADV-20260128-03). +@dashboard_rest_blueprint.route('/logout', methods=['POST']) def logout(): """ Logout function. Erase its session and redirect to index i.e login diff --git a/source/app/blueprints/rest/datastore_routes.py b/source/app/blueprints/rest/datastore_routes.py index 11090116c..69eb56031 100644 --- a/source/app/blueprints/rest/datastore_routes.py +++ b/source/app/blueprints/rest/datastore_routes.py @@ -54,33 +54,26 @@ datastore_rest_blueprint = Blueprint('datastore_rest', __name__) -# Allowlist of form fields accepted for file add / update operations. -# Restricting to this set prevents a caller from injecting arbitrary model -# columns (e.g. file_local_name, file_sha256, file_size) through the -# multipart form payload — closing a path-traversal / data-integrity vector -# (SBA-ADV-20260128-06, CWE-22 / GHSA-qhqj-vm56-4phr). -_DS_FILE_FORM_FIELDS = frozenset({ + +# Allowlist of multipart form fields a caller may write through the file +# add/update endpoints. Anything else (notably file_id, file_local_name, +# file_case_id, file_sha256, file_size, added_by_user_id, file_date_added, +# ...) is dropped before the schema is loaded. Closes the mass-assignment +# vector reported as GHSA-qhqj-8qw6-wp8v / CWE-915. +_DS_FILE_WRITABLE_FIELDS = ( 'file_original_name', 'file_description', 'file_password', 'file_is_ioc', 'file_is_evidence', 'file_parent_id', -}) - -ALLOWED_FIELDS_DS_FILE = [ - 'file_original_name', - 'file_description', - 'file_is_ioc', - 'file_is_evidence', - 'file_password', 'file_tags', - 'file_parent_id' -] +) + -def _filter_ds_form_fields(form): - """Return a plain dict with only the allowed datastore form fields.""" - return {k: v for k, v in form.items() if k in _DS_FILE_FORM_FIELDS} +def _filter_ds_file_form(form): + """Project a flask.request.form dict down to the writable allowlist.""" + return {field: form.get(field) for field in _DS_FILE_WRITABLE_FIELDS if field in form} @datastore_rest_blueprint.route('/datastore/list/tree', methods=['GET']) @@ -126,7 +119,6 @@ def datastore_info_file(cur_id: int, caseid: int): file_schema = DSFileSchema() file = file_schema.dump(file) - del file['file_local_name'] return response_success("", data=file) @@ -142,7 +134,7 @@ def datastore_update_file(cur_id: int, caseid: int): dsf_schema = DSFileSchema() try: - dsf_sc = dsf_schema.load(_filter_ds_form_fields(request.form), instance=dsf, partial=True) + dsf_sc = dsf_schema.load(_filter_ds_file_form(request.form), instance=dsf, partial=True) add_obj_history_entry(dsf_sc, 'updated') dsf.file_is_ioc = request.form.get('file_is_ioc') is not None or request.form.get('file_is_ioc') is True @@ -247,8 +239,9 @@ def datastore_view_file(cur_id: int, caseid: int): f'Update or delete virtual entry') # Keep inline display only for file types the browser cannot execute as - # script. SVG in particular can embed <script>, and HTML/XML can also - # execute JS in the application's origin, so force them to download. + # script. SVG can embed <script>; HTML/XML execute JS in the application + # origin. Force everything else to download so a malicious upload can't + # turn the datastore into a stored-XSS sink (SBA-ADV-20260126-03 / CWE-79). safe_inline_extensions = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'} file_extension = Path(destination_name).suffix.lower().lstrip('.') serve_as_attachment = file_extension not in safe_inline_extensions @@ -271,7 +264,7 @@ def datastore_add_file(cur_id: int, caseid: int): dsf_schema = DSFileSchema() try: - dsf_sc = dsf_schema.load(_filter_ds_form_fields(request.form), partial=True) + dsf_sc = dsf_schema.load(_filter_ds_file_form(request.form), partial=True) dsf_sc.file_parent_id = dsp.path_id dsf_sc.added_by_user_id = iris_current_user.id diff --git a/source/app/blueprints/rest/endpoints.py b/source/app/blueprints/rest/endpoints.py index 8bd08a8d9..6b3b669db 100644 --- a/source/app/blueprints/rest/endpoints.py +++ b/source/app/blueprints/rest/endpoints.py @@ -31,10 +31,17 @@ def response_api_success(data): def _get_next_page(paginated_elements: Pagination): + # Return the next page *number* — historically this returned + # `has_next` (a boolean), which made it impossible for clients to + # drive infinite scroll: they got `True` instead of `2`, then + # incremented from `currentPage` and worked by coincidence on page + # 1 but desynced on subsequent loads. `next_num` is None when + # there's no next page, which matches the envelope's documented + # `next_page: int | None` shape. if not paginated_elements.has_next: return None - return paginated_elements.has_next + return paginated_elements.next_num def response_api_paginated(schema, paginated_elements: Pagination): diff --git a/source/app/blueprints/rest/manage/manage_assets_type_routes.py b/source/app/blueprints/rest/manage/manage_assets_type_routes.py index 7c9f445e6..3451c7f71 100644 --- a/source/app/blueprints/rest/manage/manage_assets_type_routes.py +++ b/source/app/blueprints/rest/manage/manage_assets_type_routes.py @@ -37,6 +37,23 @@ manage_assets_type_rest_blueprint = Blueprint('manage_assets_type_rest', __name__) +# Allowlist of form fields an administrator may write via the asset-type +# add/update endpoints. Anything else (notably asset_id, the primary key) is +# silently dropped before the schema is loaded, closing the mass-assignment +# vector reported as GHSA-w78h-mx7h-qm3h / SBA-ADV-20260128-01 / CWE-915. +_ASSET_TYPE_WRITABLE_FIELDS = { + 'asset_name', + 'asset_description', + 'asset_icon_compromised', + 'asset_icon_not_compromised', + 'csrf_token', +} + + +def _filter_asset_type_form(form): + return {k: v for k, v in form.items() if k in _ASSET_TYPE_WRITABLE_FIELDS} + + @manage_assets_type_rest_blueprint.route('/manage/asset-type/list') @ac_api_requires() def list_assets(): @@ -89,7 +106,7 @@ def view_assets(cur_id): asset_schema = AssetTypeSchema() try: - asset_sc = asset_schema.load(request.form, instance=asset_type) + asset_sc = asset_schema.load(_filter_asset_type_form(request.form), instance=asset_type) fpath_nc = asset_schema.load_store_icon(request.files.get('asset_icon_not_compromised'), 'asset_icon_not_compromised') @@ -116,7 +133,7 @@ def add_assets(): asset_schema = AssetTypeSchema() try: - asset_sc = asset_schema.load(request.form) + asset_sc = asset_schema.load(_filter_asset_type_form(request.form)) fpath_nc = asset_schema.load_store_icon(request.files.get('asset_icon_not_compromised'), 'asset_icon_not_compromised') diff --git a/source/app/blueprints/rest/manage/manage_ioc_types_routes.py b/source/app/blueprints/rest/manage/manage_ioc_types_routes.py index 3f275fd89..203330a4d 100644 --- a/source/app/blueprints/rest/manage/manage_ioc_types_routes.py +++ b/source/app/blueprints/rest/manage/manage_ioc_types_routes.py @@ -35,6 +35,25 @@ manage_ioc_type_rest_blueprint = Blueprint('manage_ioc_types_rest', __name__) +# Allowlist of fields administrators may write when adding or updating an +# IOC type. Blocks mass-assignment of type_id (GHSA-w78h-mx7h-qm3h / +# CWE-915, SBA-ADV-20260128-01). +_IOC_TYPE_WRITABLE_FIELDS = { + 'csrf_token', + 'type_name', + 'type_description', + 'type_taxonomy', + 'type_validation_regex', + 'type_validation_expect', +} + + +def _filter_ioc_type_payload(jsdata): + if not isinstance(jsdata, dict): + return {} + return {k: v for k, v in jsdata.items() if k in _IOC_TYPE_WRITABLE_FIELDS} + + @manage_ioc_type_rest_blueprint.route('/manage/ioc-types/list', methods=['GET']) @ac_api_requires() def list_ioc_types(): @@ -64,7 +83,7 @@ def add_ioc_type_api(): try: - ioct_sc = ioct_schema.load(request.get_json()) + ioct_sc = ioct_schema.load(_filter_ioc_type_payload(request.get_json())) db.session.add(ioct_sc) db.session.commit() @@ -111,7 +130,7 @@ def update_ioc(cur_id): try: - ioct_sc = ioct_schema.load(request.get_json(), instance=ioc_type) + ioct_sc = ioct_schema.load(_filter_ioc_type_payload(request.get_json()), instance=ioc_type) if ioct_sc: track_activity(f"updated ioc type type {ioct_sc.type_name}") diff --git a/source/app/blueprints/rest/manage/manage_server_settings_routes.py b/source/app/blueprints/rest/manage/manage_server_settings_routes.py index adcb7d1f5..d795b6956 100644 --- a/source/app/blueprints/rest/manage/manage_server_settings_routes.py +++ b/source/app/blueprints/rest/manage/manage_server_settings_routes.py @@ -31,6 +31,7 @@ from app.models.authorization import Permissions from app.schema.marshables import ServerSettingsSchema from app.blueprints.access_controls import ac_api_requires +from app.blueprints.rest.endpoints import endpoint_deprecated from app.blueprints.responses import response_error from app.blueprints.responses import response_success from dictdiffer import diff @@ -39,6 +40,7 @@ @manage_server_settings_rest_blueprint.route('/manage/server/backups/make-db', methods=['GET']) +@endpoint_deprecated('POST', '/api/v2/manage/server/backups/db') @ac_api_requires(Permissions.server_administrator) def manage_make_db_backup(): @@ -53,6 +55,7 @@ def manage_make_db_backup(): @manage_server_settings_rest_blueprint.route('/manage/settings/update', methods=['POST']) +@endpoint_deprecated('PUT', '/api/v2/manage/server/settings') @ac_api_requires(Permissions.server_administrator) def manage_update_settings(): if not request.is_json: diff --git a/source/app/blueprints/rest/manage/manage_users.py b/source/app/blueprints/rest/manage/manage_users.py index afbc3439e..f189802be 100644 --- a/source/app/blueprints/rest/manage/manage_users.py +++ b/source/app/blueprints/rest/manage/manage_users.py @@ -84,6 +84,32 @@ def _filter_admin_user_payload(jsdata): return {k: v for k, v in jsdata.items() if k in _ADMIN_USER_WRITABLE_FIELDS} +# Allowlist of fields an administrator may write when creating or updating a +# user. Anything else the caller tries to sneak in (id, uuid, mfa_secrets, +# webauthn_credentials, mfa_setup_complete, api_key, external_id, ...) is +# silently dropped before the schema is loaded. Closes the mass-assignment +# vector reported as GHSA-w78h-mx7h-qm3h / SBA-ADV-20260128-01 / CWE-915. +_ADMIN_USER_WRITABLE_FIELDS = { + 'csrf_token', + 'user_id', + 'user_name', + 'user_login', + 'user_email', + 'user_password', + 'user_isadmin', + 'user_is_service_account', + 'user_primary_organisation_id', + 'user_roles_str', + 'active', +} + + +def _filter_admin_user_payload(jsdata): + if not isinstance(jsdata, dict): + return {} + return {k: v for k, v in jsdata.items() if k in _ADMIN_USER_WRITABLE_FIELDS} + + @manage_users_rest_blueprint.route('/manage/users/list', methods=['GET']) @ac_api_requires(Permissions.server_administrator) def manage_users_list(): diff --git a/source/app/blueprints/rest/v2/activities.py b/source/app/blueprints/rest/v2/activities.py new file mode 100644 index 000000000..6e6d962d9 --- /dev/null +++ b/source/app/blueprints/rest/v2/activities.py @@ -0,0 +1,154 @@ +# IRIS Source Code +# Copyright (C) 2025 - 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from flask import Blueprint +from flask import request + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.access_controls import ac_current_user_has_permission +from app.blueprints.iris_user import iris_current_user +from app.blueprints.rest.endpoints import response_api_success +from app.business.activity import list_activities +from app.models.authorization import Permissions + + +activities_blueprint = Blueprint('activities_rest_v2', __name__, url_prefix='/activities') + + +def _format_row(row): + # Same projection shape as the legacy /activities/list endpoint, but + # with ``activity_date`` serialised as an ISO string (no Marshmallow + # schema — every column is already a primitive after `with_entities`). + d = row._asdict() if hasattr(row, '_asdict') else dict(row) + if d.get('activity_date') is not None: + d['activity_date'] = d['activity_date'].isoformat() + return d + + +@activities_blueprint.get('') +@ac_api_requires(Permissions.activities_read, Permissions.all_activities_read) +def list_activities_endpoint(): + """Paginated, access-scoped listing of UserActivity rows. + + Query params: + - page (int, default 1) + - per_page (int, default 25, max 200) + - search (str, optional) — ILIKE filter on activity_desc + - include_non_case (bool, default false) — include rows with no + associated case. Only honoured when the caller has the + ``all_activities_read`` permission (otherwise non-case rows from + other users would leak across tenants). + - user_id (int, optional) — restrict to a single author + - case_id (int, optional) — restrict to a single case + """ + page = request.args.get('page', default=1, type=int) + per_page = request.args.get('per_page', default=25, type=int) + search_value = request.args.get('search', default=None, type=str) + user_id = request.args.get('user_id', default=None, type=int) + case_id = request.args.get('case_id', default=None, type=int) + war_room_id = request.args.get('war_room_id', default=None, type=int) + + raw_include = (request.args.get('include_non_case') or '').strip().lower() + include_non_case = raw_include in ('1', 'true', 'yes', 'on') + + # `case_ids` and `user_ids` accept the same flexible shapes the rest + # of the v2 API uses: comma-separated `?case_ids=1,2,3` and/or + # repeated `?case_ids=1&case_ids=2`. Non-integer pieces are dropped + # silently rather than 400'ing — keeps URL-state hydration robust. + def _int_list(name): + raw = request.args.getlist(name) or [] + out = [] + for r in raw: + for piece in r.split(','): + piece = piece.strip() + if not piece: + continue + try: + out.append(int(piece)) + except ValueError: + continue + return out or None + + case_ids = _int_list('case_ids') + user_ids = _int_list('user_ids') + war_room_ids = _int_list('war_room_ids') + + # Optional date window. We accept anything `datetime.fromisoformat` + # understands (YYYY-MM-DD or full ISO with offset). Bad inputs are + # ignored — same robustness principle as the int-list parser above. + from datetime import datetime + + def _iso(name): + raw = request.args.get(name) + if not raw: + return None + try: + return datetime.fromisoformat(raw) + except ValueError: + return None + + date_from = _iso('date_from') + date_to = _iso('date_to') + + def _tribool(name): + raw = (request.args.get(name) or '').strip().lower() + if raw in ('1', 'true', 'yes', 'on'): + return True + if raw in ('0', 'false', 'no', 'off'): + return False + return None + + is_from_api = _tribool('is_from_api') + is_manual = _tribool('is_manual') + + can_read_all = ac_current_user_has_permission(Permissions.all_activities_read) + # `include_non_case` only matters for the global "admin" view — + # outside that view it would surface rows from cases the caller can't + # otherwise see. + if not can_read_all: + include_non_case = False + + paginated = list_activities( + user_id=iris_current_user.id, + can_read_all=can_read_all, + page=page, + per_page=per_page, + include_non_case=include_non_case, + search_value=search_value, + scope_user_id=user_id, + case_id=case_id, + war_room_id=war_room_id, + user_ids=user_ids, + case_ids=case_ids, + war_room_ids=war_room_ids, + date_from=date_from, + date_to=date_to, + is_from_api=is_from_api, + is_manual=is_manual, + ) + + # We project ourselves rather than going through `response_api_paginated` + # because that helper expects a Marshmallow schema. The rows are already + # flat dicts of primitives. + return response_api_success(data={ + 'total': paginated.total, + 'data': [_format_row(r) for r in paginated.items], + 'last_page': paginated.pages, + 'current_page': paginated.page, + 'next_page': paginated.next_num if paginated.has_next else None, + }) diff --git a/source/app/blueprints/rest/v2/alerts.py b/source/app/blueprints/rest/v2/alerts.py index b3a4f3d21..7a16dcfce 100644 --- a/source/app/blueprints/rest/v2/alerts.py +++ b/source/app/blueprints/rest/v2/alerts.py @@ -33,6 +33,7 @@ from app.blueprints.rest.endpoints import response_api_deleted from app.blueprints.rest.parsing import parse_comma_separated_identifiers 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.business.alerts import alerts_search from app.models.authorization import Permissions @@ -48,6 +49,24 @@ from app.models.errors import ObjectNotFoundError +# Fields that must be immutable on alert update. See GHSA-8hwq-v6vm-9grr +# / SBA-ADV-20260128-05 / CWE-863 — re-attributing alert_customer_id lets +# a user with write access to one tenant move an alert under a tenant they +# cannot see; alert_id is the primary key; alert_creation_time is audit +# integrity (set once at creation). +_ALERT_READONLY_UPDATE_FIELDS = frozenset({ + 'alert_id', + 'alert_customer_id', + 'alert_creation_time', +}) + + +def _strip_readonly_alert_fields(payload): + if not isinstance(payload, dict): + return payload + return {k: v for k, v in payload.items() if k not in _ALERT_READONLY_UPDATE_FIELDS} + + class AlertsOperations: def __init__(self): @@ -122,7 +141,8 @@ def search(self): user_identifier_filter, page, per_page, - request.args.get('sort') + request.args.get('sort'), + request.args.get('incident_id', type=int), ) if filtered_alerts is None: @@ -232,7 +252,12 @@ def update(self, identifier): identifier, fallback_customer_access=ac_current_user_has_customer_access ) - request_data = request.get_json() + # Drop fields the caller must not be allowed to change on update + # (GHSA-8hwq-v6vm-9grr / SBA-ADV-20260128-05 / CWE-863). The + # customer_id is the worst: re-attributing an alert to a + # customer the caller cannot see hides it from the rightful + # owner and plants it under another tenant's view. + request_data = _strip_readonly_alert_fields(request.get_json()) updated_alert = self._schema.load(request_data, instance=alert, partial=True) activity_data = [] @@ -280,6 +305,7 @@ def delete(self, identifier): alerts_blueprint = Blueprint('alerts_rest_v2', __name__, url_prefix='/alerts') alerts_blueprint.register_blueprint(alerts_comments_blueprint) +alerts_blueprint.register_blueprint(alerts_investigation_progress_blueprint) alerts_operations = AlertsOperations() diff --git a/source/app/blueprints/rest/v2/alerts_filters.py b/source/app/blueprints/rest/v2/alerts_filters.py index 9c345c322..c9beac52e 100644 --- a/source/app/blueprints/rest/v2/alerts_filters.py +++ b/source/app/blueprints/rest/v2/alerts_filters.py @@ -71,10 +71,22 @@ def get(self, identifier): return response_api_error(e.get_message(), data=e.get_data()) def put(self, identifier): - request_data = request.get_json() + 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 ) @@ -94,6 +106,10 @@ def put(self, identifier): 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() diff --git a/source/app/blueprints/rest/v2/alerts_routes/comments.py b/source/app/blueprints/rest/v2/alerts_routes/comments.py index 84c92d629..342b658a0 100644 --- a/source/app/blueprints/rest/v2/alerts_routes/comments.py +++ b/source/app/blueprints/rest/v2/alerts_routes/comments.py @@ -99,7 +99,7 @@ def read(self, alert_identifier, identifier): def update(self, alert_identifier, identifier): if not alerts_exists(iris_current_user, (session.get('permissions') or 0), alert_identifier): return response_api_not_found() - return case_comment_update(identifier, 'events', None) + return case_comment_update(identifier, 'alerts', None) def delete(self, alert_identifier, identifier): try: diff --git a/source/app/blueprints/rest/v2/alerts_routes/investigation_progress.py b/source/app/blueprints/rest/v2/alerts_routes/investigation_progress.py new file mode 100644 index 000000000..22ae28836 --- /dev/null +++ b/source/app/blueprints/rest/v2/alerts_routes/investigation_progress.py @@ -0,0 +1,109 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from flask import Blueprint +from flask import request +from flask import session + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.access_controls import ac_current_user_has_customer_access +from app.blueprints.iris_user import iris_current_user +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.business.alerts import alerts_get +from app.business.investigation_flows import alert_progress_list +from app.business.investigation_flows import alert_progress_record +from app.business.investigation_flows import alert_progress_uncheck +from app.models.authorization import Permissions +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.schema.marshables import AlertInvestigationProgressSchema + + +alerts_investigation_progress_blueprint = Blueprint( + 'alerts_investigation_progress_rest_v2', __name__ +) + +_schema = AlertInvestigationProgressSchema() + + +def _load_alert(identifier): + return alerts_get( + iris_current_user, + session.get('permissions') or 0, + identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + + +@alerts_investigation_progress_blueprint.get('/<int:identifier>/investigation-progress') +@ac_api_requires(Permissions.alerts_read) +def list_progress(identifier): + try: + alert = _load_alert(identifier) + except ObjectNotFoundError: + return response_api_not_found() + rows = alert_progress_list(alert) + flow = alert.investigation_flow + return response_api_success({ + 'flow_id': flow.flow_id if flow else None, + 'flow_name': flow.flow_name if flow else None, + 'steps': [ + { + 'step_id': s.step_id, + 'step_order': s.step_order, + 'step_title': s.step_title, + 'step_description': s.step_description, + 'step_is_required': s.step_is_required, + } + for s in (flow.steps if flow else []) + ], + 'progress': [_schema.dump(r) for r in rows], + }) + + +@alerts_investigation_progress_blueprint.post('/<int:identifier>/investigation-progress/<int:step_id>') +@ac_api_requires(Permissions.alerts_write) +def record_progress(identifier, step_id): + try: + alert = _load_alert(identifier) + except ObjectNotFoundError: + return response_api_not_found() + payload = request.get_json() or {} + try: + row = alert_progress_record( + alert, step_id, iris_current_user.id, note=payload.get('note') + ) + except BusinessProcessingError as exc: + return response_api_error(exc.get_message(), data=exc.get_data()) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(_schema.dump(row)) + + +@alerts_investigation_progress_blueprint.delete('/<int:identifier>/investigation-progress/<int:step_id>') +@ac_api_requires(Permissions.alerts_write) +def uncheck_progress(identifier, step_id): + try: + alert = _load_alert(identifier) + except ObjectNotFoundError: + return response_api_not_found() + alert_progress_uncheck(alert, step_id) + return response_api_deleted() diff --git a/source/app/blueprints/rest/v2/auth.py b/source/app/blueprints/rest/v2/auth.py index 637f13fed..2f426e24b 100644 --- a/source/app/blueprints/rest/v2/auth.py +++ b/source/app/blueprints/rest/v2/auth.py @@ -16,6 +16,9 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +import threading +import time + import jwt import pyotp @@ -52,6 +55,66 @@ auth_blueprint = Blueprint('auth', __name__, url_prefix='/auth') +# Per-user brute-force throttle on the API MFA endpoints. The legacy +# pages flow tracks fail count + lockout in the Flask session, but the +# SPA presents a bearer token without a session, so we keep an in-process +# counter keyed by user_id. After `_MFA_FAIL_THRESHOLD` consecutive bad +# tokens we refuse all attempts for `_MFA_LOCKOUT_SECONDS` regardless of +# password validity — making the 6-digit TOTP space (10^6) infeasible to +# brute-force. The counter resets on a successful verify or after the +# lockout expires. For multi-worker deployments this is per-worker, but +# the throttle on any single worker still raises the cost meaningfully. +_MFA_FAIL_THRESHOLD = 5 +_MFA_LOCKOUT_SECONDS = 15 * 60 +_mfa_throttle_lock = threading.Lock() +_mfa_throttle = {} + + +def _mfa_throttle_check(user_id): + """Return seconds remaining in the lockout, or 0 if the user can attempt.""" + with _mfa_throttle_lock: + entry = _mfa_throttle.get(user_id) + if not entry: + return 0 + if entry.get('locked_until', 0) > time.time(): + return int(entry['locked_until'] - time.time()) + return 0 + + +def _mfa_throttle_register_failure(user_id): + with _mfa_throttle_lock: + entry = _mfa_throttle.setdefault(user_id, {'fail_count': 0, 'locked_until': 0}) + entry['fail_count'] = entry.get('fail_count', 0) + 1 + if entry['fail_count'] >= _MFA_FAIL_THRESHOLD: + entry['locked_until'] = time.time() + _MFA_LOCKOUT_SECONDS + # Reset the counter so the next post-lockout attempt isn't + # immediately locked again — the lockout is the deterrent. + entry['fail_count'] = 0 + + +def _mfa_throttle_reset(user_id): + with _mfa_throttle_lock: + _mfa_throttle.pop(user_id, None) + + +def _mfa_status_for(user): + """ + Compact MFA hint pair the SPA uses to pick its post-login route. + + `mfa_required` mirrors the server-wide policy (`SERVER_SETTINGS.enforce_mfa`) + so the client can distinguish "user hasn't set up MFA because policy is + off" from "must set up MFA before reaching the app". `mfa_setup_complete` + reflects the user's row. OIDC users bypass MFA entirely (handled in + `wrap_login_user`); for the local/LDAP login surface that calls this + helper, the pair is sufficient to route to mfa-setup vs mfa-verify vs + straight into the app. + """ + return { + 'mfa_required': bool(app.config['SERVER_SETTINGS'].get('enforce_mfa')), + 'mfa_setup_complete': bool(getattr(user, 'mfa_setup_complete', False)), + } + + @auth_blueprint.post('/login') def login(): """ @@ -62,6 +125,7 @@ def login(): logger.debug(f'User {iris_current_user.user} already logged in') user = users_get_active(iris_current_user.id) result = UserSchema(exclude=['user_password', 'mfa_secrets', 'webauthn_credentials']).dump(user) + result.update(_mfa_status_for(user)) return response_api_success(result) if is_authentication_oidc() and app.config.get('AUTHENTICATION_LOCAL_FALLBACK') is False: @@ -84,14 +148,80 @@ def login(): user_data = UserSchema(exclude=['user_password', 'mfa_secrets', 'webauthn_credentials']).dump(authed_user) - # Generate auth tokens for API access + # Generate auth tokens for API access. When MFA is required but not yet + # verified, the access token carries `mfa_verified=False` and the SPA must + # route to mfa-setup / mfa-verify before the token unlocks anything beyond + # the MFA endpoints. tokens = generate_auth_tokens(authed_user) user_data.update({'tokens': tokens}) + user_data.update(_mfa_status_for(authed_user)) track_activity(f'User {username} logged in', ctx_less=True, display_in_ui=False) return response_api_success(data=user_data) +@auth_blueprint.post('/oidc-exchange') +def oidc_exchange(): + """ + Trade a fresh OIDC-authenticated session cookie for JWT access/refresh + tokens, then invalidate the session so the cookie can't be reused. + + Security model: + - Only redeemable when the Flask session was created by a successful + OIDC callback in this same session (checked via the one-time + `oidc_authenticated` flag set in login.oidc_authorise). A plain + local-login session, or a session hydrated some other way, is + rejected — this endpoint is not a general session->JWT converter. + - Single-use: the flag is popped and the session is fully cleared + before the response is returned, so the same session cookie + cannot mint a second set of tokens even if the browser replays. + - MFA is trusted from the IdP for OIDC users (mirrors wrap_login_user's + is_oidc=True branch, which skips IRIS's local MFA prompt). Tokens + are minted with mfa_verified=True — same policy as the existing + session-based OIDC login has today. + """ + if not is_authentication_oidc(): + return response_api_error('OIDC authentication is not enabled', 400) + + if not iris_current_user.is_authenticated: + return response_api_error('Unauthorized', 401) + + if not session.pop('oidc_authenticated', False): + # The user has a valid session but it wasn't created via the OIDC + # callback (or the marker has already been consumed by a prior + # exchange). Do NOT mint tokens. + return response_api_error('No pending OIDC exchange for this session', 403) + + user = users_get_active(iris_current_user.id) + if user is None: + session.clear() + return response_api_error('User not active', 403) + + user_data = UserSchema( + exclude=['user_password', 'mfa_secrets', 'webauthn_credentials'] + ).dump(user) + + # OIDC users' MFA is enforced at the IdP. Match wrap_login_user's + # is_oidc branch and mint tokens with mfa_verified=True so the SPA + # doesn't route them through IRIS's local MFA prompt. + tokens = generate_auth_tokens(user, mfa_verified=True) + user_data.update({'tokens': tokens}) + user_data.update(_mfa_status_for(user)) + + # Invalidate the bridge cookie: log the flask-login user out and + # wipe every session key. From this point on the SPA authenticates + # solely via the JWT it just received. + logout_user() + session.clear() + + track_activity( + f'User {user.user} exchanged OIDC session for JWT tokens', + ctx_less=True, display_in_ui=False, + ) + + return response_api_success(data=user_data) + + @auth_blueprint.post('/mfa-setup') def mfa_setup(): """ @@ -117,10 +247,34 @@ def mfa_setup(): return response_api_error('Invalid token type') user_id = payload.get('user_id') + + # Reuse the verify throttle so a leaked refresh token can't be used + # to spray password/token combos against /mfa-setup either. + remaining = _mfa_throttle_check(user_id) + if remaining > 0: + return response_api_error( + f'Too many MFA attempts. Try again in {remaining} seconds.' + ) + user = users_get_active(user_id) + # Refuse to overwrite an existing enrollment from this endpoint. + # Re-running setup with a fresh secret would silently invalidate + # the legitimate user's authenticator app — an attacker holding + # password + step-1 refresh token could lock the user out and + # bind MFA to their own device. Resetting MFA must go through + # the admin user-management flow. + if user.mfa_setup_complete: + track_activity( + f"Refused MFA setup for user {user.user}: already enrolled.", + ctx_less=True, + display_in_ui=False, + ) + return response_api_error('MFA already configured for this account') + totp = pyotp.TOTP(mfa_secret) if not totp.verify(str(token)): + _mfa_throttle_register_failure(user_id) track_activity( f"Failed MFA setup for user {user.user}. Invalid token.", ctx_less=True, @@ -142,6 +296,7 @@ def mfa_setup(): has_valid_password = True if not has_valid_password: + _mfa_throttle_register_failure(user_id) track_activity( f"Failed MFA setup for user {user.user}. Invalid password.", ctx_less=True, @@ -152,6 +307,7 @@ def mfa_setup(): user.mfa_secrets = mfa_secret user.mfa_setup_complete = True db.session.commit() + _mfa_throttle_reset(user_id) track_activity( f"MFA setup successful for user {user.user}", @@ -190,6 +346,16 @@ def mfa_verify(): return response_api_error('Invalid token type') user_id = payload.get('user_id') + + # Throttle BEFORE the DB lookup. Even invalid attempts must count + # toward the lockout — otherwise an attacker could keep the user_id + # claim fixed and brute-force the TOTP space at network speed. + remaining = _mfa_throttle_check(user_id) + if remaining > 0: + return response_api_error( + f'Too many MFA attempts. Try again in {remaining} seconds.' + ) + user = users_get_active(user_id) if not user.mfa_secrets or not user.mfa_setup_complete: @@ -197,6 +363,7 @@ def mfa_verify(): totp = pyotp.TOTP(user.mfa_secrets) if not totp.verify(str(token), valid_window=1): + _mfa_throttle_register_failure(user_id) track_activity( f"Failed MFA verification for user {user.user}. Invalid token.", ctx_less=True, @@ -204,6 +371,7 @@ def mfa_verify(): ) return response_api_error('Invalid token') + _mfa_throttle_reset(user_id) track_activity( f"MFA verification successful for user {user.user}", ctx_less=True, @@ -303,8 +471,16 @@ def refresh_token_endpoint(): user_id = payload.get('user_id') user = users_get_active(user_id) - # Generate new tokens - new_tokens = generate_auth_tokens(user) + # Carry the MFA verification state across the refresh. Without this, + # `generate_auth_tokens(user)` would default `mfa_verified=False` and + # — for users with MFA enrolled — every refresh would issue a token + # that fails our central MFA enforcement check (logging the user out + # every 15 minutes). Equally important: a step-1 refresh token + # (mfa_verified=False) MUST keep producing step-1 access tokens, so + # a stolen step-1 refresh can't be laundered into a verified token + # without going through /mfa-verify. + mfa_verified = bool(payload.get('mfa_verified', False)) + new_tokens = generate_auth_tokens(user, mfa_verified=mfa_verified) return response_api_success({'tokens': new_tokens}) diff --git a/source/app/blueprints/rest/v2/avatars.py b/source/app/blueprints/rest/v2/avatars.py new file mode 100644 index 000000000..319a4e8ba --- /dev/null +++ b/source/app/blueprints/rest/v2/avatars.py @@ -0,0 +1,305 @@ +"""v2 avatar endpoints. + +Three call sites: + +* `GET /api/v2/users/<id>/avatar` — any authenticated user can fetch + any other user's avatar. The bytes are shown in mention chips, case + contributor lists, comment bubbles, alert assignment chips, side-bar + user menu, etc., so locking this behind `server_administrator` + would break the feature for every non-admin viewer. The response + carries an ETag and `Cache-Control: private, must-revalidate` so + clients revalidate cheaply rather than re-downloading on every + page render. + +* `POST /api/v2/me/avatar` / `DELETE /api/v2/me/avatar` — self-service. + Anyone authenticated can swap or remove their own avatar. The POST + expects a `multipart/form-data` with an `avatar` file part; the + handler hands the bytes to Pillow which validates the format, centre- + crops to a square, resizes to 256x256, and re-encodes as PNG before + hitting the DB. Polyglot uploads (HTML/SVG/JS with a JPEG header) + get rejected at the Pillow open step. + +* `POST/DELETE /api/v2/manage/users/<id>/avatar` — admin-only equivalents + so an administrator can correct or reset a user's avatar. + +Upload limits: +* 4 MB hard cap on the raw bytes (`MAX_AVATAR_BYTES`). +* Output is bounded by Pillow at 256x256 PNG (~30-80 KB). +""" +from __future__ import annotations + +import io +from datetime import datetime +from datetime import timezone + +from flask import Blueprint +from flask import Response +from flask import current_app +from flask import make_response +from flask import request +from PIL import Image +from PIL import UnidentifiedImageError + +from sqlalchemy import func +from sqlalchemy import or_ + +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_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.business.users import users_get +from app.db import db +from app.models.authorization import Permissions +from app.models.authorization import User +from app.models.errors import ObjectNotFoundError + + +MAX_AVATAR_BYTES = 4 * 1024 * 1024 # raw upload cap before resize +TARGET_SIZE = 256 # final output dimensions +TARGET_MIME = 'image/png' + + +def _normalise_image(raw: bytes) -> bytes: + """Validate, centre-crop, resize and re-encode an upload. + + Returns the PNG bytes ready to land in the DB, or raises + `ValueError` with a user-facing reason. + """ + try: + # `Image.open` is lazy; calling `.load()` forces Pillow to + # parse the body which is what actually rejects malformed + # / polyglot uploads. `.convert('RGBA')` normalises every + # accepted input (jpg / png / webp / gif first frame) onto a + # single colour model so the resize step is deterministic. + with Image.open(io.BytesIO(raw)) as img: + img.load() + img = img.convert('RGBA') + + # Centre-crop to a square. Most upload helpers already + # constrain the user to a square, but file pickers don't, + # and a non-square upload re-encoded straight to 256x256 + # would distort the face. + width, height = img.size + short = min(width, height) + left = (width - short) // 2 + top = (height - short) // 2 + img = img.crop((left, top, left + short, top + short)) + + img = img.resize((TARGET_SIZE, TARGET_SIZE), Image.LANCZOS) + + buf = io.BytesIO() + img.save(buf, format='PNG', optimize=True) + return buf.getvalue() + except UnidentifiedImageError as exc: + raise ValueError('File is not a recognised image') from exc + except OSError as exc: + # Pillow raises OSError on truncated bodies, EXIF parse + # failures, etc. Map them all to a generic "bad image" so the + # client gets a single error path rather than internal + # specifics. + raise ValueError('Could not decode image') from exc + + +def _serve_avatar(user) -> Response: + """Return a 200 with the avatar bytes, or 404 when none is set.""" + blob = getattr(user, 'avatar_blob', None) + if not blob: + return response_api_not_found() + + mime = getattr(user, 'avatar_mime', None) or TARGET_MIME + updated = getattr(user, 'avatar_updated_at', None) + + response = make_response(blob) + response.headers['Content-Type'] = mime + response.headers['Content-Length'] = str(len(blob)) + # `private` because the URL is the same for every user even + # though the body differs by session — we don't want shared CDN + # caches mixing bodies between viewers if one ever sits in front + # of the SPA. + response.headers['Cache-Control'] = 'private, max-age=60, must-revalidate' + if updated: + # ETag based on the millisecond timestamp gives clients a + # cheap conditional GET path and invalidates the moment the + # user reuploads. + response.headers['ETag'] = f'"{int(updated.timestamp() * 1000)}"' + response.headers['Last-Modified'] = updated.strftime('%a, %d %b %Y %H:%M:%S GMT') + return response + + +def _handle_upload(user) -> Response: + """Read the uploaded part, validate and store it on `user`.""" + file = request.files.get('avatar') + if file is None or file.filename == '': + return response_api_error('Missing avatar file part') + + # `content_length` is set by Flask when the multipart parser + # discovers a Content-Length header. We also check the body + # length explicitly below so a chunked upload can't sneak past. + if file.content_length and file.content_length > MAX_AVATAR_BYTES: + return response_api_error(f'Avatar exceeds {MAX_AVATAR_BYTES // (1024 * 1024)} MB') + + raw = file.read(MAX_AVATAR_BYTES + 1) + if len(raw) > MAX_AVATAR_BYTES: + return response_api_error(f'Avatar exceeds {MAX_AVATAR_BYTES // (1024 * 1024)} MB') + + try: + png_bytes = _normalise_image(raw) + except ValueError as exc: + return response_api_error(str(exc)) + + user.avatar_blob = png_bytes + user.avatar_mime = TARGET_MIME + user.avatar_updated_at = datetime.now(timezone.utc).replace(tzinfo=None) + db.session.commit() + + return response_api_success({ + 'user_id': user.id, + 'avatar_updated_at': user.avatar_updated_at.isoformat(), + 'mime': TARGET_MIME, + }) + + +def _clear_avatar(user) -> Response: + user.avatar_blob = None + user.avatar_mime = None + user.avatar_updated_at = None + db.session.commit() + return response_api_deleted() + + +# ----- Public read endpoint -------------------------------------------------- +# Lives on its own blueprint mounted at `/users` so it can carry a +# weaker permission requirement than the manage `/users` blueprint +# (which is `server_administrator`-only). Any authenticated user can +# fetch any other user's avatar. + +users_public_blueprint = Blueprint('users_public_rest_v2', __name__, url_prefix='/users') + + +@users_public_blueprint.get('/<int:identifier>/avatar') +@ac_api_requires() +def get_user_avatar(identifier: int) -> Response: + try: + user = users_get(identifier) + except ObjectNotFoundError: + return response_api_not_found() + if user is None: + return response_api_not_found() + return _serve_avatar(user) + + +# Lightweight directory for the @-mention autocomplete. Any authenticated +# user can list active users so mention chips work without needing the +# admin-only /manage/users endpoint. The payload is intentionally minimal +# (id, login, name) — no email, no roles, no permissions — so it can't +# be repurposed as a permission-info leak. Bounded by `_MENTION_LIMIT` +# because the mention popup only shows a handful of results. +_MENTION_LIMIT = 50 + + +@users_public_blueprint.get('/mentionable') +@ac_api_requires() +def get_mentionable_users() -> Response: + """Return active users matching `?q=<prefix>` for the mention popup. + + Match is case-insensitive prefix against `user.user` (login) OR + `user.name` (display name), plus an `ilike` fallback so mid-word + matches also surface (e.g. `q=alice` finds "Alice Doe" and + "malice@bar" alike). Empty `q` returns the first N active users + which the client can then fuzzy-filter locally. + """ + q_raw = request.args.get('q', default='', type=str) or '' + q = q_raw.strip().lower() + + query = User.query.filter(User.active == True) # noqa: E712 + if q: + # Prefix match first (cheap on indexed columns), fall back to + # substring match. Kept as one query so a single row-scan + # answers both. + like = f'%{q}%' + query = query.filter(or_( + func.lower(User.user).like(like), + func.lower(User.name).like(like), + )) + + # Deterministic order so paginated / infinite-scroll clients see a + # stable list. `name` is the primary sort — display name is what + # the user sees in the mention chip. + rows = ( + query + .order_by(func.lower(User.name).asc(), User.id.asc()) + .limit(_MENTION_LIMIT) + .all() + ) + + return response_api_success({ + 'data': [ + { + 'user_id': u.id, + 'user_login': u.user, + 'user_name': u.name, + } + for u in rows + ], + }) + + +# ----- Self-service endpoints ------------------------------------------------ + +me_avatar_blueprint = Blueprint('me_avatar_rest_v2', __name__, url_prefix='/me') + + +@me_avatar_blueprint.post('/avatar') +@ac_api_requires() +def post_my_avatar() -> Response: + user = users_get(iris_current_user.id) + if user is None: + return response_api_not_found() + return _handle_upload(user) + + +@me_avatar_blueprint.delete('/avatar') +@ac_api_requires() +def delete_my_avatar() -> Response: + user = users_get(iris_current_user.id) + if user is None: + return response_api_not_found() + return _clear_avatar(user) + + +# ----- Admin endpoints ------------------------------------------------------- + +admin_avatar_blueprint = Blueprint( + 'admin_avatar_rest_v2', __name__, url_prefix='/manage/users' +) + + +@admin_avatar_blueprint.post('/<int:identifier>/avatar') +@ac_api_requires(Permissions.server_administrator) +def post_user_avatar(identifier: int) -> Response: + try: + user = users_get(identifier) + except ObjectNotFoundError: + return response_api_not_found() + if user is None: + return response_api_not_found() + return _handle_upload(user) + + +@admin_avatar_blueprint.delete('/<int:identifier>/avatar') +@ac_api_requires(Permissions.server_administrator) +def delete_user_avatar(identifier: int) -> Response: + try: + user = users_get(identifier) + except ObjectNotFoundError: + return response_api_not_found() + if user is None: + return response_api_not_found() + return _clear_avatar(user) + + +# Silence the linter — kept around in case we need to log upload +# stats or limit by admin role later. +_ = current_app diff --git a/source/app/blueprints/rest/v2/case_routes/assets.py b/source/app/blueprints/rest/v2/case_routes/assets.py index a13e21895..45aedc5d5 100644 --- a/source/app/blueprints/rest/v2/case_routes/assets.py +++ b/source/app/blueprints/rest/v2/case_routes/assets.py @@ -36,6 +36,9 @@ from app.business.assets import assets_get from app.business.assets import assets_update from app.business.assets import assets_delete +from app.datamgmt.case.case_assets_db import get_similar_assets +from app.datamgmt.case.case_db import get_case_client_id +from app.datamgmt.manage.manage_users_db import get_user_cases_fast from app.models.errors import BusinessProcessingError from app.models.errors import ObjectNotFoundError from app.iris_engine.module_handler.module_handler import call_deprecated_on_preload_modules_hook @@ -185,3 +188,30 @@ def update_asset(case_identifier, identifier): @ac_api_requires() def delete_asset(case_identifier, identifier): return assets_operations.delete(case_identifier, identifier) + + +@case_assets_blueprint.get('/<int:identifier>/links') +@ac_api_requires() +def get_asset_other_case_links(case_identifier, identifier): + """Return the list of OTHER cases where the same asset was seen. + + "Same asset" matches the legacy v2.4.x heuristic: same name + same type + on a case belonging to the same customer that the current user has at + least read access to. The customer-scoping is important — it stops a + generic name like `localhost` from lighting up across every tenant on + the instance. + """ + if not ac_fast_check_current_user_has_case_access( + case_identifier, [CaseAccessLevel.read_only, CaseAccessLevel.full_access]): + return ac_api_return_access_denied(caseid=case_identifier) + + try: + asset = AssetsOperations._get_asset_in_case(identifier, case_identifier) + except ObjectNotFoundError: + return response_api_not_found() + + customer_id = get_case_client_id(case_identifier) + cases_access = get_user_cases_fast(iris_current_user.id) + links = list(get_similar_assets( + asset.asset_name, asset.asset_type_id, case_identifier, customer_id, cases_access)) + return response_api_success(links) diff --git a/source/app/blueprints/rest/v2/case_routes/datastore.py b/source/app/blueprints/rest/v2/case_routes/datastore.py index 65476e45f..f0161b666 100644 --- a/source/app/blueprints/rest/v2/case_routes/datastore.py +++ b/source/app/blueprints/rest/v2/case_routes/datastore.py @@ -17,100 +17,164 @@ # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. import base64 +import datetime import marshmallow.exceptions from flask import Blueprint from flask import request from flask import send_file from pathlib import Path +from app.db import db from app.blueprints.access_controls import ac_api_requires from app.blueprints.access_controls import ac_fast_check_current_user_has_case_access from app.blueprints.access_controls import ac_api_return_access_denied +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.business.cases import cases_exists +from app.datamgmt.datastore.datastore_db import datastore_add_child_node +from app.datamgmt.datastore.datastore_db import datastore_add_file_as_evidence +from app.datamgmt.datastore.datastore_db import datastore_add_file_as_ioc +from app.datamgmt.datastore.datastore_db import datastore_delete_file +from app.datamgmt.datastore.datastore_db import datastore_delete_node from app.datamgmt.datastore.datastore_db import datastore_get_file from app.datamgmt.datastore.datastore_db import datastore_get_interactive_path_node from app.datamgmt.datastore.datastore_db import datastore_get_local_file_path +from app.datamgmt.datastore.datastore_db import datastore_get_path_node +from app.datamgmt.datastore.datastore_db import datastore_get_standard_path +from app.datamgmt.datastore.datastore_db import datastore_rename_node +from app.datamgmt.datastore.datastore_db import ds_list_tree from app.iris_engine.utils.tracker import track_activity from app.models.authorization import CaseAccessLevel +from app.models.models import DataStoreFile +from app.models.models import DataStorePath from app.schema.marshables import DSFileSchema +from app.schema.marshables import DSPathSchema +from app.util import add_obj_history_entry + + +_READ_LEVELS = [CaseAccessLevel.read_only, CaseAccessLevel.full_access] +_WRITE_LEVELS = [CaseAccessLevel.full_access] + + +def _check_case_access(case_identifier, access_levels): + if not cases_exists(case_identifier): + return response_api_not_found() + if not ac_fast_check_current_user_has_case_access(case_identifier, access_levels): + return ac_api_return_access_denied(caseid=case_identifier) + return None + + +def _truthy(value): + if isinstance(value, bool): + return value + if value is None: + return False + return str(value).strip().lower() in ('1', 'true', 'yes', 'on') + + +# Allowlist of multipart form fields a caller may write through the file +# add/update endpoints. Anything else (notably file_id, file_local_name, +# file_case_id, file_sha256, file_size, added_by_user_id, file_date_added, +# ...) is dropped before the schema is loaded. Closes the mass-assignment +# vector reported as GHSA-qhqj-8qw6-wp8v / CWE-915. +_DS_FILE_WRITABLE_FIELDS = ( + 'file_original_name', + 'file_description', + 'file_password', + 'file_is_ioc', + 'file_is_evidence', + 'file_parent_id', + 'file_tags', +) + + +def _filter_ds_file_form(form): + return {field: form.get(field) for field in _DS_FILE_WRITABLE_FIELDS if field in form} class DatastoreOperations: - """Case-scoped datastore file operations used by the rich-text editor - (inline image uploads embedded in notes and case summaries). + """Case-scoped datastore operations exposed via the v2 API. - Only the two endpoints the editor actually needs are exposed here: - the interactive upload and the file view. The broader tree / folder - management still lives on the legacy `datastore_rest_blueprint` until - that UI is migrated too. + Mirrors the legacy `/datastore/...` blueprint feature-for-feature so the + new frontend can manage the per-case file tree without falling back to v1 + endpoints. Folder operations are JSON; file operations accept multipart + form data because they carry an uploaded file alongside metadata. """ - @staticmethod - def _check_case_access(case_identifier, access_levels): - if not cases_exists(case_identifier): - return response_api_not_found() + def __init__(self): + self._file_schema = DSFileSchema() + self._path_schema = DSPathSchema() - if not ac_fast_check_current_user_has_case_access(case_identifier, access_levels): - return ac_api_return_access_denied(caseid=case_identifier) + # --------------------------------------------------------------------- + # Tree + # --------------------------------------------------------------------- + def tree(self, case_identifier): + access_error = _check_case_access(case_identifier, _READ_LEVELS) + if access_error: + return access_error - return None + return response_api_success(ds_list_tree(case_identifier)) - def add_interactive(self, case_identifier): - access_error = self._check_case_access(case_identifier, [CaseAccessLevel.full_access]) + # --------------------------------------------------------------------- + # File list (flat, paginated). The legacy UI rendered the whole tree at + # once; the new UI also wants a paginated flat view so the data-table + # mode can lazy-load like every other case section. + # --------------------------------------------------------------------- + def list_files(self, case_identifier): + access_error = _check_case_access(case_identifier, _READ_LEVELS) if access_error: return access_error - dsp = datastore_get_interactive_path_node(case_identifier) - if not dsp: - return response_api_error('Invalid path node for this case') - - dsf_schema = DSFileSchema() try: - js_data = request.get_json() or {} + page = int(request.args.get('page', 1)) + per_page = int(request.args.get('per_page', 25)) + except (TypeError, ValueError): + return response_api_error('Invalid pagination parameters') - try: - file_content = base64.b64decode(js_data.get('file_content')) - filename = js_data.get('file_original_name') - except Exception as e: - return response_api_error(str(e)) + order_by = request.args.get('order_by', 'file_date_added') + sort_dir = request.args.get('sort_dir', 'desc').lower() + order_column = getattr(DataStoreFile, order_by, None) + if order_column is None: + return response_api_error(f'Invalid order_by field: {order_by}') - if not filename: - return response_api_error('Data error', data={'file_original_name': ['Missing filename']}) + query = DataStoreFile.query.filter(DataStoreFile.file_case_id == case_identifier) + query = query.order_by(order_column.desc() if sort_dir == 'desc' else order_column.asc()) - dsf_sc, existed = dsf_schema.ds_store_file_b64(filename, file_content, dsp, case_identifier) + paginated = query.paginate(page=page, per_page=per_page, error_out=False) - track_activity( - f'File "{dsf_sc.file_original_name}" added to DS', - caseid=case_identifier - ) + return response_api_success({ + 'total': paginated.total, + 'data': self._file_schema.dump(paginated.items, many=True), + 'last_page': paginated.pages, + 'current_page': paginated.page, + 'next_page': paginated.next_num if paginated.has_next else None + }) - # URL is returned relative to /api/v2 so the frontend can embed it - # directly as an <img src>. It's a REST path the browser can GET - # without any further rewriting — `cid` is part of the path. - return response_api_created({ - 'existed': existed, - 'file_url': f'/api/v2/cases/{case_identifier}/datastore/files/{dsf_sc.file_id}', - **dsf_schema.dump(dsf_sc) - }) + # --------------------------------------------------------------------- + # File CRUD + # --------------------------------------------------------------------- + def get_file_info(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _READ_LEVELS) + if access_error: + return access_error - except marshmallow.exceptions.ValidationError as e: - return response_api_error('Data error', data=e.messages) + dsf = datastore_get_file(identifier, case_identifier) + if not dsf: + return response_api_not_found() - def view(self, case_identifier, identifier): - access_error = self._check_case_access( - case_identifier, - [CaseAccessLevel.read_only, CaseAccessLevel.full_access] - ) + data = self._file_schema.dump(dsf) + data.pop('file_local_name', None) + return response_api_success(data) + + def view_file(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _READ_LEVELS) if access_error: return access_error - # Sanity-check that the file actually belongs to this case before - # resolving its on-disk location. `datastore_get_local_file_path` - # already filters by case id, but this keeps 404s consistent with - # the rest of the v2 case routes. if not datastore_get_file(identifier, case_identifier): return response_api_not_found() @@ -129,7 +193,16 @@ def view(self, case_identifier, identifier): f'Update or delete virtual entry' ) - resp = send_file(dsf.file_local_name, as_attachment=False, download_name=destination_name) + # Keep inline display only for file types the browser cannot execute + # as script. SVG can embed <script>; HTML/XML execute JS in the + # application origin. Force everything else to download so a + # malicious upload can't turn the datastore into a stored-XSS sink + # (SBA-ADV-20260126-03 / CWE-79). + safe_inline_extensions = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'} + file_extension = Path(destination_name).suffix.lower().lstrip('.') + serve_as_attachment = file_extension not in safe_inline_extensions + + resp = send_file(dsf.file_local_name, as_attachment=serve_as_attachment, download_name=destination_name) track_activity( f'File "{destination_name}" downloaded', @@ -138,6 +211,306 @@ def view(self, case_identifier, identifier): ) return resp + def add_file(self, case_identifier, folder_identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + dsp = datastore_get_path_node(folder_identifier, case_identifier) + if not dsp: + return response_api_not_found() + + try: + dsf_sc = self._file_schema.load(_filter_ds_file_form(request.form), partial=True) + + dsf_sc.file_parent_id = dsp.path_id + dsf_sc.added_by_user_id = iris_current_user.id + dsf_sc.file_date_added = datetime.datetime.now() + dsf_sc.file_local_name = 'tmp_xc' + dsf_sc.file_case_id = case_identifier + dsf_sc.file_is_ioc = _truthy(request.form.get('file_is_ioc')) + dsf_sc.file_is_evidence = _truthy(request.form.get('file_is_evidence')) + add_obj_history_entry(dsf_sc, 'created') + + if dsf_sc.file_is_ioc and not dsf_sc.file_password: + dsf_sc.file_password = 'infected' + + db.session.add(dsf_sc) + db.session.commit() + + uploaded = request.files.get('file_content') + if not uploaded: + db.session.delete(dsf_sc) + db.session.commit() + return response_api_error('Missing file content') + + ds_location = datastore_get_standard_path(dsf_sc, case_identifier) + dsf_sc.file_local_name, dsf_sc.file_size, dsf_sc.file_sha256 = self._file_schema.ds_store_file( + uploaded, + ds_location, + dsf_sc.file_is_ioc, + dsf_sc.file_password + ) + db.session.commit() + + if dsf_sc.file_is_ioc: + datastore_add_file_as_ioc(iris_current_user.id, dsf_sc) + if dsf_sc.file_is_evidence: + datastore_add_file_as_evidence(iris_current_user.id, dsf_sc, case_identifier) + + track_activity( + f'File "{dsf_sc.file_original_name}" added to DS', + caseid=case_identifier + ) + return response_api_created(self._file_schema.dump(dsf_sc)) + + except marshmallow.exceptions.ValidationError as e: + return response_api_error('Data error', data=e.messages) + + def update_file(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + dsf = datastore_get_file(identifier, case_identifier) + if not dsf: + return response_api_not_found() + + try: + dsf_sc = self._file_schema.load(_filter_ds_file_form(request.form), instance=dsf, partial=True) + add_obj_history_entry(dsf_sc, 'updated') + + if 'file_is_ioc' in request.form: + dsf.file_is_ioc = _truthy(request.form.get('file_is_ioc')) + if 'file_is_evidence' in request.form: + dsf.file_is_evidence = _truthy(request.form.get('file_is_evidence')) + + db.session.commit() + + uploaded = request.files.get('file_content') + if uploaded: + ds_location = datastore_get_standard_path(dsf_sc, case_identifier) + dsf_sc.file_local_name, dsf_sc.file_size, dsf_sc.file_sha256 = self._file_schema.ds_store_file( + uploaded, + ds_location, + dsf_sc.file_is_ioc, + dsf_sc.file_password + ) + if dsf_sc.file_is_ioc and not dsf_sc.file_password: + dsf_sc.file_password = 'infected' + db.session.commit() + + if dsf.file_is_ioc: + datastore_add_file_as_ioc(iris_current_user.id, dsf) + if dsf.file_is_evidence: + datastore_add_file_as_evidence(iris_current_user.id, dsf, case_identifier) + + track_activity( + f'File "{dsf.file_original_name}" updated in DS', + caseid=case_identifier + ) + return response_api_success(self._file_schema.dump(dsf_sc)) + + except marshmallow.exceptions.ValidationError as e: + return response_api_error('Data error', data=e.messages) + + def move_file(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + dsf = datastore_get_file(identifier, case_identifier) + if not dsf: + return response_api_not_found() + + data = request.get_json() or {} + destination = data.get('destination_node') + if destination is None: + return response_api_error('Missing destination_node') + + dsp = datastore_get_path_node(destination, case_identifier) + if not dsp: + return response_api_error('Invalid destination node ID for this case') + + dsf.file_parent_id = dsp.path_id + db.session.commit() + + track_activity( + f'File "{dsf.file_original_name}" moved to "{dsp.path_name}" in DS', + caseid=case_identifier + ) + return response_api_success(self._file_schema.dump(dsf)) + + def delete_file(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + if not datastore_get_file(identifier, case_identifier): + return response_api_not_found() + + has_error, logs = datastore_delete_file(identifier, case_identifier) + if has_error: + return response_api_error(logs) + + track_activity(f'File "{identifier}" deleted from DS', caseid=case_identifier) + return response_api_deleted() + + # --------------------------------------------------------------------- + # Folder CRUD + # --------------------------------------------------------------------- + def list_folders(self, case_identifier): + access_error = _check_case_access(case_identifier, _READ_LEVELS) + if access_error: + return access_error + + folders = DataStorePath.query.filter( + DataStorePath.path_case_id == case_identifier + ).order_by(DataStorePath.path_id).all() + return response_api_success(self._path_schema.dump(folders, many=True)) + + def get_folder(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _READ_LEVELS) + if access_error: + return access_error + + dsp = datastore_get_path_node(identifier, case_identifier) + if not dsp: + return response_api_not_found() + return response_api_success(self._path_schema.dump(dsp)) + + def add_folder(self, case_identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + data = request.get_json() or {} + parent_node = data.get('parent_node') + folder_name = data.get('folder_name') + + if not parent_node or not folder_name: + return response_api_error('parent_node and folder_name are required') + + has_error, logs, node = datastore_add_child_node(parent_node, folder_name, case_identifier) + if has_error: + return response_api_error(logs) + + track_activity(f'Folder "{folder_name}" added to DS', caseid=case_identifier) + return response_api_created(self._path_schema.dump(node)) + + def rename_folder(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + dsp = datastore_get_path_node(identifier, case_identifier) + if not dsp: + return response_api_not_found() + + data = request.get_json() or {} + folder_name = data.get('folder_name') + if not folder_name: + return response_api_error('folder_name is required') + + has_error, logs, dsp_base = datastore_rename_node(identifier, folder_name, case_identifier) + if has_error: + return response_api_error(logs) + + track_activity( + f'Folder "{identifier}" renamed to "{folder_name}" in DS', + caseid=case_identifier + ) + return response_api_success(self._path_schema.dump(dsp_base)) + + def move_folder(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + dsp = datastore_get_path_node(identifier, case_identifier) + if not dsp: + return response_api_not_found() + + data = request.get_json() or {} + destination = data.get('destination_node') + if destination is None: + return response_api_error('Missing destination_node') + + dsp_dst = datastore_get_path_node(destination, case_identifier) + if not dsp_dst: + return response_api_error('Invalid destination node ID for this case') + + if dsp.path_id == dsp_dst.path_id: + return response_api_error('Source and destination folders are the same') + + dsp.path_parent_id = dsp_dst.path_id + db.session.commit() + + track_activity( + f'Folder "{dsp.path_name}" moved to "{dsp_dst.path_name}"', + caseid=case_identifier + ) + return response_api_success(self._path_schema.dump(dsp)) + + def delete_folder(self, case_identifier, identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + dsp = datastore_get_path_node(identifier, case_identifier) + if not dsp: + return response_api_not_found() + + if dsp.path_is_root: + return response_api_error('Cannot delete root folder') + + has_error, logs = datastore_delete_node(identifier, case_identifier) + if has_error: + return response_api_error(logs) + + track_activity(f'Folder "{identifier}" deleted from DS', caseid=case_identifier) + return response_api_deleted() + + # --------------------------------------------------------------------- + # Interactive (base64) upload used by the rich-text editor + # --------------------------------------------------------------------- + def add_interactive(self, case_identifier): + access_error = _check_case_access(case_identifier, _WRITE_LEVELS) + if access_error: + return access_error + + dsp = datastore_get_interactive_path_node(case_identifier) + if not dsp: + return response_api_error('Invalid path node for this case') + + try: + js_data = request.get_json() or {} + + try: + file_content = base64.b64decode(js_data.get('file_content')) + filename = js_data.get('file_original_name') + except Exception as e: + return response_api_error(str(e)) + + if not filename: + return response_api_error('Data error', data={'file_original_name': ['Missing filename']}) + + dsf_sc, existed = self._file_schema.ds_store_file_b64(filename, file_content, dsp, case_identifier) + + track_activity( + f'File "{dsf_sc.file_original_name}" added to DS', + caseid=case_identifier + ) + + return response_api_created({ + 'existed': existed, + 'file_url': f'/api/v2/cases/{case_identifier}/datastore/files/{dsf_sc.file_id}', + **self._file_schema.dump(dsf_sc) + }) + + except marshmallow.exceptions.ValidationError as e: + return response_api_error('Data error', data=e.messages) + datastore_operations = DatastoreOperations() case_datastore_blueprint = Blueprint( @@ -147,13 +520,99 @@ def view(self, case_identifier, identifier): ) +# Tree -------------------------------------------------------------------- +@case_datastore_blueprint.get('/tree') +@ac_api_requires() +def datastore_tree(case_identifier): + return datastore_operations.tree(case_identifier) + + +# Files ------------------------------------------------------------------- +@case_datastore_blueprint.get('/files') +@ac_api_requires() +def datastore_list_files(case_identifier): + return datastore_operations.list_files(case_identifier) + + +@case_datastore_blueprint.get('/files/<int:identifier>') +@ac_api_requires() +def datastore_view_file(case_identifier, identifier): + # When a ?info=1 query parameter is set, return the file metadata as JSON + # instead of streaming the file contents. Keeps a single URL for "the + # file" while letting the UI fetch its info card separately. + if _truthy(request.args.get('info')): + return datastore_operations.get_file_info(case_identifier, identifier) + return datastore_operations.view_file(case_identifier, identifier) + + +@case_datastore_blueprint.get('/files/<int:identifier>/info') +@ac_api_requires() +def datastore_file_info(case_identifier, identifier): + return datastore_operations.get_file_info(case_identifier, identifier) + + +@case_datastore_blueprint.post('/folders/<int:folder_identifier>/files') +@ac_api_requires() +def datastore_add_file(case_identifier, folder_identifier): + return datastore_operations.add_file(case_identifier, folder_identifier) + + +@case_datastore_blueprint.post('/files/<int:identifier>') +@ac_api_requires() +def datastore_update_file(case_identifier, identifier): + return datastore_operations.update_file(case_identifier, identifier) + + +@case_datastore_blueprint.post('/files/<int:identifier>/move') +@ac_api_requires() +def datastore_move_file(case_identifier, identifier): + return datastore_operations.move_file(case_identifier, identifier) + + +@case_datastore_blueprint.delete('/files/<int:identifier>') +@ac_api_requires() +def datastore_delete_file_route(case_identifier, identifier): + return datastore_operations.delete_file(case_identifier, identifier) + + @case_datastore_blueprint.post('/files/interactive') @ac_api_requires() def datastore_add_interactive_file(case_identifier): return datastore_operations.add_interactive(case_identifier) -@case_datastore_blueprint.get('/files/<int:identifier>') +# Folders ----------------------------------------------------------------- +@case_datastore_blueprint.get('/folders') @ac_api_requires() -def datastore_view_file(case_identifier, identifier): - return datastore_operations.view(case_identifier, identifier) +def datastore_list_folders(case_identifier): + return datastore_operations.list_folders(case_identifier) + + +@case_datastore_blueprint.post('/folders') +@ac_api_requires() +def datastore_add_folder(case_identifier): + return datastore_operations.add_folder(case_identifier) + + +@case_datastore_blueprint.get('/folders/<int:identifier>') +@ac_api_requires() +def datastore_get_folder(case_identifier, identifier): + return datastore_operations.get_folder(case_identifier, identifier) + + +@case_datastore_blueprint.post('/folders/<int:identifier>/rename') +@ac_api_requires() +def datastore_rename_folder(case_identifier, identifier): + return datastore_operations.rename_folder(case_identifier, identifier) + + +@case_datastore_blueprint.post('/folders/<int:identifier>/move') +@ac_api_requires() +def datastore_move_folder(case_identifier, identifier): + return datastore_operations.move_folder(case_identifier, identifier) + + +@case_datastore_blueprint.delete('/folders/<int:identifier>') +@ac_api_requires() +def datastore_delete_folder(case_identifier, identifier): + return datastore_operations.delete_folder(case_identifier, identifier) diff --git a/source/app/blueprints/rest/v2/case_routes/events.py b/source/app/blueprints/rest/v2/case_routes/events.py index c23c6bac0..266ac9903 100644 --- a/source/app/blueprints/rest/v2/case_routes/events.py +++ b/source/app/blueprints/rest/v2/case_routes/events.py @@ -74,9 +74,16 @@ def create(self, case_identifier): event_assets = request_data.get('event_assets') event_iocs = request_data.get('event_iocs') sync_iocs_assets = request_data.get('event_sync_iocs_assets', False) + timeline_ids = request_data.get('timeline_ids') - event = events_create(case_identifier, event, event_category_id, event_assets, event_iocs, sync_iocs_assets) + event = events_create(case_identifier, event, event_category_id, event_assets, + event_iocs, sync_iocs_assets, timeline_ids=timeline_ids) result = self._schema.dump(event) + # Stamp the timeline membership on the response so the SPA + # doesn't need a second round-trip to know where the event + # ended up. + from app.business.case_timelines import get_event_timeline_ids + result['timeline_ids'] = get_event_timeline_ids(event.event_id) notify(case_identifier, 'events', 'updated', event.event_id, object_data=result) return response_api_created(result) @@ -95,6 +102,8 @@ def read(self, case_identifier, identifier): return ac_api_return_access_denied(caseid=event.case_id) result = self._schema.dump(event) + from app.business.case_timelines import get_event_timeline_ids + result['timeline_ids'] = get_event_timeline_ids(event.event_id) return response_api_success(result) except ObjectNotFoundError: return response_api_not_found() @@ -119,10 +128,14 @@ def update(self, case_identifier, identifier): event_assets = request_data.get('event_assets') event_iocs = request_data.get('event_iocs') event_sync_iocs_assets = request_data.get('event_sync_iocs_assets') + timeline_ids = request_data.get('timeline_ids') - event = events_update(event, event_category_id, event_assets, event_iocs, event_sync_iocs_assets) + event = events_update(event, event_category_id, event_assets, event_iocs, + event_sync_iocs_assets, timeline_ids=timeline_ids) result = self._schema.dump(event) + from app.business.case_timelines import get_event_timeline_ids + result['timeline_ids'] = get_event_timeline_ids(event.event_id) notify(case_identifier, 'events', 'updated', identifier, object_data=result) return response_api_success(result) diff --git a/source/app/blueprints/rest/v2/case_routes/iocs.py b/source/app/blueprints/rest/v2/case_routes/iocs.py index 903ff5e66..288e0b420 100644 --- a/source/app/blueprints/rest/v2/case_routes/iocs.py +++ b/source/app/blueprints/rest/v2/case_routes/iocs.py @@ -38,6 +38,9 @@ from app.business.iocs import iocs_delete from app.business.iocs import iocs_update from app.business.iocs import iocs_filter +from app.datamgmt.case.case_iocs_db import get_ioc_links +from app.iris_engine.access_control.utils import ac_get_fast_user_cases_access +from app.blueprints.iris_user import iris_current_user from app.models.authorization import CaseAccessLevel from app.schema.marshables import IocSchemaForAPIV2 from app.blueprints.access_controls import ac_api_return_access_denied @@ -191,3 +194,33 @@ def update_ioc(case_identifier, identifier): @ac_api_requires() def delete_case_ioc(case_identifier, identifier): return iocs_operations.delete(case_identifier, identifier) + + +@case_iocs_blueprint.get('/<int:identifier>/links') +@ac_api_requires() +def get_ioc_other_case_links(case_identifier, identifier): + """Return the list of OTHER cases where the same IOC value+type was seen. + + Mirrors the v2.4.x analyst affordance: when working a new case, surfacing + that an IOC has been seen on a previous one (and which one) is one of + the highest-signal pivots an analyst gets. The legacy UI lit a small + badge on each IOC row and exposed the same data through a tooltip; we + keep that semantic and let the SPA decide how to render it. + + The lookup is gated by the same per-user case-access filter the legacy + `/case/ioc/list` route used, so analysts only see hits in cases they're + already allowed to read — no information leakage across access + boundaries. + """ + if not ac_fast_check_current_user_has_case_access( + case_identifier, [CaseAccessLevel.read_only, CaseAccessLevel.full_access]): + return ac_api_return_access_denied(caseid=case_identifier) + + try: + ioc = IocsOperations._get_ioc_in_case(identifier, case_identifier) + except ObjectNotFoundError: + return response_api_not_found() + + user_search_limitations = ac_get_fast_user_cases_access(iris_current_user.id) + links = get_ioc_links(ioc.ioc_id, user_search_limitations) + return response_api_success([row._asdict() for row in links]) diff --git a/source/app/blueprints/rest/v2/case_routes/notes.py b/source/app/blueprints/rest/v2/case_routes/notes.py index 5b216f4d8..74824c6f0 100644 --- a/source/app/blueprints/rest/v2/case_routes/notes.py +++ b/source/app/blueprints/rest/v2/case_routes/notes.py @@ -28,6 +28,7 @@ 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.schema.marshables import CaseNoteRevisionSchema from app.schema.marshables import CaseNoteSchema from app.models.authorization import CaseAccessLevel from app.models.models import Notes @@ -35,6 +36,10 @@ from app.business.notes import notes_get from app.business.notes import notes_update from app.business.notes import notes_delete +from app.business.notes import notes_delete_revision +from app.business.notes import notes_get_revision +from app.business.notes import notes_list_revisions +from app.business.notes import notes_restore_revision from app.business.notes import notes_search from app.business.cases import cases_exists from app.models.errors import BusinessProcessingError @@ -202,6 +207,88 @@ def delete(self, case_identifier, identifier): except ObjectNotFoundError: return response_api_not_found() + # ------- Revisions --------------------------------------------------- + # Brought back from the legacy `/case/notes/<id>/revisions/...` + # surface so the new SPA can show note history again. Read access + # only needs `read_only` on the case; restore / delete a revision + # require `full_access` because they mutate state visible to + # everyone on the case. + # + # `notes_list_revisions` returns a SQLAlchemy `with_entities` row + # set rather than ORM instances — the legacy code dumps that + # straight through the schema, so we do the same. + + def list_revisions(self, case_identifier, identifier): + try: + note = notes_get(identifier) + self._check_note_and_case_identifier_match(note, case_identifier) + if not ac_fast_check_current_user_has_case_access( + note.note_case_id, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): + return ac_api_return_access_denied(caseid=note.note_case_id) + + revisions = notes_list_revisions(identifier) + schema = CaseNoteRevisionSchema(many=True) + return response_api_success(schema.dump(revisions)) + + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + def get_revision(self, case_identifier, identifier, revision_number): + try: + note = notes_get(identifier) + self._check_note_and_case_identifier_match(note, case_identifier) + if not ac_fast_check_current_user_has_case_access( + note.note_case_id, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): + return ac_api_return_access_denied(caseid=note.note_case_id) + + revision = notes_get_revision(identifier, revision_number) + if revision is None: + return response_api_not_found() + return response_api_success(CaseNoteRevisionSchema().dump(revision)) + + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + def delete_revision(self, case_identifier, identifier, revision_number): + try: + note = notes_get(identifier) + self._check_note_and_case_identifier_match(note, case_identifier) + if not ac_fast_check_current_user_has_case_access( + note.note_case_id, [CaseAccessLevel.full_access] + ): + return ac_api_return_access_denied(caseid=note.note_case_id) + + notes_delete_revision(identifier, revision_number) + 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()) + + def restore_revision(self, case_identifier, identifier, revision_number): + try: + note = notes_get(identifier) + self._check_note_and_case_identifier_match(note, case_identifier) + if not ac_fast_check_current_user_has_case_access( + note.note_case_id, [CaseAccessLevel.full_access] + ): + return ac_api_return_access_denied(caseid=note.note_case_id) + + restored = notes_restore_revision(identifier, revision_number) + return response_api_success(self._schema.dump(restored)) + + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + notes_operations = NotesOperations() case_notes_blueprint = Blueprint('case_notes', @@ -243,3 +330,29 @@ def update_note(case_identifier, identifier): @ac_api_requires() def delete_note(case_identifier, identifier): return notes_operations.delete(case_identifier, identifier) + + +# ---- Revisions -------------------------------------------------------------- + +@case_notes_blueprint.get('/<int:identifier>/revisions') +@ac_api_requires() +def list_note_revisions(case_identifier, identifier): + return notes_operations.list_revisions(case_identifier, identifier) + + +@case_notes_blueprint.get('/<int:identifier>/revisions/<int:revision_number>') +@ac_api_requires() +def get_note_revision(case_identifier, identifier, revision_number): + return notes_operations.get_revision(case_identifier, identifier, revision_number) + + +@case_notes_blueprint.delete('/<int:identifier>/revisions/<int:revision_number>') +@ac_api_requires() +def delete_note_revision(case_identifier, identifier, revision_number): + return notes_operations.delete_revision(case_identifier, identifier, revision_number) + + +@case_notes_blueprint.post('/<int:identifier>/revisions/<int:revision_number>/restore') +@ac_api_requires() +def restore_note_revision(case_identifier, identifier, revision_number): + return notes_operations.restore_revision(case_identifier, identifier, revision_number) diff --git a/source/app/blueprints/rest/v2/case_routes/notes_directories.py b/source/app/blueprints/rest/v2/case_routes/notes_directories.py index c288dc1ff..6b6b3f1e7 100644 --- a/source/app/blueprints/rest/v2/case_routes/notes_directories.py +++ b/source/app/blueprints/rest/v2/case_routes/notes_directories.py @@ -62,7 +62,12 @@ def _load(self, request_data, **kwargs): def search(self, case_identifier): if not cases_exists(case_identifier): return response_api_not_found() - if not ac_fast_check_current_user_has_case_access(case_identifier, [CaseAccessLevel.full_access]): + # Listing folders is a read operation — read-only users need it to + # navigate the notes tree. Only mutating endpoints (create/update/ + # delete) below require full_access. + if not ac_fast_check_current_user_has_case_access( + case_identifier, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): return ac_api_return_access_denied(case_identifier) pagination_parameters = parse_pagination_parameters(request, default_order_by='name', default_direction='asc') diff --git a/source/app/blueprints/rest/v2/case_routes/timelines.py b/source/app/blueprints/rest/v2/case_routes/timelines.py new file mode 100644 index 000000000..c4278b6c3 --- /dev/null +++ b/source/app/blueprints/rest/v2/case_routes/timelines.py @@ -0,0 +1,181 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. + +""" +v2 REST routes for the per-case "named timelines" feature. + +URL layout (mounted under `/api/v2/cases/<int:case_identifier>/timelines`): + + GET / list every timeline on the case + POST / create a new timeline + GET /<int:id> fetch a single timeline + PUT /<int:id> update name / description / color + DELETE /<int:id> delete (not allowed on the default timeline) + +Response shape follows the rest of the v2 API: +`response_api_success(data=...)` ships the data dict directly at the +top level (no envelope). Error responses are HTTP 400 with a +`{message: "..."}` body via `response_api_error`. +""" + +from flask import Blueprint +from flask import request + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.access_controls import ac_api_return_access_denied +from app.blueprints.access_controls import ac_fast_check_current_user_has_case_access +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.business.cases import cases_exists +from app.business.case_timelines import case_timeline_create +from app.business.case_timelines import case_timeline_delete +from app.business.case_timelines import case_timeline_get +from app.business.case_timelines import case_timeline_list +from app.business.case_timelines import case_timeline_update +from app.models.authorization import CaseAccessLevel +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError + + +case_timelines_blueprint = Blueprint( + 'case_timelines_rest_v2', __name__, + url_prefix='/<int:case_identifier>/timelines' +) + + +def _serialize(timeline): + return { + 'timeline_id': timeline.timeline_id, + 'case_id': timeline.case_id, + 'name': timeline.name, + 'description': timeline.description, + 'color': timeline.color, + 'is_default': bool(timeline.is_default), + 'created_at': timeline.created_at.isoformat() if timeline.created_at else None, + 'created_by_id': timeline.created_by_id, + } + + +def _require_read_access(case_identifier): + if not cases_exists(case_identifier): + return response_api_not_found() + if not ac_fast_check_current_user_has_case_access( + case_identifier, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): + return ac_api_return_access_denied(caseid=case_identifier) + return None + + +def _require_full_access(case_identifier): + if not cases_exists(case_identifier): + return response_api_not_found() + if not ac_fast_check_current_user_has_case_access( + case_identifier, [CaseAccessLevel.full_access] + ): + return ac_api_return_access_denied(caseid=case_identifier) + return None + + +@case_timelines_blueprint.get('') +@ac_api_requires() +def list_timelines(case_identifier): + err = _require_read_access(case_identifier) + if err is not None: + return err + return response_api_success( + data=[_serialize(t) for t in case_timeline_list(case_identifier)] + ) + + +@case_timelines_blueprint.post('') +@ac_api_requires() +def create_timeline(case_identifier): + err = _require_full_access(case_identifier) + if err is not None: + return err + + raw = request.get_json() + if not isinstance(raw, dict): + return response_api_error('Invalid request') + + try: + timeline = case_timeline_create( + case_identifier, + 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(timeline)) + + +@case_timelines_blueprint.get('/<int:timeline_id>') +@ac_api_requires() +def get_timeline(case_identifier, timeline_id): + err = _require_read_access(case_identifier) + if err is not None: + return err + try: + timeline = case_timeline_get(case_identifier, timeline_id) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(data=_serialize(timeline)) + + +@case_timelines_blueprint.put('/<int:timeline_id>') +@ac_api_requires() +def update_timeline(case_identifier, timeline_id): + err = _require_full_access(case_identifier) + if err is not None: + return err + + raw = request.get_json() + if not isinstance(raw, dict): + return response_api_error('Invalid request') + + try: + timeline = case_timeline_update( + case_identifier, timeline_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(data=_serialize(timeline)) + + +@case_timelines_blueprint.delete('/<int:timeline_id>') +@ac_api_requires() +def delete_timeline(case_identifier, timeline_id): + err = _require_full_access(case_identifier) + if err is not None: + return err + try: + case_timeline_delete(case_identifier, timeline_id) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + return response_api_deleted() diff --git a/source/app/blueprints/rest/v2/cases.py b/source/app/blueprints/rest/v2/cases.py index 6e3f57846..a78035bfa 100644 --- a/source/app/blueprints/rest/v2/cases.py +++ b/source/app/blueprints/rest/v2/cases.py @@ -34,6 +34,7 @@ from app.blueprints.rest.endpoints import response_api_error from app.blueprints.rest.endpoints import response_api_paginated from app.blueprints.rest.parsing import parse_pagination_parameters +from app.business.activity import activity_search_in_case from app.blueprints.rest.v2.case_routes.assets import case_assets_blueprint from app.blueprints.rest.v2.case_routes.iocs import case_iocs_blueprint from app.blueprints.rest.v2.case_routes.notes import case_notes_blueprint @@ -41,12 +42,20 @@ from app.blueprints.rest.v2.case_routes.tasks import case_tasks_blueprint from app.blueprints.rest.v2.case_routes.evidences import case_evidences_blueprint from app.blueprints.rest.v2.case_routes.events import case_events_blueprint +from app.blueprints.rest.v2.case_routes.timelines import case_timelines_blueprint from app.blueprints.rest.v2.case_routes.datastore import case_datastore_blueprint from app.blueprints.iris_user import iris_current_user +from app.business.cases import case_unlink_alert +from app.business.cases import case_unlink_incident from app.business.cases import cases_create +from app.business.cases import cases_close from app.business.cases import cases_delete +from app.business.cases import cases_exists from app.business.cases import cases_get_by_identifier +from app.business.cases import cases_reopen from app.business.cases import cases_update +from app.datamgmt.manage.manage_users_db import get_users_list_restricted_from_case +from app.datamgmt.manage.manage_access_control_db import get_case_effective_access from app.models.errors import BusinessProcessingError, ObjectNotFoundError from app.business.cases import cases_filter from app.schema.marshables import CaseSchemaForAPIV2 @@ -84,7 +93,12 @@ def search(self): case_soc_id = request.args.get('case_soc_id', None, type=str) start_open_date = request.args.get('start_open_date', None, type=str) end_open_date = request.args.get('end_open_date', None, type=str) + start_close_date = request.args.get('start_close_date', None, type=str) + end_close_date = request.args.get('end_close_date', None, type=str) is_open = request.args.get('is_open', None, type=parse_boolean) + # Free-text search across case name, customer name, and (numeric) case id. + # Powers the context switcher's search box; an empty / whitespace value is ignored. + quick_search = request.args.get('quick_search', None, type=str) filtered_cases = cases_filter( iris_current_user, @@ -101,7 +115,10 @@ def search(self): case_soc_id, start_open_date, end_open_date, - is_open + is_open, + quick_search=quick_search, + start_close_date=start_close_date, + end_close_date=end_close_date, ) return response_api_paginated(self._schema, filtered_cases) @@ -115,7 +132,7 @@ def filter(self) -> Response: return response_api_error("Invalid logic (expected 'and' or 'or')") raw_filters = request.args.get('filters', None, type=str) - advanced_filters: list[dict[str, Any]] | None = None + advanced_filters: Any = None if raw_filters: try: @@ -124,52 +141,82 @@ def filter(self) -> Response: except Exception: return response_api_error('Invalid filters JSON') - if not isinstance(parsed, list): - return response_api_error('Invalid filters (expected a JSON array)') - - advanced_filters = [] - for i, f in enumerate(parsed): + # Accept either: + # * Legacy flat list of conditions combined by the + # top-level `logic` query param (`?logic=and`). + # * Nested group object: `{ logic, items: [<cond>|<group>] }` + # where groups can recurse arbitrarily deep. + # The validator below normalises both into the nested form + # so the SQL builder downstream only has to handle one + # shape. + ALLOWED_OPS = { + 'equals', 'not', + 'starts_with', 'not_starts_with', + 'contains', 'not_contains', + 'ends_with', 'not_ends_with', + 'empty', 'not_empty' + } + MAX_DEPTH = 8 # safety cap on recursion depth + + def _validate_condition(f: Any, path: str) -> dict[str, Any]: if not isinstance(f, dict): - return response_api_error(f'Invalid filter at index {i} (expected object)') - + raise ValueError(f'Invalid condition at {path} (expected object)') field_id = f.get('fieldId') operation = f.get('operation') value = f.get('value', '') if not isinstance(field_id, str) or not field_id: - return response_api_error(f'Invalid fieldId at index {i}') + raise ValueError(f'Invalid fieldId at {path}') if not isinstance(operation, str) or not operation: - return response_api_error(f'Invalid operation at index {i}') + raise ValueError(f'Invalid operation at {path}') if not isinstance(value, str): - return response_api_error(f'Invalid value at index {i}') - - operation = operation.lower() - - allowed_ops = { - 'equals', - 'not', - 'starts_with', - 'not_starts_with', - 'contains', - 'not_contains', - 'ends_with', - 'not_ends_with', - 'empty', - 'not_empty' - } - if operation not in allowed_ops: - return response_api_error(f'Invalid operation at index {i}') - - if operation in ('empty', 'not_empty'): + raise ValueError(f'Invalid value at {path}') + + op = operation.lower() + if op not in ALLOWED_OPS: + raise ValueError(f'Invalid operation at {path}') + if op in ('empty', 'not_empty'): value = '' - advanced_filters.append( - { - 'fieldId': field_id, - 'operation': operation, - 'value': value - } - ) + return {'fieldId': field_id, 'operation': op, 'value': value} + + def _validate_group(node: Any, depth: int, path: str) -> dict[str, Any]: + if depth > MAX_DEPTH: + raise ValueError(f'Filter group too deeply nested at {path}') + if not isinstance(node, dict): + raise ValueError(f'Invalid group at {path} (expected object)') + group_logic = str(node.get('logic', 'and')).lower() + if group_logic not in ('and', 'or'): + raise ValueError(f"Invalid logic at {path} (expected 'and' or 'or')") + items = node.get('items') + if not isinstance(items, list): + raise ValueError(f'Invalid items at {path} (expected list)') + + normalised: list[dict[str, Any]] = [] + for i, item in enumerate(items): + sub_path = f'{path}.items[{i}]' + if isinstance(item, dict) and ( + 'items' in item or 'logic' in item and 'fieldId' not in item + ): + normalised.append(_validate_group(item, depth + 1, sub_path)) + else: + normalised.append(_validate_condition(item, sub_path)) + + return {'logic': group_logic, 'items': normalised} + + try: + if isinstance(parsed, list): + # Legacy shape: wrap in a single root group whose + # logic is the page-level `logic` query param. + advanced_filters = _validate_group( + {'logic': logic, 'items': parsed}, 0, 'filters' + ) + elif isinstance(parsed, dict): + advanced_filters = _validate_group(parsed, 0, 'filters') + else: + return response_api_error('Invalid filters (expected array or object)') + except ValueError as e: + return response_api_error(str(e)) case_ids_str = request.args.get('case_ids', None, type=str) if case_ids_str: @@ -314,6 +361,30 @@ def delete(self, identifier): except BusinessProcessingError as e: return response_api_error(e.get_message(), e.get_data()) + def close(self, identifier): + if not ac_fast_check_current_user_has_case_access(identifier, [CaseAccessLevel.full_access]): + return ac_api_return_access_denied(caseid=identifier) + + try: + case = cases_close(identifier) + return response_api_success(CaseDetailsSchema().dump(case)) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), e.get_data()) + + def reopen(self, identifier): + if not ac_fast_check_current_user_has_case_access(identifier, [CaseAccessLevel.full_access]): + return ac_api_return_access_denied(caseid=identifier) + + try: + case = cases_reopen(identifier) + return response_api_success(CaseDetailsSchema().dump(case)) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), e.get_data()) + # Create blueprint & import child blueprints cases_blueprint = Blueprint('cases', @@ -326,6 +397,7 @@ def delete(self, identifier): cases_blueprint.register_blueprint(case_tasks_blueprint) cases_blueprint.register_blueprint(case_evidences_blueprint) cases_blueprint.register_blueprint(case_events_blueprint) +cases_blueprint.register_blueprint(case_timelines_blueprint) cases_blueprint.register_blueprint(case_datastore_blueprint) cases_operations = CasesOperations() @@ -365,3 +437,226 @@ def rest_v2_cases_update(identifier): @ac_api_requires(Permissions.standard_user) def case_routes_delete(identifier): return cases_operations.delete(identifier) + + +@cases_blueprint.post('/<int:identifier>/close') +@ac_api_requires(Permissions.standard_user) +def case_routes_close(identifier): + return cases_operations.close(identifier) + + +@cases_blueprint.post('/<int:identifier>/reopen') +@ac_api_requires(Permissions.standard_user) +def case_routes_reopen(identifier): + return cases_operations.reopen(identifier) + + +@cases_blueprint.get('/<int:identifier>/access/users') +@ac_api_requires() +def list_case_access_users(identifier): + """Return every user with effective access to this case along with + their access level. Used by the frontend to populate task-assignee + pickers and to surface who can see a given case. + + Access level is the integer enum from `CaseAccessLevel` (1 = deny, + 2 = read_only, 4 = full_access). Callers that need only assignable + users typically filter on full_access (4) client-side, matching the + legacy iris-web behaviour. + """ + if not ac_fast_check_current_user_has_case_access( + identifier, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): + return ac_api_return_access_denied(caseid=identifier) + + users = get_users_list_restricted_from_case(identifier) + return response_api_success(users) + + +@cases_blueprint.get('/<int:identifier>/access/me') +@ac_api_requires() +def get_case_access_me(identifier): + """Return the current user's effective access level for this case. + + The SPA reads this once per case load to gate edit/delete affordances + before the user can attempt a 403. The integer matches `CaseAccessLevel` + (1 = deny_all, 2 = read_only, 4 = full_access). A user with no row in + `UserCaseEffectiveAccess` is treated as deny_all so the frontend can + still render a coherent "no access" state. + """ + if not cases_exists(identifier): + return response_api_not_found() + + level = get_case_effective_access(iris_current_user.id, identifier) + if level is None: + level = CaseAccessLevel.deny_all.value + return response_api_success({'access_level': int(level)}) + + +@cases_blueprint.get('/<int:identifier>/followers') +@ac_api_requires() +def list_case_followers(identifier): + """Return the users following this case. + + Each entry is `{user_id, user_name, user_login}`. The dashboard + "Follow" toggle on the case header reads this list to render the + current follower count and to highlight whether *you* are + following. + """ + from app.models.authorization import User + from app.models.authorization import UserFollowedCase + + if not cases_exists(identifier): + return response_api_not_found() + if not ac_fast_check_current_user_has_case_access( + identifier, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): + return ac_api_return_access_denied(caseid=identifier) + + rows = ( + db.session.query(User.id, User.user, User.name) + .join(UserFollowedCase, UserFollowedCase.user_id == User.id) + .filter(UserFollowedCase.case_id == identifier) + .order_by(User.name.asc()) + .all() + ) + return response_api_success(data=[ + {'user_id': r.id, 'user_login': r.user, 'user_name': r.name} + for r in rows + ]) + + +@cases_blueprint.get('/<int:identifier>/source-incident') +@ac_api_requires() +def get_case_source_incident(identifier): + """Return the incident this case was created from, if any. + + Mirrors the "linked alerts" chip in the case topbar: an analyst who + can read the case can see which incident it was escalated / merged + from. Read-only, gated by case read access — the incident row is + already visible to anyone with customer access, so exposing the + lookup by case id doesn't broaden the surface. + + Returns 200 with `null` when the case has no source incident so the + frontend can conditionally render the chip without a 404. + """ + from app.business.incidents import incidents_get_by_case + + if not cases_exists(identifier): + return response_api_not_found() + if not ac_fast_check_current_user_has_case_access( + identifier, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): + return ac_api_return_access_denied(caseid=identifier) + + incident = incidents_get_by_case(identifier) + if incident is None: + return response_api_success(data=None) + + return response_api_success(data={ + 'incident_id': incident.incident_id, + 'incident_title': incident.incident_title, + 'incident_status': incident.status.status_name if incident.status else None, + }) + + +@cases_blueprint.delete('/<int:identifier>/alerts/<int:alert_id>') +@ac_api_requires(Permissions.standard_user) +def rest_v2_case_unlink_alert(identifier, alert_id): + """Detach one alert from this case and reset its status. + + Case-scoped ACL: the caller must have full access to the case, which + is the object being mutated (alert.cases). We don't recheck alert + permissions — the case-membership relationship is symmetric, so + write on the case is enough to prune its own list. + """ + if not cases_exists(identifier): + return response_api_not_found() + if not ac_fast_check_current_user_has_case_access( + identifier, [CaseAccessLevel.full_access] + ): + return ac_api_return_access_denied(caseid=identifier) + + case = cases_get_by_identifier(identifier) + try: + case_unlink_alert(case, alert_id) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as exc: + return response_api_error(exc.get_message(), data=exc.get_data()) + return response_api_deleted() + + +@cases_blueprint.delete('/<int:identifier>/source-incident') +@ac_api_requires(Permissions.standard_user) +def rest_v2_case_unlink_incident(identifier): + """Unlink the incident that produced this case. + + Clears the incident's back-reference, moves it back to Investigating, + and detaches every member alert from the case. Alerts get their + status rolled back to Assigned so they re-appear in the analyst + queue. No-op (200 with `{unlinked: false}`) when the case wasn't + sourced from an incident. + """ + if not cases_exists(identifier): + return response_api_not_found() + if not ac_fast_check_current_user_has_case_access( + identifier, [CaseAccessLevel.full_access] + ): + return ac_api_return_access_denied(caseid=identifier) + + case = cases_get_by_identifier(identifier) + try: + incident = case_unlink_incident(case) + except BusinessProcessingError as exc: + return response_api_error(exc.get_message(), data=exc.get_data()) + + if incident is None: + return response_api_success(data={'unlinked': False}) + return response_api_success(data={ + 'unlinked': True, + 'incident_id': incident.incident_id, + }) + + +@cases_blueprint.get('/<int:identifier>/war-rooms') +@ac_api_requires() +def list_case_war_rooms(identifier): + """Return war rooms this case is attached to. + + Used by the case detail topbar to render the "in war room" badge + + quick-jump menu. Read-only — gated by case read access. + """ + from app.business.war_rooms import war_rooms_for_case + from app.blueprints.rest.v2.war_rooms.serializers import serialize_case_war_room_summary + + if not cases_exists(identifier): + return response_api_not_found() + if not ac_fast_check_current_user_has_case_access( + identifier, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): + return ac_api_return_access_denied(caseid=identifier) + + rows = war_rooms_for_case(identifier) + return response_api_success(data=[serialize_case_war_room_summary(r) for r in rows]) + + +@cases_blueprint.get('/<int:identifier>/activities') +@ac_api_requires() +def list_case_activities(identifier): + """Return the recent user activity log for this case. + + Mirrors the legacy `/case/activities/list` endpoint, which the frontend + uses to surface "people involved" on a case. Each row carries the user + name, the activity date, the description and whether the entry was + produced by an API caller. + """ + if not cases_exists(identifier): + return response_api_not_found() + + if not ac_fast_check_current_user_has_case_access( + identifier, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): + return ac_api_return_access_denied(caseid=identifier) + + activities = activity_search_in_case(identifier) + return response_api_success(activities) diff --git a/source/app/blueprints/rest/v2/cases_filters.py b/source/app/blueprints/rest/v2/cases_filters.py new file mode 100644 index 000000000..94940d1b8 --- /dev/null +++ b/source/app/blueprints/rest/v2/cases_filters.py @@ -0,0 +1,176 @@ +"""v2 cases-filters endpoints. + +Thin re-export of the alerts-filters business layer with `filter_type` +defaulted to ``cases`` so the UI's cases overview page can save / list +/ delete its own filter presets without colliding with the alerts +ones. The underlying `SavedFilter` row stores the type as free text, +so this is a routing concern only — no new model / no new business +logic. + +We mount the same five verbs as alerts-filters (list / get / create / +update / delete) and apply identical authorisation: a saved filter is +visible to its owner when private, or to everyone when public. The +business layer already enforces this. +""" +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_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.iris_user import iris_current_user +from app.business.alerts_filters import alert_filter_add +from app.business.alerts_filters import alert_filter_delete +from app.business.alerts_filters import alert_filter_get +from app.business.alerts_filters import alert_filter_list +from app.business.alerts_filters import alert_filter_update +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.schema.marshables import SavedFilterSchema + + +class CasesFiltersOperations: + 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): + # Always stamp filter_type='cases' even if the client omitted + # it — the cases endpoint owns this namespace. Also force + # `created_by` from the session user so a malicious client + # can't impersonate another owner. + request_data = request.get_json() or {} + request_data['created_by'] = iris_current_user.id + request_data.setdefault('filter_type', 'cases') + if request_data.get('filter_type') != 'cases': + return response_api_error("filter_type must be 'cases' for this endpoint") + + 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: + include_public = request.args.get('include_public', '1') == '1' + items = alert_filter_list( + iris_current_user, + filter_type='cases', + 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) + if saved_filter.filter_type != 'cases': + return response_api_not_found() + 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 {} + # The filter_type can't be moved out of 'cases' through this + # endpoint — it would be a no-op route swap from the user's + # perspective and a footgun for the alerts UI. + request_data['filter_type'] = 'cases' + # Pin the owner: `alert_filter_get` returns a row when the + # user can *read* it (public filters or own private filters), + # but write authority is stricter than read. Forcing + # `created_by` to the session user prevents a malicious + # client from changing ownership through this PUT — same + # spirit as `create()` above. We also enforce the ownership + # check below so a user can only edit their own filter. + request_data['created_by'] = iris_current_user.id + + try: + saved_filter = alert_filter_get(iris_current_user, identifier) + if saved_filter.filter_type != 'cases': + return response_api_not_found() + if saved_filter.created_by != iris_current_user.id: + # Read access does not imply write access — a public + # filter is readable by everyone but only the owner + # can mutate it. Mirror the 404 the not-found path + # uses so existence isn't disclosed. + 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.filter_type != 'cases': + return response_api_not_found() + # Same ownership check as PUT: only the creator may delete + # the filter. Public filters are readable by everyone but + # mutable only by their owner. + 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()) + + +cases_filters_blueprint = Blueprint( + 'cases_filters_rest_v2', __name__, url_prefix='/cases-filters' +) +cases_filters_operations = CasesFiltersOperations() + + +@cases_filters_blueprint.post('') +@ac_api_requires() +def create_case_filter(): + return cases_filters_operations.create() + + +@cases_filters_blueprint.get('') +@ac_api_requires() +def list_case_filters(): + return cases_filters_operations.list() + + +@cases_filters_blueprint.get('/<int:identifier>') +@ac_api_requires() +def get_case_filter(identifier): + return cases_filters_operations.get(identifier) + + +@cases_filters_blueprint.put('/<int:identifier>') +@ac_api_requires() +def update_case_filter(identifier): + return cases_filters_operations.put(identifier) + + +@cases_filters_blueprint.delete('/<int:identifier>') +@ac_api_requires() +def delete_case_filter(identifier): + return cases_filters_operations.delete(identifier) diff --git a/source/app/blueprints/rest/v2/custom_dashboards.py b/source/app/blueprints/rest/v2/custom_dashboards.py new file mode 100644 index 000000000..4dbd39dc7 --- /dev/null +++ b/source/app/blueprints/rest/v2/custom_dashboards.py @@ -0,0 +1,329 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +from flask import Blueprint +from flask import request +from marshmallow import ValidationError + +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.datamgmt.custom_dashboard.custom_dashboard_db import ( + DashboardAccessError, + DashboardNotFoundError, + DashboardSystemReadOnlyError, + create_dashboard_for_user, + delete_dashboard_for_user, + get_dashboard_for_user, + list_dashboards_for_user, + serialize_dashboard, + update_dashboard_for_user, +) +from app.datamgmt.custom_dashboard.named_aggregations import ( + ComputedFilters, + NamedAggregationError, + compute_named_aggregation, + list_named_aggregations, +) +from app.datamgmt.custom_dashboard.query_engine import ( + QueryExecutionError, + WidgetQueryExecutor, + format_widget_payload, +) +from app.datamgmt.custom_dashboard.schema import CustomDashboardSchema +from app.blueprints.access_controls import ac_current_user_has_permission +from app.models.authorization import Permissions + + +custom_dashboards_blueprint = Blueprint( + 'custom_dashboards', + __name__, + url_prefix='/custom-dashboards', +) + + +_WIDGET_PRESETS: List[Dict[str, Any]] = [ + { + 'id': 'preset-alerts-by-severity', + 'name': 'Alerts by severity', + 'chart_type': 'pie', + 'fields': [ + {'table': 'severities', 'column': 'severity_name', 'alias': 'severity'}, + {'table': 'alerts', 'column': 'alert_id', 'aggregation': 'count', 'alias': 'total'}, + ], + 'group_by': ['severities.severity_name'], + }, + { + 'id': 'preset-cases-by-classification', + 'name': 'Cases by classification', + 'chart_type': 'bar', + 'fields': [ + {'table': 'case_classification', 'column': 'name_expanded', 'alias': 'classification'}, + {'table': 'cases', 'column': 'case_id', 'aggregation': 'count', 'alias': 'total'}, + ], + 'group_by': ['case_classification.name_expanded'], + }, + { + 'id': 'preset-evidence-by-type', + 'name': 'Evidence by type', + 'chart_type': 'bar', + 'fields': [ + {'table': 'case_asset_types', 'column': 'asset_name', 'alias': 'asset_type'}, + {'table': 'case_assets', 'column': 'asset_id', 'aggregation': 'count', 'alias': 'total'}, + ], + 'group_by': ['case_asset_types.asset_name'], + }, + { + 'id': 'preset-mttd-kpi', + 'name': 'Mean time to detect', + 'chart_type': 'number', + 'fields': [{'table': 'computed', 'column': 'mttd_seconds', 'alias': 'mttd_seconds'}], + 'options': {'value_format': 'duration'}, + }, + { + 'id': 'preset-mttr-kpi', + 'name': 'Mean time to resolve', + 'chart_type': 'number', + 'fields': [{'table': 'computed', 'column': 'mttr_seconds', 'alias': 'mttr_seconds'}], + 'options': {'value_format': 'duration'}, + }, +] + + +def _parse_datetime_param(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + return datetime.fromisoformat(value.replace('Z', '+00:00')) + except (ValueError, TypeError): + return None + + +def _extract_timeframe(payload: Dict[str, Any]) -> Tuple[Optional[datetime], Optional[datetime]]: + timeframe = payload.get('timeframe') or {} + return ( + _parse_datetime_param(timeframe.get('start')), + _parse_datetime_param(timeframe.get('end')), + ) + + +def _extract_filters(payload: Dict[str, Any]) -> ComputedFilters: + filters = payload.get('filters') or {} + return ComputedFilters( + customer_id=filters.get('customer_id') if isinstance(filters.get('customer_id'), int) else None, + severity_id=filters.get('severity_id') if isinstance(filters.get('severity_id'), int) else None, + case_status_id=filters.get('case_status_id') if isinstance(filters.get('case_status_id'), int) else None, + window=filters.get('window') if isinstance(filters.get('window'), str) else None, + ) + + +def _widget_references_computed(widget: Dict[str, Any]) -> Optional[str]: + for field in widget.get('fields') or []: + if not isinstance(field, dict): + continue + if field.get('table') == 'computed': + return field.get('column') + return None + + +def _render_widget( + widget: Dict[str, Any], + timeframe: Tuple[Optional[datetime], Optional[datetime]], + filters: ComputedFilters, +) -> Dict[str, Any]: + computed_name = _widget_references_computed(widget) + if computed_name: + try: + value = compute_named_aggregation(computed_name, timeframe, filters) + except NamedAggregationError as e: + raise QueryExecutionError(str(e)) + return { + 'name': widget.get('name'), + 'chart_type': widget.get('chart_type', 'number'), + 'computed': computed_name, + 'value': value, + 'options': widget.get('options') or {}, + 'layout': widget.get('layout') or {}, + } + + executor = WidgetQueryExecutor(widget) + result = executor.execute(timeframe) + payload = format_widget_payload(result, widget, timeframe) + payload['name'] = widget.get('name') + payload['chart_type'] = widget.get('chart_type') + payload['layout'] = widget.get('layout') or {} + return payload + + +@custom_dashboards_blueprint.get('') +@ac_api_requires(Permissions.custom_dashboards_read) +def list_dashboards(): + dashboards = list_dashboards_for_user(iris_current_user.id) + return response_api_success(data=[serialize_dashboard(d) for d in dashboards]) + + +@custom_dashboards_blueprint.get('/schema') +@ac_api_requires(Permissions.custom_dashboards_read) +def get_editor_schema(): + schema = { + 'tables': list(WidgetQueryExecutor._TABLES.keys()), + 'columns': { + name: list(table['columns'].keys()) + for name, table in WidgetQueryExecutor._TABLES.items() + }, + 'named_aggregations': list_named_aggregations(), + 'aggregations': ['count', 'sum', 'avg', 'min', 'max', 'ratio'], + 'operators': ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'between', 'contains'], + 'chart_types': ['line', 'bar', 'pie', 'number', 'percentage', 'table', 'timechart'], + 'time_buckets': ['minute', '5minute', '15minute', 'hour', 'day', 'week', 'month', 'year'], + } + return response_api_success(data=schema) + + +@custom_dashboards_blueprint.get('/presets') +@ac_api_requires(Permissions.custom_dashboards_read) +def get_presets(): + return response_api_success(data=_WIDGET_PRESETS) + + +@custom_dashboards_blueprint.get('/<dashboard_uuid>') +@ac_api_requires(Permissions.custom_dashboards_read) +def get_dashboard(dashboard_uuid: str): + try: + dashboard = get_dashboard_for_user(dashboard_uuid, iris_current_user.id) + except DashboardNotFoundError: + return response_api_not_found() + except DashboardAccessError: + return response_api_error('Dashboard is not accessible.') + return response_api_success(data=serialize_dashboard(dashboard)) + + +@custom_dashboards_blueprint.post('') +@ac_api_requires(Permissions.custom_dashboards_write) +def create_dashboard(): + payload = request.get_json(silent=True) or {} + schema = CustomDashboardSchema() + try: + schema.load(payload) + except ValidationError as e: + return response_api_error('Invalid dashboard payload.', data=e.messages) + + allow_share = ac_current_user_has_permission(Permissions.custom_dashboards_share) + dashboard = create_dashboard_for_user(iris_current_user.id, payload, allow_share) + return response_api_created(data=serialize_dashboard(dashboard)) + + +@custom_dashboards_blueprint.put('/<dashboard_uuid>') +@ac_api_requires(Permissions.custom_dashboards_write) +def update_dashboard(dashboard_uuid: str): + payload = request.get_json(silent=True) or {} + schema = CustomDashboardSchema(partial=True) + try: + schema.load(payload) + except ValidationError as e: + return response_api_error('Invalid dashboard payload.', data=e.messages) + + allow_share = ac_current_user_has_permission(Permissions.custom_dashboards_share) + try: + dashboard = update_dashboard_for_user(dashboard_uuid, iris_current_user.id, payload, allow_share) + except DashboardNotFoundError: + return response_api_not_found() + except DashboardSystemReadOnlyError: + return response_api_error('System dashboards cannot be modified through the API.') + except DashboardAccessError: + return response_api_error('Dashboard is not accessible.') + return response_api_success(data=serialize_dashboard(dashboard)) + + +@custom_dashboards_blueprint.delete('/<dashboard_uuid>') +@ac_api_requires(Permissions.custom_dashboards_write) +def delete_dashboard(dashboard_uuid: str): + try: + delete_dashboard_for_user(dashboard_uuid, iris_current_user.id) + except DashboardNotFoundError: + return response_api_not_found() + except DashboardSystemReadOnlyError: + return response_api_error('System dashboards cannot be deleted through the API.') + except DashboardAccessError: + return response_api_error('Dashboard is not accessible.') + return response_api_deleted() + + +@custom_dashboards_blueprint.post('/<dashboard_uuid>/render') +@ac_api_requires(Permissions.custom_dashboards_read) +def render_dashboard(dashboard_uuid: str): + try: + get_dashboard_for_user(dashboard_uuid, iris_current_user.id) + except DashboardNotFoundError: + return response_api_not_found() + except DashboardAccessError: + return response_api_error('Dashboard is not accessible.') + + payload = request.get_json(silent=True) or {} + definition = payload.get('definition') or {} + schema = CustomDashboardSchema(partial=True) + try: + schema.load(definition) + except ValidationError as e: + return response_api_error('Invalid dashboard definition.', data=e.messages) + + timeframe = _extract_timeframe(payload) + filters = _extract_filters(payload) + + def _render_one(widget: Dict[str, Any]) -> Dict[str, Any]: + try: + return _render_widget(widget, timeframe, filters) + except QueryExecutionError as e: + return { + 'name': widget.get('name'), + 'chart_type': widget.get('chart_type'), + 'error': str(e), + 'layout': widget.get('layout') or {}, + } + + sections_in = definition.get('sections') or [] + rendered_widgets: List[Dict[str, Any]] = [] + rendered_sections: List[Dict[str, Any]] = [] + + if sections_in: + for section in sections_in: + widgets_in = section.get('widgets') or [] + section_rendered = [_render_one(w) for w in widgets_in] + rendered_widgets.extend(section_rendered) + rendered_sections.append({ + 'id': section.get('id'), + 'title': section.get('title'), + 'description': section.get('description'), + 'show_divider': bool(section.get('show_divider')), + 'widgets': section_rendered, + }) + else: + for widget in definition.get('widgets') or []: + rendered_widgets.append(_render_one(widget)) + + return response_api_success(data={ + 'widgets': rendered_widgets, + 'sections': rendered_sections, + }) diff --git a/source/app/blueprints/rest/v2/dashboard.py b/source/app/blueprints/rest/v2/dashboard.py index 5eda20964..64fbe8451 100644 --- a/source/app/blueprints/rest/v2/dashboard.py +++ b/source/app/blueprints/rest/v2/dashboard.py @@ -16,8 +16,12 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +import datetime + from flask import Blueprint from flask import request +from sqlalchemy import and_ +from sqlalchemy import func from app.blueprints.access_controls import ac_api_requires from app.blueprints.iris_user import iris_current_user @@ -25,8 +29,12 @@ from app.business.cases import cases_filter_by_user from app.business.cases import cases_filter_by_reviewer from app.business.tasks import tasks_filter_by_user +from app.datamgmt.activities.activities_db import get_recent_activities_for_user +from app.datamgmt.activities.activities_db import get_recent_major_case_activities_for_user +from app.models.alerts import Alert +from app.models.alerts import AlertStatus +from app.models.cases import Cases from app.schema.marshables import CaseDetailsSchema -from app.schema.marshables import CaseTaskSchema from app.schema.marshables import CaseSchema dashboard_blueprint = Blueprint('dashboard', @@ -50,8 +58,83 @@ def list_own_cases(): @dashboard_blueprint.get('/tasks/list') @ac_api_requires() def list_own_tasks(): - ct = tasks_filter_by_user() - return response_api_success(data=CaseTaskSchema(many=True).dump(ct)) + # `tasks_filter_by_user` projects flat row tuples (task_id, task_title, + # task_case, case_id, status_name, …), not model instances — running + # those through CaseTaskSchema would silently drop the nested `case` + # and `status` fields. Ship the rows as-is so the frontend sees the + # case id, case title, status name, and last update. + rows = tasks_filter_by_user() + data = [] + for row in rows: + d = row._asdict() if hasattr(row, '_asdict') else dict(row) + if d.get('task_last_update') is not None: + d['task_last_update'] = d['task_last_update'].isoformat() + data.append(d) + return response_api_success(data=data) + + +@dashboard_blueprint.get('/activities/recent') +@ac_api_requires() +def list_recent_activities(): + """Recent UI-visible activity entries the current user is allowed to + see. Scoped to cases the user has access to (plus their own activity) + so a multi-tenant deployment doesn't leak cross-customer events. + + Query params: + - limit: int (default 20, max 100) — page size + - offset: int (default 0) — pagination cursor + """ + limit = request.args.get('limit', default=20, type=int) + if limit < 1: + limit = 20 + if limit > 100: + limit = 100 + + offset = request.args.get('offset', default=0, type=int) + if offset < 0: + offset = 0 + + rows = get_recent_activities_for_user(iris_current_user.id, limit=limit, offset=offset) + # The DB query already projected the needed columns — no marshmallow + # schema would add anything useful. Stamp dates as ISO strings and ship. + data = [] + for row in rows: + d = dict(row) + if d.get('activity_date') is not None: + d['activity_date'] = d['activity_date'].isoformat() + data.append(d) + + return response_api_success(data=data) + + +@dashboard_blueprint.get('/activities/cases/major') +@ac_api_requires() +def list_recent_major_case_activities(): + """Recent major case activities (created / closed) for dashboard use. + + Query params: + - limit: int (default 20, max 100) — page size + - offset: int (default 0) — pagination cursor + """ + limit = request.args.get('limit', default=20, type=int) + if limit < 1: + limit = 20 + if limit > 100: + limit = 100 + + offset = request.args.get('offset', default=0, type=int) + if offset < 0: + offset = 0 + + rows = get_recent_major_case_activities_for_user(iris_current_user.id, limit=limit, offset=offset) + data = [] + for row in rows: + d = dict(row) + if d.get('activity_date') is not None: + d['activity_date'] = d['activity_date'].isoformat() + data.append(d) + + return response_api_success(data=data) # TODO this endpoint does not adhere to the conventions (verb in URL). @@ -66,3 +149,77 @@ def list_own_reviews(): only=["case_id", "case_name", "review_status.status_name", "status_id"] ).dump(reviews)) + + +# Status names the home page tile treats as "still on the user's plate". The +# legacy production code used the exact strings "New", "Pending", "In progress" +# (case-insensitive) — keep parity so a deployment that customised the alert +# status names still gets a meaningful "Alerts assigned to you" count. +_OPEN_ALERT_STATUS_NAMES = ('new', 'pending', 'in progress') + + +@dashboard_blueprint.get('/kpis') +@ac_api_requires() +def get_dashboard_kpis(): + """Compact KPI block for the SvelteKit home tile. + + Returns just the few numbers the dashboard tile renders — keeps the + payload tiny so the call is cheap on every navigation. The full + Statistics page is served by the seeded custom dashboard, not by + this endpoint. + + Response shape: + { + "assigned_alerts": { + "count": int, + "filter": {"alert_owner_id": int, "alert_status_id": [int, ...]} + }, + "open_cases_count": int, + "cases_closed_last_30d": int, + } + + The `filter` block under `assigned_alerts` lets the frontend deep-link + into the alerts page with the same predicate that produced the count + — no second round-trip to discover which status ids count as "open". + """ + open_status_ids = [ + row.status_id for row in AlertStatus.query + .with_entities(AlertStatus.status_id, AlertStatus.status_name) + .filter(func.lower(AlertStatus.status_name).in_(_OPEN_ALERT_STATUS_NAMES)) + .all() + ] + + assigned_alerts_count = 0 + if open_status_ids: + assigned_alerts_count = ( + Alert.query + .filter(Alert.alert_owner_id == iris_current_user.id) + .filter(Alert.alert_status_id.in_(open_status_ids)) + .count() + ) + + open_cases_count = ( + Cases.query + .filter(Cases.close_date.is_(None)) + .count() + ) + + thirty_days_ago = datetime.date.today() - datetime.timedelta(days=30) + cases_closed_last_30d = ( + Cases.query + .filter(Cases.close_date.isnot(None)) + .filter(Cases.close_date >= thirty_days_ago) + .count() + ) + + return response_api_success(data={ + 'assigned_alerts': { + 'count': assigned_alerts_count, + 'filter': { + 'alert_owner_id': iris_current_user.id, + 'alert_status_id': open_status_ids, + }, + }, + 'open_cases_count': open_cases_count, + 'cases_closed_last_30d': cases_closed_last_30d, + }) diff --git a/source/app/blueprints/rest/v2/dim_tasks.py b/source/app/blueprints/rest/v2/dim_tasks.py new file mode 100644 index 000000000..03db6f57c --- /dev/null +++ b/source/app/blueprints/rest/v2/dim_tasks.py @@ -0,0 +1,101 @@ +# IRIS Source Code +# Copyright (C) 2025 - 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from flask import Blueprint +from flask import request + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.rest.endpoints import response_api_not_found +from app.blueprints.rest.endpoints import response_api_success +from app.business.asynchronous_tasks import asynchronous_task_get_by_id +from app.business.asynchronous_tasks import asynchronous_tasks_list +from app.business.asynchronous_tasks import dim_tasks_get + + +dim_tasks_blueprint = Blueprint('dim_tasks_rest_v2', __name__, url_prefix='/dim-tasks') + + +@dim_tasks_blueprint.get('') +@ac_api_requires() +def list_dim_tasks_endpoint(): + """Paginated listing of Celery (Dim) tasks. + + Query params: + - page (int, default 1) + - per_page (int, default 25, clamped to 1..200) + - search (str, optional) — ILIKE filter over task name + task id + - status (str, optional) — exact-match on the Celery status column + (SUCCESS / FAILURE / PENDING / STARTED / RETRY). + """ + page = request.args.get('page', default=1, type=int) + per_page = request.args.get('per_page', default=25, type=int) + # Clamp here so a hostile caller can't ask for 100k rows. + per_page = max(1, min(per_page, 200)) + + search_value = (request.args.get('search') or '').strip() or None + status = (request.args.get('status') or '').strip() or None + + items, paginated = asynchronous_tasks_list( + page=page, + per_page=per_page, + search_value=search_value, + status=status, + ) + + return response_api_success(data={ + 'total': paginated.total, + 'data': items, + 'last_page': paginated.pages, + 'current_page': paginated.page, + 'next_page': paginated.next_num if paginated.has_next else None, + }) + + +@dim_tasks_blueprint.get('/<task_id>') +@ac_api_requires() +def get_dim_task_endpoint(task_id): + """Detail view for a single Dim task. + + Returns the full Celery AsyncResult metadata (logs, traceback, etc.) + plus the row-shape projection used by the listing — handy because + the frontend can hydrate a slide-out panel without a second request. + + 404 only if the meta row itself is missing. A successful AsyncResult + fetch for an unknown id still returns a "PENDING" stub from Celery, + so we use the DB lookup as the source of truth for existence. + """ + projected = asynchronous_task_get_by_id(task_id) + if projected is None: + return response_api_not_found() + + # `dim_tasks_get` does the heavy lifting (unpickling task.info, + # parsing logs/traceback). Keys are human-readable strings — keep + # them as-is so the frontend can render them verbatim in a key/value + # detail card. + details = dim_tasks_get(task_id) + + # `Task finished on` is a Python datetime — serialise to ISO so JSON + # round-trips cleanly. Other values are already primitives. + finished_on = details.get('Task finished on') + if finished_on is not None and hasattr(finished_on, 'isoformat'): + details = {**details, 'Task finished on': finished_on.isoformat()} + + return response_api_success(data={ + 'row': projected, + 'details': details, + }) diff --git a/source/app/blueprints/rest/v2/incident_rules.py b/source/app/blueprints/rest/v2/incident_rules.py new file mode 100644 index 000000000..466231cea --- /dev/null +++ b/source/app/blueprints/rest/v2/incident_rules.py @@ -0,0 +1,211 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from flask import Blueprint +from flask import request +from marshmallow.exceptions import ValidationError + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.access_controls import ac_current_user_has_customer_access +from app.blueprints.access_controls import ac_current_user_permissions_mask +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.business.incident_rules import backfill_rule +from app.business.incident_rules import rule_dry_run +from app.business.incident_rules import rules_create +from app.business.incident_rules import rules_delete +from app.business.incident_rules import rules_get +from app.business.incident_rules import rules_list +from app.business.incident_rules import rules_update +from app.models.authorization import Permissions +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.schema.marshables import IncidentRuleSchema + + +# Fields the caller must never be able to set / mutate via the request +# body — they carry audit / server-owned attribution and would otherwise +# let a caller spoof "created by <someone else>" or reassign a rule's +# ownership. `rule_id` / `rule_uuid` / `rule_created_at` are dropped for +# similar reasons (they're server-assigned identity / timestamps). +_RULE_READONLY_FIELDS = frozenset({ + 'rule_id', + 'rule_uuid', + 'rule_created_by', + 'rule_created_at', + 'rule_updated_at', +}) + + +def _strip_readonly(payload): + if not isinstance(payload, dict): + return payload + return {k: v for k, v in payload.items() if k not in _RULE_READONLY_FIELDS} + + +def _caller(): + """Common `(user, permissions)` tuple that the business-layer scope + helpers expect. Uses `ac_current_user_permissions_mask` — the public + accessor over the underlying mask helper — so both session and + API-key auth paths resolve to the caller's true permission mask. + Reading `session['permissions']` directly would miss the API-key + branch (permissions live on `flask.g` there).""" + return iris_current_user, ac_current_user_permissions_mask() + + +incident_rules_blueprint = Blueprint('incident_rules_rest_v2', __name__, url_prefix='/incident-rules') + +_schema = IncidentRuleSchema() + + +@incident_rules_blueprint.get('') +@ac_api_requires(Permissions.incident_rules_read) +def list_rules(): + user, perms = _caller() + rows = rules_list(user, perms) + return response_api_success([_schema.dump(r) for r in rows]) + + +@incident_rules_blueprint.post('') +@ac_api_requires(Permissions.incident_rules_write) +def create_rule(): + user, perms = _caller() + payload = _strip_readonly(request.get_json() or {}) + # Server-owned attribution — set AFTER stripping any spoofed value + # the client may have supplied. + payload['rule_created_by'] = iris_current_user.id + try: + rule = _schema.load(payload) + except ValidationError as exc: + return response_api_error('Data error', data=exc.messages) + try: + result = rules_create( + user, perms, rule, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except BusinessProcessingError as exc: + return response_api_error(exc.get_message(), data=exc.get_data()) + return response_api_created(_schema.dump(result)) + + +@incident_rules_blueprint.get('/<int:identifier>') +@ac_api_requires(Permissions.incident_rules_read) +def read_rule(identifier): + user, perms = _caller() + try: + rule = rules_get( + user, perms, identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(_schema.dump(rule)) + + +@incident_rules_blueprint.put('/<int:identifier>') +@ac_api_requires(Permissions.incident_rules_write) +def update_rule(identifier): + user, perms = _caller() + try: + rule = rules_get( + user, perms, identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + payload = _strip_readonly(request.get_json() or {}) + try: + _schema.load(payload, instance=rule, partial=True) + except ValidationError as exc: + return response_api_error('Data error', data=exc.messages) + try: + result = rules_update( + user, perms, rule, payload, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except BusinessProcessingError as exc: + return response_api_error(exc.get_message(), data=exc.get_data()) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(_schema.dump(result)) + + +@incident_rules_blueprint.delete('/<int:identifier>') +@ac_api_requires(Permissions.incident_rules_write) +def delete_rule(identifier): + user, perms = _caller() + try: + rule = rules_get( + user, perms, identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + rules_delete(rule) + return response_api_deleted() + + +@incident_rules_blueprint.post('/<int:identifier>/test') +@ac_api_requires(Permissions.incident_rules_read) +def test_rule(identifier): + user, perms = _caller() + try: + rule = rules_get( + user, perms, identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + payload = request.get_json() or {} + sample_days = payload.get('sample_days', 7) + limit = payload.get('limit', 50) + matches = rule_dry_run(rule, sample_days=sample_days, limit=limit) + return response_api_success({ + 'rule_id': rule.rule_id, + 'matching_alert_ids': matches, + 'sample_days': sample_days, + }) + + +@incident_rules_blueprint.post('/<int:identifier>/backfill') +@ac_api_requires(Permissions.incident_rules_write) +def backfill(identifier): + """Apply this rule's action to *historical* alerts. Alerts already + grouped into an incident are skipped so the rule can't hijack an + analyst's manual grouping. The dedupe / time-window logic keeps the + operation idempotent — re-running produces no duplicates. + + Loaded through the scope-gated `rules_get` so a caller who can't + see every tenant in `rule.rule_customer_scope` gets a 404 (matching + the getter's behaviour) rather than a cross-tenant write.""" + user, perms = _caller() + try: + rule = rules_get( + user, perms, identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + payload = request.get_json() or {} + sample_days = payload.get('sample_days', 30) + result = backfill_rule(rule, sample_days=sample_days) + return response_api_success({'rule_id': rule.rule_id, **result}) diff --git a/source/app/blueprints/rest/v2/incidents.py b/source/app/blueprints/rest/v2/incidents.py new file mode 100644 index 000000000..52e817594 --- /dev/null +++ b/source/app/blueprints/rest/v2/incidents.py @@ -0,0 +1,362 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from flask import Blueprint +from flask import request +from flask import session +from marshmallow.exceptions import ValidationError + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.access_controls import ac_current_user_has_customer_access +from app.blueprints.access_controls import ac_current_user_has_permission +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_paginated +from app.blueprints.rest.endpoints import response_api_success +from app.blueprints.rest.v2.incidents_routes.comments import ( + incidents_comments_blueprint, +) +from app.blueprints.rest.v2.incidents_routes.investigation_progress import ( + incidents_investigation_progress_blueprint, +) +from app.blueprints.access_controls import ac_api_return_access_denied +from app.blueprints.access_controls import ac_fast_check_current_user_has_case_access +from app.business.cases import case_unlink_incident +from app.business.cases import cases_get_by_identifier +from app.business.incidents import incident_add_alerts +from app.business.incidents import incident_correlation_graph +from app.business.incidents import incident_escalate_to_case +from app.business.incidents import incident_merge_to_case +from app.business.incidents import incident_remove_alert +from app.models.authorization import CaseAccessLevel +from app.business.incidents import incidents_create +from app.business.incidents import incidents_delete +from app.business.incidents import incidents_get +from app.business.incidents import incidents_search +from app.business.incidents import incidents_update +from app.models.authorization import Permissions +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.schema.marshables import IncidentSchema + + +# Immutable-on-update fields (CVE class fixed by _ALERT_READONLY_UPDATE_FIELDS +# on the alerts blueprint — re-attributing incident_customer_id would let a +# user with write access to one tenant move an incident into another). +_INCIDENT_READONLY_UPDATE_FIELDS = frozenset({ + 'incident_id', + 'incident_customer_id', + 'incident_creation_time', + 'incident_case_id', + 'incident_dedupe_key', + 'incident_source_rule_id', +}) + + +def _strip_readonly(payload): + if not isinstance(payload, dict): + return payload + return {k: v for k, v in payload.items() if k not in _INCIDENT_READONLY_UPDATE_FIELDS} + + +incidents_blueprint = Blueprint('incidents_rest_v2', __name__, url_prefix='/incidents') +incidents_blueprint.register_blueprint(incidents_investigation_progress_blueprint) +incidents_blueprint.register_blueprint(incidents_comments_blueprint) + +_schema = IncidentSchema() + + +@incidents_blueprint.get('') +@ac_api_requires(Permissions.incidents_read) +def list_incidents(): + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 10, type=int) + + user_filter = iris_current_user.id + if ac_current_user_has_permission(Permissions.server_administrator): + user_filter = None + + paginated = incidents_search( + customer_id=request.args.get('customer_id', type=int), + status_id=request.args.get('status_id', type=int), + title=request.args.get('title'), + user_identifier_filter=user_filter, + page=page, + per_page=per_page, + sort=request.args.get('sort'), + ) + return response_api_paginated(_schema, paginated) + + +@incidents_blueprint.post('') +@ac_api_requires(Permissions.incidents_write) +def create_incident(): + payload = request.get_json() or {} + try: + incident = _schema.load(payload) + except ValidationError as exc: + return response_api_error('Data error', data=exc.messages) + if not ac_current_user_has_customer_access(incident.incident_customer_id): + return response_api_error('User not entitled to create incidents for the client') + + alert_ids = payload.get('alert_ids') or [] + result = incidents_create(incident) + if alert_ids: + incident_add_alerts(result, alert_ids) + return response_api_created(_schema.dump(result)) + + +@incidents_blueprint.get('/<int:identifier>') +@ac_api_requires(Permissions.incidents_read) +def read_incident(identifier): + try: + incident = incidents_get( + iris_current_user, + session.get('permissions') or 0, + identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(_schema.dump(incident)) + + +@incidents_blueprint.put('/<int:identifier>') +@ac_api_requires(Permissions.incidents_write) +def update_incident(identifier): + try: + incident = incidents_get( + iris_current_user, + session.get('permissions') or 0, + identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + payload = _strip_readonly(request.get_json() or {}) + try: + # partial load lets the schema validate types without requiring all fields + _schema.load(payload, instance=incident, partial=True) + except ValidationError as exc: + return response_api_error('Data error', data=exc.messages) + result = incidents_update(incident, payload) + return response_api_success(_schema.dump(result)) + + +@incidents_blueprint.delete('/<int:identifier>') +@ac_api_requires(Permissions.incidents_delete) +def delete_incident(identifier): + try: + incident = incidents_get( + iris_current_user, + session.get('permissions') or 0, + identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + incidents_delete(incident) + return response_api_deleted() + + +@incidents_blueprint.post('/<int:identifier>/alerts') +@ac_api_requires(Permissions.incidents_write) +def add_alerts(identifier): + try: + incident = incidents_get( + iris_current_user, + session.get('permissions') or 0, + identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + payload = request.get_json() or {} + alert_ids = payload.get('alert_ids') or [] + if not isinstance(alert_ids, list): + return response_api_error('alert_ids must be a list of integers') + result = incident_add_alerts(incident, alert_ids) + return response_api_success(_schema.dump(result)) + + +@incidents_blueprint.delete('/<int:identifier>/alerts/<int:alert_id>') +@ac_api_requires(Permissions.incidents_write) +def remove_alert(identifier, alert_id): + try: + incident = incidents_get( + iris_current_user, + session.get('permissions') or 0, + identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + result = incident_remove_alert(incident, alert_id) + return response_api_success(_schema.dump(result)) + + +@incidents_blueprint.post('/<int:identifier>/escalate') +@ac_api_requires(Permissions.incidents_write) +def escalate(identifier): + try: + incident = incidents_get( + iris_current_user, + session.get('permissions') or 0, + identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + payload = request.get_json() or {} + try: + case = incident_escalate_to_case( + incident, + template_id=payload.get('template_id'), + case_title=payload.get('case_title'), + note=payload.get('note'), + import_as_event=bool(payload.get('import_as_event', False)), + case_tags=payload.get('case_tags', '') or '', + ) + except BusinessProcessingError as exc: + return response_api_error(exc.get_message(), data=exc.get_data()) + return response_api_success({ + 'incident_id': incident.incident_id, + 'case_id': case.case_id, + }) + + +@incidents_blueprint.post('/<int:identifier>/merge') +@ac_api_requires(Permissions.incidents_write) +def merge(identifier): + """Merge an incident's alerts into an already-existing case. + + Mirrors the alert-merge endpoint (`POST /alerts/merge/<alert_id>`) but + fanned across every alert on the incident. `target_case_id` in the body + picks the destination case; the caller must have full case access to + it (read-only isn't enough — merging mutates the case description, + IOCs, assets, and optionally the timeline). + """ + try: + incident = incidents_get( + iris_current_user, + session.get('permissions') or 0, + identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + + payload = request.get_json() or {} + target_case_id = payload.get('target_case_id') + if not isinstance(target_case_id, int): + return response_api_error('target_case_id (int) is required') + + if not ac_fast_check_current_user_has_case_access( + target_case_id, [CaseAccessLevel.full_access] + ): + return response_api_error( + 'Caller does not have write access to the target case', + data={'case_id': target_case_id}, + ) + + try: + case = incident_merge_to_case( + incident, + target_case_id=target_case_id, + note=payload.get('note'), + import_as_event=bool(payload.get('import_as_event', False)), + case_tags=payload.get('case_tags', '') or '', + ) + except BusinessProcessingError as exc: + return response_api_error(exc.get_message(), data=exc.get_data()) + + return response_api_success({ + 'incident_id': incident.incident_id, + 'case_id': case.case_id, + }) + + +@incidents_blueprint.delete('/<int:identifier>/case') +@ac_api_requires(Permissions.incidents_write) +def unlink_case(identifier): + """Reverse an incident->case escalation/merge from the incident side. + + Same behaviour as `DELETE /api/v2/cases/{case_id}/source-incident` + but rooted at the incident URL so the incident-detail page's + "Unlink from case" menu can hit it without knowing the case id. + Requires the caller to have write access to the target case — a + user who can't touch the case shouldn't be able to strip its + source-incident link either. + """ + try: + incident = incidents_get( + iris_current_user, + session.get('permissions') or 0, + identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + + if incident.incident_case_id is None: + return response_api_success(data={'unlinked': False}) + + linked_case_id = incident.incident_case_id + if not ac_fast_check_current_user_has_case_access( + linked_case_id, [CaseAccessLevel.full_access] + ): + return ac_api_return_access_denied(caseid=linked_case_id) + + case = cases_get_by_identifier(linked_case_id) + try: + case_unlink_incident(case) + except BusinessProcessingError as exc: + return response_api_error(exc.get_message(), data=exc.get_data()) + + return response_api_success(data={ + 'unlinked': True, + 'incident_id': incident.incident_id, + }) + + +@incidents_blueprint.get('/<int:identifier>/graph') +@ac_api_requires() +def graph(identifier): + """Correlation graph for an incident. + + Returns `{nodes, edges}` linking every member alert to its IOCs and + assets, with IOC/asset nodes deduplicated across alerts so shared + indicators show as junction points — the analyst's whole reason for + looking at this view. Read-only, gated by incident (customer) + access; the payload only carries labels/titles/ids that a reader + already sees on the alerts tab, so no extra ACL is needed. + """ + try: + incident = incidents_get( + iris_current_user, + session.get('permissions') or 0, + identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + + return response_api_success(data=incident_correlation_graph(incident)) diff --git a/source/app/blueprints/graphql/__init__.py b/source/app/blueprints/rest/v2/incidents_routes/__init__.py similarity index 100% rename from source/app/blueprints/graphql/__init__.py rename to source/app/blueprints/rest/v2/incidents_routes/__init__.py diff --git a/source/app/blueprints/rest/v2/incidents_routes/comments.py b/source/app/blueprints/rest/v2/incidents_routes/comments.py new file mode 100644 index 000000000..5fe637fc2 --- /dev/null +++ b/source/app/blueprints/rest/v2/incidents_routes/comments.py @@ -0,0 +1,125 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from flask import Blueprint +from flask import request +from flask import session +from marshmallow.exceptions import ValidationError + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.access_controls import ac_api_return_access_denied +from app.blueprints.access_controls import ac_current_user_has_customer_access +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_paginated +from app.blueprints.rest.endpoints import response_api_success +from app.blueprints.rest.parsing import parse_pagination_parameters +from app.business.comments import comments_create_for_incident +from app.business.comments import comments_delete_for_incident +from app.business.comments import comments_get_filtered_by_incident +from app.business.comments import comments_get_for_incident +from app.models.authorization import Permissions +from app.models.errors import ObjectNotFoundError +from app.schema.marshables import CommentSchema + + +incidents_comments_blueprint = Blueprint( + 'incidents_comments', __name__, url_prefix='/<int:incident_identifier>/comments' +) +_schema = CommentSchema() + + +@incidents_comments_blueprint.get('') +@ac_api_requires(Permissions.incidents_read) +def list_incident_comments(incident_identifier): + pagination = parse_pagination_parameters(request) + try: + comments = comments_get_filtered_by_incident( + iris_current_user, + (session.get('permissions') or 0), + incident_identifier, + pagination, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_paginated(_schema, comments) + + +@incidents_comments_blueprint.post('') +@ac_api_requires(Permissions.incidents_write) +def create_incident_comment(incident_identifier): + try: + comment = _schema.load(request.get_json() or {}) + except ValidationError as exc: + return response_api_error('Data error', data=exc.normalized_messages()) + try: + comments_create_for_incident( + iris_current_user, + (session.get('permissions') or 0), + comment, + incident_identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_created(_schema.dump(comment)) + + +@incidents_comments_blueprint.get('/<int:identifier>') +@ac_api_requires(Permissions.incidents_read) +def read_incident_comment(incident_identifier, identifier): + # Verify caller can see the parent incident before returning the + # comment — otherwise an authorised-by-comment-id lookup would leak + # comment text across tenants. + try: + comments_get_filtered_by_incident( + iris_current_user, + (session.get('permissions') or 0), + incident_identifier, + parse_pagination_parameters(request), + fallback_customer_access=ac_current_user_has_customer_access, + ) + comment = comments_get_for_incident(incident_identifier, identifier) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(_schema.dump(comment)) + + +@incidents_comments_blueprint.delete('/<int:identifier>') +@ac_api_requires(Permissions.incidents_write) +def delete_incident_comment(incident_identifier, identifier): + try: + # Access check on the parent incident. + comments_get_filtered_by_incident( + iris_current_user, + (session.get('permissions') or 0), + incident_identifier, + parse_pagination_parameters(request), + fallback_customer_access=ac_current_user_has_customer_access, + ) + comment = comments_get_for_incident(incident_identifier, identifier) + except ObjectNotFoundError: + return response_api_not_found() + if comment.comment_user_id != iris_current_user.id: + return ac_api_return_access_denied() + comments_delete_for_incident(comment) + return response_api_deleted() diff --git a/source/app/blueprints/rest/v2/incidents_routes/investigation_progress.py b/source/app/blueprints/rest/v2/incidents_routes/investigation_progress.py new file mode 100644 index 000000000..057fd3839 --- /dev/null +++ b/source/app/blueprints/rest/v2/incidents_routes/investigation_progress.py @@ -0,0 +1,113 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""Incident-scoped investigation-flow progress endpoints. Mirrors the +alert-scoped sub-blueprint (`alerts_routes/investigation_progress.py`) +so the frontend can render an identical checklist UI on both entities.""" + +from flask import Blueprint +from flask import request +from flask import session + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.access_controls import ac_current_user_has_customer_access +from app.blueprints.iris_user import iris_current_user +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.business.incidents import incidents_get +from app.business.investigation_flows import incident_progress_list +from app.business.investigation_flows import incident_progress_record +from app.business.investigation_flows import incident_progress_uncheck +from app.models.authorization import Permissions +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.schema.marshables import IncidentInvestigationProgressSchema + + +incidents_investigation_progress_blueprint = Blueprint( + 'incidents_investigation_progress_rest_v2', __name__ +) + +_schema = IncidentInvestigationProgressSchema() + + +def _load_incident(identifier): + return incidents_get( + iris_current_user, + session.get('permissions') or 0, + identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + + +@incidents_investigation_progress_blueprint.get('/<int:identifier>/investigation-progress') +@ac_api_requires(Permissions.incidents_read) +def list_progress(identifier): + try: + incident = _load_incident(identifier) + except ObjectNotFoundError: + return response_api_not_found() + rows = incident_progress_list(incident) + flow = incident.investigation_flow + return response_api_success({ + 'flow_id': flow.flow_id if flow else None, + 'flow_name': flow.flow_name if flow else None, + 'steps': [ + { + 'step_id': s.step_id, + 'step_order': s.step_order, + 'step_title': s.step_title, + 'step_description': s.step_description, + 'step_is_required': s.step_is_required, + } + for s in (flow.steps if flow else []) + ], + 'progress': [_schema.dump(r) for r in rows], + }) + + +@incidents_investigation_progress_blueprint.post('/<int:identifier>/investigation-progress/<int:step_id>') +@ac_api_requires(Permissions.incidents_write) +def record_progress(identifier, step_id): + try: + incident = _load_incident(identifier) + except ObjectNotFoundError: + return response_api_not_found() + payload = request.get_json() or {} + try: + row = incident_progress_record( + incident, step_id, iris_current_user.id, note=payload.get('note') + ) + except BusinessProcessingError as exc: + return response_api_error(exc.get_message(), data=exc.get_data()) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(_schema.dump(row)) + + +@incidents_investigation_progress_blueprint.delete('/<int:identifier>/investigation-progress/<int:step_id>') +@ac_api_requires(Permissions.incidents_write) +def uncheck_progress(identifier, step_id): + try: + incident = _load_incident(identifier) + except ObjectNotFoundError: + return response_api_not_found() + incident_progress_uncheck(incident, step_id) + return response_api_deleted() diff --git a/source/app/blueprints/rest/v2/investigation_flows.py b/source/app/blueprints/rest/v2/investigation_flows.py new file mode 100644 index 000000000..b8f5574ba --- /dev/null +++ b/source/app/blueprints/rest/v2/investigation_flows.py @@ -0,0 +1,250 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from flask import Blueprint +from flask import request +from marshmallow.exceptions import ValidationError + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.access_controls import ac_current_user_has_customer_access +from app.blueprints.access_controls import ac_current_user_permissions_mask +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.business.investigation_flows import deploy_flow +from app.business.investigation_flows import flow_step_create +from app.business.investigation_flows import flow_step_delete +from app.business.investigation_flows import flow_step_get +from app.business.investigation_flows import flow_step_update +from app.business.investigation_flows import flows_create +from app.business.investigation_flows import flows_delete +from app.business.investigation_flows import flows_get +from app.business.investigation_flows import flows_list +from app.business.investigation_flows import flows_update +from app.models.authorization import Permissions +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.schema.marshables import InvestigationFlowSchema +from app.schema.marshables import InvestigationFlowStepSchema + + +# Fields the caller must never set / mutate via the request body — see +# `_RULE_READONLY_FIELDS` on the rules blueprint for the same rationale. +_FLOW_READONLY_FIELDS = frozenset({ + 'flow_id', + 'flow_uuid', + 'flow_created_by', + 'flow_created_at', + 'flow_updated_at', +}) + + +def _strip_readonly(payload): + if not isinstance(payload, dict): + return payload + return {k: v for k, v in payload.items() if k not in _FLOW_READONLY_FIELDS} + + +def _caller(): + """See `_caller` on the rules blueprint — same rationale: use the + canonical permission-mask helper so session and API-key auth paths + both resolve to the caller's real permissions.""" + return iris_current_user, ac_current_user_permissions_mask() + + +investigation_flows_blueprint = Blueprint( + 'investigation_flows_rest_v2', __name__, url_prefix='/investigation-flows' +) + +_flow_schema = InvestigationFlowSchema() +_step_schema = InvestigationFlowStepSchema() + + +@investigation_flows_blueprint.get('') +@ac_api_requires(Permissions.investigation_flows_read) +def list_flows(): + user, perms = _caller() + return response_api_success([_flow_schema.dump(f) for f in flows_list(user, perms)]) + + +@investigation_flows_blueprint.post('') +@ac_api_requires(Permissions.investigation_flows_write) +def create_flow(): + user, perms = _caller() + payload = _strip_readonly(request.get_json() or {}) + # Server-owned attribution — set AFTER stripping any spoofed value. + payload['flow_created_by'] = iris_current_user.id + try: + flow = _flow_schema.load(payload) + except ValidationError as exc: + return response_api_error('Data error', data=exc.messages) + try: + result = flows_create( + user, perms, flow, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except BusinessProcessingError as exc: + return response_api_error(exc.get_message(), data=exc.get_data()) + return response_api_created(_flow_schema.dump(result)) + + +@investigation_flows_blueprint.get('/<int:identifier>') +@ac_api_requires(Permissions.investigation_flows_read) +def read_flow(identifier): + user, perms = _caller() + try: + flow = flows_get( + user, perms, identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(_flow_schema.dump(flow)) + + +@investigation_flows_blueprint.put('/<int:identifier>') +@ac_api_requires(Permissions.investigation_flows_write) +def update_flow(identifier): + user, perms = _caller() + try: + flow = flows_get( + user, perms, identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + payload = _strip_readonly(request.get_json() or {}) + try: + _flow_schema.load(payload, instance=flow, partial=True) + except ValidationError as exc: + return response_api_error('Data error', data=exc.messages) + try: + result = flows_update( + user, perms, flow, payload, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except BusinessProcessingError as exc: + return response_api_error(exc.get_message(), data=exc.get_data()) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(_flow_schema.dump(result)) + + +@investigation_flows_blueprint.delete('/<int:identifier>') +@ac_api_requires(Permissions.investigation_flows_write) +def delete_flow(identifier): + user, perms = _caller() + try: + flow = flows_get( + user, perms, identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + flows_delete(flow) + return response_api_deleted() + + +@investigation_flows_blueprint.post('/<int:identifier>/steps') +@ac_api_requires(Permissions.investigation_flows_write) +def create_step(identifier): + user, perms = _caller() + try: + flow = flows_get( + user, perms, identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + payload = request.get_json() or {} + try: + step = _step_schema.load(payload) + except ValidationError as exc: + return response_api_error('Data error', data=exc.messages) + return response_api_created(_step_schema.dump(flow_step_create(flow, step))) + + +@investigation_flows_blueprint.put('/<int:flow_id>/steps/<int:step_id>') +@ac_api_requires(Permissions.investigation_flows_write) +def update_step(flow_id, step_id): + user, perms = _caller() + try: + step = flow_step_get( + user, perms, step_id, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + # Enforce path/body consistency — a step id from a foreign flow must + # 404 the same way as a non-existent step, so the URL can't be used + # to smuggle a step-under-flow reassignment. + if step.flow_id != flow_id: + return response_api_not_found() + payload = request.get_json() or {} + try: + _step_schema.load(payload, instance=step, partial=True) + except ValidationError as exc: + return response_api_error('Data error', data=exc.messages) + return response_api_success(_step_schema.dump(flow_step_update(step, payload))) + + +@investigation_flows_blueprint.delete('/<int:flow_id>/steps/<int:step_id>') +@ac_api_requires(Permissions.investigation_flows_write) +def delete_step(flow_id, step_id): + user, perms = _caller() + try: + step = flow_step_get( + user, perms, step_id, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + if step.flow_id != flow_id: + return response_api_not_found() + flow_step_delete(step) + return response_api_deleted() + + +@investigation_flows_blueprint.post('/<int:identifier>/deploy') +@ac_api_requires(Permissions.investigation_flows_write) +def deploy(identifier): + """Back-fill this flow onto historical alerts / incidents whose + investigation-flow FK is empty and whose contents match the flow's + own conditions. Returns per-target attach counts. + + Loaded through the scope-gated `flows_get` — a caller who can't act + on every tenant in `flow.flow_customer_scope` gets a 404 the same + way the read endpoint would, closing the deploy-then-cross-tenant + path the security review flagged.""" + user, perms = _caller() + try: + flow = flows_get( + user, perms, identifier, + fallback_customer_access=ac_current_user_has_customer_access, + ) + except ObjectNotFoundError: + return response_api_not_found() + alerts_attached, incidents_attached = deploy_flow(flow) + return response_api_success({ + 'flow_id': flow.flow_id, + 'alerts_attached': alerts_attached, + 'incidents_attached': incidents_attached, + }) diff --git a/source/app/blueprints/rest/v2/mail.py b/source/app/blueprints/rest/v2/mail.py new file mode 100644 index 000000000..14ce0d684 --- /dev/null +++ b/source/app/blueprints/rest/v2/mail.py @@ -0,0 +1,201 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""v2 REST endpoints for mail-ingest rules + ingest log. + +Server-admin gated (creating a rule = deciding what turns into an +alert/case, so this can't be a standard-user surface). The rules +table is the one operators tweak most; the ingest log is read-only +audit output. +""" + +from __future__ import annotations + +from flask import Blueprint +from flask import 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_not_found +from app.blueprints.rest.endpoints import response_api_success +from app.db import db +from app.models.authorization import Permissions +from app.models.mail import MailIngestLog +from app.models.mail import MailIngestRule +from app.models.mail import VALID_ACTIONS + + +mail_blueprint = Blueprint( + 'mail_rest_v2', __name__, url_prefix='/manage/mail') + + +def _serialize_rule(row: MailIngestRule) -> dict: + return { + 'id': row.id, + 'name': row.name, + 'priority': row.priority, + 'enabled': bool(row.enabled), + 'match_subject_regex': row.match_subject_regex, + 'match_from_regex': row.match_from_regex, + 'match_to_regex': row.match_to_regex, + 'action': row.action, + 'customer_id': row.customer_id, + 'case_template_id': row.case_template_id, + 'severity_id': row.severity_id, + 'assignee_user_id': row.assignee_user_id, + 'created_by_id': row.created_by_id, + 'created_at': row.created_at.isoformat() if row.created_at else None, + 'updated_at': row.updated_at.isoformat() if row.updated_at else None, + } + + +def _serialize_log(row: MailIngestLog) -> dict: + return { + 'message_id': row.message_id, + 'received_at': row.received_at.isoformat() if row.received_at else None, + 'outcome': row.outcome, + 'outcome_object_id': row.outcome_object_id, + 'rule_id': row.rule_id, + 'from_addr': row.from_addr, + 'subject': row.subject, + 'error': row.error, + } + + +# Fields that a client can set on a rule (excludes id, created_by_id, +# timestamps — those are server-owned). +_RULE_WRITABLE_FIELDS = { + 'name', 'priority', 'enabled', + 'match_subject_regex', 'match_from_regex', 'match_to_regex', + 'action', + 'customer_id', 'case_template_id', 'severity_id', 'assignee_user_id', +} + + +def _apply_rule_body(row: MailIngestRule, body: dict) -> None: + """Copy the writable fields from `body` onto `row`. + + Validates the action string against the enum-ish tuple in the + models module. Other fields are typed by SQLAlchemy — a wrong + type will raise on commit which the caller surfaces as a 400. + """ + for field in _RULE_WRITABLE_FIELDS: + if field not in body: + continue + value = body[field] + if field == 'action' and value not in VALID_ACTIONS: + raise ValueError( + f'Unknown action {value!r}. ' + f'Expected one of {list(VALID_ACTIONS)}.' + ) + setattr(row, field, value) + + +# --------------------------------------------------------------------- +# Rules +# --------------------------------------------------------------------- + +@mail_blueprint.get('/rules') +@ac_api_requires(Permissions.server_administrator) +def list_rules(): + rows = ( + MailIngestRule.query + .order_by(MailIngestRule.priority.asc(), MailIngestRule.id.asc()) + .all() + ) + return response_api_success({ + 'data': [_serialize_rule(r) for r in rows], + }) + + +@mail_blueprint.post('/rules') +@ac_api_requires(Permissions.server_administrator) +def create_rule(): + body = request.get_json(silent=True) or {} + if not body.get('name'): + return response_api_error('name is required') + row = MailIngestRule(name=body['name'], + created_by_id=iris_current_user.id) + try: + _apply_rule_body(row, body) + db.session.add(row) + db.session.commit() + except ValueError as exc: + db.session.rollback() + return response_api_error(str(exc)) + return response_api_success(_serialize_rule(row)) + + +@mail_blueprint.get('/rules/<int:rule_id>') +@ac_api_requires(Permissions.server_administrator) +def get_rule(rule_id: int): + row = MailIngestRule.query.get(rule_id) + if row is None: + return response_api_not_found() + return response_api_success(_serialize_rule(row)) + + +@mail_blueprint.put('/rules/<int:rule_id>') +@ac_api_requires(Permissions.server_administrator) +def update_rule(rule_id: int): + row = MailIngestRule.query.get(rule_id) + if row is None: + return response_api_not_found() + body = request.get_json(silent=True) or {} + try: + _apply_rule_body(row, body) + db.session.commit() + except ValueError as exc: + db.session.rollback() + return response_api_error(str(exc)) + return response_api_success(_serialize_rule(row)) + + +@mail_blueprint.delete('/rules/<int:rule_id>') +@ac_api_requires(Permissions.server_administrator) +def delete_rule(rule_id: int): + row = MailIngestRule.query.get(rule_id) + if row is None: + return response_api_not_found() + db.session.delete(row) + db.session.commit() + return response_api_success({'deleted': rule_id}) + + +# --------------------------------------------------------------------- +# Ingest log (read-only) +# --------------------------------------------------------------------- + +@mail_blueprint.get('/ingest-log') +@ac_api_requires(Permissions.server_administrator) +def list_ingest_log(): + """Return the most recent ingest-log rows. `?limit=` bounded at + 500 to keep the payload small for an on-page log viewer.""" + limit = request.args.get('limit', default=100, type=int) + limit = max(1, min(int(limit or 100), 500)) + rows = ( + MailIngestLog.query + .order_by(MailIngestLog.received_at.desc()) + .limit(limit) + .all() + ) + return response_api_success({ + 'data': [_serialize_log(r) for r in rows], + }) + + +# --------------------------------------------------------------------- +# Ad-hoc poll trigger (admin diagnostic) +# --------------------------------------------------------------------- + +@mail_blueprint.post('/poll-now') +@ac_api_requires(Permissions.server_administrator) +def poll_now(): + """Run one IMAP poll immediately. Blocks on the fetch — mostly a + diagnostic for admins tuning rules; regular polling is driven by + the Celery beat schedule.""" + from app.iris_engine.mail.inbound import poll_inbound_mail + result = poll_inbound_mail.apply().result + return response_api_success(result if isinstance(result, dict) else {'result': result}) diff --git a/source/app/blueprints/rest/v2/manage.py b/source/app/blueprints/rest/v2/manage.py index e16905efe..ef18ce338 100644 --- a/source/app/blueprints/rest/v2/manage.py +++ b/source/app/blueprints/rest/v2/manage.py @@ -22,6 +22,13 @@ from app.blueprints.rest.v2.manage_routes.users import users_blueprint from app.blueprints.rest.v2.manage_routes.customers import customers_blueprint from app.blueprints.rest.v2.manage_routes.server import server_blueprint +from app.blueprints.rest.v2.manage_routes.modules import modules_blueprint +from app.blueprints.rest.v2.manage_routes.case_objects import case_objects_blueprint +from app.blueprints.rest.v2.manage_routes.case_templates import case_templates_blueprint +from app.blueprints.rest.v2.manage_routes.custom_attributes import custom_attributes_blueprint +from app.blueprints.rest.v2.manage_routes.report_templates import report_templates_blueprint +from app.blueprints.rest.v2.manage_routes.access_control import access_control_blueprint +from app.blueprints.rest.v2.manage_routes.taxonomies import taxonomies_blueprint manage_v2_blueprint = Blueprint("manage", __name__, url_prefix="/manage") @@ -29,3 +36,10 @@ manage_v2_blueprint.register_blueprint(users_blueprint) manage_v2_blueprint.register_blueprint(customers_blueprint) manage_v2_blueprint.register_blueprint(server_blueprint) +manage_v2_blueprint.register_blueprint(modules_blueprint) +manage_v2_blueprint.register_blueprint(case_objects_blueprint) +manage_v2_blueprint.register_blueprint(case_templates_blueprint) +manage_v2_blueprint.register_blueprint(custom_attributes_blueprint) +manage_v2_blueprint.register_blueprint(report_templates_blueprint) +manage_v2_blueprint.register_blueprint(access_control_blueprint) +manage_v2_blueprint.register_blueprint(taxonomies_blueprint) diff --git a/source/app/blueprints/rest/v2/manage_routes/access_control.py b/source/app/blueprints/rest/v2/manage_routes/access_control.py new file mode 100644 index 000000000..5d6c54e19 --- /dev/null +++ b/source/app/blueprints/rest/v2/manage_routes/access_control.py @@ -0,0 +1,228 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""v2 endpoints supporting the Access Control admin page. + +Two small concerns: + + * `GET /schema` — exposes the `Permissions` (bitmask) and + `CaseAccessLevel` enums + their canonical labels. The frontend + renders the permission editor + the case-access dropdown + directly from this so adding a new permission bit or access + level only needs a backend change. + * `GET /accessible-cases` — server-side scoped case picker for + the per-user / per-group case-access modals. Returns at most + 200 rows, ordered newest-first, filtered by an optional `search` + ILIKE on case name + SOC id. Each row is re-checked against the + current user's case ACL — even though the page is admin-gated, + an admin who's removed themselves from a case still doesn't see + it. + * `POST /recompute-all` — wrapper around the existing + `ac_recompute_all_users_effective_ac()` helper so admins can + rebuild the effective-access cache from the UI after a manual + DB poke. +""" + +from typing import Any +from typing import Dict +from typing import List + +from flask import Blueprint +from flask import Response +from flask import request + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.access_controls import ac_fast_check_current_user_has_case_access +from app.blueprints.rest.endpoints import response_api_success +from app.iris_engine.access_control.utils import ac_recompute_all_users_effective_ac +from app.models.authorization import CaseAccessLevel +from app.models.authorization import Permissions +from app.models.cases import Cases + + +access_control_blueprint = Blueprint( + 'access_control_rest_v2', __name__, url_prefix='/access-control' +) + + +# Human-readable labels + short descriptions for each permission bit. +# Keyed by enum name. Strictly additive — adding a new permission to +# `app.models.authorization.Permissions` will surface in the response +# even without a label here; the UI falls back to the enum name. +_PERMISSION_LABELS: Dict[str, Dict[str, str]] = { + 'standard_user': { + 'label': 'Standard user', + 'description': 'Bare minimum required to log in. Always implicitly set.', + }, + 'server_administrator': { + 'label': 'Server administrator', + 'description': 'Full administrative access. Take care assigning this.', + }, + 'alerts_read': { + 'label': 'Read alerts', + 'description': 'View the alerts queue.', + }, + 'alerts_write': { + 'label': 'Write alerts', + 'description': 'Triage, edit and escalate alerts.', + }, + 'alerts_delete': { + 'label': 'Delete alerts', + 'description': 'Permanently delete alerts.', + }, + 'search_across_cases': { + 'label': 'Search across cases', + 'description': 'Global search reaches every case (subject to per-case access).', + }, + 'customers_read': { + 'label': 'Read customers', + 'description': 'View the customer directory.', + }, + 'customers_write': { + 'label': 'Write customers', + 'description': 'Create, edit and delete customer records.', + }, + 'case_templates_read': { + 'label': 'Read case templates', + 'description': 'View the case template library.', + }, + 'case_templates_write': { + 'label': 'Write case templates', + 'description': 'Create, edit and delete case templates.', + }, + 'activities_read': { + 'label': 'Read own activities', + 'description': "View activity entries the user generated.", + }, + 'all_activities_read': { + 'label': 'Read all activities', + 'description': 'View activity entries generated by every user.', + }, + 'custom_dashboards_read': { + 'label': 'Read custom dashboards', + 'description': 'View shared custom dashboards (including the seeded Statistics dashboard) and their widgets.', + }, + 'custom_dashboards_write': { + 'label': 'Write custom dashboards', + 'description': 'Create, edit and delete own custom dashboards.', + }, + 'custom_dashboards_share': { + 'label': 'Share custom dashboards', + 'description': 'Share own dashboards so other users can view them.', + }, +} + +_CASE_ACCESS_LABELS: Dict[str, Dict[str, str]] = { + 'deny_all': { + 'label': 'Deny all', + 'description': 'Explicitly refuse access (overrides group / org grants).', + }, + 'read_only': { + 'label': 'Read only', + 'description': 'View the case and its objects without editing.', + }, + 'full_access': { + 'label': 'Full access', + 'description': 'View, edit and add objects to the case.', + }, +} + + +@access_control_blueprint.get('/schema') +@ac_api_requires(Permissions.server_administrator) +def get_access_control_schema() -> Response: + """Enum metadata for the Access Control editor. + + Permissions are exposed as a flat list of `{name, value, label, + description}` ordered by their bit value — that's the order the + editor renders the checklist. CaseAccessLevel is shipped the + same way but as a small mutually-exclusive set the UI renders as + a select. + """ + perms = [] + for perm in sorted(Permissions, key=lambda p: p.value): + meta = _PERMISSION_LABELS.get(perm.name, {}) + perms.append({ + 'name': perm.name, + 'value': perm.value, + 'label': meta.get('label', perm.name.replace('_', ' ').capitalize()), + 'description': meta.get('description', ''), + }) + + access_levels = [] + for level in sorted(CaseAccessLevel, key=lambda l: l.value): + meta = _CASE_ACCESS_LABELS.get(level.name, {}) + access_levels.append({ + 'name': level.name, + 'value': level.value, + 'label': meta.get('label', level.name.replace('_', ' ').capitalize()), + 'description': meta.get('description', ''), + }) + + return response_api_success({ + 'permissions': perms, + 'case_access_levels': access_levels, + }) + + +@access_control_blueprint.get('/accessible-cases') +@ac_api_requires(Permissions.server_administrator) +def list_accessible_cases() -> Response: + """Lightweight case picker for the per-user / per-group case-access + modals. + + Returns at most 200 rows ordered by `case_id DESC`, optionally + filtered by a `search` ILIKE on case name + SOC id. Each + candidate is re-checked with `ac_fast_check_current_user_has_case_access` + so an admin who's explicitly removed themselves from a case + still doesn't see it in the picker. + """ + from sqlalchemy import or_ + query = Cases.query.with_entities(Cases.case_id, Cases.name, Cases.soc_id) + search = (request.args.get('search') or '').strip() or None + if search: + needle = f'%{search}%' + query = query.filter(or_(Cases.name.ilike(needle), Cases.soc_id.ilike(needle))) + query = query.order_by(Cases.case_id.desc()).limit(200) + + items: List[Dict[str, Any]] = [] + for row in query.all(): + if not ac_fast_check_current_user_has_case_access( + row.case_id, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): + continue + items.append({ + 'case_id': row.case_id, + 'name': row.name, + 'soc_id': row.soc_id, + }) + + return response_api_success({'data': items}) + + +@access_control_blueprint.post('/recompute-all') +@ac_api_requires(Permissions.server_administrator) +def recompute_all_users_access() -> Response: + """Rebuild the effective-access cache for every user. + + Expensive on large fleets — the legacy UI gated this behind a + confirmation. The page does the same client-side; the endpoint + itself just runs the helper synchronously. + """ + ac_recompute_all_users_effective_ac() + return response_api_success({'message': 'Recomputed effective access for all users'}) diff --git a/source/app/blueprints/rest/v2/manage_routes/case_objects.py b/source/app/blueprints/rest/v2/manage_routes/case_objects.py new file mode 100644 index 000000000..ee3531cfb --- /dev/null +++ b/source/app/blueprints/rest/v2/manage_routes/case_objects.py @@ -0,0 +1,397 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""v2 endpoints for the "Case Objects" admin family. + +A single page in the legacy UI bundles five taxonomy types — asset +types, IOC types, case classifications, case states and evidence +types. The CRUD surface for each is identical in shape (list / get / +create / update / delete with substring search on a single name +field) and the only per-resource variance is the model, schema and +primary-key name. So we have one shared `TaxonomyOperations` class, +instantiate it five times with a per-resource `TaxonomyConfig`, and +register each as its own Flask sub-blueprint under +`/manage/case-objects/<resource>`. + +Adding a sixth taxonomy here is a one-liner: define the config, add +the blueprint to the registration list at the bottom of the file. +""" + +from dataclasses import dataclass +from typing import Any +from typing import Callable +from typing import Iterable +from typing import Optional +from typing import Type + +from flask import Blueprint +from flask import Response +from flask import request +from marshmallow import ValidationError +from sqlalchemy.exc import IntegrityError + +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_paginated +from app.blueprints.rest.endpoints import response_api_success +from app.blueprints.rest.parsing import parse_pagination_parameters +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.iris_engine.utils.tracker import track_activity +from app.models.assets import AssetsType +from app.models.authorization import Permissions +from app.models.cases import CaseClassification +from app.models.cases import CaseState +from app.models.errors import ElementInUseError +from app.models.errors import ObjectNotFoundError +from app.models.evidences import EvidenceTypes +from app.models.models import IocType +from app.schema.marshables import AssetTypeSchema +from app.schema.marshables import CaseClassificationSchema +from app.schema.marshables import CaseStateSchema +from app.schema.marshables import EvidenceTypeSchema +from app.schema.marshables import IocTypeSchema +from app.schema.marshables import store_icon + + +@dataclass(frozen=True) +class TaxonomyConfig: + """Per-resource configuration for `TaxonomyOperations`. + + `pk_name` is the model's primary key column name (not always `id` + — legacy schema choices means we have `asset_id`, `type_id`, + `state_id` and `id` floating around). `search_columns` are the + columns to ILIKE against for the search query parameter, in + order. `protected_check` is an optional callable that lets a + taxonomy refuse a delete for a record the engine considers + structural (e.g. the seeded "Open" / "Closed" case states). + `writable_fields` is the allowlist of input fields the create / + update endpoints will forward to the schema; anything else + (notably the primary key) is dropped before load to close the + mass-assignment vector reported as GHSA-w78h-mx7h-qm3h / + SBA-ADV-20260128-01 / CWE-915. The PK is excluded on purpose: + `update()` forces it from the URL parameter, `create()` lets the + DB assign it. + """ + + url_prefix: str + blueprint_name: str + model: Type[Any] + schema_factory: Callable[[], Any] + pk_name: str + search_columns: Iterable[str] + activity_label: str + writable_fields: Iterable[str] + protected_check: Optional[Callable[[Any], Optional[str]]] = None + + +class TaxonomyOperations: + """REST surface shared by every Case Objects sub-resource. + + Methods read the URL identifier directly off the route — Flask + passes it as keyword `identifier`. The schema is instantiated per + request because Marshmallow stateful validators (e.g. the + auto-injected `verify_unique` post-load hook) sometimes carry + state between calls when reused as a module-level singleton. + """ + + def __init__(self, config: TaxonomyConfig): + self._config = config + self._writable = frozenset(config.writable_fields) + + def _filter_payload(self, data): + """Drop everything not in the per-resource writable allowlist. + + Closes the mass-assignment vector reported as GHSA-w78h-mx7h-qm3h + / SBA-ADV-20260128-01 / CWE-915. The primary key is never in the + allowlist — `update()` forces it from the URL parameter. + """ + if not isinstance(data, dict): + return {} + return {k: v for k, v in data.items() if k in self._writable} + + def _get(self, identifier): + row = self._config.model.query.filter( + getattr(self._config.model, self._config.pk_name) == identifier + ).first() + if row is None: + raise ObjectNotFoundError() + return row + + def search(self): + pagination_parameters = parse_pagination_parameters(request) + query = self._config.model.query + search = (request.args.get('search') or '').strip() or None + if search: + needle = f'%{search}%' + clauses = [] + for column_name in self._config.search_columns: + column = getattr(self._config.model, column_name, None) + if column is not None: + clauses.append(column.ilike(needle)) + if clauses: + from sqlalchemy import or_ + query = query.filter(or_(*clauses)) + paginated = paginate(self._config.model, pagination_parameters, query) + return response_api_paginated(self._config.schema_factory(), paginated) + + def read(self, identifier): + try: + return response_api_success(self._config.schema_factory().dump(self._get(identifier))) + except ObjectNotFoundError: + return response_api_not_found() + + def create(self): + schema = self._config.schema_factory() + try: + request_data = self._filter_payload(request.get_json()) + row = schema.load(request_data) + db_create(row) + track_activity(f'Added {self._config.activity_label} {self._readable(row)}', ctx_less=True) + return response_api_created(schema.dump(row)) + except ValidationError as e: + return response_api_error('Data error', data=e.messages) + + def update(self, identifier): + schema = self._config.schema_factory() + try: + row = self._get(identifier) + request_data = self._filter_payload(request.get_json()) + # Schemas with `verify_unique` post-load look at the PK on + # the loaded instance to skip the uniqueness check against + # the row itself — force the value here so a partial update + # that omits the PK doesn't trip the check. + request_data[self._config.pk_name] = identifier + schema.load(request_data, instance=row, partial=True) + db.session.commit() + track_activity(f'Updated {self._config.activity_label} {self._readable(row)}', ctx_less=True) + return response_api_success(schema.dump(row)) + except ValidationError as e: + return response_api_error('Data error', data=e.messages) + except ObjectNotFoundError: + return response_api_not_found() + + def delete(self, identifier): + try: + row = self._get(identifier) + if self._config.protected_check is not None: + msg = self._config.protected_check(row) + if msg: + raise ElementInUseError(msg) + label = self._readable(row) + try: + db_delete(row) + except IntegrityError: + # FK from cases/iocs/assets back to the taxonomy — the + # legacy UI shows the same message on these. Surface + # 400 rather than letting the exception bubble. + db.session.rollback() + raise ElementInUseError( + f'This {self._config.activity_label} is still referenced and cannot be deleted' + ) + track_activity(f'Deleted {self._config.activity_label} {label}', ctx_less=True) + return response_api_deleted() + except ObjectNotFoundError: + return response_api_not_found() + except ElementInUseError as e: + return response_api_error(e.get_message()) + + def _readable(self, row): + """Pick the human-readable label for activity log lines. + + Each model has a different "display name" column; rather than + threading it through TaxonomyConfig we just try the common + candidates. Falls back to the PK so the activity entry is + still searchable if a taxonomy doesn't match. + """ + for candidate in ('name', 'asset_name', 'type_name', 'state_name'): + value = getattr(row, candidate, None) + if value: + return value + return f'#{getattr(row, self._config.pk_name, "?")}' + + +def _build_blueprint(config: TaxonomyConfig) -> Blueprint: + """Wire a `TaxonomyOperations` instance into a Flask blueprint. + + Keeping the route declarations local to this factory means each + resource gets its own URL prefix (`/asset-types`, `/ioc-types`, + …) without copying the five identical route definitions. + """ + blueprint = Blueprint( + config.blueprint_name, + __name__, + url_prefix=f'/{config.url_prefix}', + ) + operations = TaxonomyOperations(config) + + @blueprint.get('') + @ac_api_requires() + def list_taxonomy() -> Response: + return operations.search() + + @blueprint.post('') + @ac_api_requires(Permissions.server_administrator) + def create_taxonomy() -> Response: + return operations.create() + + @blueprint.get('/<int:identifier>') + @ac_api_requires() + def read_taxonomy(identifier: int) -> Response: + return operations.read(identifier) + + @blueprint.put('/<int:identifier>') + @ac_api_requires(Permissions.server_administrator) + def update_taxonomy(identifier: int) -> Response: + return operations.update(identifier) + + @blueprint.delete('/<int:identifier>') + @ac_api_requires(Permissions.server_administrator) + def delete_taxonomy(identifier: int) -> Response: + return operations.delete(identifier) + + return blueprint + + +def _case_state_protected(row: CaseState) -> Optional[str]: + """Refuse deletes for the seeded "protected" case states. + + Mirrors the legacy guard in `manage_case_state.py` — the engine + relies on Open / Closed existing for case lifecycle. + """ + if getattr(row, 'protected', False): + return 'This case state is protected and cannot be deleted' + return None + + +_CONFIGS = [ + TaxonomyConfig( + url_prefix='asset-types', + blueprint_name='case_objects_asset_types_rest_v2', + model=AssetsType, + schema_factory=AssetTypeSchema, + pk_name='asset_id', + search_columns=('asset_name', 'asset_description'), + activity_label='asset type', + writable_fields=('asset_name', 'asset_description', + 'asset_icon_compromised', 'asset_icon_not_compromised'), + ), + TaxonomyConfig( + url_prefix='ioc-types', + blueprint_name='case_objects_ioc_types_rest_v2', + model=IocType, + schema_factory=IocTypeSchema, + pk_name='type_id', + search_columns=('type_name', 'type_description', 'type_taxonomy'), + activity_label='IOC type', + writable_fields=('type_name', 'type_description', 'type_taxonomy', + 'type_validation_regex', 'type_validation_expect'), + ), + TaxonomyConfig( + url_prefix='case-classifications', + blueprint_name='case_objects_case_classifications_rest_v2', + model=CaseClassification, + schema_factory=CaseClassificationSchema, + pk_name='id', + search_columns=('name', 'name_expanded', 'description'), + activity_label='case classification', + writable_fields=('name', 'name_expanded', 'description'), + ), + TaxonomyConfig( + url_prefix='case-states', + blueprint_name='case_objects_case_states_rest_v2', + model=CaseState, + schema_factory=CaseStateSchema, + pk_name='state_id', + search_columns=('state_name', 'state_description'), + activity_label='case state', + protected_check=_case_state_protected, + writable_fields=('state_name', 'state_description'), + ), + TaxonomyConfig( + url_prefix='evidence-types', + blueprint_name='case_objects_evidence_types_rest_v2', + model=EvidenceTypes, + schema_factory=EvidenceTypeSchema, + pk_name='id', + search_columns=('name', 'description'), + activity_label='evidence type', + writable_fields=('name', 'description'), + ), +] + + +case_objects_blueprint = Blueprint('case_objects_rest_v2', __name__, url_prefix='/case-objects') + +for _config in _CONFIGS: + case_objects_blueprint.register_blueprint(_build_blueprint(_config)) + + +# Asset-type icons ------------------------------------------------------- +# +# Asset types are the only Case Object taxonomy that carries images. Two +# fields (`asset_icon_compromised` / `asset_icon_not_compromised`) store +# filenames under /static/assets/img/graph/<filename>. Uploading icons +# is a separate operation from the JSON CRUD above — multipart/form-data +# doesn't fit cleanly into the shared TaxonomyOperations contract — so +# we expose dedicated upload endpoints. The frontend orchestrates the +# two-step "create asset type → upload icon" flow. + +_ASSET_TYPE_ICON_FIELDS = {'compromised', 'not_compromised'} + + +@case_objects_blueprint.post('/asset-types/<int:identifier>/icon/<string:field>') +@ac_api_requires(Permissions.server_administrator) +def upload_asset_type_icon(identifier: int, field: str) -> Response: + """Upload one of an asset type's two icons. + + `field` is either `compromised` or `not_compromised`. The file + must be sent as multipart form-data under the key `file` and pass + the existing `allowed_file_icon` check (png/svg only). On success + the new filename is persisted on the column + `asset_icon_<field>` and the full updated row is returned so the + frontend can refresh its detail pane in one round-trip. + """ + if field not in _ASSET_TYPE_ICON_FIELDS: + return response_api_error(f"Unknown icon field: {field}") + + asset_type = AssetsType.query.filter(AssetsType.asset_id == identifier).first() + if asset_type is None: + return response_api_not_found() + + upload = request.files.get('file') + if upload is None or not upload.filename: + return response_api_error('No file uploaded') + + stored_filename, message = store_icon(upload) + if stored_filename is None: + return response_api_error(message or 'Icon upload failed') + + column_name = f'asset_icon_{field}' + setattr(asset_type, column_name, stored_filename) + db.session.commit() + track_activity( + f'Updated icon {field} for asset type {asset_type.asset_name}', + ctx_less=True, + ) + return response_api_success(AssetTypeSchema().dump(asset_type)) diff --git a/source/app/blueprints/rest/v2/manage_routes/case_templates.py b/source/app/blueprints/rest/v2/manage_routes/case_templates.py new file mode 100644 index 000000000..f2ab95c19 --- /dev/null +++ b/source/app/blueprints/rest/v2/manage_routes/case_templates.py @@ -0,0 +1,449 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""v2 endpoints for the Case Templates admin family. + +Differences from the legacy `/manage/case-templates/...` surface: + +* Bodies are plain JSON — no more `{case_template_json: "stringified"}` + envelope. The legacy form was a workaround for upload modals that + posted file contents as a single string field; the v2 page reads + the file client-side and POSTs the parsed object directly. +* List supports pagination + ILIKE substring search via `search`. +""" + +from typing import Any +from typing import Dict +from typing import Optional + +from flask import Blueprint +from flask import Response +from flask import request +from marshmallow import ValidationError +from sqlalchemy.exc import IntegrityError + +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_paginated +from app.blueprints.rest.endpoints import response_api_success +from app.blueprints.rest.parsing import parse_pagination_parameters +from app.datamgmt.filtering import paginate +from app.datamgmt.manage.manage_case_templates_db import delete_case_template_by_id +from app.datamgmt.manage.manage_case_templates_db import get_case_template_by_id +from app.datamgmt.manage.manage_case_templates_db import validate_case_template +from app.db import db +from app.iris_engine.utils.tracker import track_activity +from app.models.authorization import Permissions +from app.models.models import CaseTemplate +from app.schema.marshables import CaseTemplateSchema + + +case_templates_blueprint = Blueprint('case_templates_rest_v2', __name__, url_prefix='/case-templates') + + +_SEARCH_COLUMNS = ('name', 'display_name', 'description', 'author', 'title_prefix') + + +def _to_dict(payload: Any) -> Dict[str, Any]: + """Tolerate both v2-native (object) and legacy (`case_template_json`) bodies. + + The legacy IRIS upload modal POSTed the raw file contents as a + string field — keep accepting that so an admin who has an old + template JSON file can paste it via the upload flow without + having to wrap it. The page itself sends a plain object. + """ + if isinstance(payload, dict) and 'case_template_json' in payload and isinstance( + payload['case_template_json'], str + ): + import json as _json + try: + parsed = _json.loads(payload['case_template_json']) + except Exception as exc: + raise ValueError(f'Invalid JSON in case_template_json: {exc}') + return parsed if isinstance(parsed, dict) else {} + return payload if isinstance(payload, dict) else {} + + +def _persist_from_dict(template_dict: Dict[str, Any], *, update_target: Optional[CaseTemplate] = None) -> CaseTemplate: + """Shared validate + load + commit step for create + update. + + Returns the persisted (or refreshed) `CaseTemplate` row. Raises + `ValueError` with a user-facing message on validation failure so + the route layer can surface it cleanly. + """ + is_update = update_target is not None + error = validate_case_template(template_dict, update=is_update) + if error is not None: + raise ValueError(error) + + schema = CaseTemplateSchema() + try: + if is_update: + data = schema.load(template_dict, partial=True) + update_target.update_from_dict(data) + db.session.commit() + return update_target + template_dict.setdefault('created_by_user_id', iris_current_user.id) + data = schema.load(template_dict) + row = CaseTemplate(**data) + db.session.add(row) + db.session.commit() + return row + except ValidationError as exc: + db.session.rollback() + raise ValueError(str(exc.messages)) + except IntegrityError as exc: + db.session.rollback() + raise ValueError(f'Database error: {exc}') + + +def _list_query(): + """SQLAlchemy query feeding the paginated list. + + We `paginate(model=CaseTemplate, ...)` so the existing pagination + helper can apply `order_by` + `sort_dir` against any column on + the model — same shape as every other v2 list endpoint. + """ + query = CaseTemplate.query + search = (request.args.get('search') or '').strip() or None + if search: + from sqlalchemy import or_ + needle = f'%{search}%' + clauses = [] + for column_name in _SEARCH_COLUMNS: + column = getattr(CaseTemplate, column_name, None) + if column is not None: + clauses.append(column.ilike(needle)) + if clauses: + query = query.filter(or_(*clauses)) + return query + + +# ----- Routes ---------------------------------------------------------- + +@case_templates_blueprint.get('') +@ac_api_requires(Permissions.case_templates_read) +def list_case_templates() -> Response: + pagination_parameters = parse_pagination_parameters(request) + paginated = paginate(CaseTemplate, pagination_parameters, _list_query()) + return response_api_paginated(CaseTemplateSchema(), paginated) + + +@case_templates_blueprint.get('/<int:identifier>') +@ac_api_requires(Permissions.case_templates_read) +def get_case_template(identifier: int) -> Response: + row = get_case_template_by_id(identifier) + if row is None: + return response_api_not_found() + return response_api_success(CaseTemplateSchema().dump(row)) + + +@case_templates_blueprint.post('') +@ac_api_requires(Permissions.case_templates_write) +def create_case_template() -> Response: + try: + template_dict = _to_dict(request.get_json() or {}) + except ValueError as exc: + return response_api_error(str(exc)) + + if not template_dict: + return response_api_error('Missing template body') + + try: + row = _persist_from_dict(template_dict) + except ValueError as exc: + return response_api_error('Invalid case template', data=str(exc)) + + track_activity(f"Case template '{row.name}' added", ctx_less=True) + return response_api_created(CaseTemplateSchema().dump(row)) + + +@case_templates_blueprint.put('/<int:identifier>') +@ac_api_requires(Permissions.case_templates_write) +def update_case_template(identifier: int) -> Response: + row = get_case_template_by_id(identifier) + if row is None: + return response_api_not_found() + + try: + template_dict = _to_dict(request.get_json() or {}) + except ValueError as exc: + return response_api_error(str(exc)) + + if not template_dict: + return response_api_error('Missing template body') + + try: + row = _persist_from_dict(template_dict, update_target=row) + except ValueError as exc: + return response_api_error('Invalid case template', data=str(exc)) + + track_activity(f"Case template '{row.name}' updated", ctx_less=True) + return response_api_success(CaseTemplateSchema().dump(row)) + + +@case_templates_blueprint.delete('/<int:identifier>') +@ac_api_requires(Permissions.case_templates_write) +def delete_case_template(identifier: int) -> Response: + row = get_case_template_by_id(identifier) + if row is None: + return response_api_not_found() + name = row.name + delete_case_template_by_id(identifier) + db.session.commit() + track_activity(f"Case template '{name}' deleted", ctx_less=True) + return response_api_deleted() + + +# ----- Schema introspection ------------------------------------------- +# +# Powers the interactive editor on the Settings Case Templates page. +# The frontend needs to know, per field: type, required, optional +# length cap, free-form help. Rather than duplicating that metadata +# client-side we introspect `CaseTemplateSchema` at request time so a +# field added to the Marshmallow schema shows up in the UI without +# any frontend change. +# +# The nested template shapes for tasks + note directories + notes are +# NOT real Marshmallow schemas — `validate_case_template` enforces +# them with hand-rolled checks on raw dicts. We mirror those rules +# here as static descriptors so the frontend renders the same fields +# the validator accepts. When the validator gains a new field this +# descriptor must be updated to match. + +import marshmallow.fields as mf +import marshmallow.validate as mv + + +def _describe_marshmallow_field(field) -> Dict[str, Any]: + """Best-effort projection of a Marshmallow field to a UI descriptor. + + Picks a coarse `kind` the frontend can switch on (`string`, + `text`, `integer`, `list[string]`, `list[object]`) and surfaces + `required`, `allow_none`, `missing` plus any `Length` validator's + max constraint — those are the four hints the form widgets need. + + Everything more specific (e.g. enum membership) is left to the + schema's own `verify_*` hooks at save time; we don't try to + enumerate every Marshmallow validator type. + """ + # The plain `String` field is the catch-all; we treat long-form + # description / summary as `text` (multi-line) by name convention + # since Marshmallow doesn't model that distinction. + if isinstance(field, mf.Integer): + kind = 'integer' + elif isinstance(field, mf.List): + # Inspect the inner type to disambiguate string lists (tags) + # from object lists (note_directories with their `notes` + # children). Anything else falls back to 'list[object]'. + inner = field.inner + if isinstance(inner, mf.String): + kind = 'list[string]' + elif isinstance(inner, mf.Dict): + kind = 'list[object]' + else: + kind = 'list[object]' + elif isinstance(field, mf.Boolean): + kind = 'boolean' + elif isinstance(field, mf.DateTime): + kind = 'datetime' + else: + kind = 'string' + + max_length = None + for validator in field.validators or (): + if isinstance(validator, mv.Length) and validator.max is not None: + max_length = validator.max + break + + return { + 'kind': kind, + 'required': bool(field.required), + 'allow_none': bool(field.allow_none), + 'dump_only': bool(field.dump_only), + 'max_length': max_length, + } + + +# Nested template shapes — these match the rules enforced by +# `validate_case_template` in datamgmt/manage/manage_case_templates_db.py. +_TASK_TEMPLATE_FIELDS = [ + { + 'name': 'title', + 'label': 'Title', + 'kind': 'string', + 'required': True, + 'help': 'Becomes the new task title.', + }, + { + 'name': 'description', + 'label': 'Description', + 'kind': 'text', + 'required': False, + 'help': 'Optional. Appears in the task detail panel.', + }, + { + 'name': 'tags', + 'label': 'Tags', + 'kind': 'list[string]', + 'required': False, + 'help': 'Optional. Comma-separated when applied.', + }, +] + +_NOTE_TEMPLATE_FIELDS = [ + { + 'name': 'title', + 'label': 'Title', + 'kind': 'string', + 'required': True, + }, + { + 'name': 'content', + 'label': 'Content', + 'kind': 'text', + 'required': False, + 'help': 'Markdown is supported.', + }, +] + +_NOTE_DIRECTORY_TEMPLATE_FIELDS = [ + { + 'name': 'title', + 'label': 'Directory name', + 'kind': 'string', + 'required': True, + }, + { + 'name': 'notes', + 'label': 'Notes', + 'kind': 'list[object]', + 'required': False, + 'item_schema': 'note', + }, +] + +# Friendly labels + per-field help text. Keyed by the schema's +# field name so a future rename on the schema only needs to update +# the schema; this dict can stay or be edited deliberately. +_FIELD_LABELS: Dict[str, Dict[str, str]] = { + 'name': { + 'label': 'Name', + 'help': "Short, unique slug. Required.", + }, + 'display_name': { + 'label': 'Display name', + 'help': "Shown in the picker; falls back to `name`.", + }, + 'description': { + 'label': 'Description', + 'help': "Admin-side description, not visible on cases.", + }, + 'author': {'label': 'Author', 'help': 'Optional. Up to 128 characters.'}, + 'title_prefix': { + 'label': 'Case title prefix', + 'help': 'Prepended to the case name on apply. Up to 32 characters.', + }, + 'summary': { + 'label': 'Summary', + 'help': 'Appended to the case description on apply.', + }, + 'tags': {'label': 'Tags', 'help': 'Appended to the case tags on apply.'}, + 'classification': { + 'label': 'Classification', + 'help': "Must match the `name` of an existing case classification.", + }, + 'note_directories': { + 'label': 'Note directories', + 'help': 'Created on the case on apply.', + }, + 'tasks': { + 'label': 'Tasks', + 'help': 'Created on the case on apply, with status "To Do".', + }, +} + +# Field-name overrides where the introspected `kind` is wrong for the +# UI (Marshmallow has no `text` field, but `description` / `summary` +# render better as textareas). Keyed by schema field name. +_KIND_OVERRIDES: Dict[str, str] = { + 'description': 'text', + 'summary': 'text', +} + +# Which item descriptor a `list[object]` field renders inside the +# repeater. Keyed by schema field name; missing entries fall back to +# a generic "object" with no fields, which the UI renders as a +# read-only JSON snippet so the form stays useful for unknown shapes. +_ITEM_SCHEMA_BY_FIELD: Dict[str, str] = { + 'tasks': 'task', + 'note_directories': 'note_directory', +} + + +@case_templates_blueprint.get('/schema') +@ac_api_requires(Permissions.case_templates_read) +def get_case_template_schema() -> Response: + """Describe `CaseTemplateSchema` + nested template shapes. + + Returned shape is consumed by the interactive editor to build the + form. Keeping introspection here (rather than client-side) keeps + the form in lockstep with the backend schema — adding a field to + `CaseTemplateSchema` adds an input on the form on the next reload. + + `tasks` is special-cased: it's stored as raw JSON on the model + and never declared as a Marshmallow field, but the post-modifier + treats it as a structured list. We expose it as if it were a + declared field so the UI can build the same repeater the rest of + the form uses. + """ + schema = CaseTemplateSchema() + fields_out: List[Dict[str, Any]] = [] + + # CaseTemplateSchema.fields is an OrderedDict; preserves + # declaration order, which is the order users see in the form. + for name, field in schema.fields.items(): + descriptor = _describe_marshmallow_field(field) + meta = _FIELD_LABELS.get(name, {}) + kind = _KIND_OVERRIDES.get(name, descriptor['kind']) + entry: Dict[str, Any] = { + 'name': name, + 'label': meta.get('label', name.replace('_', ' ').capitalize()), + 'help': meta.get('help', ''), + **descriptor, + 'kind': kind, + } + item_schema = _ITEM_SCHEMA_BY_FIELD.get(name) + if item_schema is not None: + entry['item_schema'] = item_schema + fields_out.append(entry) + + return response_api_success({ + 'fields': fields_out, + 'item_schemas': { + 'task': _TASK_TEMPLATE_FIELDS, + 'note_directory': _NOTE_DIRECTORY_TEMPLATE_FIELDS, + 'note': _NOTE_TEMPLATE_FIELDS, + }, + }) + + diff --git a/source/app/blueprints/rest/v2/manage_routes/custom_attributes.py b/source/app/blueprints/rest/v2/manage_routes/custom_attributes.py new file mode 100644 index 000000000..4949e7120 --- /dev/null +++ b/source/app/blueprints/rest/v2/manage_routes/custom_attributes.py @@ -0,0 +1,182 @@ +"""v2 admin endpoints for the CustomAttribute taxonomy. + +Each row of `custom_attribute` describes the *schema* attached to one +object type — case, IOC, asset, task, note, evidence, event, client. +`attribute_content` is a JSON document: + + { + "<Tab name>": { + "<Field name>": { + "type": "input_string"|"input_textfield"|"input_checkbox" + |"input_select"|"input_date"|"input_datetime" + |"raw"|"html", + "value": <default value>, + "mandatory": <bool>, + "options": ["only", "for", "input_select"] + } + } + } + +The SPA admin page reads the row, lets the user edit the JSON in a +form (the legacy interface had a freeform JSON editor — we mirror +that), then PATCHes it back. The backend re-runs +`validate_attribute(...)` (same helper the legacy route used) so the +schema can't drift into an unsupported shape and `update_all_attributes` +back-fills the existing object rows with the new tabs / fields so the +detail pages render the new entries on the next load. +""" +from __future__ import annotations + +import json +from typing import Any +from typing import Dict +from typing import List + +from flask import Blueprint +from flask import Response +from flask import request + +from app.blueprints.access_controls import ac_api_requires +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.datamgmt.manage.manage_attribute_db import update_all_attributes +from app.datamgmt.manage.manage_attribute_db import validate_attribute +from app.db import db +from app.iris_engine.utils.tracker import track_activity +from app.models.authorization import Permissions +from app.models.models import CustomAttribute + + +# Object types the legacy code knew about. Mirrors the `attribute_for` +# values stored on existing rows. +_ALLOWED_OBJECT_TYPES = { + 'case', + 'ioc', + 'asset', + 'task', + 'note', + 'evidence', + 'event', + 'client', +} + + +def _serialize(row: CustomAttribute) -> Dict[str, Any]: + return { + 'attribute_id': row.attribute_id, + 'attribute_display_name': row.attribute_display_name, + 'attribute_description': row.attribute_description, + 'attribute_for': row.attribute_for, + 'attribute_content': row.attribute_content or {}, + } + + +custom_attributes_blueprint = Blueprint( + 'custom_attributes_rest_v2', __name__, url_prefix='/custom-attributes' +) + + +@custom_attributes_blueprint.get('') +@ac_api_requires() +def list_custom_attributes() -> Response: + """List every attribute-definition row. + + Readable by any authenticated user because the schema is + needed to render the detail-view tabs (not just by admins). Write + access is still admin-only via PUT below. + """ + object_type = (request.args.get('attribute_for') or '').strip().lower() or None + if object_type and object_type not in _ALLOWED_OBJECT_TYPES: + return response_api_error( + f"Unknown attribute_for filter: {object_type}" + ) + + query = CustomAttribute.query + if object_type is not None: + query = query.filter(CustomAttribute.attribute_for == object_type) + + rows = query.order_by(CustomAttribute.attribute_for.asc()).all() + return response_api_success([_serialize(r) for r in rows]) + + +@custom_attributes_blueprint.get('/<int:identifier>') +@ac_api_requires() +def get_custom_attribute(identifier: int) -> Response: + row = CustomAttribute.query.filter(CustomAttribute.attribute_id == identifier).first() + if row is None: + return response_api_not_found() + return response_api_success(_serialize(row)) + + +@custom_attributes_blueprint.put('/<int:identifier>') +@ac_api_requires(Permissions.server_administrator) +def update_custom_attribute(identifier: int) -> Response: + """Update one attribute-definition row. + + Body shape: + + { + "attribute_content": "<JSON-stringified schema>" | {schema}, + "complete_overwrite": <bool>, # optional + "partial_overwrite": <bool> # optional + } + + `attribute_content` is accepted as either a raw object or a + JSON-encoded string — the legacy admin page sent it stringified; + the new SPA panel will send the object directly. Both paths feed + into `validate_attribute` which expects a string, so we re-encode + when needed. + + `complete_overwrite` / `partial_overwrite` propagate into + `update_all_attributes` so the change is back-filled across every + existing record of the matching object type. + """ + row = CustomAttribute.query.filter(CustomAttribute.attribute_id == identifier).first() + if row is None: + return response_api_not_found() + + 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) + if logs: + return response_api_error('Invalid attribute schema', data=logs) + + previous = row.attribute_content + row.attribute_content = parsed + + if 'attribute_display_name' in body and isinstance(body['attribute_display_name'], str): + row.attribute_display_name = body['attribute_display_name'].strip() or row.attribute_display_name + if 'attribute_description' in body and isinstance(body['attribute_description'], str): + row.attribute_description = body['attribute_description'].strip() or row.attribute_description + + db.session.commit() + + complete_overwrite = bool(body.get('complete_overwrite')) + partial_overwrite = bool(body.get('partial_overwrite')) + update_all_attributes( + row.attribute_for, + partial_overwrite=partial_overwrite, + complete_overwrite=complete_overwrite, + previous_attribute=previous, + ) + + track_activity( + f'Updated custom attribute schema "{row.attribute_display_name}" ({row.attribute_for})', + ctx_less=True, + ) + + return response_api_success(_serialize(row)) diff --git a/source/app/blueprints/rest/v2/manage_routes/customers.py b/source/app/blueprints/rest/v2/manage_routes/customers.py index d969a5b40..c55da5e2f 100644 --- a/source/app/blueprints/rest/v2/manage_routes/customers.py +++ b/source/app/blueprints/rest/v2/manage_routes/customers.py @@ -29,14 +29,21 @@ from app.blueprints.access_controls import ac_api_requires from app.blueprints.access_controls import ac_current_user_has_permission from app.models.authorization import Permissions +from app.schema.marshables import ContactSchema from app.schema.marshables import CustomerSchema from app.models.errors import ObjectNotFoundError from app.models.errors import ElementInUseError +from app.models.errors import BusinessProcessingError from app.business.customers import customers_create_with_user from app.business.customers import customers_filter from app.business.customers import customers_get from app.business.customers import customers_update from app.business.customers import customers_delete +from app.business.customers_contacts import customers_contacts_create +from app.business.customers_contacts import customers_contacts_delete +from app.business.customers_contacts import customers_contacts_get +from app.business.customers_contacts import customers_contacts_list +from app.business.customers_contacts import customers_contacts_update from app.blueprints.iris_user import iris_current_user from app.blueprints.rest.parsing import parse_pagination_parameters @@ -49,7 +56,16 @@ def __init__(self): def search(self): pagination_parameters = parse_pagination_parameters(request) user_is_server_administrator = ac_current_user_has_permission(Permissions.server_administrator) - customers = customers_filter(iris_current_user, pagination_parameters, user_is_server_administrator) + # `search` is an ILIKE substring match across name + description. + # Empty / whitespace-only values fall through as None so they + # don't add a no-op `LIKE '%%'` clause. + search = (request.args.get('search') or '').strip() or None + customers = customers_filter( + iris_current_user, + pagination_parameters, + user_is_server_administrator, + search=search, + ) return response_api_paginated(self._schema, customers) def create(self): @@ -66,6 +82,13 @@ def read(self, identifier): try: customer = customers_get(identifier) result = self._schema.dump(customer) + # Embed contacts so the UI can render the detail panel + # without a second round-trip. Matches the legacy GET + # /manage/customers/<id> behaviour the SvelteKit client + # already types for (`Customer.contacts?: ...`). + result['contacts'] = ContactSchema().dump( + customers_contacts_list(identifier), many=True + ) return response_api_success(result) except ObjectNotFoundError: return response_api_not_found() @@ -97,9 +120,89 @@ def delete(identifier): return response_api_error(e.get_message()) +class CustomersContactsOperations: + """REST surface for `/customers/<id>/contacts[/<contact_id>]`. + + Mirrors `CustomersOperations` shape (search / create / read / + update / delete) so the routing layer below stays uniform. Every + handler verifies that the contact (when one is named) actually + belongs to the parent customer — a contact_id from another + customer would otherwise authorise edits via a sibling URL. + """ + + def __init__(self): + self._schema = ContactSchema() + + @staticmethod + def _ensure_parent(client_id): + customers_get(client_id) + + def _ensure_owns(self, client_id, contact_id): + contact = customers_contacts_get(contact_id) + if contact.client_id != client_id: + raise ObjectNotFoundError() + return contact + + def list(self, client_id): + try: + self._ensure_parent(client_id) + return response_api_success( + self._schema.dump(customers_contacts_list(client_id), many=True) + ) + except ObjectNotFoundError: + return response_api_not_found() + + def create(self, client_id): + try: + self._ensure_parent(client_id) + request_data = request.get_json() or {} + # Force the parent id from the URL — the schema requires + # client_id, and trusting the body would let a client write + # contacts under a different customer. + request_data['client_id'] = client_id + contact = self._schema.load(request_data) + customers_contacts_create(contact) + return response_api_created(self._schema.dump(contact)) + except ValidationError as e: + return response_api_error('Data error', data=e.messages) + except ObjectNotFoundError: + return response_api_not_found() + + def read(self, client_id, contact_id): + try: + contact = self._ensure_owns(client_id, contact_id) + return response_api_success(self._schema.dump(contact)) + except ObjectNotFoundError: + return response_api_not_found() + + def update(self, client_id, contact_id): + try: + contact = self._ensure_owns(client_id, contact_id) + request_data = request.get_json() or {} + request_data['client_id'] = client_id + self._schema.load(request_data, instance=contact, partial=True) + customers_contacts_update(contact) + return response_api_success(self._schema.dump(contact)) + except ValidationError as e: + return response_api_error('Data error', data=e.messages) + except ObjectNotFoundError: + return response_api_not_found() + + def delete(self, client_id, contact_id): + try: + contact = self._ensure_owns(client_id, contact_id) + customers_contacts_delete(contact) + return response_api_deleted() + except ObjectNotFoundError: + return response_api_not_found() + except (ElementInUseError, BusinessProcessingError) as e: + return response_api_error(e.get_message()) + + customers_blueprint = Blueprint('customers_rest_v2', __name__, url_prefix='/customers') customers_operations = CustomersOperations() +customers_contacts_operations = CustomersContactsOperations() @customers_blueprint.get('') @@ -130,3 +233,33 @@ def put_customer(identifier): @ac_api_requires(Permissions.customers_write) def delete_user(identifier): return customers_operations.delete(identifier) + + +@customers_blueprint.get('/<int:identifier>/contacts') +@ac_api_requires(Permissions.customers_read) +def list_customer_contacts(identifier): + return customers_contacts_operations.list(identifier) + + +@customers_blueprint.post('/<int:identifier>/contacts') +@ac_api_requires(Permissions.customers_write) +def create_customer_contact(identifier): + return customers_contacts_operations.create(identifier) + + +@customers_blueprint.get('/<int:identifier>/contacts/<int:contact_id>') +@ac_api_requires(Permissions.customers_read) +def get_customer_contact(identifier, contact_id): + return customers_contacts_operations.read(identifier, contact_id) + + +@customers_blueprint.put('/<int:identifier>/contacts/<int:contact_id>') +@ac_api_requires(Permissions.customers_write) +def put_customer_contact(identifier, contact_id): + return customers_contacts_operations.update(identifier, contact_id) + + +@customers_blueprint.delete('/<int:identifier>/contacts/<int:contact_id>') +@ac_api_requires(Permissions.customers_write) +def delete_customer_contact(identifier, contact_id): + return customers_contacts_operations.delete(identifier, contact_id) diff --git a/source/app/blueprints/rest/v2/manage_routes/groups.py b/source/app/blueprints/rest/v2/manage_routes/groups.py index 1a05ed379..aae4a21ae 100644 --- a/source/app/blueprints/rest/v2/manage_routes/groups.py +++ b/source/app/blueprints/rest/v2/manage_routes/groups.py @@ -16,27 +16,59 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +"""v2 endpoints for the Groups admin surface. + +Extends the previously-thin CRUD to support the Access Control page: + + * paginated `GET /` with ILIKE search across group name + description + * `GET /<id>/members` + `PUT /<id>/members` + `DELETE /<id>/members/<user_id>` + * `GET /<id>/cases-access` + * `POST /<id>/cases-access` — supports `auto_follow_cases=true` or + an explicit `cases_list` + * `DELETE /<id>/cases-access` — bulk removal + +Permission bitmask edits go through the existing `PUT /<id>` route +since they're a regular column on the Group model — no separate +endpoint needed. + +All routes are gated on `Permissions.server_administrator` and +delegate to the existing legacy data + business helpers; no +duplication. +""" + from flask import Blueprint +from flask import Response from flask import request from marshmallow import ValidationError +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_success +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_deleted -from app.blueprints.access_controls import ac_api_requires -from app.schema.marshables import AuthorizationGroupSchema +from app.blueprints.rest.endpoints import response_api_success from app.business.groups import groups_create +from app.business.groups import groups_delete from app.business.groups import groups_get from app.business.groups import groups_update -from app.business.groups import groups_delete +from app.datamgmt.manage.manage_groups_db import add_all_cases_access_to_group +from app.datamgmt.manage.manage_groups_db import add_case_access_to_group +from app.datamgmt.manage.manage_groups_db import get_group_details +from app.datamgmt.manage.manage_groups_db import get_group_with_members +from app.datamgmt.manage.manage_groups_db import remove_cases_access_from_group +from app.datamgmt.manage.manage_groups_db import remove_user_from_group +from app.datamgmt.manage.manage_groups_db import update_group_members +from app.datamgmt.manage.manage_users_db import get_user +from app.db import db +from app.iris_engine.access_control.utils import ac_ldp_group_removal +from app.iris_engine.access_control.utils import ac_ldp_group_update +from app.iris_engine.access_control.utils import ac_recompute_effective_ac_from_users_list from app.models.authorization import Permissions from app.models.authorization import ac_flag_match_mask from app.models.errors import BusinessProcessingError from app.models.errors import ObjectNotFoundError -from app.blueprints.iris_user import iris_current_user -from app.iris_engine.access_control.utils import ac_ldp_group_update +from app.schema.marshables import AuthorizationGroupSchema class Groups: @@ -44,6 +76,49 @@ class Groups: def __init__(self): self._schema = AuthorizationGroupSchema() + def search(self): + """Paginated group list with ILIKE search. + + Search applies to name + description with OR semantics so the + page can filter by either. + """ + page = request.args.get('page', default=1, type=int) + per_page = request.args.get('per_page', default=25, type=int) + per_page = max(1, min(per_page, 200)) + search = (request.args.get('search') or '').strip() or None + + from app.models.authorization import Group + from sqlalchemy import or_ + query = Group.query + if search: + needle = f'%{search}%' + query = query.filter( + or_(Group.group_name.ilike(needle), Group.group_description.ilike(needle)) + ) + paginated = query.order_by(Group.group_id.asc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + # Hydrate `group_members` + `group_permissions_list` on each + # row so the page can render the master list without a second + # round-trip per row. `get_group_with_members` is the legacy + # helper that attaches these. + items = [] + for g in paginated.items: + hydrated = get_group_with_members(g.group_id) + if hydrated is not None: + items.append(self._schema.dump(hydrated)) + else: + items.append(self._schema.dump(g)) + + return response_api_success({ + 'total': paginated.total, + 'data': items, + 'last_page': paginated.pages, + 'current_page': paginated.page, + 'next_page': paginated.next_num if paginated.has_next else None, + }) + def create(self): try: request_data = request.get_json() @@ -57,7 +132,10 @@ def create(self): def read(self, identifier): try: group = groups_get(identifier) - result = self._schema.dump(group) + # Re-hydrate with members + permission list so the editor + # has everything it needs to render in one shot. + hydrated = get_group_with_members(identifier) or group + result = self._schema.dump(hydrated) return response_api_success(result) except ObjectNotFoundError: return response_api_not_found() @@ -68,11 +146,20 @@ def update(self, identifier): request_data = request.get_json() request_data['group_id'] = identifier updated_group = self._schema.load(request_data, instance=group, partial=True) - if not ac_flag_match_mask(request_data['group_permissions'], Permissions.server_administrator.value) and ac_ldp_group_update(iris_current_user.id): - return response_api_error('That might not be a good idea Dave', data='Update the group permissions will lock you out') + # Lock-out prevention copied from the legacy handler: + # demoting the server-admin bit on a group the caller + # belongs to would orphan their own admin access. The + # backing helper checks group membership transitively. + if 'group_permissions' in request_data and not ac_flag_match_mask( + request_data['group_permissions'], Permissions.server_administrator.value + ) and ac_ldp_group_update(iris_current_user.id): + return response_api_error( + 'That might not be a good idea Dave', + data='Updating the group permissions will lock you out', + ) groups_update() - result = self._schema.dump(updated_group) - return response_api_success(result) + hydrated = get_group_with_members(identifier) or updated_group + return response_api_success(self._schema.dump(hydrated)) except ValidationError as e: return response_api_error('Data error', data=e.messages) @@ -96,6 +183,12 @@ def delete(self, identifier): groups = Groups() +@groups_blueprint.get('') +@ac_api_requires(Permissions.server_administrator) +def search_groups(): + return groups.search() + + @groups_blueprint.post('') @ac_api_requires(Permissions.server_administrator) def create_group(): @@ -118,3 +211,179 @@ def update_group(identifier): @ac_api_requires(Permissions.server_administrator) def delete_group(identifier): return groups.delete(identifier) + + +# ---- Members --------------------------------------------------------- + +def _require_group(group_id: int): + """Internal: load with members + 404 if missing.""" + group = get_group_with_members(group_id) + if group is None: + return None, response_api_not_found() + return group, None + + +@groups_blueprint.get('/<int:identifier>/members') +@ac_api_requires(Permissions.server_administrator) +def list_group_members(identifier: int) -> Response: + group, err = _require_group(identifier) + if err is not None: + return err + return response_api_success({'data': group.group_members}) + + +@groups_blueprint.put('/<int:identifier>/members') +@ac_api_requires(Permissions.server_administrator) +def put_group_members(identifier: int) -> Response: + """Replace the group's member list. + + Body: `{members: [user_id, ...]}` (or legacy `group_members`). + The data-layer helper performs an in-place diff (remove orphans, + insert newcomers, leave existing in place) so this isn't an + O(N²) churn even with hundreds of users. + """ + body = request.get_json() or {} + members = body.get('members') + if members is None: + members = body.get('group_members') + if not isinstance(members, list): + return response_api_error('`members` must be a list of user IDs') + for uid in members: + if not isinstance(uid, int): + return response_api_error(f'Invalid user id: {uid}') + + group, err = _require_group(identifier) + if err is not None: + return err + + update_group_members(group, members) + refreshed = get_group_with_members(identifier) + return response_api_success(AuthorizationGroupSchema().dump(refreshed)) + + +@groups_blueprint.delete('/<int:identifier>/members/<int:user_id>') +@ac_api_requires(Permissions.server_administrator) +def remove_group_member(identifier: int, user_id: int) -> Response: + """Drop a single user from the group. + + Refuses if the caller removing this user would lock the caller + out — `ac_ldp_group_removal` does the transitive check. + """ + group, err = _require_group(identifier) + if err is not None: + return err + user = get_user(user_id) + if user is None: + return response_api_not_found() + + if ac_ldp_group_removal(user_id=user.id, group_id=group.group_id): + return response_api_error( + "I cannot let you do that Dave", + data='Removing yourself from the group would lose your access rights', + ) + + remove_user_from_group(group, user) + refreshed = get_group_with_members(identifier) + return response_api_success(AuthorizationGroupSchema().dump(refreshed)) + + +# ---- Group case access ---------------------------------------------- + +@groups_blueprint.get('/<int:identifier>/cases-access') +@ac_api_requires(Permissions.server_administrator) +def get_group_cases_access(identifier: int) -> Response: + """List the group's per-case grants. + + The hydrated `Group` object exposes the rows under + `group_cases_access`; we return the full projection so the page + can render the picker without computing anything client-side. + """ + group = get_group_details(identifier) + if group is None: + return response_api_not_found() + return response_api_success(AuthorizationGroupSchema().dump(group)) + + +@groups_blueprint.post('/<int:identifier>/cases-access') +@ac_api_requires(Permissions.server_administrator) +def add_group_cases_access(identifier: int) -> Response: + """Grant the group access over cases. + + Body shape variants: + * `{access_level, auto_follow_cases: true}` — grant access on + every existing case and persist the auto-follow flag so + future cases get the grant automatically. + * `{access_level, cases_list: [int, ...]}` — explicit list. + + Either path triggers a recompute of effective access for every + member of the group. + """ + body = request.get_json() or {} + + access_level = body.get('access_level') + if not isinstance(access_level, int): + try: + access_level = int(access_level) + except (TypeError, ValueError): + return response_api_error('`access_level` must be an int') + + auto_follow = bool(body.get('auto_follow_cases')) + cases_list = body.get('cases_list') + + if not auto_follow and not isinstance(cases_list, list): + return response_api_error('`cases_list` must be a list when `auto_follow_cases` is false') + + group, err = _require_group(identifier) + if err is not None: + return err + + if auto_follow: + group, logs = add_all_cases_access_to_group(group, access_level) + group.group_auto_follow = True + group.group_auto_follow_access_level = access_level + db.session.commit() + else: + group, logs = add_case_access_to_group(group, cases_list, access_level) + group.group_auto_follow = False + db.session.commit() + + if not group: + return response_api_error(logs) + + refreshed = get_group_details(identifier) + ac_recompute_effective_ac_from_users_list(refreshed.group_members) + + return response_api_success(AuthorizationGroupSchema().dump(refreshed)) + + +@groups_blueprint.delete('/<int:identifier>/cases-access') +@ac_api_requires(Permissions.server_administrator) +def delete_group_cases_access(identifier: int) -> Response: + """Drop the group's grants for `cases`. + + Body: `{cases: [int, ...]}`. Members' effective access is + recomputed before the response returns so subsequent reads see + the new state without a manual recompute call. + """ + body = request.get_json() or {} + cases = body.get('cases') + if not isinstance(cases, list): + return response_api_error('`cases` must be a list') + + group, err = _require_group(identifier) + if err is not None: + return err + + try: + success, logs = remove_cases_access_from_group(group.group_id, cases) + db.session.commit() + except Exception as exc: + db.session.rollback() + return response_api_error(str(exc)) + + if not success: + return response_api_error(logs) + + ac_recompute_effective_ac_from_users_list(group.group_members) + refreshed = get_group_details(identifier) + return response_api_success(AuthorizationGroupSchema().dump(refreshed)) diff --git a/source/app/blueprints/rest/v2/manage_routes/modules.py b/source/app/blueprints/rest/v2/manage_routes/modules.py new file mode 100644 index 000000000..ae8f10c5e --- /dev/null +++ b/source/app/blueprints/rest/v2/manage_routes/modules.py @@ -0,0 +1,174 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""v2 REST endpoints for the modules ("plugins") admin page. + +The list payload is intentionally small (a few dozen rows at most across +any real-world deployment) so there's no `page` / `per_page` here — +just a flat list. Add pagination if a deployment ever ships more than +~200 modules. +""" + +from flask import Blueprint +from flask import 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.business.modules import module_get_detail +from app.business.modules import modules_add +from app.business.modules import modules_delete +from app.business.modules import modules_disable +from app.business.modules import modules_enable +from app.business.modules import modules_export_config +from app.business.modules import modules_hooks_list +from app.business.modules import modules_import_config +from app.business.modules import modules_list +from app.business.modules import modules_set_parameter +from app.models.authorization import Permissions +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError + + +modules_blueprint = Blueprint('modules_rest_v2', __name__, url_prefix='/modules') + + +@modules_blueprint.get('') +@ac_api_requires(Permissions.server_administrator) +def list_modules(): + page = request.args.get('page', default=1, type=int) + per_page = request.args.get('per_page', default=25, type=int) + return response_api_success(modules_list(page=page, per_page=per_page)) + + +@modules_blueprint.post('') +@ac_api_requires(Permissions.server_administrator) +def add_module(): + request_data = request.get_json() or {} + module_name = request_data.get('module_name') + + try: + module = modules_add(module_name) + return response_api_created(module_get_detail(module.id)) + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +@modules_blueprint.get('/<int:identifier>') +@ac_api_requires(Permissions.server_administrator) +def get_module(identifier): + try: + return response_api_success(module_get_detail(identifier)) + except ObjectNotFoundError: + return response_api_not_found() + + +@modules_blueprint.delete('/<int:identifier>') +@ac_api_requires(Permissions.server_administrator) +def delete_module(identifier): + try: + modules_delete(identifier) + 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()) + + +@modules_blueprint.post('/<int:identifier>/enable') +@ac_api_requires(Permissions.server_administrator) +def enable_module(identifier): + try: + modules_enable(identifier) + return response_api_success(module_get_detail(identifier)) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +@modules_blueprint.post('/<int:identifier>/disable') +@ac_api_requires(Permissions.server_administrator) +def disable_module(identifier): + try: + modules_disable(identifier) + return response_api_success(module_get_detail(identifier)) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +@modules_blueprint.put('/<int:identifier>/parameters/<path:param_name>') +@ac_api_requires(Permissions.server_administrator) +def set_module_parameter(identifier, param_name): + """Update one parameter. + + The legacy API encoded `mod_id##param_name` as base64 in the URL — + we don't carry that scheme over: the v2 URL puts `module_id` and + `param_name` as discrete path segments. `path:` matches slashes so + parameter names that contain `/` (rare but legal) round-trip cleanly. + """ + request_data = request.get_json() or {} + if 'parameter_value' not in request_data: + return response_api_error('Missing field: parameter_value') + + try: + return response_api_success( + modules_set_parameter(identifier, param_name, request_data['parameter_value']) + ) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +@modules_blueprint.get('/<int:identifier>/export-config') +@ac_api_requires(Permissions.server_administrator) +def export_module_config(identifier): + try: + return response_api_success(modules_export_config(identifier)) + except ObjectNotFoundError: + return response_api_not_found() + + +@modules_blueprint.post('/<int:identifier>/import-config') +@ac_api_requires(Permissions.server_administrator) +def import_module_config(identifier): + request_data = request.get_json() or {} + payload = request_data.get('module_configuration') + if payload is None: + return response_api_error('Missing field: module_configuration') + + try: + return response_api_success(modules_import_config(identifier, payload)) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message(), data=e.get_data()) + + +@modules_blueprint.get('/hooks') +@ac_api_requires(Permissions.server_administrator) +def list_modules_hooks(): + page = request.args.get('page', default=1, type=int) + per_page = request.args.get('per_page', default=25, type=int) + return response_api_success(modules_hooks_list(page=page, per_page=per_page)) diff --git a/source/app/blueprints/rest/v2/manage_routes/report_templates.py b/source/app/blueprints/rest/v2/manage_routes/report_templates.py new file mode 100644 index 000000000..bb6e57619 --- /dev/null +++ b/source/app/blueprints/rest/v2/manage_routes/report_templates.py @@ -0,0 +1,621 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""v2 endpoints for the Report Templates admin family. + +Differences from the legacy `/manage/templates/...` surface: + +* Bodies for metadata-only updates are plain JSON instead of + multipart — uploads stay multipart, but renames / description + edits / language swap don't need to re-upload the file. +* Read endpoints paginate + accept a `search` query parameter. +* `POST /<id>/render` triggers the existing + `generate_investigation_report` / `generate_activities_report` + flow and streams the resulting file back inline. The current user + must hold `read_only`-or-better on the target case — same gate + every case-scoped v2 endpoint uses. +* `GET /accessible-cases` powers the case picker in the editor, also + scoped to cases the caller can access. +* `GET /schema` ships the model field metadata + the seeded + `languages` / `report_types` rows so the form can render selects + without extra round-trips. +""" + +import os +import random +import string +import tempfile +from datetime import datetime +from typing import Any +from typing import Dict +from typing import List +from typing import Optional + +from flask import Blueprint +from flask import Response +from flask import request +from flask import send_file +from sqlalchemy.exc import IntegrityError +from werkzeug.utils import secure_filename + +from app import app +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.access_controls import ac_fast_check_current_user_has_case_access +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_paginated +from app.blueprints.rest.endpoints import response_api_success +from app.blueprints.rest.parsing import parse_pagination_parameters +from app.business.reports.reports import generate_activities_report +from app.business.reports.reports import generate_investigation_report +from app.datamgmt.filtering import paginate +from app.db import db +from app.iris_engine.utils.tracker import track_activity +from app.models.authorization import CaseAccessLevel +from app.models.authorization import Permissions +from app.models.authorization import User +from app.models.cases import Cases +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.models.models import CaseTemplateReport +from app.models.models import Languages +from app.models.models import ReportType +from app.util import FileRemover + + +report_templates_blueprint = Blueprint( + 'report_templates_rest_v2', __name__, url_prefix='/report-templates' +) + + +# Same extension allowlist as the legacy upload — DocxGenerator handles +# .doc/.docx, the Jinja+text pipeline handles .md/.html. Anything else +# would just blow up in the renderer with a less helpful error. +_ALLOWED_EXTENSIONS = {'md', 'html', 'doc', 'docx'} + +# Same file remover used by the legacy report routes — cleans up the +# rendered file from /tmp once the response has streamed. +_FILE_REMOVER = FileRemover() + + +def _allowed_filename(filename: str) -> bool: + return ( + '.' in filename + and filename.rsplit('.', 1)[1].lower() in _ALLOWED_EXTENSIONS + ) + + +def _random_filename(extension: str) -> str: + letters = string.ascii_lowercase + stem = ''.join(random.choice(letters) for _ in range(18)) + return f'{stem}{extension}' + + +def _serialize_template(template: CaseTemplateReport) -> Dict[str, Any]: + """Single source of truth for the template's JSON projection. + + Used by every list / read / update / render-success response so + the frontend sees one shape everywhere. Includes the joined + `created_by` user name and `language` / `report_type` names since + the picker UI uses them as readable labels. + """ + return { + 'id': template.id, + 'name': template.name, + 'description': template.description, + 'naming_format': template.naming_format, + 'internal_reference': template.internal_reference, + 'date_created': template.date_created.isoformat() if template.date_created else None, + 'created_by_user_id': template.created_by_user_id, + 'created_by': template.created_by_user.name if template.created_by_user else None, + 'language_id': template.language_id, + 'language_code': template.language.code if template.language else None, + 'language_name': template.language.name if template.language else None, + 'report_type_id': template.report_type_id, + 'report_type_name': template.report_type.name if template.report_type else None, + } + + +def _get_template(identifier: int) -> CaseTemplateReport: + row = CaseTemplateReport.query.filter(CaseTemplateReport.id == identifier).first() + if row is None: + raise ObjectNotFoundError() + return row + + +def _list_query(): + query = CaseTemplateReport.query + search = (request.args.get('search') or '').strip() or None + if search: + from sqlalchemy import or_ + needle = f'%{search}%' + query = query.filter( + or_( + CaseTemplateReport.name.ilike(needle), + CaseTemplateReport.description.ilike(needle), + CaseTemplateReport.naming_format.ilike(needle), + ) + ) + return query + + +# ----- CRUD ----------------------------------------------------------- + +@report_templates_blueprint.get('') +@ac_api_requires(Permissions.server_administrator) +def list_report_templates() -> Response: + pagination_parameters = parse_pagination_parameters(request) + paginated = paginate(CaseTemplateReport, pagination_parameters, _list_query()) + # `response_api_paginated` takes a Marshmallow schema; we don't + # have one for this model, so synthesise the envelope directly. + items = [_serialize_template(t) for t in paginated.items] + return response_api_success({ + 'total': paginated.total, + 'data': items, + 'last_page': paginated.pages, + 'current_page': paginated.page, + 'next_page': paginated.next_num if paginated.has_next else None, + }) + + +@report_templates_blueprint.get('/<int:identifier>') +@ac_api_requires(Permissions.server_administrator) +def get_report_template(identifier: int) -> Response: + try: + return response_api_success(_serialize_template(_get_template(identifier))) + except ObjectNotFoundError: + return response_api_not_found() + + +@report_templates_blueprint.post('') +@ac_api_requires(Permissions.server_administrator) +def create_report_template() -> Response: + """Multipart upload: metadata in form fields + the template file + under the `file` key. Mirrors the legacy endpoint so existing + template files round-trip without modification. + """ + upload = request.files.get('file') + if upload is None or not upload.filename: + return response_api_error('No file uploaded') + + if not _allowed_filename(upload.filename): + return response_api_error( + f"File extension not allowed. Use one of: {', '.join(sorted(_ALLOWED_EXTENSIONS))}" + ) + + name = (request.form.get('name') or '').strip() + if not name: + return response_api_error('Field `name` is required') + + description = (request.form.get('description') or '').strip() + naming_format = (request.form.get('naming_format') or '').strip() + language_id = request.form.get('language_id', type=int) + report_type_id = request.form.get('report_type_id', type=int) + + if language_id is None or report_type_id is None: + return response_api_error('`language_id` and `report_type_id` are required') + + if Languages.query.filter(Languages.id == language_id).first() is None: + return response_api_error('Unknown language_id') + if ReportType.query.filter(ReportType.id == report_type_id).first() is None: + return response_api_error('Unknown report_type_id') + + safe_name = secure_filename(upload.filename) + _, extension = os.path.splitext(safe_name) + stored_filename = _random_filename(extension) + + try: + upload.save(os.path.join(app.config['TEMPLATES_PATH'], stored_filename)) + except Exception as exc: + return response_api_error(f'Unable to save uploaded file: {exc}') + + template = CaseTemplateReport( + name=name, + description=description, + naming_format=naming_format, + internal_reference=stored_filename, + language_id=language_id, + report_type_id=report_type_id, + created_by_user_id=iris_current_user.id, + date_created=datetime.utcnow(), + ) + try: + db.session.add(template) + db.session.commit() + except IntegrityError as exc: + db.session.rollback() + # Best-effort cleanup so we don't orphan the just-uploaded + # file when the metadata insert fails. The fs delete is + # secondary — if it fails the orphan stays but the API still + # surfaces the original IntegrityError. + try: + os.unlink(os.path.join(app.config['TEMPLATES_PATH'], stored_filename)) + except Exception: + pass + return response_api_error(f'Database error: {exc}') + + track_activity(f"Report template '{template.name}' added", ctx_less=True) + return response_api_created(_serialize_template(template)) + + +@report_templates_blueprint.put('/<int:identifier>') +@ac_api_requires(Permissions.server_administrator) +def update_report_template(identifier: int) -> Response: + """Metadata-only update. Takes JSON so admins can rename / re-tag + a template without re-uploading its file. To replace the file + itself, use `PUT /<id>/file` (multipart) — kept separate so this + route can stay plain JSON. + """ + try: + template = _get_template(identifier) + except ObjectNotFoundError: + return response_api_not_found() + + body = request.get_json() or {} + if not isinstance(body, dict): + return response_api_error('Body must be a JSON object') + + if 'name' in body: + name = (body.get('name') or '').strip() + if not name: + return response_api_error('`name` cannot be empty') + template.name = name + + for plain_field in ('description', 'naming_format'): + if plain_field in body: + template.__setattr__(plain_field, (body.get(plain_field) or '').strip()) + + if 'language_id' in body: + lang_id = body['language_id'] + if not isinstance(lang_id, int) or Languages.query.filter(Languages.id == lang_id).first() is None: + return response_api_error('Unknown language_id') + template.language_id = lang_id + + if 'report_type_id' in body: + rt_id = body['report_type_id'] + if not isinstance(rt_id, int) or ReportType.query.filter(ReportType.id == rt_id).first() is None: + return response_api_error('Unknown report_type_id') + template.report_type_id = rt_id + + try: + db.session.commit() + except IntegrityError as exc: + db.session.rollback() + return response_api_error(f'Database error: {exc}') + + track_activity(f"Report template '{template.name}' updated", ctx_less=True) + return response_api_success(_serialize_template(template)) + + +@report_templates_blueprint.delete('/<int:identifier>') +@ac_api_requires(Permissions.server_administrator) +def delete_report_template(identifier: int) -> Response: + """Delete metadata + the underlying file. Best-effort file + unlink: if it fails (already removed manually, FS permissions + drift) the row still goes — the legacy endpoint did the same. + """ + try: + template = _get_template(identifier) + except ObjectNotFoundError: + return response_api_not_found() + + name = template.name + fs_error: Optional[str] = None + try: + os.unlink(os.path.join(app.config['TEMPLATES_PATH'], template.internal_reference)) + except FileNotFoundError: + pass + except Exception as exc: + fs_error = str(exc) + + CaseTemplateReport.query.filter(CaseTemplateReport.id == identifier).delete() + db.session.commit() + + track_activity(f"Report template '{name}' deleted", ctx_less=True) + + if fs_error: + return response_api_success({ + 'warning': f'Template row deleted but the file could not be removed: {fs_error}' + }) + return response_api_deleted() + + +# ----- File replacement ---------------------------------------------- + +@report_templates_blueprint.put('/<int:identifier>/file') +@ac_api_requires(Permissions.server_administrator) +def replace_report_template_file(identifier: int) -> Response: + """Replace the underlying template file in place. + + Multipart: a single `file` field, same allowlist as the create + endpoint. The renderer reads the file from disk every time a + report is generated, so an in-place swap is safe between + requests — no caching to invalidate. + + Strategy: write the new file under a fresh random name first, + then atomically flip `internal_reference` to point at it and + unlink the old file. If the upload or DB update fails we drop + the staged file, leaving the original intact. + """ + try: + template = _get_template(identifier) + except ObjectNotFoundError: + return response_api_not_found() + + upload = request.files.get('file') + if upload is None or not upload.filename: + return response_api_error('No file uploaded') + + if not _allowed_filename(upload.filename): + return response_api_error( + f"File extension not allowed. Use one of: {', '.join(sorted(_ALLOWED_EXTENSIONS))}" + ) + + safe_name = secure_filename(upload.filename) + _, extension = os.path.splitext(safe_name) + new_filename = _random_filename(extension) + new_path = os.path.join(app.config['TEMPLATES_PATH'], new_filename) + + try: + upload.save(new_path) + except Exception as exc: + return response_api_error(f'Unable to save uploaded file: {exc}') + + old_filename = template.internal_reference + template.internal_reference = new_filename + try: + db.session.commit() + except IntegrityError as exc: + db.session.rollback() + # DB write lost — discard the just-uploaded file so we don't + # orphan it on disk. The old file is still pointed at by the + # template row, so the render flow remains usable. + try: + os.unlink(new_path) + except Exception: + pass + return response_api_error(f'Database error: {exc}') + + # Best-effort cleanup of the previous file. If this fails the + # only downside is an orphan on disk — the row already points at + # the new file, so users see the update. Don't fail the request. + try: + os.unlink(os.path.join(app.config['TEMPLATES_PATH'], old_filename)) + except FileNotFoundError: + pass + except Exception: + # Log via track_activity so the orphan is traceable. + track_activity( + f"Report template '{template.name}' file replaced; " + f"could not remove previous file '{old_filename}'", + ctx_less=True, + ) + else: + track_activity( + f"Report template '{template.name}' file replaced", + ctx_less=True, + ) + + return response_api_success(_serialize_template(template)) + + +# ----- File download -------------------------------------------------- + +@report_templates_blueprint.get('/<int:identifier>/download') +@ac_api_requires(Permissions.server_administrator) +def download_report_template(identifier: int) -> Response: + """Stream the raw template file back so an admin can inspect / + edit / copy it locally. Filename uses the human-readable + `template.name` with the stored extension.""" + try: + template = _get_template(identifier) + except ObjectNotFoundError: + return response_api_not_found() + + fpath = os.path.join(app.config['TEMPLATES_PATH'], template.internal_reference) + if not os.path.exists(fpath): + return response_api_error('Template file is missing on disk') + + _, extension = os.path.splitext(template.internal_reference) + # Drop the leading dot and any path separators a malicious name + # could carry over from earlier renames. + safe_display = secure_filename(template.name) or 'report_template' + return send_file(fpath, as_attachment=True, download_name=f'{safe_display}{extension}') + + +# ----- Render against a case ----------------------------------------- + +@report_templates_blueprint.post('/<int:identifier>/render') +@ac_api_requires(Permissions.server_administrator) +def render_report_template(identifier: int) -> Response: + """Render the template against a real case and stream the result. + + Body: `{case_id: int, safe_mode?: bool}`. The current user must + hold `read_only`-or-better on the case — that gate matches what + the legacy `/case/report/generate-{investigation,activities}/...` + routes enforce via `ac_requires_case_identifier`. Dispatch + between investigation / activities is driven by the template's + `report_type` so the UI doesn't have to know which generator to + call. + """ + try: + template = _get_template(identifier) + except ObjectNotFoundError: + return response_api_not_found() + + body = request.get_json() or {} + case_id = body.get('case_id') + if not isinstance(case_id, int): + return response_api_error('Missing or invalid case_id') + + safe_mode = bool(body.get('safe_mode')) + + if not ac_fast_check_current_user_has_case_access( + case_id, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): + # Mask "exists but you can't see it" as 404, same convention + # as the v2 case routes. + return response_api_not_found() + + if Cases.query.filter(Cases.case_id == case_id).first() is None: + return response_api_not_found() + + tmp_dir = tempfile.mkdtemp() + report_type_name = template.report_type.name if template.report_type else '' + + try: + if report_type_name == 'Investigation': + fpath = generate_investigation_report(case_id, template.id, safe_mode, tmp_dir) + elif report_type_name == 'Activities': + fpath = generate_activities_report(case_id, template.id, safe_mode, tmp_dir) + else: + return response_api_error( + f"Unsupported report type '{report_type_name}' on this template" + ) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as exc: + return response_api_error(exc.get_message(), data=exc.get_data()) + + response = send_file(fpath, as_attachment=True) + _FILE_REMOVER.cleanup_once_done(response, tmp_dir) + return response + + +# ----- Case picker for the render preview ---------------------------- + +@report_templates_blueprint.get('/accessible-cases') +@ac_api_requires(Permissions.server_administrator) +def list_accessible_cases() -> Response: + """Lightweight list of cases the current user can render against. + + Returns at most 200 rows ordered by `case_id DESC`, optionally + filtered by a `search` ILIKE on case name + SOC id. The page + refreshes this list on every keystroke (debounced); 200 is the + inflexion point past which a user is expected to start typing. + + We still re-check `ac_fast_check_current_user_has_case_access` + per row even though the route is admin-gated, so an admin who + explicitly removed themselves from a case still doesn't see it. + """ + from sqlalchemy import or_ + query = Cases.query.with_entities(Cases.case_id, Cases.name, Cases.soc_id) + search = (request.args.get('search') or '').strip() or None + if search: + needle = f'%{search}%' + query = query.filter(or_(Cases.name.ilike(needle), Cases.soc_id.ilike(needle))) + query = query.order_by(Cases.case_id.desc()).limit(200) + + items: List[Dict[str, Any]] = [] + for row in query.all(): + if not ac_fast_check_current_user_has_case_access( + row.case_id, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ): + continue + items.append({ + 'case_id': row.case_id, + 'name': row.name, + 'soc_id': row.soc_id, + }) + + return response_api_success({'data': items}) + + +# ----- Schema + lookups ---------------------------------------------- + +# Friendly labels + help keyed by model field name. The list endpoint +# powers the interactive editor — adding a column on +# CaseTemplateReport just needs an entry here and a render in the +# page. +_FIELD_META = { + 'name': {'label': 'Name', 'help': 'Required. Shown in the picker.'}, + 'description': {'label': 'Description', 'help': "Admin-side description."}, + 'naming_format': { + 'label': 'Output filename format', + 'help': 'Supports tags: %date%, %customer%, %case_name%, %code_name%.', + }, + 'language_id': {'label': 'Language', 'help': 'Used by the renderer for locale-aware formatting.'}, + 'report_type_id': { + 'label': 'Report type', + 'help': "'Investigation' renders the full case export; 'Activities' renders the activity log.", + }, +} + + +@report_templates_blueprint.get('/schema') +@ac_api_requires(Permissions.server_administrator) +def get_report_template_schema() -> Response: + """Editor metadata + seeded lookups in a single round-trip. + + Languages and report types are seeded at install time and only + grow when the operator adds new ones via SQL; bundling them with + the schema keeps the form rendering free of extra requests on + open. + """ + languages = [ + {'id': lang.id, 'name': lang.name, 'code': lang.code} + for lang in Languages.query.order_by(Languages.name).all() + ] + report_types = [ + {'id': rt.id, 'name': rt.name} + for rt in ReportType.query.order_by(ReportType.name).all() + ] + + fields = [ + {'name': 'name', 'kind': 'string', 'required': True, **_FIELD_META['name']}, + {'name': 'description', 'kind': 'text', 'required': False, **_FIELD_META['description']}, + { + 'name': 'naming_format', + 'kind': 'string', + 'required': False, + **_FIELD_META['naming_format'], + }, + { + 'name': 'language_id', + 'kind': 'select', + 'required': True, + 'options_ref': 'languages', + **_FIELD_META['language_id'], + }, + { + 'name': 'report_type_id', + 'kind': 'select', + 'required': True, + 'options_ref': 'report_types', + **_FIELD_META['report_type_id'], + }, + ] + + return response_api_success({ + 'fields': fields, + 'lookups': { + 'languages': languages, + 'report_types': report_types, + }, + 'allowed_extensions': sorted(_ALLOWED_EXTENSIONS), + 'naming_format_tags': ['%date%', '%customer%', '%case_name%', '%code_name%'], + }) + + +# Keep User import "used" — it's referenced indirectly through the +# CaseTemplateReport.created_by_user relationship in the serialiser +# but Python linters can't follow that. +_ = User diff --git a/source/app/blueprints/rest/v2/manage_routes/server.py b/source/app/blueprints/rest/v2/manage_routes/server.py index 50ead3641..302265be1 100644 --- a/source/app/blueprints/rest/v2/manage_routes/server.py +++ b/source/app/blueprints/rest/v2/manage_routes/server.py @@ -15,17 +15,85 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -from flask import Response + +"""v2 endpoints for server-wide settings + the DB backup trigger. + +Layout mirrors the rest of the v2 manage surface: a single +`ServerOperations` class, thin routes that delegate. + +`PUT /server/settings` accepts a partial body — only the fields the +admin actually changed need to be sent. The full row is returned on +success so the UI can refresh in one round-trip. + +`POST /server/backups/db` is the v2 replacement for the legacy GET +`/manage/server/backups/make-db`. The legacy GET stays for any +external automation calling it today; the v2 route is POST because +backup is a side-effecting action that shouldn't ride on GET. +""" + +import marshmallow from flask import Blueprint +from flask import Response +from flask import request from app import app -from app.blueprints.rest.endpoints import response_api_success +from app import celery +from app.blueprints.access_controls import ac_api_requires from app.blueprints.rest.endpoints import response_api_error +from app.blueprints.rest.endpoints import response_api_success +from app.datamgmt.manage.manage_srv_settings_db import get_server_settings_as_dict +from app.datamgmt.manage.manage_srv_settings_db import get_srv_settings +from app.db import db +from app.iris_engine.backup.backup import backup_iris_db +from app.iris_engine.mail.outbound import mail_send_system +from app.iris_engine.mail.secrets import encrypt_secret +from app.iris_engine.updater.updater import remove_periodic_update_checks +from app.iris_engine.updater.updater import setup_periodic_update_checks +from app.iris_engine.utils.tracker import track_activity +from app.models.authorization import Permissions +from app.schema.marshables import ServerSettingsSchema +from dictdiffer import diff + + +# Mail passwords are held as ciphertext in the DB; the schema marks +# these fields `load_only=True` so a GET never leaks even the +# ciphertext. Instead the read path emits *_password_set booleans so +# the SPA can render a "•••••" placeholder for a set field vs an +# empty input for an unset one. +_MAIL_PASSWORD_FIELDS = ('mail_smtp_password', 'mail_imap_password') + + +def _mail_password_flags(settings) -> dict: + """Compact `{<field>_set: bool}` map for the mail password fields.""" + return { + f'{field}_set': bool(getattr(settings, field, None)) + for field in _MAIL_PASSWORD_FIELDS + } + + +def _encrypt_mail_passwords_in_body(body: dict) -> None: + """In-place: wrap any plaintext mail password with Fernet. + + Called before the Marshmallow schema load so the encrypted string + is what actually lands in the DB. An empty-string value means the + admin wants to CLEAR the password (map to None). Missing keys are + left alone — partial update, keep the current ciphertext. + """ + for field in _MAIL_PASSWORD_FIELDS: + if field not in body: + continue + raw = body[field] + if raw is None or raw == '': + body[field] = None + continue + body[field] = encrypt_secret(raw) class ServerOperations: def __init__(self): - pass + self._schema = ServerSettingsSchema() + + # ----- Authentication ------------------------------------------ @staticmethod def get_authentication_settings(): @@ -38,6 +106,155 @@ def get_authentication_settings(): except Exception as e: return response_api_error("Data error", data=str(e)) + # ----- Server settings ---------------------------------------- + + def read_settings(self) -> Response: + """Return the full settings row + a small `versions` block. + + The version block isn't on `ServerSettings`; it's read straight + from `app.config` + the alembic head. Bundling the two lets the + page render its read-only top-of-page strip without a second + round-trip. + + Mail passwords are load-only on the schema so the dump never + includes them; we attach `*_password_set` booleans instead so + the SPA can render placeholder inputs for set-but-hidden + fields. + """ + settings = get_srv_settings() + from app.datamgmt.manage.manage_srv_settings_db import get_alembic_revision + + settings_dump = self._schema.dump(settings) + settings_dump.update(_mail_password_flags(settings)) + + return response_api_success({ + 'settings': settings_dump, + 'versions': { + 'iris_version': app.config.get('IRIS_VERSION'), + 'api_min': app.config.get('API_MIN_VERSION'), + 'api_max': app.config.get('API_MAX_VERSION'), + 'module_interface_min': app.config.get('MODULES_INTERFACE_MIN_VERSION'), + 'module_interface_max': app.config.get('MODULES_INTERFACE_MAX_VERSION'), + 'db_revision': get_alembic_revision(), + }, + }) + + def update_settings(self) -> Response: + """Partial-update the singleton settings row. + + The schema is loaded with `partial=True` so the admin can send + only the fields they changed. We compute a diff against the + pre-update dump so the activity-log entry mentions exactly + which keys moved — useful when chasing "who turned off MFA?". + Mirrors the legacy `/manage/settings/update` behaviour 1:1 + (including the periodic-update-check side effect when the + `enable_updates_check` flag flips). + """ + if not request.is_json: + return response_api_error('Invalid request') + + body = request.get_json() or {} + settings = get_srv_settings() + original_update_check = settings.enable_updates_check + + # Wrap plaintext mail passwords in Fernet BEFORE the schema + # load — the DB column should never hold a plaintext value. + _encrypt_mail_passwords_in_body(body) + # Snapshot mail interval so we can nudge the beat scheduler + # if the operator changes it below. + old_imap_interval = settings.mail_imap_poll_interval_sec + old_imap_enabled = settings.mail_imap_enabled + + try: + original_dump = self._schema.dump(settings) + differences = list(diff(original_dump, body)) + changes = [ + {d[1]: d[2]} for d in differences if d[0] == 'change' + ] + updated = self._schema.load(body, instance=settings, partial=True) + db.session.commit() + + # Periodic update-check Celery task is added/removed only + # when the toggle actually flips. Reading the cached value + # *before* the load+commit is critical — `updated` is the + # same instance as `settings`, so its attribute is already + # the new value once load() returns. + if original_update_check != updated.enable_updates_check: + if updated.enable_updates_check: + setup_periodic_update_checks(celery) + else: + remove_periodic_update_checks() + + # If the IMAP interval or enabled flag flipped, refresh + # the beat schedule so the next tick uses the new value + # without a worker restart. + if (updated.mail_imap_poll_interval_sec != old_imap_interval + or updated.mail_imap_enabled != old_imap_enabled): + try: + from app.iris_engine.mail.inbound import _register_mail_beat_schedule + _register_mail_beat_schedule(celery) + except Exception: + app.logger.exception('Failed to refresh mail beat schedule') + + track_activity(f'Server settings updated: {changes}', ctx_less=True) + # Re-cache the dump on app.config so other code paths that + # read `app.config['SERVER_SETTINGS']` see the new values + # without an extra DB hit. The legacy route did the same. + settings_dump = self._schema.dump(updated) + settings_dump.update(_mail_password_flags(updated)) + app.config['SERVER_SETTINGS'] = settings_dump + return response_api_success(settings_dump) + + except marshmallow.exceptions.ValidationError as exc: + return response_api_error('Data error', data=exc.messages) + + # ----- Mail test-send ------------------------------------------ + + @staticmethod + def send_test_mail() -> Response: + """Send a probe mail via the current SMTP config. + + Body: `{"to": "user@example.com"}`. Runs synchronously (not + via the Celery task) so the admin sees the outcome in the + same request — this is a diagnostic path, not the normal + delivery path. + """ + body = request.get_json(silent=True) or {} + to_addr = (body.get('to') or '').strip() + if not to_addr or '@' not in to_addr: + return response_api_error('to must be a valid email address') + try: + delivered = mail_send_system( + [to_addr], + subject='[IRIS] SMTP test message', + body='This is a test email from IRIS. ' + 'If you received it, your SMTP configuration works.\n', + ) + except Exception as exc: + return response_api_error('SMTP delivery failed', data=str(exc)) + if not delivered: + return response_api_error( + 'SMTP is not configured or is disabled — check the mail ' + 'section on the Server Settings page.' + ) + track_activity(f'Sent SMTP test email to {to_addr}', ctx_less=True) + return response_api_success({'delivered': True, 'to': to_addr}) + + # ----- Database backup ----------------------------------------- + + @staticmethod + def make_db_backup() -> Response: + """Trigger a synchronous Postgres dump. + + Returns the log lines from the dump runner on success so the + admin can see what got backed up where. On failure the same + log lines come back as `data` on the error envelope. + """ + has_error, logs = backup_iris_db() + if has_error: + return response_api_error('Backup failed', data=logs) + return response_api_success({'logs': logs}) + server_blueprint = Blueprint("server_rest_v2", __name__, url_prefix="/server") @@ -47,3 +264,33 @@ def get_authentication_settings(): @server_blueprint.get("/authentication-settings") def server_get_authsettings() -> Response: return server_operations.get_authentication_settings() + + +@server_blueprint.get('/settings') +@ac_api_requires(Permissions.server_administrator) +def server_get_settings() -> Response: + return server_operations.read_settings() + + +@server_blueprint.put('/settings') +@ac_api_requires(Permissions.server_administrator) +def server_put_settings() -> Response: + return server_operations.update_settings() + + +@server_blueprint.post('/backups/db') +@ac_api_requires(Permissions.server_administrator) +def server_make_db_backup() -> Response: + return server_operations.make_db_backup() + + +@server_blueprint.post('/mail/test-send') +@ac_api_requires(Permissions.server_administrator) +def server_send_test_mail() -> Response: + return server_operations.send_test_mail() + + +# Silence the unused-import linter — `get_server_settings_as_dict` is +# re-exported here so any future v2 route that needs the cached dict +# (rather than the ORM row) finds it under the conventional name. +_ = get_server_settings_as_dict diff --git a/source/app/blueprints/rest/v2/manage_routes/taxonomies.py b/source/app/blueprints/rest/v2/manage_routes/taxonomies.py new file mode 100644 index 000000000..5c8389b54 --- /dev/null +++ b/source/app/blueprints/rest/v2/manage_routes/taxonomies.py @@ -0,0 +1,215 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""v2 read-only taxonomy endpoints. + +The seven taxonomies here are seed data: small tables (typically +<20 rows) populated at install time, occasionally extended via SQL +or by admin scripts. The UI only needs to *read* them — case/IOC/ +asset/alert modals look up the rows to populate dropdowns. + +A single file with one blueprint per resource (mounted under +`/api/v2/manage/<resource>`) keeps these next to the heavier +`case_objects.py` taxonomies for discoverability. Each blueprint +exposes only `GET /` with optional pagination + ILIKE `search` — no +write methods. Adding write methods later only needs the existing +`Permissions.server_administrator` decorator and a Marshmallow +`load_instance=True` schema. +""" + +from typing import Any +from typing import Iterable +from typing import Type + +from flask import Blueprint +from flask import Response +from flask import request +from sqlalchemy import or_ + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.rest.endpoints import response_api_success +from app.datamgmt.filtering import paginate +from app.blueprints.rest.parsing import parse_pagination_parameters +from app.models.alerts import AlertResolutionStatus +from app.models.alerts import AlertStatus +from app.models.alerts import Severity +from app.models.assets import AnalysisStatus +from app.models.authorization import Permissions +from app.models.incidents import IncidentStatus +from app.models.iocs import Tlp +from app.models.models import EventCategory +from app.models.models import TaskStatus +from app.schema.marshables import AlertResolutionSchema +from app.schema.marshables import AlertStatusSchema +from app.schema.marshables import AnalysisStatusSchema +from app.schema.marshables import EventCategorySchema +from app.schema.marshables import IncidentStatusSchema +from app.schema.marshables import SeveritySchema +from app.schema.marshables import TaskStatusSchema +from app.schema.marshables import TlpSchema + + +def _list_endpoint( + model: Type[Any], + schema_factory, + search_columns: Iterable[str], + order_column: str, +): + """Build a `GET /` handler for a single taxonomy. + + Returns a paginated envelope so the frontend's generic + list-state plumbing works out of the box. `search` is an ILIKE + against the columns listed; `order_column` is the deterministic + sort applied before paginating. + """ + def handler() -> Response: + pagination_parameters = parse_pagination_parameters(request) + query = model.query + search = (request.args.get('search') or '').strip() or None + if search: + needle = f'%{search}%' + clauses = [] + for column_name in search_columns: + column = getattr(model, column_name, None) + if column is not None: + clauses.append(column.ilike(needle)) + if clauses: + query = query.filter(or_(*clauses)) + order_column_obj = getattr(model, order_column, None) + if order_column_obj is not None: + query = query.order_by(order_column_obj.asc()) + paginated = paginate(model, pagination_parameters, query) + return response_api_success({ + 'total': paginated.total, + 'data': schema_factory().dump(paginated.items, many=True), + 'last_page': paginated.pages, + 'current_page': paginated.page, + 'next_page': paginated.next_num if paginated.has_next else None, + }) + + return handler + + +def _build_readonly_blueprint( + url_prefix: str, + blueprint_name: str, + model: Type[Any], + schema_factory, + search_columns: Iterable[str], + order_column: str, +) -> Blueprint: + bp = Blueprint(blueprint_name, __name__, url_prefix=f'/{url_prefix}') + + @bp.get('') + @ac_api_requires() + def list_route() -> Response: + return _list_endpoint(model, schema_factory, search_columns, order_column)() + + return bp + + +# Each taxonomy gets its own blueprint so URLs stay readable in logs +# and existing-prefix-based proxy rules can target them individually. +severities_blueprint = _build_readonly_blueprint( + url_prefix='severities', + blueprint_name='severities_rest_v2', + model=Severity, + schema_factory=SeveritySchema, + search_columns=('severity_name', 'severity_description'), + order_column='severity_id', +) + +tlp_blueprint = _build_readonly_blueprint( + url_prefix='tlp', + blueprint_name='tlp_rest_v2', + model=Tlp, + schema_factory=TlpSchema, + search_columns=('tlp_name',), + order_column='tlp_id', +) + +alert_statuses_blueprint = _build_readonly_blueprint( + url_prefix='alert-statuses', + blueprint_name='alert_statuses_rest_v2', + model=AlertStatus, + schema_factory=AlertStatusSchema, + search_columns=('status_name', 'status_description'), + order_column='status_id', +) + +alert_resolutions_blueprint = _build_readonly_blueprint( + url_prefix='alert-resolutions', + blueprint_name='alert_resolutions_rest_v2', + model=AlertResolutionStatus, + schema_factory=AlertResolutionSchema, + search_columns=('resolution_status_name', 'resolution_status_description'), + order_column='resolution_status_id', +) + +# Incident statuses (Open / Investigating / Dismissed / Escalated) — +# seeded at boot. Exposed here so the incident detail page can populate +# the status dropdown without hard-coding the four rows. +incident_statuses_blueprint = _build_readonly_blueprint( + url_prefix='incident-statuses', + blueprint_name='incident_statuses_rest_v2', + model=IncidentStatus, + schema_factory=IncidentStatusSchema, + search_columns=('status_name', 'status_description'), + order_column='status_id', +) + +analysis_statuses_blueprint = _build_readonly_blueprint( + url_prefix='analysis-statuses', + blueprint_name='analysis_statuses_rest_v2', + model=AnalysisStatus, + schema_factory=AnalysisStatusSchema, + search_columns=('name',), + order_column='id', +) + +event_categories_blueprint = _build_readonly_blueprint( + url_prefix='event-categories', + blueprint_name='event_categories_rest_v2', + model=EventCategory, + schema_factory=EventCategorySchema, + search_columns=('name',), + order_column='id', +) + +task_statuses_blueprint = _build_readonly_blueprint( + url_prefix='task-statuses', + blueprint_name='task_statuses_rest_v2', + model=TaskStatus, + schema_factory=TaskStatusSchema, + search_columns=('status_name', 'status_description'), + order_column='id', +) + + +# A single parent blueprint groups them under /api/v2/manage so the +# `manage.py` registration line stays a single import per family. +taxonomies_blueprint = Blueprint('taxonomies_rest_v2', __name__) + +taxonomies_blueprint.register_blueprint(severities_blueprint) +taxonomies_blueprint.register_blueprint(tlp_blueprint) +taxonomies_blueprint.register_blueprint(alert_statuses_blueprint) +taxonomies_blueprint.register_blueprint(alert_resolutions_blueprint) +taxonomies_blueprint.register_blueprint(incident_statuses_blueprint) +taxonomies_blueprint.register_blueprint(analysis_statuses_blueprint) +taxonomies_blueprint.register_blueprint(event_categories_blueprint) +taxonomies_blueprint.register_blueprint(task_statuses_blueprint) diff --git a/source/app/blueprints/rest/v2/manage_routes/users.py b/source/app/blueprints/rest/v2/manage_routes/users.py index d59a4b6d6..0fc7e9c9a 100644 --- a/source/app/blueprints/rest/v2/manage_routes/users.py +++ b/source/app/blueprints/rest/v2/manage_routes/users.py @@ -16,24 +16,92 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +"""v2 endpoints for the Users admin surface. + +Extends the previously-thin CRUD with the subresources the Access +Control page needs: + + * paginated `GET /` with ILIKE search across login + display name + * `GET /<id>/groups` + `PUT /<id>/groups` — group membership + * `GET /<id>/customers` + `PUT /<id>/customers` — customer access + * `GET /<id>/cases-access` — list explicit cases + * `POST /<id>/cases-access` / `DELETE /<id>/cases-access` — bulk edit + * `POST /<id>/activate` / `POST /<id>/deactivate` + * `POST /<id>/api-key/renew` + * `POST /<id>/mfa/reset` + * `POST /<id>/recompute-access` + * `GET /<id>/audit` — effective access trace + +All routes are gated on `Permissions.server_administrator`. The +implementations delegate to the existing legacy data + business +helpers — no duplication, just thin v2 wrappers. +""" + +import secrets +from typing import Any +from typing import Dict +from typing import List + from flask import Blueprint +from flask import Response from flask import request from marshmallow import ValidationError 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_success +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_deleted -from app.schema.marshables import UserSchemaForAPIV2 -from app.models.authorization import Permissions -from app.models.errors import ObjectNotFoundError -from app.models.errors import BusinessProcessingError +from app.blueprints.rest.endpoints import response_api_success from app.business.users import users_create +from app.business.users import users_delete from app.business.users import users_get +from app.business.users import users_reset_mfa from app.business.users import users_update -from app.business.users import users_delete +from app.business.groups import groups_exist +from app.datamgmt.manage.manage_users_db import add_case_access_to_user +from app.datamgmt.manage.manage_users_db import get_filtered_users +from app.datamgmt.manage.manage_users_db import get_user +from app.datamgmt.manage.manage_users_db import get_user_details +from app.datamgmt.manage.manage_users_db import remove_cases_access_from_user +from app.datamgmt.manage.manage_users_db import update_user_customers +from app.datamgmt.manage.manage_users_db import update_user_groups +from app.db import db +from app.iris_engine.access_control.utils import ac_recompute_effective_ac +from app.iris_engine.access_control.utils import ac_trace_effective_user_permissions +from app.iris_engine.access_control.utils import ac_trace_user_effective_cases_access_2 +from app.iris_engine.utils.tracker import track_activity +from app.models.authorization import Permissions +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.schema.marshables import UserSchemaForAPIV2 + + +# Allowlist of fields an administrator may write when creating or updating a +# user via the v2 admin endpoints. Anything else the caller tries to sneak in +# (uuid, mfa_secrets, webauthn_credentials, mfa_setup_complete, api_key, +# external_id, ...) is silently dropped before the schema is loaded. Closes +# the mass-assignment vector reported as GHSA-w78h-mx7h-qm3h / +# SBA-ADV-20260128-01 / CWE-915. +_ADMIN_USER_WRITABLE_FIELDS = { + 'user_id', + 'user_active', + 'user_name', + 'user_login', + 'user_email', + 'user_password', + 'user_isadmin', + 'user_is_service_account', + 'user_primary_organisation_id', + 'user_roles_str', +} + + +def _filter_admin_user_payload(data): + if not isinstance(data, dict): + return {} + return {k: v for k, v in data.items() if k in _ADMIN_USER_WRITABLE_FIELDS} class Users: @@ -41,9 +109,43 @@ class Users: def __init__(self): self._schema = UserSchemaForAPIV2() + def search(self): + """Paginated user list with ILIKE search on login + display name. + + Search applies to either column with an OR so admins can find + someone by typing either the friendly name or the login slug. + """ + page = request.args.get('page', default=1, type=int) + per_page = request.args.get('per_page', default=25, type=int) + per_page = max(1, min(per_page, 200)) + search = (request.args.get('search') or '').strip() or None + + # `get_filtered_users` accepts user_name + user_login as + # independent ILIKE filters; calling it twice would be ugly so + # we pass the same needle as both and ORM_collapses them via + # the function's existing `and_` reduction. To preserve OR + # semantics we instead query manually here. + from app.models.authorization import User + from sqlalchemy import or_ + query = User.query + if search: + needle = f'%{search}%' + query = query.filter(or_(User.name.ilike(needle), User.user.ilike(needle))) + paginated = query.order_by(User.id.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + return response_api_success({ + 'total': paginated.total, + 'data': [self._schema.dump(u) for u in paginated.items], + 'last_page': paginated.pages, + 'current_page': paginated.page, + 'next_page': paginated.next_num if paginated.has_next else None, + }) + def create(self): try: - request_data = request.get_json() + request_data = _filter_admin_user_payload(request.get_json()) request_data['user_id'] = 0 request_data['user_active'] = request_data.get('user_active', True) user = self._schema.load(request_data) @@ -65,7 +167,7 @@ def update(self, identifier): try: user = users_get(identifier) - request_data = request.get_json() + request_data = _filter_admin_user_payload(request.get_json()) request_data['user_id'] = identifier new_user = self._schema.load(request_data, instance=user, partial=True) user_updated = users_update(new_user, request_data.get('user_password')) @@ -94,6 +196,12 @@ def delete(self, identifier): users_blueprint = Blueprint('users_rest_v2', __name__, url_prefix='/users') +@users_blueprint.get('') +@ac_api_requires(Permissions.server_administrator) +def search_users(): + return users.search() + + @users_blueprint.post('') @ac_api_requires(Permissions.server_administrator) def create_user(): @@ -102,7 +210,7 @@ def create_user(): @users_blueprint.get('/<int:identifier>') @ac_api_requires(Permissions.server_administrator) -def get_user(identifier): +def get_user_endpoint(identifier): return users.read(identifier) @@ -116,3 +224,346 @@ def put_user(identifier): @ac_api_requires(Permissions.server_administrator) def delete_user(identifier): return users.delete(identifier) + + +# ---- Subresources ---------------------------------------------------- + +def _require_user(user_id: int): + """Internal: load + 404 if missing.""" + user = get_user(user_id) + if user is None: + return None, response_api_not_found() + return user, None + + +@users_blueprint.get('/<int:identifier>/groups') +@ac_api_requires(Permissions.server_administrator) +def list_user_groups(identifier: int) -> Response: + """Return the user's group membership as a list of `{id, name}`. + + Powers the "Groups" tab on the User edit modal and the membership + picker. We don't add a "groups available to add" sub-endpoint + here — the page already loads the full group list elsewhere. + """ + user, err = _require_user(identifier) + if err is not None: + return err + return response_api_success({ + 'data': [ + {'group_id': g.group_id, 'group_name': g.group_name} + for g in user.groups + ] + }) + + +@users_blueprint.put('/<int:identifier>/groups') +@ac_api_requires(Permissions.server_administrator) +def put_user_groups(identifier: int) -> Response: + """Replace the user's group membership. + + Body: `{groups: [int, ...]}`. Each group id is validated to exist + before any DB write. Matches the legacy "groups_membership" key + by also accepting that for back-compat. + """ + body = request.get_json() or {} + groups = body.get('groups') + if groups is None: + groups = body.get('groups_membership') + if not isinstance(groups, list): + return response_api_error('`groups` must be a list of group IDs') + + user, err = _require_user(identifier) + if err is not None: + return err + + for gid in groups: + if not isinstance(gid, int) or not groups_exist(gid): + return response_api_error(f'Unknown group id: {gid}') + + update_user_groups(identifier, groups) + track_activity(f'groups membership of user {identifier} updated', ctx_less=True) + return response_api_success(get_user_details(identifier)) + + +@users_blueprint.put('/<int:identifier>/customers') +@ac_api_requires(Permissions.server_administrator) +def put_user_customers(identifier: int) -> Response: + """Replace the user's customer membership. + + Body: `{customers: [int, ...]}`. The data-layer call mirrors the + legacy `update_user_customers`; here we just normalise the body. + """ + body = request.get_json() or {} + customers = body.get('customers') + if customers is None: + customers = body.get('customers_membership') + if not isinstance(customers, list): + return response_api_error('`customers` must be a list of customer IDs') + for cid in customers: + if not isinstance(cid, int): + return response_api_error(f'Invalid customer id: {cid}') + + user, err = _require_user(identifier) + if err is not None: + return err + + update_user_customers(user_id=identifier, customers=customers) + track_activity(f'customers membership of user {identifier} updated', ctx_less=True) + return response_api_success(get_user_details(identifier)) + + +@users_blueprint.get('/<int:identifier>/cases-access') +@ac_api_requires(Permissions.server_administrator) +def get_user_cases_access(identifier: int) -> Response: + """List the user's *explicit* per-case access rows. + + Effective access (after merging group / org grants) is exposed + by the audit endpoint below — this one shows just what the admin + can directly edit. + """ + details = get_user_details(user_id=identifier) + if details is None: + return response_api_not_found() + # `get_user_details` returns v2-shaped keys; fall back to the + # legacy key the older serialiser used. + cases_access = details.get('user_cases_access', details.get('cases_access', [])) + return response_api_success({'data': cases_access}) + + +@users_blueprint.post('/<int:identifier>/cases-access') +@ac_api_requires(Permissions.server_administrator) +def add_user_cases_access(identifier: int) -> Response: + """Grant `access_level` over `cases_list` to the user. + + Body: `{cases_list: [int, ...], access_level: int}`. Existing + grants for the same case are overwritten in place — the + underlying helper handles both insert + update. + """ + body = request.get_json() or {} + cases_list = body.get('cases_list') + access_level = body.get('access_level') + if not isinstance(cases_list, list): + return response_api_error('`cases_list` must be a list') + if not isinstance(access_level, int): + try: + access_level = int(access_level) + except (TypeError, ValueError): + return response_api_error('`access_level` must be an int') + + user, err = _require_user(identifier) + if err is not None: + return err + + user, logs = add_case_access_to_user(user, cases_list, access_level) + if not user: + return response_api_error(logs) + + track_activity( + f'case access level {access_level} for case(s) {cases_list} set for user {user.user}', + ctx_less=True, + ) + return response_api_success(get_user_details(identifier)) + + +@users_blueprint.delete('/<int:identifier>/cases-access') +@ac_api_requires(Permissions.server_administrator) +def delete_user_cases_access(identifier: int) -> Response: + """Drop explicit case grants for `cases`. + + Body: `{cases: [int, ...]}`. DELETE with a body is unusual but + intentional: removing a list of grants is conceptually a single + bulk operation and forcing it through GET-with-query or POST + would be even noisier. + """ + body = request.get_json() or {} + cases = body.get('cases') + if not isinstance(cases, list): + return response_api_error('`cases` must be a list') + + user, err = _require_user(identifier) + if err is not None: + return err + + try: + success, logs = remove_cases_access_from_user(user.id, cases) + db.session.commit() + except Exception as exc: + db.session.rollback() + return response_api_error(str(exc)) + + if not success: + return response_api_error(logs) + + track_activity( + f'cases access for case(s) {cases} deleted for user {user.user}', + ctx_less=True, + ) + return response_api_success(get_user_details(identifier)) + + +@users_blueprint.post('/<int:identifier>/activate') +@ac_api_requires(Permissions.server_administrator) +def activate_user(identifier: int) -> Response: + user, err = _require_user(identifier) + if err is not None: + return err + user.active = True + db.session.commit() + track_activity(f'user {user.user} activated', ctx_less=True) + return response_api_success(Users()._schema.dump(user)) + + +@users_blueprint.post('/<int:identifier>/deactivate') +@ac_api_requires(Permissions.server_administrator) +def deactivate_user(identifier: int) -> Response: + """Disable a user account. + + Refuses if the caller is trying to deactivate themselves — the + legacy route had the same guard and it's worth keeping; an admin + locking themselves out is irrecoverable without DB access. + """ + if iris_current_user.id == identifier: + return response_api_error( + 'Refusing to deactivate yourself — you would lock yourself out.' + ) + user, err = _require_user(identifier) + if err is not None: + return err + user.active = False + db.session.commit() + track_activity(f'user {user.user} deactivated', ctx_less=True) + return response_api_success(Users()._schema.dump(user)) + + +@users_blueprint.post('/<int:identifier>/api-key/renew') +@ac_api_requires(Permissions.server_administrator) +def renew_user_api_key(identifier: int) -> Response: + """Rotate the user's API key. Returns the *new* key in the body + once — the admin should copy it immediately; we don't surface it + again on subsequent reads.""" + user, err = _require_user(identifier) + if err is not None: + return err + new_key = secrets.token_urlsafe(nbytes=64) + user.api_key = new_key + db.session.commit() + track_activity(f'API key of user {user.user} renewed', ctx_less=True) + return response_api_success({ + 'user_id': user.id, + 'user': user.user, + 'api_key': new_key, + }) + + +@users_blueprint.post('/<int:identifier>/mfa/reset') +@ac_api_requires(Permissions.server_administrator) +def reset_user_mfa(identifier: int) -> Response: + """Clear MFA secrets for the user. The underlying business + helper raises `BusinessProcessingError` when the user can't be + found (rather than a typed not-found) — surface both paths + cleanly.""" + try: + users_reset_mfa(identifier) + except BusinessProcessingError as exc: + return response_api_error(exc.get_message()) + track_activity(f'MFA reset for user #{identifier}', ctx_less=True) + return response_api_success({'message': 'MFA reset'}) + + +@users_blueprint.post('/<int:identifier>/recompute-access') +@ac_api_requires(Permissions.server_administrator) +def recompute_user_access(identifier: int) -> Response: + """Force-recompute the user's effective case access cache. + + Useful after a manual DB poke or to debug effective-access + drift. The legacy UI exposed this on the User Audit modal. + """ + user, err = _require_user(identifier) + if err is not None: + return err + ac_recompute_effective_ac(identifier) + return response_api_success({'message': 'Recomputed'}) + + +@users_blueprint.get('/<int:identifier>/audit') +@ac_api_requires(Permissions.server_administrator) +def audit_user(identifier: int) -> Response: + """Effective-access trace. + + Returns: + * `access_audit`: per-case rows showing where the user's + effective access came from (direct user grant, group, org). + * `permissions_audit`: per-permission rows showing which + group(s) contribute each bit of the user's effective + permission bitmask. + + Both projections are computed on-the-fly by the iris_engine + helpers; nothing is persisted. + """ + user, err = _require_user(identifier) + if err is not None: + return err + return response_api_success({ + 'access_audit': ac_trace_user_effective_cases_access_2(identifier), + 'permissions_audit': ac_trace_effective_user_permissions(identifier), + }) + + +# ---- Per-user preferences ------------------------------------------------- +# +# Read/write a single top-level key in the caller's own `preferences` +# JSONB bag. Not admin-gated: every logged-in user manages their own +# settings. The key is validated as a slug so a malicious caller can't +# stuff arbitrary paths into the JSON tree. Values are stored verbatim +# and returned as-is; the SPA owns the shape per feature namespace +# (e.g. `war_room_stream`). + +import re as _pref_re + +_PREF_KEY_RE = _pref_re.compile(r'^[a-z][a-z0-9_-]{0,63}$') + + +def _get_pref_user(): + from app.models.authorization import User + return User.query.filter_by(id=iris_current_user.id).first() + + +@users_blueprint.get('/me/preferences/<key>') +@ac_api_requires() +def get_my_preference(key: str): + if not _PREF_KEY_RE.match(key or ''): + return response_api_error('Invalid preference key') + me = _get_pref_user() + if me is None: + return response_api_not_found() + prefs = me.preferences or {} + return response_api_success({'key': key, 'value': prefs.get(key)}) + + +@users_blueprint.put('/me/preferences/<key>') +@ac_api_requires() +def put_my_preference(key: str): + if not _PREF_KEY_RE.match(key or ''): + return response_api_error('Invalid preference key') + raw = request.get_json(silent=True) or {} + if 'value' not in raw: + return response_api_error('Body must contain a `value` field') + value = raw['value'] + me = _get_pref_user() + if me is None: + return response_api_not_found() + # Copy-and-replace so SQLAlchemy sees the JSONB as dirty — mutating + # the dict in place would be missed by the change-tracking layer + # (JSONB is not automatically deep-tracked). + prefs = dict(me.preferences or {}) + if value is None: + prefs.pop(key, None) + else: + prefs[key] = value + me.preferences = prefs + db.session.commit() + return response_api_success({'key': key, 'value': prefs.get(key)}) + + +# Keep linter quiet about unused imports referenced indirectly. +_ = (Any, Dict, List) diff --git a/source/app/blueprints/rest/v2/notifications.py b/source/app/blueprints/rest/v2/notifications.py new file mode 100644 index 000000000..fa26f561a --- /dev/null +++ b/source/app/blueprints/rest/v2/notifications.py @@ -0,0 +1,249 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""v2 REST endpoints for the notification core. + +Two blueprints: + +* `notifications_blueprint` — mounted at `/notifications` — user-scoped + reads (list, unread count, mark read) and the per-user preferences + under `/notification-settings`. +* `admin_notifications_blueprint` — mounted at + `/manage/notification-settings` — admin-only defaults. + +Every user-scoped endpoint answers only for `iris_current_user.id`. +Passing another user id in a body/query is silently ignored — the +service layer scopes on the session id regardless. +""" + +from __future__ import annotations + +from flask import Blueprint +from flask import 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.iris_engine.notifications.service import clear as clear_notifications +from app.iris_engine.notifications.service import get_admin_settings +from app.iris_engine.notifications.service import get_effective_settings +from app.iris_engine.notifications.service import list_for_user +from app.iris_engine.notifications.service import mark_read +from app.iris_engine.notifications.service import unread_count +from app.iris_engine.notifications.service import upsert_admin_settings +from app.iris_engine.notifications.service import upsert_user_settings +from app.models.authorization import Permissions +from app.models.notifications import CHANNELS +from app.models.notifications import EVENT_TYPES + + +notifications_blueprint = Blueprint( + 'notifications_rest_v2', __name__, url_prefix='/notifications') + + +def _serialize(row): + return { + 'id': row.id, + 'event_type': row.event_type, + 'title': row.title, + 'body': row.body, + 'link': row.link, + 'source_type': row.source_type, + 'source_id': row.source_id, + 'read_at': row.read_at.isoformat() if row.read_at else None, + 'created_at': row.created_at.isoformat() if row.created_at else None, + } + + +# --------------------------------------------------------------------------- +# Feed +# --------------------------------------------------------------------------- + +@notifications_blueprint.get('') +@ac_api_requires() +def get_notifications(): + """List the current user's notifications. + + Query params: + * `unread_only` — 'true' filters to unread rows only + * `limit` — default 50, max 200 + * `before_id` — cursor for infinite scroll + """ + unread_only = request.args.get('unread_only', 'false').lower() == 'true' + limit = request.args.get('limit', default=50, type=int) + before_id = request.args.get('before_id', type=int) + + rows = list_for_user( + user_id=iris_current_user.id, + unread_only=unread_only, + limit=limit, + before_id=before_id, + ) + return response_api_success({ + 'data': [_serialize(r) for r in rows], + 'unread_count': unread_count(iris_current_user.id), + }) + + +@notifications_blueprint.get('/unread-count') +@ac_api_requires() +def get_unread_count(): + """Bell badge poll — cheap `SELECT count(*)`.""" + return response_api_success({ + 'unread_count': unread_count(iris_current_user.id), + }) + + +@notifications_blueprint.post('/mark-read') +@ac_api_requires() +def post_mark_read(): + """Mark notifications read. Body: `{"ids": [...]}` OR `{"all": true}`. + + Any id in `ids` that doesn't belong to the current user is + silently ignored (the service layer filters on user_id). + """ + payload = request.get_json(silent=True) or {} + all_flag = bool(payload.get('all')) + raw_ids = payload.get('ids') or [] + if not all_flag and not isinstance(raw_ids, list): + return response_api_error('ids must be an array') + + ids = [] + if not all_flag: + for i in raw_ids: + try: + ids.append(int(i)) + except (TypeError, ValueError): + # Ignore garbage rather than 400 — the client shouldn't + # have to sanitise, and marking a subset is fine. + continue + if not ids: + return response_api_error('No valid ids provided') + + affected = mark_read( + user_id=iris_current_user.id, + ids=ids if not all_flag else None, + all_=all_flag, + ) + return response_api_success({ + 'affected': affected, + 'unread_count': unread_count(iris_current_user.id), + }) + + +@notifications_blueprint.post('/clear') +@ac_api_requires() +def post_clear(): + """Permanently delete notifications. Body: `{"ids": [...]}` OR + `{"all": true}`. + + Same defensive scoping as mark-read — the service only ever + touches rows owned by the session user. + """ + payload = request.get_json(silent=True) or {} + all_flag = bool(payload.get('all')) + raw_ids = payload.get('ids') or [] + if not all_flag and not isinstance(raw_ids, list): + return response_api_error('ids must be an array') + + ids = [] + if not all_flag: + for i in raw_ids: + try: + ids.append(int(i)) + except (TypeError, ValueError): + continue + if not ids: + return response_api_error('No valid ids provided') + + affected = clear_notifications( + user_id=iris_current_user.id, + ids=ids if not all_flag else None, + all_=all_flag, + ) + return response_api_success({ + 'affected': affected, + 'unread_count': unread_count(iris_current_user.id), + }) + + +# --------------------------------------------------------------------------- +# User preferences +# --------------------------------------------------------------------------- + +@notifications_blueprint.get('/settings') +@ac_api_requires() +def get_settings(): + """Return the effective settings grid for the current user. + + Shape: `{event_types: [...], channels: [...], + settings: {event: {channel: bool}}}` — the SPA renders the grid + from `event_types` × `channels` and marks each cell from + `settings`. + """ + return response_api_success({ + 'event_types': list(EVENT_TYPES), + 'channels': list(CHANNELS), + 'settings': get_effective_settings(iris_current_user.id), + }) + + +@notifications_blueprint.put('/settings') +@ac_api_requires() +def put_settings(): + """Upsert per-user settings. Body: `{settings: {event: {channel: bool}}}`. + + Unknown events/channels are dropped; the service returns the + effective merged view so the SPA can render post-save without + another round-trip. + """ + payload = request.get_json(silent=True) or {} + settings = payload.get('settings') + if not isinstance(settings, dict): + return response_api_error('settings must be an object') + + merged = upsert_user_settings(iris_current_user.id, settings) + return response_api_success({ + 'event_types': list(EVENT_TYPES), + 'channels': list(CHANNELS), + 'settings': merged, + }) + + + +# --------------------------------------------------------------------------- +# Admin defaults — separate blueprint, permission-gated +# --------------------------------------------------------------------------- + +admin_notifications_blueprint = Blueprint( + 'admin_notifications_rest_v2', __name__, + url_prefix='/manage/notification-settings', +) + + +@admin_notifications_blueprint.get('') +@ac_api_requires(Permissions.server_administrator) +def get_admin(): + return response_api_success({ + 'event_types': list(EVENT_TYPES), + 'channels': list(CHANNELS), + 'settings': get_admin_settings(), + }) + + +@admin_notifications_blueprint.put('') +@ac_api_requires(Permissions.server_administrator) +def put_admin(): + payload = request.get_json(silent=True) or {} + settings = payload.get('settings') + if not isinstance(settings, dict): + return response_api_error('settings must be an object') + + merged = upsert_admin_settings(settings) + return response_api_success({ + 'event_types': list(EVENT_TYPES), + 'channels': list(CHANNELS), + 'settings': merged, + }) diff --git a/source/app/blueprints/rest/v2/profile.py b/source/app/blueprints/rest/v2/profile.py index 09d2c38df..459a38d45 100644 --- a/source/app/blueprints/rest/v2/profile.py +++ b/source/app/blueprints/rest/v2/profile.py @@ -16,16 +16,34 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +import secrets from flask import Blueprint +from flask import current_app from flask import request +from flask import session from marshmallow import ValidationError +from sqlalchemy.exc import IntegrityError +from app import bc +from app.db import db from app.blueprints.iris_user import iris_current_user from app.blueprints.rest.endpoints import response_api_success +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.access_controls import ac_api_requires +from app.blueprints.access_controls import ac_fast_check_current_user_has_case_access +from app.business.cases import cases_exists from app.business.users import users_get from app.business.users import users_update +from app.iris_engine.access_control.utils import ac_get_effective_permissions_of_user +from app.iris_engine.access_control.utils import ac_recompute_effective_ac +from app.models.authorization import CaseAccessLevel +from app.models.authorization import Permissions +from app.models.authorization import UserFollowedCase +from app.models.cases import Cases +from app.schema.marshables import CaseSchemaForAPIV2 from app.schema.marshables import UserSchemaForAPIV2 @@ -43,15 +61,215 @@ def get(self): def update(self): try: user = users_get(iris_current_user.id) - request_data = request.get_json() - request_data['user_id'] = iris_current_user.id + # Self-service profile updates expose only a password change in + # the GUI. Restricting the payload to that one field stops any + # client from sneaking attributes the schema would otherwise + # accept (user_login, user_email, user_isadmin, user_name, ...) + # and overwriting the user's own row — the mass-assignment + # vector reported as GHSA-w78h-mx7h-qm3h / SBA-ADV-20260128-01 / + # CWE-915. `user_current_password` is not a model field and is + # popped off before the schema sees the payload. + raw = request.get_json() + if not isinstance(raw, dict): + raw = {} + new_password = raw.get('user_password') + current_password = raw.get('user_current_password') + + if new_password: + if not current_password: + return response_api_error( + 'Current password is required to change password', + data={'user_current_password': ['Required field']} + ) + if not bc.check_password_hash(user.password, current_password): + return response_api_error( + 'Current password is incorrect', + data={'user_current_password': ['Incorrect password']} + ) + + request_data = { + 'user_password': new_password, + 'user_id': iris_current_user.id, + } + user = self._update_request_schema.load(request_data, instance=user, partial=True) - user = users_update(user, request_data.get('user_password')) + user = users_update(user, new_password) result = self._schema.dump(user) return response_api_success(result) except ValidationError as e: return response_api_error('Data error', data=e.messages) + def renew_api_key(self): + user = users_get(iris_current_user.id) + user.api_key = secrets.token_urlsafe(nbytes=64) + db.session.commit() + result = self._schema.dump(user) + return response_api_success(result) + + def refresh_permissions(self): + user = users_get(iris_current_user.id) + ac_recompute_effective_ac(iris_current_user.id) + session['permissions'] = ac_get_effective_permissions_of_user(user) + result = self._schema.dump(user) + return response_api_success(result) + + def get_context(self): + """Compact bootstrap payload the SPA needs on every load. + + Returns the running IRIS version, the demo-mode flag, and the + effective permission mask + matching enum names for the current + user. The SPA uses this to render the version strip in the side + bar and to gate menu entries the user isn't allowed to reach. + + Kept lightweight on purpose: no DB writes, no joins beyond what + `ac_get_effective_permissions_of_user` already does. Any + authenticated user can call it — this is *their own* context. + """ + user = users_get(iris_current_user.id) + mask = ac_get_effective_permissions_of_user(user) + # `standard_user` is implicit for every authenticated user, even + # if the group bitmask doesn't include it (admins, service + # accounts). Include it so the SPA can treat it as a baseline. + if user is not None: + mask |= Permissions.standard_user.value + names = [p.name for p in Permissions if (mask & p.value) == p.value] + + demo_mode = current_app.config.get('DEMO_MODE_ENABLED') == 'True' + + return response_api_success({ + 'iris_version': current_app.config.get('IRIS_VERSION'), + 'demo_mode': demo_mode, + 'permissions': { + 'mask': mask, + 'names': names, + }, + # Per-user UI preferences that the SPA shell needs on the + # very first render so it doesn't flash one layout and then + # snap into the persisted one. Currently a single boolean + # for the collapsed side bar; keep the dict shape so we can + # add more (theme, density, …) without breaking the SPA. + 'preferences': { + 'has_mini_sidebar': bool(getattr(user, 'has_mini_sidebar', False)) if user else False, + }, + }) + + def list_followed_cases(self): + """Return cases the current user follows. + + Followed cases are surfaced on the dashboard's "Following" tile + regardless of ownership. Rows the user has lost access to + (revoked group membership, customer reassignment, etc.) are + filtered out so the dashboard never renders a tile the user + cannot open. + """ + followed = ( + Cases.query + .join(UserFollowedCase, UserFollowedCase.case_id == Cases.case_id) + .filter(UserFollowedCase.user_id == iris_current_user.id) + .order_by(UserFollowedCase.created_at.desc()) + .all() + ) + + # Drop entries the user is no longer allowed to see. We use the + # fast check (read_only or full_access) because the dashboard + # only renders a title + link — no protected fields are shipped. + visible = [ + c for c in followed + if ac_fast_check_current_user_has_case_access( + c.case_id, [CaseAccessLevel.read_only, CaseAccessLevel.full_access] + ) + ] + + # Use the v2 API schema so the response matches what the SPA's + # `Case` type expects (case_name / case_customer / severity / + # state). `CaseDetailsSchema` here would ship the raw model + # field names (`name`, `client`, ...) and the dashboard + # "Following" tile would render rows with no title. + return response_api_success(data=CaseSchemaForAPIV2(many=True).dump(visible)) + + def follow_case(self): + """Add a case to the current user's followed-cases list. + + Idempotent: re-following an already-followed case returns 201 + rather than 400 to keep the SPA's "Follow" button safe to retry + on flaky connections. + """ + raw = request.get_json() + if not isinstance(raw, dict): + return response_api_error('Invalid request') + case_id = raw.get('case_id') + if not isinstance(case_id, int): + return response_api_error('case_id must be an integer') + + if not cases_exists(case_id): + return response_api_not_found() + # Users can only follow cases they can actually open. Without + # this gate the dashboard would silently start linking to a + # case the user has no read access to. + if not ac_fast_check_current_user_has_case_access( + case_id, [CaseAccessLevel.read_only, CaseAccessLevel.full_access]): + return response_api_error('No access to this case') + + existing = ( + UserFollowedCase.query + .filter_by(user_id=iris_current_user.id, case_id=case_id) + .first() + ) + if existing is not None: + return response_api_created({'case_id': case_id, 'followed': True}) + + follow = UserFollowedCase(user_id=iris_current_user.id, case_id=case_id) + db.session.add(follow) + try: + db.session.commit() + except IntegrityError: + # Race with a concurrent POST from the same user. Same + # outcome as the existing-row branch above. + db.session.rollback() + return response_api_created({'case_id': case_id, 'followed': True}) + + def unfollow_case(self, case_id): + """Remove a case from the current user's followed-cases list. + + Idempotent: unfollowing a case that isn't followed returns 204 + rather than 404 so the SPA can fire-and-forget on toggle clicks + without first round-tripping the current state. + """ + UserFollowedCase.query.filter_by( + user_id=iris_current_user.id, case_id=case_id + ).delete(synchronize_session=False) + db.session.commit() + return response_api_deleted() + + def update_preferences(self): + """Persist a small dict of UI preferences on the current user. + + Today only `has_mini_sidebar` is accepted — the SPA toggles + this when the user folds the side bar so the next session + opens with the same layout. Returns the updated preference + block so the client can re-seed its in-memory copy without a + second round-trip. + """ + user = users_get(iris_current_user.id) + if user is None: + return response_api_error('Unknown user') + + raw = request.get_json() + if not isinstance(raw, dict): + return response_api_error('Invalid request') + + if 'has_mini_sidebar' in raw: + if not isinstance(raw['has_mini_sidebar'], bool): + return response_api_error( + 'has_mini_sidebar must be a boolean' + ) + user.has_mini_sidebar = raw['has_mini_sidebar'] + + db.session.commit() + return response_api_success({ + 'has_mini_sidebar': bool(user.has_mini_sidebar), + }) + profile_operations = ProfileOperations() profile_blueprint = Blueprint('profile_rest_v2', __name__, url_prefix='/me') @@ -67,3 +285,45 @@ def get_profile(): @ac_api_requires() def update_profile(): return profile_operations.update() + + +@profile_blueprint.post('/api-key/renew') +@ac_api_requires() +def renew_api_key(): + return profile_operations.renew_api_key() + + +@profile_blueprint.post('/permissions/refresh') +@ac_api_requires() +def refresh_permissions(): + return profile_operations.refresh_permissions() + + +@profile_blueprint.get('/context') +@ac_api_requires() +def get_context(): + return profile_operations.get_context() + + +@profile_blueprint.put('/preferences') +@ac_api_requires() +def update_preferences(): + return profile_operations.update_preferences() + + +@profile_blueprint.get('/followed-cases') +@ac_api_requires() +def list_followed_cases(): + return profile_operations.list_followed_cases() + + +@profile_blueprint.post('/followed-cases') +@ac_api_requires() +def follow_case(): + return profile_operations.follow_case() + + +@profile_blueprint.delete('/followed-cases/<int:case_id>') +@ac_api_requires() +def unfollow_case(case_id): + return profile_operations.unfollow_case(case_id) diff --git a/source/app/blueprints/rest/v2/search.py b/source/app/blueprints/rest/v2/search.py new file mode 100644 index 000000000..511cb4665 --- /dev/null +++ b/source/app/blueprints/rest/v2/search.py @@ -0,0 +1,91 @@ +# IRIS Source Code +# Copyright (C) 2024 - 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from flask import Blueprint +from flask import request + +from app.models.authorization import Permissions +from app.blueprints.iris_user import iris_current_user +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.rest.endpoints import response_api_error +from app.blueprints.rest.endpoints import response_api_success +from app.business.search import search_across +from app.business.search import SUPPORTED_SEARCH_TYPES + + +search_blueprint = Blueprint('search_rest_v2', __name__, url_prefix='/search') + + +@search_blueprint.get('') +@ac_api_requires(Permissions.search_across_cases) +def search_across_cases(): + # `types` is a comma-separated list (notes,iocs,comments). We accept + # repeated `?types=notes&types=iocs` form too — Flask's getlist + a + # split-on-comma normalises both shapes. + raw_types = request.args.getlist('types') or [] + search_types = [] + for raw in raw_types: + for piece in raw.split(','): + piece = piece.strip() + if piece: + search_types.append(piece) + + if not search_types: + return response_api_error( + f'Missing types. Expected one or more of {SUPPORTED_SEARCH_TYPES}' + ) + + unknown = [t for t in search_types if t not in SUPPORTED_SEARCH_TYPES] + if unknown: + return response_api_error( + f'Unsupported search type(s): {unknown}. Expected one or more of {SUPPORTED_SEARCH_TYPES}' + ) + + search_value = request.args.get('value') + if not search_value: + return response_api_error('Missing search value') + + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 25, type=int) + case_id = request.args.get('case_id', default=None, type=int) + + # `case_ids` accepts either comma-separated `?case_ids=1,2,3` or + # repeated `?case_ids=1&case_ids=2` — same flexible shape as the + # `types` param above. + raw_case_ids = request.args.getlist('case_ids') or [] + case_ids = [] + for raw in raw_case_ids: + for piece in raw.split(','): + piece = piece.strip() + if piece: + try: + case_ids.append(int(piece)) + except ValueError: + continue + + result = search_across( + search_value=search_value, + search_types=search_types, + user_id=iris_current_user.id, + page=page, + per_page=per_page, + case_id=case_id, + case_ids=case_ids or None, + ) + + return response_api_success(data=result) diff --git a/source/app/blueprints/rest/v2/war_rooms/__init__.py b/source/app/blueprints/rest/v2/war_rooms/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/source/app/blueprints/rest/v2/war_rooms/access.py b/source/app/blueprints/rest/v2/war_rooms/access.py new file mode 100644 index 000000000..5b2d15982 --- /dev/null +++ b/source/app/blueprints/rest/v2/war_rooms/access.py @@ -0,0 +1,46 @@ +# 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. + +"""Per-endpoint access checks shared across war-room sub-blueprints.""" + +from app.blueprints.access_controls import ac_api_return_access_denied +from app.blueprints.access_controls import ac_current_user_has_permission +from app.blueprints.access_controls import ac_fast_check_current_user_has_war_room_access +from app.blueprints.rest.endpoints import response_api_not_found +from app.business.war_rooms import war_room_exists +from app.models.authorization import Permissions +from app.models.authorization import WarRoomAccessLevel + + +def require_war_room_read(war_room_id): + if not war_room_exists(war_room_id): + return response_api_not_found() + if not ac_current_user_has_permission(Permissions.war_rooms_read) \ + and not ac_current_user_has_permission(Permissions.server_administrator): + return ac_api_return_access_denied() + if ac_fast_check_current_user_has_war_room_access( + war_room_id, + [WarRoomAccessLevel.read_only, WarRoomAccessLevel.full_access], + ) is None: + return ac_api_return_access_denied() + return None + + +def require_war_room_write(war_room_id): + if not war_room_exists(war_room_id): + return response_api_not_found() + if not ac_current_user_has_permission(Permissions.war_rooms_write) \ + and not ac_current_user_has_permission(Permissions.server_administrator): + return ac_api_return_access_denied() + if ac_fast_check_current_user_has_war_room_access( + war_room_id, + [WarRoomAccessLevel.full_access], + ) is None: + return ac_api_return_access_denied() + return None diff --git a/source/app/blueprints/rest/v2/war_rooms/chat.py b/source/app/blueprints/rest/v2/war_rooms/chat.py new file mode 100644 index 000000000..abb63b866 --- /dev/null +++ b/source/app/blueprints/rest/v2/war_rooms/chat.py @@ -0,0 +1,729 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Chat-stream REST routes for war rooms. + +Mounted under `/api/v2/war-rooms/<id>/chat`. Cursor-paginated via +`?before=<msg_id>` so the SPA can scroll back without offsets that +shift when a new message lands. + +Slash commands are resolved here: the body is parsed, the matching +sub-system is invoked (task / case attach / sitrep), and a system +message is recorded in the same transaction so the chat is also the +audit log. +""" + +from flask import Blueprint, request + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.access_controls import ac_current_user_has_permission +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_chat import create_message +from app.business.war_room_chat import create_reply +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 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 parse_slash +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 unfollow_thread +from app.business.war_room_chat import update_message +from app.models.authorization import Permissions +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError + + +war_rooms_chat_blueprint = Blueprint( + 'war_rooms_chat_rest_v2', __name__, url_prefix='/<int:war_room_id>/chat' +) + + +def _serialize(row, reactions=None): + return { + 'message_id': row.message_id, + 'war_room_id': row.war_room_id, + 'author_id': row.author_id, + 'author_login': row.author_login, + 'author_name': row.author_name, + 'body': row.body, + 'kind': row.kind, + 'ref_type': row.ref_type, + 'ref_id': row.ref_id, + 'ref_case_id': row.ref_case_id, + # `activity_type` is populated on virtual UserActivity rows + # only — chat rows never carry it directly (the column is + # ignored at query time for cross-version compat). Defaults + # to None for plain messages / system rows. + 'activity_type': getattr(row, 'activity_type', None), + # Threading metadata. `parent_message_id` is None on roots and + # on virtual UA rows (which never participate in threads). + # `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), + '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 [], + } + + +def _emit_socket(war_room_id, event_name, payload): + """Best-effort fan-out to the war-room socket channel. + + Failures are swallowed: the REST write already succeeded; if no + socketio worker is running (CLI / test mode) we don't want to + surface a 500. + """ + try: + from app import socket_io + except Exception: + return + try: + socket_io.emit(event_name, payload, room=f'war_room_{war_room_id}') + except Exception: + pass + + +@war_rooms_chat_blueprint.get('') +@ac_api_requires() +def list_chat(war_room_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + + before = request.args.get('before', type=int) + limit = request.args.get('limit', type=int) + kinds_raw = request.args.get('kinds', type=str) + case_ids_raw = request.args.get('case_ids', type=str) + # `search` drives the top-of-stream quick-filter — empty string is + # normalised to None so the SPA can just always pass the field. + search_raw = request.args.get('search', type=str) + search = search_raw.strip() if isinstance(search_raw, str) else None + if search == '': + search = None + + kinds = [k.strip() for k in kinds_raw.split(',') if k.strip()] if kinds_raw else None + case_ids = None + if case_ids_raw: + try: + case_ids = [int(x) for x in case_ids_raw.split(',') if x.strip()] + except ValueError: + return response_api_error('Invalid case_ids') + + rows = list_messages(war_room_id, before=before, limit=limit, + kinds=kinds, case_ids=case_ids, search=search) + reactions = list_reactions([r.message_id for r in rows]) + return response_api_success( + data=[_serialize(r, reactions.get(r.message_id)) for r in rows] + ) + + +_VALID_STATES = {'open', 'active', 'standby', 'closed'} +_PRIORITY_LEVELS = {'low', 'medium', 'high', 'critical'} +_PRIORITY_ALIASES = {'med': 'medium', 'mid': 'medium', 'crit': 'critical'} + + +def _resolve_slash(war_room_id, cmd, rest): + """Translate a recognised slash command into a structured message. + + Returns (kind, body, ref_type, ref_id, ref_case_id) for the system + row, or None if the command is unrecognised (the route layer then + falls through to a normal message). + """ + if cmd == 'note': + return ('note', rest, 'war_room_chat', None, None) + if cmd == 'pin': + return ('pin', rest, 'war_room_chat', None, None) + + if cmd == 'decision': + if not rest: + raise BusinessProcessingError( + 'Usage: /decision <what we decided>' + ) + return ('decision', rest, 'war_room_chat', None, None) + + if cmd == 'attach': + # `/attach <case_id> [reason]` + parts = rest.split(None, 1) + if not parts or not parts[0].isdigit(): + raise BusinessProcessingError('Usage: /attach <case_id> [reason]') + case_id = int(parts[0]) + note = parts[1] if len(parts) > 1 else None + from app.business.war_rooms import war_room_attach_case + from app.blueprints.access_controls import ac_fast_check_current_user_has_case_access + from app.models.authorization import CaseAccessLevel + if ac_fast_check_current_user_has_case_access( + case_id, [CaseAccessLevel.full_access] + ) is None: + raise BusinessProcessingError( + f'You need full access on case #{case_id} to attach it' + ) + war_room_attach_case(war_room_id, case_id, + attached_by_id=iris_current_user.id, note=note) + body = f'Attached case #{case_id}' + (f' — {note}' if note else '') + return ('case_attached', body, 'case', case_id, case_id) + + if cmd == 'detach': + # `/detach <case_id>` — symmetrical with /attach. Useful in the + # rare crisis where a case was attached in error or is moved out. + parts = rest.split(None, 1) + if not parts or not parts[0].isdigit(): + raise BusinessProcessingError('Usage: /detach <case_id>') + case_id = int(parts[0]) + from app.business.war_rooms import war_room_detach_case + from app.models.errors import ObjectNotFoundError + try: + war_room_detach_case(war_room_id, case_id) + except ObjectNotFoundError: + raise BusinessProcessingError( + f'Case #{case_id} is not attached to this war room' + ) + return ( + 'case_detached', f'Detached case #{case_id}', + 'case', case_id, case_id, + ) + + if cmd == 'task': + # `/task <title>` — single-arg form. + # `/task @user <title>` — assign on creation (resolves the + # mention to a User row via login or display name). + if not rest: + raise BusinessProcessingError('Usage: /task [@user] <title>') + from app.business.war_room_tasks import war_room_task_create + assignee_id = None + assignee_label = None + title = rest + if rest.startswith('@'): + head, _, tail = rest.partition(' ') + if not tail.strip(): + raise BusinessProcessingError( + 'Usage: /task @user <title>' + ) + assignee_id, assignee_label = _resolve_user_handle(head[1:]) + title = tail.strip() + task = war_room_task_create( + war_room_id, title=title, + created_by_id=iris_current_user.id, + assignee_id=assignee_id, + ) + body = ( + f'Created task for {assignee_label}: {title}' + if assignee_label else f'Created task: {title}' + ) + return ('task_assigned', body, 'war_room_task', task.task_id, None) + + if cmd == 'assign': + # `/assign @user <title>` — shorthand for `/task @user <title>`. + if not rest or not rest.startswith('@'): + raise BusinessProcessingError('Usage: /assign @user <title>') + head, _, tail = rest.partition(' ') + if not tail.strip(): + raise BusinessProcessingError('Usage: /assign @user <title>') + from app.business.war_room_tasks import war_room_task_create + assignee_id, assignee_label = _resolve_user_handle(head[1:]) + title = tail.strip() + task = war_room_task_create( + war_room_id, title=title, + created_by_id=iris_current_user.id, + assignee_id=assignee_id, + ) + body = f'Assigned to {assignee_label}: {title}' + return ('task_assigned', body, 'war_room_task', task.task_id, None) + + if cmd == 'sitrep': + if not rest: + raise BusinessProcessingError('Usage: /sitrep <title>') + from app.business.war_room_sitreps import sitrep_draft + sit = sitrep_draft(war_room_id, title=rest, + authored_by_id=iris_current_user.id) + return ('sitrep_published', f'Drafted SitRep: {rest}', + 'sitrep', sit.sitrep_id, None) + + if cmd == 'state': + # `/state <open|active|standby|closed>` — flip the war-room + # lifecycle without leaving the stream. Useful during a crisis + # when the IC wants to wave the room into Active or wind it down. + target = rest.strip().lower() + if target not in _VALID_STATES: + raise BusinessProcessingError( + 'Usage: /state <open|active|standby|closed>' + ) + from app.business.war_rooms import war_room_update + war_room_update( + war_room_id, state=target, + closed_by_id=iris_current_user.id if target == 'closed' else None, + ) + return ( + 'priority' if target in ('active', 'closed') else 'system', + f'War room state set to {target}', + 'war_room', war_room_id, None, + ) + + if cmd == 'priority': + # `/priority <low|medium|high|critical>` — purely a banner row. + # State-side effects on the war room could be added later but + # for now this just stamps a visible row in the stream the team + # can rally around. + level = rest.strip().lower() + level = _PRIORITY_ALIASES.get(level, level) + if level not in _PRIORITY_LEVELS: + raise BusinessProcessingError( + 'Usage: /priority <low|medium|high|critical>' + ) + if level in ('high', 'critical'): + # Hot-up the war room when the operator declares a hot + # priority — saves them an extra /state command. + from app.business.war_rooms import war_room_update + war_room_update(war_room_id, state='active') + return ( + 'priority', + f'Priority set to {level.upper()}', + 'war_room', war_room_id, None, + ) + + if cmd == 'summary': + # Auto-generate a SitRep draft seeded with the current war-room + # snapshot. Operator just polishes the prose before publishing. + from app.business.war_room_sitreps import sitrep_draft, _snapshot + snap = _snapshot(war_room_id) + body_md_lines = [ + '## Snapshot', + f'- Attached cases: ' + ( + ', '.join(f'#{c}' for c in (snap.get('attached_case_ids') or [])) or '—' + ), + f'- Open tasks: {snap.get("tasks_open", 0)}', + f'- Closed tasks: {snap.get("tasks_closed", 0)}', + '', + '## Situation', + rest.strip() or '_Describe the current situation._', + '', + '## Next steps', + '_What we plan to do next._', + ] + title = rest.strip()[:120] or f'Auto SitRep — {snap.get("captured_at", "now")[:10]}' + sit = sitrep_draft( + war_room_id, + title=title, + body_md='\n'.join(body_md_lines), + authored_by_id=iris_current_user.id, + ) + return ( + 'sitrep_published', + f'Drafted SitRep: {title}', + 'sitrep', sit.sitrep_id, None, + ) + + if cmd == 'thread': + # `/thread <title>` — open a named topic the team can rally + # replies under. The resolved row becomes a normal `message` with + # a `thread_title` set, so it shows up in the threads sidebar + # immediately even before anyone has replied. + from app.business.war_room_chat import _threads_supported + if not _threads_supported(): + raise BusinessProcessingError( + 'Threads are not enabled on this server yet — ' + 'apply the latest migrations.' + ) + title = rest.strip() + if not title: + raise BusinessProcessingError('Usage: /thread <title>') + if len(title) > 160: + raise BusinessProcessingError( + 'Thread title must be at most 160 characters' + ) + # Tag the resolved tuple with a sentinel so the post handler + # knows to call set_thread_title after creating the root. We + # encode it via ref_type — the create path treats it as a + # marker, not a foreign-key ref. + return ('message', title, '__set_thread_title__', None, None) + + if cmd in ('help', '?'): + body = ( + 'Commands: /note /pin /decision /attach /detach /task /assign ' + '/sitrep /summary /state /priority /thread' + ) + return ('system', body, None, None, None) + + return None + + +def _resolve_user_handle(handle): + """Look up a `@handle` to a (user_id, display_label) pair. + + Matches against `user.user` (login) first, then `user.name` + (display name). Case-insensitive. Raises a `BusinessProcessingError` + when no match is found so the operator sees a useful hint instead + of a silent no-assignee task. + """ + from app.models.authorization import User + from app.db import db + from sqlalchemy import or_, func + if not handle: + raise BusinessProcessingError('Empty user mention') + h = handle.strip() + row = ( + db.session.query(User.id, User.user, User.name) + .filter( + or_( + func.lower(User.user) == h.lower(), + func.lower(User.name) == h.lower(), + ) + ) + .first() + ) + if row is None: + raise BusinessProcessingError( + f'No user matched @{handle}. Use a login or full name.' + ) + return row.id, (row.name or row.user) + + +@war_rooms_chat_blueprint.post('') +@ac_api_requires() +def post_chat(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') + body = raw.get('body') + if not isinstance(body, str): + return response_api_error('body is required') + + slash = parse_slash(body) + if slash is not None: + try: + resolved = _resolve_slash(war_room_id, slash[0], slash[1]) + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + except ImportError: + # A required sub-system isn't importable (test envs, a + # feature module not yet packaged). Surface a generic + # "unavailable" message — exception text would leak + # internal module paths to the operator's browser. + from app.logger import logger + logger.exception('Slash command module missing') + return response_api_error( + 'Command unavailable in this build.' + ) + except Exception: # noqa: BLE001 — generic catch with correlation id + # Any unexpected error inside a slash handler used to bubble + # up as a 500 with no body, which made `/summary` and the + # like look like silent failures. Log full traceback + # server-side with a correlation id and return a generic + # message — don't echo `repr(e)` to the SPA, since that + # surfaces table names, file paths, etc. + import uuid + from app.logger import logger + err_id = uuid.uuid4().hex[:8] + logger.exception( + 'Slash command failed', extra={'err_id': err_id} + ) + return response_api_error( + f'Command failed (ref {err_id}). See server logs.' + ) + + if resolved is not None: + kind, body, ref_type, ref_id, ref_case_id = resolved + # `/thread <title>` shoves the title through the `body` slot + # of the resolved tuple and uses a sentinel `ref_type` so we + # know to seed `thread_title` on the new root. + wants_thread = ref_type == '__set_thread_title__' + if wants_thread: + ref_type = None + 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, + ) + 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}) + + # Unknown command. Returning an explicit 400 — instead of + # falling through to `create_message(body)` and storing the + # literal `/foo` text as a normal message — keeps the + # operator's intent obvious. They get a "command not found" + # toast and can correct themselves; nothing pollutes the + # stream as a confused chat bubble. + return response_api_error( + f'Unknown command "/{slash[0]}". Try /help.' + ) + + try: + msg = create_message(war_room_id, iris_current_user.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}) + + +@war_rooms_chat_blueprint.patch('/<int:message_id>') +@ac_api_requires() +def edit_chat(war_room_id, message_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: + update_message(war_room_id, message_id, iris_current_user.id, + raw.get('body')) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + _emit_socket(war_room_id, 'message:edit', {'message_id': message_id}) + return response_api_success({'message_id': message_id}) + + +@war_rooms_chat_blueprint.delete('/<int:message_id>') +@ac_api_requires() +def remove_chat(war_room_id, message_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: + delete_message(war_room_id, message_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, 'message:delete', {'message_id': message_id}) + return response_api_deleted() + + +# ----- Threads ------------------------------------------------------------- + + +def _serialize_thread_root(row, followed_ids): + """Compact serializer for the threads sidebar. + + Returns just enough to render a list row — the full body and the + replies come from the dedicated /replies endpoint when the operator + opens the thread. + """ + return { + 'message_id': row.message_id, + 'thread_title': row.thread_title, + 'preview': (row.body or '')[:200] if row.body else None, + 'kind': row.kind, + 'author_id': row.author_id, + 'author_login': row.author_login, + 'author_name': row.author_name, + 'reply_count': int(row.reply_count) if row.reply_count else 0, + 'last_activity_at': ( + row.last_reply_at.isoformat() if row.last_reply_at + else (row.created_at.isoformat() if row.created_at else None) + ), + 'created_at': row.created_at.isoformat() if row.created_at else None, + 'deleted_at': row.deleted_at.isoformat() if row.deleted_at else None, + 'is_followed': row.message_id in followed_ids, + } + + +@war_rooms_chat_blueprint.get('/threads') +@ac_api_requires() +def list_threads(war_room_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + limit = request.args.get('limit', type=int) + rows = list_thread_roots(war_room_id, limit=limit) + followed = set(list_followed_thread_ids(war_room_id, iris_current_user.id)) + return response_api_success( + data=[_serialize_thread_root(r, followed) for r in rows] + ) + + +@war_rooms_chat_blueprint.get('/trace-log') +@ac_api_requires() +def list_trace(war_room_id): + """Return every decision / pin / note in the war room, including + replies inside threads. + + Used by the "Decisions & Pins" sidebar index — the operator needs + a complete, time-ordered "who decided what and when" view without + having to page the entire stream back manually. + """ + err = require_war_room_read(war_room_id) + if err is not None: + 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]) + + +@war_rooms_chat_blueprint.get('/<int:message_id>/replies') +@ac_api_requires() +def list_message_replies(war_room_id, message_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + limit = request.args.get('limit', type=int) + try: + rows = list_replies(war_room_id, message_id, limit=limit) + except ObjectNotFoundError: + return response_api_not_found() + reactions = list_reactions([r.message_id for r in rows]) + return response_api_success( + data=[_serialize(r, reactions.get(r.message_id)) for r in rows] + ) + + +@war_rooms_chat_blueprint.post('/<int:message_id>/replies') +@ac_api_requires() +def post_reply(war_room_id, message_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') + body = raw.get('body') + if not isinstance(body, str): + return response_api_error('body is required') + + # Slash commands inside a reply. Only the "trace" kinds — /decision, + # /pin, /note — persist as structured rows on the thread itself; the + # room-wide commands (/attach, /task, /state, …) belong on the main + # stream, not buried in a thread. We reject those explicitly rather + # than silently storing them as plain replies so the operator's + # intent isn't lost. + kind = 'message' + slash = parse_slash(body) + if slash is not None: + cmd, rest = slash + if cmd in ('decision', 'pin', 'note'): + if cmd == 'decision' and not rest: + return response_api_error('Usage: /decision <what we decided>') + kind = cmd + body = rest + else: + return response_api_error( + f'/{cmd} is a room-wide command — post it on the main ' + f'stream, not inside a thread.' + ) + + try: + msg = create_reply( + war_room_id, message_id, iris_current_user.id, body, kind=kind, + ) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + _emit_socket(war_room_id, 'thread:reply', { + 'root_id': msg.parent_message_id, 'message_id': msg.message_id, + }) + return response_api_created({ + 'message_id': msg.message_id, + 'parent_message_id': msg.parent_message_id, + 'kind': msg.kind, + }) + + +@war_rooms_chat_blueprint.patch('/<int:message_id>/thread-title') +@ac_api_requires() +def patch_thread_title(war_room_id, message_id): + """Name, rename, or clear a thread's title. + + Body: `{"title": "..."}`. An empty string or null removes the + title. Works whether the caller passes the root or any reply id — + the business layer folds to the root. + """ + 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: + root = set_thread_title(war_room_id, message_id, raw.get('title')) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + _emit_socket(war_room_id, 'thread:retitle', { + 'message_id': root.message_id, 'title': root.thread_title, + }) + return response_api_success({ + 'message_id': root.message_id, + 'thread_title': root.thread_title, + }) + + +@war_rooms_chat_blueprint.post('/<int:message_id>/follow') +@ac_api_requires() +def post_follow(war_room_id, message_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + try: + added = follow_thread( + war_room_id, message_id, iris_current_user.id + ) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success({'message_id': message_id, 'added': added}) + + +@war_rooms_chat_blueprint.delete('/<int:message_id>/follow') +@ac_api_requires() +def delete_follow(war_room_id, message_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + try: + removed = unfollow_thread( + war_room_id, message_id, iris_current_user.id + ) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success({'message_id': message_id, 'removed': removed}) + + +@war_rooms_chat_blueprint.post('/<int:message_id>/reactions') +@ac_api_requires() +def post_reaction(war_room_id, message_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') + emoji = raw.get('emoji') + try: + added = toggle_reaction(war_room_id, message_id, + iris_current_user.id, emoji) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + _emit_socket(war_room_id, 'message:reaction', { + 'message_id': message_id, 'user_id': iris_current_user.id, + 'emoji': emoji, 'added': added, + }) + return response_api_success({'message_id': message_id, 'added': added}) diff --git a/source/app/blueprints/rest/v2/war_rooms/datastore.py b/source/app/blueprints/rest/v2/war_rooms/datastore.py new file mode 100644 index 000000000..da3baa7e9 --- /dev/null +++ b/source/app/blueprints/rest/v2/war_rooms/datastore.py @@ -0,0 +1,136 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""War-room datastore REST routes.""" + +from flask import Blueprint, request, send_file + +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_datastore import ( + war_room_attached_cases, + war_room_datastore_delete, + war_room_datastore_get, + war_room_datastore_list, + war_room_datastore_open, + war_room_datastore_save, +) +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError + + +war_rooms_datastore_blueprint = Blueprint( + 'war_rooms_datastore_rest_v2', __name__, + url_prefix='/<int:war_room_id>/datastore' +) + + +def _serialize(row): + return { + 'file_id': row.file_id, + 'war_room_id': row.war_room_id, + 'filename': row.filename, + 'description': row.description, + 'size_bytes': row.size_bytes, + 'mime_type': row.mime_type, + 'sha256': row.sha256, + 'uploaded_at': row.uploaded_at.isoformat() if row.uploaded_at else None, + 'uploaded_by_id': row.uploaded_by_id, + 'tags': row.tags, + } + + +@war_rooms_datastore_blueprint.get('') +@ac_api_requires() +def list_files(war_room_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + rows = war_room_datastore_list(war_room_id) + attached = war_room_attached_cases(war_room_id) + return response_api_success({ + 'files': [_serialize(r) for r in rows], + 'attached_case_ids': attached, + }) + + +@war_rooms_datastore_blueprint.post('') +@ac_api_requires() +def upload_file(war_room_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + + if 'file' not in request.files: + return response_api_error('A "file" multipart field is required') + file_storage = request.files['file'] + if not file_storage.filename: + return response_api_error('Empty filename') + + try: + row = war_room_datastore_save( + war_room_id, + file_stream=file_storage.stream, + filename=file_storage.filename, + mime_type=file_storage.mimetype, + description=request.form.get('description'), + tags=request.form.get('tags'), + uploaded_by_id=iris_current_user.id, + ) + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + + return response_api_created(_serialize(row)) + + +@war_rooms_datastore_blueprint.get('/<int:file_id>') +@ac_api_requires() +def get_file_meta(war_room_id, file_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + try: + row = war_room_datastore_get(war_room_id, file_id) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(_serialize(row)) + + +@war_rooms_datastore_blueprint.get('/<int:file_id>/content') +@ac_api_requires() +def download_file(war_room_id, file_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + try: + row = war_room_datastore_get(war_room_id, file_id) + fh = war_room_datastore_open(row) + except ObjectNotFoundError: + return response_api_not_found() + return send_file( + fh, + mimetype=row.mime_type or 'application/octet-stream', + as_attachment=True, + download_name=row.filename, + ) + + +@war_rooms_datastore_blueprint.delete('/<int:file_id>') +@ac_api_requires() +def delete_file(war_room_id, file_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + war_room_datastore_delete(war_room_id, file_id) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_deleted() diff --git a/source/app/blueprints/rest/v2/war_rooms/notes.py b/source/app/blueprints/rest/v2/war_rooms/notes.py new file mode 100644 index 000000000..16c084865 --- /dev/null +++ b/source/app/blueprints/rest/v2/war_rooms/notes.py @@ -0,0 +1,128 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""War-room notes REST routes.""" + +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_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.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError + + +war_rooms_notes_blueprint = Blueprint( + 'war_rooms_notes_rest_v2', __name__, url_prefix='/<int:war_room_id>/notes' +) + + +def _serialize(n): + return { + 'note_id': n.note_id, + 'war_room_id': n.war_room_id, + 'title': n.title, + 'content': n.content, + 'created_at': n.created_at.isoformat() if n.created_at else None, + 'updated_at': n.updated_at.isoformat() if n.updated_at else None, + 'created_by_id': n.created_by_id, + 'updated_by_id': n.updated_by_id, + } + + +@war_rooms_notes_blueprint.get('') +@ac_api_requires() +def list_notes(war_room_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + return response_api_success(data=[_serialize(n) for n in war_room_note_list(war_room_id)]) + + +@war_rooms_notes_blueprint.post('') +@ac_api_requires() +def create_note(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: + note = war_room_note_create( + war_room_id, title=raw.get('title'), + content=raw.get('content'), created_by_id=iris_current_user.id, + ) + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + emit_system_event( + war_room_id, 'note', + f'Created note: {note.title}', + author_id=iris_current_user.id, + ref_type='war_room_note', ref_id=note.note_id, + ) + return response_api_created(_serialize(note)) + + +@war_rooms_notes_blueprint.get('/<int:note_id>') +@ac_api_requires() +def get_note(war_room_id, note_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + try: + note = war_room_note_get(war_room_id, note_id) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(_serialize(note)) + + +@war_rooms_notes_blueprint.patch('/<int:note_id>') +@ac_api_requires() +def update_note(war_room_id, note_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: + note = war_room_note_update( + war_room_id, note_id, + title=raw.get('title'), + content=raw.get('content'), + 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)) + + +@war_rooms_notes_blueprint.delete('/<int:note_id>') +@ac_api_requires() +def delete_note(war_room_id, note_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + war_room_note_delete(war_room_id, note_id) + except ObjectNotFoundError: + return response_api_not_found() + 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 new file mode 100644 index 000000000..3cfaa9e77 --- /dev/null +++ b/source/app/blueprints/rest/v2/war_rooms/root.py @@ -0,0 +1,431 @@ +# 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 the War Room feature. + +URL layout (mounted at `/api/v2/war-rooms`): + + GET / list war rooms visible to the caller + POST / create a war room [war_rooms_create] + GET /<id> fetch a single war room + PATCH /<id> update name/state/etc. [war_rooms_write] + DELETE /<id> delete (cascade) [war_rooms_create] + + GET /<id>/members list members + POST /<id>/members add member (also grants ACL) + DELETE /<id>/members/<uid> remove member + + GET /<id>/cases list attached cases + POST /<id>/cases attach a case + DELETE /<id>/cases/<cid> detach a case +""" + +from flask import Blueprint +from flask import request + +from app.blueprints.access_controls import ac_api_requires +from app.blueprints.access_controls import ac_api_return_access_denied +from app.blueprints.access_controls import ac_current_user_has_permission +from app.blueprints.access_controls import ac_fast_check_current_user_has_case_access +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_chat import emit_system_event +from app.blueprints.rest.v2.war_rooms.serializers import serialize_case_attachment +from app.blueprints.rest.v2.war_rooms.serializers import serialize_member +from app.blueprints.rest.v2.war_rooms.serializers import serialize_war_room +from app.business.war_rooms import ( + war_room_add_member, + war_room_archive, + war_room_attach_case, + war_room_cases_list, + war_room_create, + war_room_delete, + war_room_detach_case, + war_room_get, + war_room_list_for_user, + war_room_members_list, + war_room_people, + war_room_remove_member, + war_room_unarchive, + war_room_update, +) +from app.models.authorization import CaseAccessLevel +from app.models.authorization import Permissions +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError + + +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.notes import war_rooms_notes_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.timelines import war_rooms_timelines_blueprint + + +war_rooms_blueprint = Blueprint( + 'war_rooms_rest_v2', __name__, url_prefix='/war-rooms' +) +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_timelines_blueprint) +war_rooms_blueprint.register_blueprint(war_rooms_sitreps_blueprint) +war_rooms_blueprint.register_blueprint(war_rooms_datastore_blueprint) + + +def _is_admin(): + return ac_current_user_has_permission(Permissions.server_administrator) + + +@war_rooms_blueprint.get('') +@ac_api_requires() +def list_war_rooms(): + # The list endpoint is gated by `war_rooms_read` so non-permitted + # users get a 403 rather than an empty list (an empty list would + # falsely suggest the feature is enabled but they have no rooms). + if not ac_current_user_has_permission(Permissions.war_rooms_read) \ + and not _is_admin(): + return ac_api_return_access_denied() + + state = request.args.get('state', type=str) + search = request.args.get('search', type=str) + # `archived` accepts a small controlled vocabulary: + # * absent / 'false' / '0' — live rooms only (default) + # * 'true' / '1' — archived rooms only + # * 'any' / 'all' — both + archived_raw = (request.args.get('archived', type=str) or '').strip().lower() + if archived_raw in ('true', '1'): + archived = True + elif archived_raw in ('any', 'all'): + archived = 'any' + else: + archived = False + + rooms = war_room_list_for_user( + iris_current_user.id, is_admin=_is_admin(), + state=state, search=search, archived=archived, + ) + return response_api_success(data=[serialize_war_room(r) for r in rooms]) + + +@war_rooms_blueprint.post('') +@ac_api_requires() +def create_war_room(): + if not ac_current_user_has_permission(Permissions.war_rooms_create) \ + and not _is_admin(): + return ac_api_return_access_denied() + + raw = request.get_json() + if not isinstance(raw, dict): + return response_api_error('Invalid request') + + try: + war_room = war_room_create( + name=raw.get('name'), + description=raw.get('description'), + state=raw.get('state'), + severity_id=raw.get('severity_id'), + color=raw.get('color'), + created_by_id=iris_current_user.id, + custom_attributes=raw.get('custom_attributes'), + ) + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + + return response_api_created(serialize_war_room(war_room)) + + +@war_rooms_blueprint.get('/<int:war_room_id>') +@ac_api_requires() +def get_war_room(war_room_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + try: + war_room = war_room_get(war_room_id) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(data=serialize_war_room(war_room)) + + +@war_rooms_blueprint.patch('/<int:war_room_id>') +@ac_api_requires() +def update_war_room(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: + war_room = war_room_update( + war_room_id, + name=raw.get('name'), + description=raw.get('description'), + state=raw.get('state'), + severity_id=raw.get('severity_id'), + color=raw.get('color'), + custom_attributes=raw.get('custom_attributes'), + closed_by_id=iris_current_user.id, + ) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + + # Surface state changes in the activity panel. Field-level edits + # (rename, color, description) are intentionally not logged here — + # they're low signal during a crisis. State transitions are. + if 'state' in raw and raw['state']: + emit_system_event( + war_room_id, 'system', + f'War room state changed to {raw["state"]}', + author_id=iris_current_user.id, + ) + + return response_api_success(data=serialize_war_room(war_room)) + + +@war_rooms_blueprint.delete('/<int:war_room_id>') +@ac_api_requires() +def delete_war_room(war_room_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + # Hard-delete is gated more tightly than a state flip — only + # creators (or admins) can remove a room from the database. + if not ac_current_user_has_permission(Permissions.war_rooms_create) \ + and not _is_admin(): + return ac_api_return_access_denied() + try: + war_room_delete(war_room_id) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_deleted() + + +# --- Archive --------------------------------------------------------------- +# +# Archive is a filing decision independent from the operational state +# (`open` / `active` / `standby` / `closed`). A user with write access +# can archive or unarchive the room; state and every child row are +# preserved. `POST` archives, `DELETE` unarchives — matches the +# member/follower REST shape used elsewhere in this file. + +@war_rooms_blueprint.post('/<int:war_room_id>/archive') +@ac_api_requires() +def archive_war_room(war_room_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + war_room = war_room_archive( + war_room_id, archived_by_id=iris_current_user.id, + ) + except ObjectNotFoundError: + return response_api_not_found() + emit_system_event( + war_room_id, 'system', 'War room archived', + author_id=iris_current_user.id, + ) + return response_api_success(data=serialize_war_room(war_room)) + + +@war_rooms_blueprint.delete('/<int:war_room_id>/archive') +@ac_api_requires() +def unarchive_war_room(war_room_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + war_room = war_room_unarchive(war_room_id) + except ObjectNotFoundError: + return response_api_not_found() + emit_system_event( + war_room_id, 'system', 'War room unarchived', + author_id=iris_current_user.id, + ) + return response_api_success(data=serialize_war_room(war_room)) + + +# --- Members ---------------------------------------------------------------- + +@war_rooms_blueprint.get('/<int:war_room_id>/members') +@ac_api_requires() +def list_members(war_room_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + rows = war_room_members_list(war_room_id) + return response_api_success(data=[serialize_member(r) for r in rows]) + + +@war_rooms_blueprint.post('/<int:war_room_id>/members') +@ac_api_requires() +def add_member(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') + + user_id = raw.get('user_id') + if not isinstance(user_id, int): + return response_api_error('user_id is required') + + try: + war_room_add_member( + war_room_id, user_id, + role=raw.get('role'), + added_by_id=iris_current_user.id, + access_level=raw.get('access_level'), + ) + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + + rows = war_room_members_list(war_room_id) + member_row = next((r for r in rows if r.user_id == user_id), None) + if member_row is not None: + emit_system_event( + war_room_id, 'system', + f'Added member {member_row.name or member_row.login} ' + f'as {member_row.role}', + author_id=iris_current_user.id, + ) + return response_api_created([serialize_member(r) for r in rows if r.user_id == user_id][0]) + + +@war_rooms_blueprint.delete('/<int:war_room_id>/members/<int:user_id>') +@ac_api_requires() +def remove_member(war_room_id, user_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + war_room_remove_member(war_room_id, user_id) + emit_system_event( + war_room_id, 'system', + f'Removed member #{user_id}', + author_id=iris_current_user.id, + ) + return response_api_deleted() + + +# --- People banner ---------------------------------------------------------- + +@war_rooms_blueprint.get('/<int:war_room_id>/people') +@ac_api_requires() +def list_people(war_room_id): + """Return the union of war-room members and every user with + effective access to any attached case. + + Surfaced as a banner above the war-room tabs so an operator can + see at a glance who's on the war. Read-only — adding members + still goes through the Members tab. + """ + err = require_war_room_read(war_room_id) + if err is not None: + return err + return response_api_success(data=war_room_people(war_room_id)) + + +# --- Case attachment -------------------------------------------------------- + +@war_rooms_blueprint.get('/<int:war_room_id>/cases') +@ac_api_requires() +def list_cases(war_room_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + rows = war_room_cases_list(war_room_id) + return response_api_success(data=[serialize_case_attachment(r) for r in rows]) + + +@war_rooms_blueprint.post('/<int:war_room_id>/cases') +@ac_api_requires() +def attach_case(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') + + case_id = raw.get('case_id') + if not isinstance(case_id, int): + return response_api_error('case_id is required') + + # Cross-resource check: the actor must have full_access on the case + # being attached. War-room write access alone is not enough — that + # would let an operator with no case visibility quietly bring a + # case into a war room and grant downstream visibility to the room's + # ACL holders. + if ac_fast_check_current_user_has_case_access( + case_id, [CaseAccessLevel.full_access] + ) is None: + return ac_api_return_access_denied(caseid=case_id) + + try: + link = war_room_attach_case( + war_room_id, case_id, + attached_by_id=iris_current_user.id, + note=raw.get('note'), + ) + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + + # Reuse the case-attached chat kind so the activity panel shows the + # event alongside chat-emitted `/attach` events. ref_case_id stamps + # the case so a per-case filter on the chat surfaces it later. + emit_system_event( + war_room_id, 'case_attached', + f'Attached case #{case_id}' + + (f' — {raw.get("note")}' if raw.get('note') else ''), + author_id=iris_current_user.id, + ref_type='case', ref_id=case_id, ref_case_id=case_id, + activity_type='case.attached', + ) + + return response_api_created({ + 'war_room_id': link.war_room_id, + 'case_id': link.case_id, + 'attached_at': link.attached_at.isoformat() if link.attached_at else None, + 'note': link.note, + }) + + +@war_rooms_blueprint.delete('/<int:war_room_id>/cases/<int:case_id>') +@ac_api_requires() +def detach_case(war_room_id, case_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + war_room_detach_case(war_room_id, case_id) + except ObjectNotFoundError: + return response_api_not_found() + emit_system_event( + war_room_id, 'case_detached', + f'Detached case #{case_id}', + author_id=iris_current_user.id, + ref_type='case', ref_id=case_id, ref_case_id=case_id, + activity_type='case.detached', + ) + return response_api_deleted() diff --git a/source/app/blueprints/rest/v2/war_rooms/serializers.py b/source/app/blueprints/rest/v2/war_rooms/serializers.py new file mode 100644 index 000000000..401982c98 --- /dev/null +++ b/source/app/blueprints/rest/v2/war_rooms/serializers.py @@ -0,0 +1,80 @@ +# 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. + +"""Plain dict serializers for war-room objects. + +These are intentionally not marshmallow schemas — the war-room object +graph is shallow and the v2 API ships flat dicts already (see +`case_timelines.py` for the same pattern). Centralising the shape here +means the REST sub-modules don't need to repeat field lists. +""" + + +def serialize_war_room(war_room): + return { + 'war_room_id': war_room.war_room_id, + 'war_room_uuid': str(war_room.war_room_uuid) if war_room.war_room_uuid else None, + 'name': war_room.name, + 'description': war_room.description, + 'state': war_room.state, + 'severity_id': war_room.severity_id, + 'color': war_room.color, + 'created_at': war_room.created_at.isoformat() if war_room.created_at else None, + 'created_by_id': war_room.created_by_id, + 'closed_at': war_room.closed_at.isoformat() if war_room.closed_at else None, + 'closed_by_id': war_room.closed_by_id, + 'archived_at': war_room.archived_at.isoformat() if war_room.archived_at else None, + 'archived_by_id': war_room.archived_by_id, + 'custom_attributes': war_room.custom_attributes, + } + + +def serialize_member(row): + return { + 'war_room_id': row.war_room_id, + 'user_id': row.user_id, + 'user_login': row.login, + 'user_name': row.name, + 'role': row.role, + 'added_at': row.added_at.isoformat() if row.added_at else None, + } + + +def serialize_case_attachment(row): + return { + 'war_room_id': row.war_room_id, + 'case_id': row.case_id, + 'case_name': row.case_name, + # Customer / owner / lifecycle joined server-side so the SPA can + # render a rich row + filter on these fields without a per-row + # fetch. + 'customer_id': getattr(row, 'customer_id', None), + 'customer_name': getattr(row, 'customer_name', None), + 'owner_id': getattr(row, 'owner_id', None), + 'owner_login': getattr(row, 'owner_login', None), + 'owner_name': getattr(row, 'owner_name', None), + 'open_date': row.open_date.isoformat() if getattr(row, 'open_date', None) else None, + 'close_date': row.close_date.isoformat() if getattr(row, 'close_date', None) else None, + 'state_id': getattr(row, 'state_id', None), + 'state_name': getattr(row, 'state_name', None), + 'task_count': int(getattr(row, 'task_count', 0) or 0), + 'task_open_count': int(getattr(row, 'task_open_count', 0) or 0), + 'attached_at': row.attached_at.isoformat() if row.attached_at else None, + 'note': row.note, + } + + +def serialize_case_war_room_summary(row): + """Shape used by `/cases/<id>/war-rooms` (case detail badge).""" + return { + 'war_room_id': row.war_room_id, + 'name': row.name, + 'state': row.state, + 'color': row.color, + } diff --git a/source/app/blueprints/rest/v2/war_rooms/sitreps.py b/source/app/blueprints/rest/v2/war_rooms/sitreps.py new file mode 100644 index 000000000..e14ebf3cf --- /dev/null +++ b/source/app/blueprints/rest/v2/war_rooms/sitreps.py @@ -0,0 +1,261 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""SitRep REST routes + export endpoints.""" + +from flask import Blueprint, Response, request, send_file, current_app +import io + +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_chat import create_message +from app.business.war_room_sitreps import ( + sitrep_as_html, + sitrep_as_markdown, + sitrep_delete, + sitrep_draft, + sitrep_get, + sitrep_list, + sitrep_publish, + sitrep_update, +) +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError + + +war_rooms_sitreps_blueprint = Blueprint( + 'war_rooms_sitreps_rest_v2', __name__, + url_prefix='/<int:war_room_id>/sitreps' +) + + +def _serialize(s, include_body=True): + d = { + 'sitrep_id': s.sitrep_id, + 'war_room_id': s.war_room_id, + 'version': s.version, + 'title': s.title, + 'authored_by_id': s.authored_by_id, + 'authored_at': s.authored_at.isoformat() if s.authored_at else None, + 'published': bool(s.published), + 'snapshot_json': s.snapshot_json, + } + if include_body: + d['body_md'] = s.body_md + return d + + +def _safe_filename(name, ext): + out = ''.join(c if c.isalnum() or c in ('-', '_') else '-' for c in (name or 'sitrep')) + out = out.strip('-_') or 'sitrep' + return f'{out[:100]}.{ext}' + + +@war_rooms_sitreps_blueprint.get('') +@ac_api_requires() +def list_sitreps(war_room_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + rows = sitrep_list(war_room_id) + return response_api_success(data=[_serialize(s, include_body=False) for s in rows]) + + +@war_rooms_sitreps_blueprint.post('') +@ac_api_requires() +def create_sitrep(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: + sit = sitrep_draft( + war_room_id, + title=raw.get('title'), + body_md=raw.get('body_md') or '', + authored_by_id=iris_current_user.id, + ) + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + return response_api_created(_serialize(sit)) + + +@war_rooms_sitreps_blueprint.get('/<int:sitrep_id>') +@ac_api_requires() +def get_sitrep(war_room_id, sitrep_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + try: + sit = sitrep_get(war_room_id, sitrep_id) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_success(_serialize(sit)) + + +@war_rooms_sitreps_blueprint.patch('/<int:sitrep_id>') +@ac_api_requires() +def update_sitrep(war_room_id, sitrep_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: + sit = sitrep_update(war_room_id, sitrep_id, + title=raw.get('title'), + body_md=raw.get('body_md')) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + return response_api_success(_serialize(sit)) + + +@war_rooms_sitreps_blueprint.post('/<int:sitrep_id>/publish') +@ac_api_requires() +def publish_sitrep(war_room_id, sitrep_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + sit = sitrep_publish(war_room_id, sitrep_id) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + # Mirror the publish event into the chat so the team sees it inline. + try: + create_message( + war_room_id, iris_current_user.id, + body=f'Published SitRep v{sit.version}: {sit.title}', + kind='sitrep_published', ref_type='sitrep', ref_id=sit.sitrep_id, + ) + except Exception: + pass + return response_api_success(_serialize(sit)) + + +@war_rooms_sitreps_blueprint.delete('/<int:sitrep_id>') +@ac_api_requires() +def delete_sitrep(war_room_id, sitrep_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + sitrep_delete(war_room_id, sitrep_id) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + return response_api_deleted() + + +@war_rooms_sitreps_blueprint.get('/<int:sitrep_id>/export.md') +@ac_api_requires() +def export_markdown(war_room_id, sitrep_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + try: + sit = sitrep_get(war_room_id, sitrep_id) + except ObjectNotFoundError: + return response_api_not_found() + md = sitrep_as_markdown(sit) + buf = io.BytesIO(md.encode('utf-8')) + return send_file( + buf, + mimetype='text/markdown; charset=utf-8', + as_attachment=True, + download_name=_safe_filename(sit.title, 'md'), + ) + + +@war_rooms_sitreps_blueprint.get('/<int:sitrep_id>/export.html') +@ac_api_requires() +def export_html(war_room_id, sitrep_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + try: + sit = sitrep_get(war_room_id, sitrep_id) + except ObjectNotFoundError: + return response_api_not_found() + return Response( + sitrep_as_html(sit), + mimetype='text/html; charset=utf-8', + headers={'Content-Disposition': + f'attachment; filename="{_safe_filename(sit.title, "html")}"'}, + ) + + +@war_rooms_sitreps_blueprint.get('/<int:sitrep_id>/export.pdf') +@ac_api_requires() +def export_pdf(war_room_id, sitrep_id): + """PDF export. + + Best-effort: if `weasyprint` or `xhtml2pdf` is installed in the + server image we render server-side. If not, we still return the + rich HTML with a `Content-Disposition: attachment` header that + most browsers happily print-to-PDF — the operator still gets a + self-contained, styled document without us shelling out to a + binary that may not be available. + """ + err = require_war_room_read(war_room_id) + if err is not None: + return err + try: + sit = sitrep_get(war_room_id, sitrep_id) + except ObjectNotFoundError: + return response_api_not_found() + + html = sitrep_as_html(sit) + filename = _safe_filename(sit.title, 'pdf') + + try: + from weasyprint import HTML # type: ignore + pdf_bytes = HTML(string=html).write_pdf() + return Response( + pdf_bytes, + mimetype='application/pdf', + headers={'Content-Disposition': f'attachment; filename="{filename}"'}, + ) + except Exception: + pass + + try: + from xhtml2pdf import pisa # type: ignore + out = io.BytesIO() + result = pisa.CreatePDF(html, dest=out) + if not result.err: + out.seek(0) + return Response( + out.getvalue(), + mimetype='application/pdf', + headers={'Content-Disposition': f'attachment; filename="{filename}"'}, + ) + except Exception: + pass + + # PDF backend unavailable. Return the rich HTML so the browser can + # print-to-PDF — but flip the extension back to .html so users know + # what they got. + fallback_name = _safe_filename(sit.title, 'html') + return Response( + html, + mimetype='text/html; charset=utf-8', + headers={'Content-Disposition': + f'attachment; filename="{fallback_name}"', + 'X-Pdf-Backend': 'unavailable'}, + ) diff --git a/source/app/blueprints/rest/v2/war_rooms/tasks.py b/source/app/blueprints/rest/v2/war_rooms/tasks.py new file mode 100644 index 000000000..4708918ea --- /dev/null +++ b/source/app/blueprints/rest/v2/war_rooms/tasks.py @@ -0,0 +1,217 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""War-room tasks REST routes.""" + +from datetime import datetime + +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_chat import emit_system_event +from app.business.war_room_tasks import ( + war_room_task_close, + war_room_task_create, + war_room_task_delete, + war_room_task_get, + war_room_task_list, + war_room_task_reopen, + war_room_task_update, +) +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError + + +war_rooms_tasks_blueprint = Blueprint( + 'war_rooms_tasks_rest_v2', __name__, url_prefix='/<int:war_room_id>/tasks' +) + + +def _parse_due(raw): + if raw is None or raw == '': + return None + if isinstance(raw, datetime): + return raw + if not isinstance(raw, str): + raise BusinessProcessingError('due_at must be an ISO date string') + try: + # Accept both "YYYY-MM-DD" and full ISO. + if len(raw) == 10: + return datetime.fromisoformat(raw) + return datetime.fromisoformat(raw.replace('Z', '+00:00')) + except ValueError: + raise BusinessProcessingError('due_at must be an ISO date string') + + +def _serialize_row(row): + return { + 'task_id': row.task_id, + 'war_room_id': row.war_room_id, + 'title': row.title, + 'description': row.description, + 'status_id': row.status_id, + 'assignee_id': row.assignee_id, + 'assignee_login': row.assignee_login, + 'assignee_name': row.assignee_name, + 'due_at': row.due_at.isoformat() if row.due_at else None, + 'source_case_id': row.source_case_id, + 'source_case_task_id': row.source_case_task_id, + 'created_at': row.created_at.isoformat() if row.created_at else None, + 'created_by_id': row.created_by_id, + # Display names for each actor on the task. Joined server-side + # so the SPA renders the row in one shot. + 'created_by_login': getattr(row, 'created_by_login', None), + 'created_by_name': getattr(row, 'created_by_name', None), + 'closed_at': row.closed_at.isoformat() if row.closed_at else None, + 'closed_by_id': row.closed_by_id, + 'closed_by_login': getattr(row, 'closed_by_login', None), + 'closed_by_name': getattr(row, 'closed_by_name', None), + 'tags': row.tags, + } + + +def _serialize_obj(task): + return { + 'task_id': task.task_id, + 'war_room_id': task.war_room_id, + 'title': task.title, + 'description': task.description, + 'status_id': task.status_id, + 'assignee_id': task.assignee_id, + '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, + 'closed_at': task.closed_at.isoformat() if task.closed_at else None, + 'closed_by_id': task.closed_by_id, + 'tags': task.tags, + } + + +@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]) + + +@war_rooms_tasks_blueprint.post('') +@ac_api_requires() +def create_task(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: + due_at = _parse_due(raw.get('due_at')) + task = war_room_task_create( + war_room_id, + title=raw.get('title'), + description=raw.get('description'), + status_id=raw.get('status_id'), + assignee_id=raw.get('assignee_id'), + due_at=due_at, + source_case_id=raw.get('source_case_id'), + source_case_task_id=raw.get('source_case_task_id'), + tags=raw.get('tags'), + created_by_id=iris_current_user.id, + ) + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + emit_system_event( + war_room_id, 'task_assigned', + f'Created task: {task.title}', + author_id=iris_current_user.id, + ref_type='war_room_task', ref_id=task.task_id, + ) + return response_api_created(_serialize_obj(task)) + + +@war_rooms_tasks_blueprint.patch('/<int:task_id>') +@ac_api_requires() +def update_task(war_room_id, task_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: + 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')} + if 'due_at' in raw: + fields['due_at'] = _parse_due(raw['due_at']) + task = war_room_task_update(war_room_id, task_id, **fields) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + return response_api_success(_serialize_obj(task)) + + +@war_rooms_tasks_blueprint.post('/<int:task_id>/close') +@ac_api_requires() +def close_task(war_room_id, task_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + task = war_room_task_close(war_room_id, task_id, + closed_by_id=iris_current_user.id) + except ObjectNotFoundError: + return response_api_not_found() + emit_system_event( + war_room_id, 'task_completed', + f'Closed task: {task.title}', + author_id=iris_current_user.id, + ref_type='war_room_task', ref_id=task.task_id, + ) + return response_api_success(_serialize_obj(task)) + + +@war_rooms_tasks_blueprint.post('/<int:task_id>/reopen') +@ac_api_requires() +def reopen_task(war_room_id, task_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + task = war_room_task_reopen(war_room_id, task_id) + except ObjectNotFoundError: + return response_api_not_found() + emit_system_event( + war_room_id, 'task_assigned', + f'Reopened task: {task.title}', + author_id=iris_current_user.id, + ref_type='war_room_task', ref_id=task.task_id, + ) + return response_api_success(_serialize_obj(task)) + + +@war_rooms_tasks_blueprint.delete('/<int:task_id>') +@ac_api_requires() +def delete_task(war_room_id, task_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + war_room_task_delete(war_room_id, task_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 new file mode 100644 index 000000000..c4a71dcc4 --- /dev/null +++ b/source/app/blueprints/rest/v2/war_rooms/timelines.py @@ -0,0 +1,245 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""War-room timelines REST routes.""" + +from datetime import datetime + +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_timelines import ( + create_timeline, + create_timeline_event, + delete_timeline, + delete_timeline_event, + get_timeline, + list_timeline_events, + list_timelines, + update_timeline, + update_timeline_event, +) +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError + + +war_rooms_timelines_blueprint = Blueprint( + 'war_rooms_timelines_rest_v2', __name__, + url_prefix='/<int:war_room_id>/timelines' +) + + +def _serialize_timeline(t): + return { + 'timeline_id': t.timeline_id, + 'war_room_id': t.war_room_id, + 'name': t.name, + 'description': t.description, + 'color': t.color, + 'is_default': bool(t.is_default), + 'created_at': t.created_at.isoformat() if t.created_at else None, + 'created_by_id': t.created_by_id, + } + + +def _serialize_event(e): + return { + 'id': e.id, + 'timeline_id': e.timeline_id, + 'case_id': e.case_id, + 'event_id': e.event_id, + 'title': e.title, + 'content': e.content, + '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), + 'created_at': e.created_at.isoformat() if e.created_at else None, + 'created_by_id': e.created_by_id, + } + + +@war_rooms_timelines_blueprint.get('') +@ac_api_requires() +def list_room_timelines(war_room_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + return response_api_success( + data=[_serialize_timeline(t) for t in list_timelines(war_room_id)] + ) + + +@war_rooms_timelines_blueprint.post('') +@ac_api_requires() +def create_room_timeline(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: + t = create_timeline( + 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_timeline(t)) + + +@war_rooms_timelines_blueprint.patch('/<int:timeline_id>') +@ac_api_requires() +def update_room_timeline(war_room_id, timeline_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: + t = update_timeline(war_room_id, timeline_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_timeline(t)) + + +@war_rooms_timelines_blueprint.delete('/<int:timeline_id>') +@ac_api_requires() +def delete_room_timeline(war_room_id, timeline_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + delete_timeline(war_room_id, timeline_id) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + return response_api_deleted() + + +@war_rooms_timelines_blueprint.get('/events') +@ac_api_requires() +def list_events(war_room_id): + err = require_war_room_read(war_room_id) + if err is not None: + return err + timeline_ids_raw = request.args.get('timeline_ids', type=str) + timeline_ids = None + if timeline_ids_raw: + try: + timeline_ids = [int(x) for x in timeline_ids_raw.split(',') if x.strip()] + except ValueError: + return response_api_error('Invalid timeline_ids') + rows = list_timeline_events(war_room_id, timeline_ids=timeline_ids) + return response_api_success(data=[_serialize_event(e) for e in rows]) + + +def _parse_event_date(raw): + if raw is None or raw == '': + return None + if isinstance(raw, datetime): + return raw + if not isinstance(raw, str): + raise BusinessProcessingError('event_date must be an ISO date string') + try: + return datetime.fromisoformat(raw.replace('Z', '+00:00')) + except ValueError: + raise BusinessProcessingError('event_date must be an ISO date string') + + +@war_rooms_timelines_blueprint.post('/<int:timeline_id>/events') +@ac_api_requires() +def add_event(war_room_id, timeline_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: + event_date = _parse_event_date(raw.get('event_date')) + row = create_timeline_event( + war_room_id, timeline_id, + title=raw.get('title'), + content=raw.get('content'), + event_date=event_date, + event_tz=raw.get('event_tz'), + color=raw.get('color'), + category=raw.get('category'), + case_id=raw.get('case_id'), + event_id=raw.get('event_id'), + created_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_created(_serialize_event(row)) + + +@war_rooms_timelines_blueprint.patch('/events/<int:event_id>') +@ac_api_requires() +def patch_event(war_room_id, event_id): + """Partial update for a war-room timeline event. + + Body fields are all optional; only those present are touched. A + missing key is left as-is; an explicit `null` clears the field. + `timeline_id` re-parents the event (drag-between-timelines). + """ + 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') + kwargs = {} + for key in ('title', 'content', 'event_tz', 'color', 'category', + 'timeline_id'): + if key in raw: + kwargs[key] = raw[key] + if 'event_date' in raw: + try: + kwargs['event_date'] = _parse_event_date(raw['event_date']) + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + try: + row = update_timeline_event(war_room_id, event_id, **kwargs) + except ObjectNotFoundError: + return response_api_not_found() + except BusinessProcessingError as e: + return response_api_error(e.get_message()) + return response_api_success(_serialize_event(row)) + + +@war_rooms_timelines_blueprint.delete('/events/<int:event_id>') +@ac_api_requires() +def remove_event(war_room_id, event_id): + err = require_war_room_write(war_room_id) + if err is not None: + return err + try: + delete_timeline_event(war_room_id, event_id) + except ObjectNotFoundError: + return response_api_not_found() + return response_api_deleted() diff --git a/source/app/blueprints/socket_io_event_handlers/collab_event_handlers.py b/source/app/blueprints/socket_io_event_handlers/collab_event_handlers.py new file mode 100644 index 000000000..a580e29d9 --- /dev/null +++ b/source/app/blueprints/socket_io_event_handlers/collab_event_handlers.py @@ -0,0 +1,466 @@ +# 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. + +"""SocketIO namespace `/collab` — real-time collaborative editing. + +Transport layer for a server-authoritative Yjs setup: + + * Receive `join {doc}` → resolve ACL → `ensure_snapshot` (seeds Y.Doc + from source column on first open) → `join_room(doc)` → emit + `sync-init` with the authoritative `y_state` bytes. + * Receive `sync {doc, update}` → ACL re-check → `apply_wire_update` + merges into the server's authoritative Y.Doc, persists → rebroadcast + the update to peers (they apply it into their local Y.Docs; CRDT + convergence guarantees identical state). + * Receive `awareness {doc, update, client_id}` → rebroadcast + + server-attested `awareness-identity` companion for spoof-proof + cursor labels. + * Receive `leave {doc}` (or disconnect) → `leave_room` → if that + was the last client on the doc, flush the Y.Doc back to the source + column as markdown. + +`content_md` is NEVER on the wire in this version — the client only +sends Yjs updates, and the server owns the markdown rendering. +Everything else — doc-name schema, ACL rules, snapshot storage — +lives in `app.business.collab`. +""" + +import base64 +import logging + +from flask import g, request +from flask_socketio import emit +from flask_socketio import join_room +from flask_socketio import leave_room + +from app import socket_io +from app.blueprints.access_controls import is_user_authenticated +from app.blueprints.iris_user import iris_current_user +from app.business.auth import validate_auth_token +from app.business.collab import DocResolutionError +from app.business.collab import apply_wire_update +from app.business.collab import ensure_snapshot +from app.business.collab import flush_to_source +from app.business.collab import resolve_doc + + +logger = logging.getLogger(__name__) + + +NAMESPACE = '/collab' + + +# --- Resource caps -------------------------------------------------------- +# The `/collab` transport is authenticated but the payloads are opaque +# blobs — we can't inspect a Yjs update to decide whether it's "safe", +# so we cap size + count instead. All limits are per-message except +# `_MAX_DOCS_PER_SID` which is per-connection. + +# Base64-encoded Yjs update. A typical single-keystroke update is +# under 200 bytes; a bulk paste of a large document might reach a few +# hundred KB. 1 MiB is a comfortable ceiling that covers legitimate +# use and hard-stops anything trying to burn RAM. +_MAX_UPDATE_B64_LEN = 1 * 1024 * 1024 + +# Awareness updates are cursor + user identity — always small. +_MAX_AWARENESS_B64_LEN = 64 * 1024 + +# Cap concurrent docs per connection so a single misbehaving client +# can't burn resolver/DB cycles by joining thousands of doc-names. +_MAX_DOCS_PER_SID = 32 + +# Track which docs each sid has joined so we can drive per-doc leave +# on disconnect. flask-socketio doesn't hand us the room list on a +# raw disconnect event, so we mirror it here. Doubles as the source +# of truth for the sender-membership check that gates every relay. +_sid_docs: dict[str, set[str]] = {} + +# `g.auth_user` populated on the connect handler doesn't survive to +# subsequent events (each event runs in its own request context). We +# mirror the authenticated user id per-sid so downstream handlers can +# resolve identity without redoing the JWT validation on every message. +_sid_user_ids: dict[str, int] = {} + +# Per-doc mapping from Yjs `client_id` → authenticated `user_id`, so we +# only emit `awareness-identity` when a client_id is new to the room (or +# its identity has changed). Without this we broadcast identity on every +# keystroke of every peer, which triggers the client to overwrite the +# local awareness state and re-render every remote cursor — that's what +# the user reported as "text blinking because it comes and goes." +_doc_client_identities: dict[str, dict[int, int]] = {} + + +def _current_user_id(): + """Return the authenticated user id for the socket event in flight. + + Checks the session/g-based `iris_current_user` first (works when the + session cookie is present, e.g. classical login flow). Falls back + to our per-sid mirror populated on connect from the socket.io + `auth` payload — that's the path used by the SPA behind an SSO + proxy where the WS upgrade strips the `Authorization` header. + """ + user = iris_current_user._get_current_object() # type: ignore[attr-defined] + if user is not None: + uid = getattr(user, 'id', None) + if uid: + return uid + return _sid_user_ids.get(getattr(request, 'sid', None)) + + +def _current_user_name(): + user = iris_current_user._get_current_object() # type: ignore[attr-defined] + if user is not None: + # `user.user` is the login handle used in existing socket handlers; + # keep parity so awareness user labels look the same everywhere. + name = getattr(user, 'user', None) or getattr(user, 'name', None) + if name: + return name + # Fallback path (auth-payload only, no session context): look up + # the user by id. Cache miss is fine — the resolver still works + # with just the id, we lose the pretty name in awareness relays + # for one lookup. + uid = _sid_user_ids.get(getattr(request, 'sid', None)) + if not uid: + return None + from app.datamgmt.manage.manage_users_db import get_user + user_row = get_user(uid) + return getattr(user_row, 'user', None) if user_row else None + + +@socket_io.on('connect', namespace=NAMESPACE) +def on_connect(auth): + """Reject unauthenticated sessions at connect time. + + Auth resolution order: + 1. Standard `is_user_authenticated(request)` — session cookie or + `Authorization` header if the deployment happens to preserve + it (rare through nginx WS proxies, but supported). + 2. Fallback: token supplied via socket.io's `auth` handshake + payload. Browsers won't attach custom headers to WS handshakes + so the SPA sends the Flask JWT via `io(url, {auth: {token}})`; + Flask-SocketIO passes it here as the `auth` argument. + + On success we store the user id in `_sid_user_ids` so downstream + events (which run in fresh request contexts and don't inherit + `g.auth_user`) can still identify the caller via + `_current_user_id()`. + """ + user_id = None + + if is_user_authenticated(request): + # Header/session path — `iris_current_user` is populated for + # this request context. Grab the id off it directly. + user_obj = iris_current_user._get_current_object() # type: ignore[attr-defined] + user_id = getattr(user_obj, 'id', None) if user_obj else None + + if user_id is None and isinstance(auth, dict): + token = auth.get('token') + if isinstance(token, str) and token: + user_data = validate_auth_token(token) + if user_data and not ( + user_data.get('mfa_required') and not user_data.get('mfa_verified') + ): + g.auth_user = user_data + g.auth_token_user_id = user_data['user_id'] + user_id = user_data['user_id'] + + if user_id is None: + return False + + _sid_docs[request.sid] = set() + _sid_user_ids[request.sid] = user_id + return True + + +@socket_io.on('disconnect', namespace=NAMESPACE) +def on_disconnect(): + """Clean up rooms this sid was in, and flush any doc that lost its + last client.""" + _sid_user_ids.pop(request.sid, None) + docs = _sid_docs.pop(request.sid, set()) + for doc_name in docs: + leave_room(doc_name) + _maybe_flush_if_empty(doc_name) + + +def _room_is_empty(doc_name): + """True if no sids are currently in the doc's room. + + flask-socketio exposes `.rooms` on the underlying server; we walk + it defensively because the API surface has changed between minor + versions. + """ + try: + server = socket_io.server + # `rooms` on a Server returns a dict keyed by namespace when + # called with no args; we want the count for our namespace. + room_members = server.manager.get_participants(NAMESPACE, doc_name) + # `get_participants` is an iterator of (sid, eio_sid) pairs. + for _ in room_members: + return False + return True + except Exception: + # If we can't tell, err on the side of NOT flushing here — the + # doc will still flush when another client eventually joins + # and leaves cleanly. Flushing too often is harmless (idempotent + # writes) but flushing incorrectly on a live doc could produce + # a spurious activity-log entry. + logger.debug('collab: could not enumerate room %s', doc_name) + return False + + +def _maybe_flush_if_empty(doc_name): + """If the doc has no more connected clients, write its markdown + back to the source column and fire the audit log entry.""" + if not _room_is_empty(doc_name): + return + try: + flush_to_source(doc_name) + except Exception: + # Never let a flush failure surface as a socket-level error — + # the client isn't waiting on this. Log and move on. + logger.exception('collab: flush failed for %s', doc_name) + # Drop the per-doc identity cache so the next fresh open re-broadcasts + # identities cleanly. Also stops the map from growing forever across + # doc lifetimes. + _doc_client_identities.pop(doc_name, None) + + +@socket_io.on('join', namespace=NAMESPACE) +def on_join(data): + """Client asks to join a doc's room. + + Payload: `{doc: '<kind>:<id>'}`. On success we join the room and + immediately emit `sync-init` back to the caller only, carrying the + authoritative `y_state` bytes (base64) and the caller's write permission. + + Emits `permission-denied` if the caller lacks read access, or + `resolution-error` if the doc-name shape is wrong / the target + doesn't exist. + """ + user_id = _current_user_id() + if not user_id or not isinstance(data, dict): + return + doc_name = data.get('doc') + if not isinstance(doc_name, str): + emit('resolution-error', {'reason': 'doc must be a string'}) + return + + # Cap the number of docs a single connection can be attached to. + # Every join spends resolver + DB cycles (ACL + `ensure_snapshot`, + # which may run a markdown→Y.Doc migration on first open), so an + # unbounded loop is a cheap DoS. `_MAX_DOCS_PER_SID` is well above + # any legitimate editor use — the SPA opens one doc per mounted + # editor, and even a heavy multi-panel view stays single-digit. + joined = _sid_docs.setdefault(request.sid, set()) + if doc_name not in joined and len(joined) >= _MAX_DOCS_PER_SID: + emit('resolution-error', + {'doc': doc_name, 'reason': 'too many docs joined on this connection'}) + return + + try: + resolved = resolve_doc(doc_name, user_id) + except DocResolutionError as exc: + emit('resolution-error', {'doc': doc_name, 'reason': str(exc)}) + return + + if not resolved['exists']: + emit('resolution-error', {'doc': doc_name, 'reason': 'not found'}) + return + if not resolved['can_read']: + emit('permission-denied', {'doc': doc_name, 'reason': 'no read access'}) + return + + # ensure_snapshot returns non-empty bytes on success — even for a + # completely fresh doc, the value is the update for an empty Y.Doc + # (small header). If the seeding raises, we don't want to admit the + # client to the room since they'd be stuck with no state to apply. + try: + y_state_bytes = ensure_snapshot(doc_name, resolved['current_content']) + except Exception: + logger.exception('collab: ensure_snapshot failed for %s', doc_name) + emit('resolution-error', {'doc': doc_name, 'reason': 'server error'}) + return + + join_room(doc_name) + joined.add(doc_name) + + emit('sync-init', { + 'doc': doc_name, + 'y_state': base64.b64encode(y_state_bytes).decode('ascii'), + 'can_write': resolved['can_write'], + 'user': { + 'id': user_id, + 'name': _current_user_name(), + }, + }) + + +@socket_io.on('leave', namespace=NAMESPACE) +def on_leave(data): + """Explicit leave (e.g. the editor unmounted).""" + if not isinstance(data, dict): + return + doc_name = data.get('doc') + if not isinstance(doc_name, str): + return + leave_room(doc_name) + docs = _sid_docs.get(request.sid) + if docs: + docs.discard(doc_name) + _maybe_flush_if_empty(doc_name) + + +@socket_io.on('sync', namespace=NAMESPACE) +def on_sync(data): + """A Yjs update from one client — apply it to the authoritative + Y.Doc, then fan out to the room. + + Payload: `{doc, update: base64}`. No `content_md` — server owns + markdown rendering, we only accept opaque Yjs updates on the wire. + + Defense in depth: + 1. Sender must be a joined participant of `doc` (per our own + `_sid_docs` mirror). Blocks a client from squirting an update + into a room they never authenticated against. + 2. ACL is re-resolved on every message so a mid-session revoke + takes effect immediately. + 3. Payload size is capped so a single client can't OOM the + merge path or blow up the `collab_doc.y_state` blob. + 4. We only rebroadcast if the merge into the server's + authoritative Y.Doc succeeded — a malformed update that fails + `merge_updates` is dropped, not passed on to peers. + """ + user_id = _current_user_id() + if not user_id or not isinstance(data, dict): + return + doc_name = data.get('doc') + update_b64 = data.get('update') + if not isinstance(doc_name, str): + return + + # (1) Sender-membership gate. + if doc_name not in _sid_docs.get(request.sid, set()): + emit('permission-denied', + {'doc': doc_name, 'reason': 'not a participant of this doc'}) + return + + # (3) Size cap, before any DB work. + if not isinstance(update_b64, str) or not update_b64: + return + if len(update_b64) > _MAX_UPDATE_B64_LEN: + emit('resolution-error', + {'doc': doc_name, 'reason': 'update too large or malformed'}) + return + + # (2) ACL re-check. + try: + resolved = resolve_doc(doc_name, user_id) + except DocResolutionError: + return + if not resolved.get('can_write'): + emit('permission-denied', {'doc': doc_name, 'reason': 'no write access'}) + return + + # (4) Merge into the server-authoritative Y.Doc. Only relay if the + # merge succeeded — a corrupt update stops here rather than + # poisoning peers. + try: + applied = apply_wire_update(doc_name, update_b64, user_id) + except Exception: + logger.exception('collab: apply_wire_update failed for %s', doc_name) + return + if applied is None: + return + + emit('sync', { + 'doc': doc_name, + 'update': update_b64, + 'origin': _current_user_name(), + }, to=doc_name, skip_sid=request.sid) + + +@socket_io.on('awareness', namespace=NAMESPACE) +def on_awareness(data): + """Awareness (cursor + user identity) update from one client. + + Never persisted — ephemeral by design. + + Defenses: + * Sender-membership gate: without this a client could squirt + fake cursor decorations into a room they never authenticated + against, just by knowing the doc name. Awareness carries the + client-controlled `user.name` label so this would trivially + enable impersonation-in-somebody-else's-room. + * Size cap: awareness payloads are always small (cursor positions + + a name). Anything above 64 KiB is a client bug or an attack. + + We deliberately skip the ACL re-resolve here — it's expensive per + keystroke and the sender-membership gate already establishes that + the sender was authorised at join time. A mid-session ACL revoke + will still kick them out on their next `sync` (which does re-check), + and the room they're spraying awareness into is one they legitimately + have read access to right up until that point. + """ + user_id = _current_user_id() + if not user_id or not isinstance(data, dict): + return + doc_name = data.get('doc') + update_b64 = data.get('update') + if not isinstance(doc_name, str) or not isinstance(update_b64, str): + return + if len(update_b64) > _MAX_AWARENESS_B64_LEN: + return + + if doc_name not in _sid_docs.get(request.sid, set()): + # Silent drop — no `permission-denied` reply here because a + # legitimate client will never hit this branch and we don't + # want to give a scanner a signal to differentiate valid vs. + # invalid doc_names. + return + + # The client is welcome to spoof `user.name` inside the awareness + # blob (we can't decode + rewrite it server-side without pulling in + # a full Yjs Python implementation). Instead, we let the sender + # attach their Yjs `client_id` in the message envelope, we map it + # to the *authenticated* username on the server, and we rebroadcast + # that mapping. Peers use the mapping to override the untrusted + # label at render time (see client `SocketYjsProvider`). + # + # We ONLY broadcast the identity when it's new — the first time we + # see a given client_id on this doc, or when its authenticated + # user_id changes (which shouldn't happen in a single session but + # is defensive). Broadcasting on every keystroke would trigger the + # client's rewrite→re-render loop and produce visible cursor flicker. + client_id = data.get('client_id') + if isinstance(client_id, int): + room_identities = _doc_client_identities.setdefault(doc_name, {}) + if room_identities.get(client_id) != user_id: + room_identities[client_id] = user_id + emit('awareness-identity', { + 'doc': doc_name, + 'client_id': client_id, + 'name': _current_user_name(), + 'user_id': user_id, + }, to=doc_name) + + emit('awareness', { + 'doc': doc_name, + 'update': update_b64, + 'origin': _current_user_name(), + }, to=doc_name, skip_sid=request.sid) + + +def register_collab_socket_handlers(): + """Called from app __init__ — forces this module to import so the + `@socket_io.on(...)` decorators register. Mirrors the pattern used + by the notification and case-notes handlers. + """ + logger.debug('collab: /collab namespace registered') + return None diff --git a/source/app/blueprints/socket_io_event_handlers/notification_event_handlers.py b/source/app/blueprints/socket_io_event_handlers/notification_event_handlers.py new file mode 100644 index 000000000..8b726eb72 --- /dev/null +++ b/source/app/blueprints/socket_io_event_handlers/notification_event_handlers.py @@ -0,0 +1,141 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""SocketIO namespace for user-scoped notifications. + +Clients open `/notifications` and emit `join` (no payload) to be added +to their own `user-<self.id>` room. That room name is derived entirely +from the authenticated session — the client CANNOT ask to join another +user's room. Server-emitted `new_notification` events are addressed to +`user-<recipient>` rooms only, so cross-user leakage requires either a +session-fixation bug elsewhere in the app OR joining someone else's +room; the latter is what this handler prevents. +""" + +import logging + +from flask import g, request +from flask_socketio import emit +from flask_socketio import join_room +from flask_socketio import leave_room + +from app import socket_io +from app.blueprints.access_controls import is_user_authenticated +from app.blueprints.iris_user import iris_current_user +from app.business.auth import validate_auth_token + + +logger = logging.getLogger(__name__) + + +NAMESPACE = '/notifications' + + +# `g.auth_user` set by the auth-payload fallback in `on_connect` doesn't +# survive to subsequent events (each event gets its own request context), +# so we mirror the authenticated user id per-sid. See `_current_user_id`. +_sid_user_ids: dict[str, int] = {} + + +def _current_user_id(): + """Return the current authenticated user's id, or None.""" + # `iris_current_user` is a LocalProxy — resolve to the underlying + # user object before touching attributes so we don't blow up on + # unauthenticated sessions. + user = iris_current_user._get_current_object() # type: ignore[attr-defined] + if user is not None: + uid = getattr(user, 'id', None) + if uid: + return uid + return _sid_user_ids.get(getattr(request, 'sid', None)) + + +@socket_io.on('connect', namespace=NAMESPACE) +def on_connect(auth): + """Require auth at connect time so unauthenticated sockets never + persist in the namespace. Returning False rejects the connection. + + Auth resolution: session/header first, then fall back to the token + supplied via socket.io's `auth` handshake payload — the browser + strips custom headers on WS upgrade so a header-only path breaks + behind SSO proxies. Matches the pattern used by the `/collab` + namespace; see `collab_event_handlers.on_connect` for the writeup. + + We **auto-join** the caller's own user room here as part of the + connect handshake — waiting for a follow-up `emit('join')` from + the client left a race window (a few hundred ms on WebSocket, up + to a full long-poll cycle on polling transport) where server-side + emits landed in an empty room and were silently dropped. Since + the room name is derived entirely from the authenticated session, + the auto-join can't be abused to eavesdrop on someone else. + """ + user_id = None + + if is_user_authenticated(request): + user_obj = iris_current_user._get_current_object() # type: ignore[attr-defined] + user_id = getattr(user_obj, 'id', None) if user_obj else None + + if user_id is None and isinstance(auth, dict): + token = auth.get('token') + if isinstance(token, str) and token: + user_data = validate_auth_token(token) + if user_data and not ( + user_data.get('mfa_required') and not user_data.get('mfa_verified') + ): + g.auth_user = user_data + g.auth_token_user_id = user_data['user_id'] + user_id = user_data['user_id'] + + if not user_id: + return False + + _sid_user_ids[request.sid] = user_id + join_room(f'user-{user_id}') + logger.debug('notification namespace: user-%s joined on connect', user_id) + return True + + +@socket_io.on('disconnect', namespace=NAMESPACE) +def on_disconnect(): + """Drop the per-sid identity so we don't leak entries as sockets + churn. Room membership is cleaned up by flask-socketio itself. + """ + _sid_user_ids.pop(request.sid, None) + + +@socket_io.on('join', namespace=NAMESPACE) +def on_join(_data=None): + """Legacy join handler — kept for older clients that still emit + `join` explicitly after connect. Idempotent: the connect handler + already joined the room, so this is now a no-op ack. + + The `_data` payload is ignored entirely — the room is derived + from the session id, NOT the payload. + """ + user_id = _current_user_id() + if not user_id: + return + # Idempotent — flask-socketio's join_room is a no-op when the + # session is already in the room. + join_room(f'user-{user_id}') + emit('joined', {'ok': True, 'user_id': user_id}) + + +@socket_io.on('leave', namespace=NAMESPACE) +def on_leave(_data=None): + user_id = _current_user_id() + if not user_id: + return + leave_room(f'user-{user_id}') + + +def register_notification_socket_handlers(): + """Called from app __init__ after the other socket handlers. + + The `@socket_io.on` decorators above register at import time, so + this function is a no-op body — its purpose is to force the module + to be imported (which triggers those decorators). Mirrors the + pattern used by the case / notes / update handlers. + """ + return None diff --git a/source/app/business/access_controls.py b/source/app/business/access_controls.py index b21720933..111dc02b5 100644 --- a/source/app/business/access_controls.py +++ b/source/app/business/access_controls.py @@ -24,6 +24,7 @@ from app.datamgmt.manage.manage_access_control_db import check_ua_case_client from app.datamgmt.manage.manage_access_control_db import user_has_client_access from app.logger import logger +from app.models.authorization import Permissions from app.models.authorization import UserCaseAccess from app.models.authorization import ac_has_permission_server_administrator from app.models.authorization import CaseAccessLevel @@ -77,7 +78,23 @@ def ac_fast_check_user_has_case_access(user_id, cid, expected_access_levels: lis access_level = get_case_effective_access(user_id, cid) if not access_level: - # The user has no direct access, check if he is part of the client + # No direct grant. Before falling through to the client-membership + # check, confirm the user actually has app-level permissions — + # OIDC-only sessions without standard_user / server_administrator + # should not auto-inherit cases via customer membership. Mirrors + # the v2.4.29 guard in iris-engine ac_fast_check_user_has_case_access. + # Late import: iris_engine.access_control.utils and manage_users_db + # both import back into this module, so resolve them at call time. + from app.iris_engine.access_control.utils import ac_get_effective_permissions_of_user + from app.datamgmt.manage.manage_users_db import get_user + user = get_user(user_id) + if user is None: + return None + permissions = ac_get_effective_permissions_of_user(user) + if not ac_flag_match_mask(permissions, Permissions.server_administrator.value) \ + and not ac_flag_match_mask(permissions, Permissions.standard_user.value): + return None + access_level = check_ua_case_client(user_id, cid) if not access_level: return None @@ -120,3 +137,62 @@ def access_controls_user_has_customer_access( return False return False + + +def access_controls_user_has_customer_scope( + user, + permissions, + customer_scope, + fallback_customer_access=None +): + """Verify the caller can act on a resource declared with the given + `customer_scope` shape used by incident rules and investigation flows. + + Semantics — mirror the tenant-safety pattern used elsewhere: + * `customer_scope == None` (a null-scope "global" resource) requires + `server_administrator`. Non-admins must never be able to create, + edit, deploy, or back-fill a resource that would affect every + tenant on the box. + * A non-empty list requires access to EVERY customer id in it + (subset check). Rejecting a partial-access payload is the safe + default: acting on the resource touches every customer in the + list, so any single unreachable id is a cross-tenant write. + * An empty list is treated the same as null-scope — the resource + has no meaningful tenant boundary, so admin-only is the right + gate rather than silently allowing a global write.""" + if ac_has_permission_server_administrator(permissions): + return True + + if not customer_scope: # None or [] + return False + + if not isinstance(customer_scope, (list, tuple)): + # Payload shape guard — schema validation should already have + # caught this, but a defensive False here means "unknown shape, + # deny" rather than "unknown shape, allow". + return False + + for customer_id in customer_scope: + if not access_controls_user_has_customer_access( + user, permissions, customer_id, + fallback_customer_access=fallback_customer_access, + ): + return False + return True + + +def access_controls_user_accessible_customers(user, permissions): + """Return the set of customer ids the caller can read. `None` marker + means "no filter — server_administrator sees all". Used by list + endpoints so they don't leak resources scoped to unreachable tenants.""" + from app.models.authorization import UserClient # local import — avoid boot-time cycle + + if ac_has_permission_server_administrator(permissions): + return None + user_id = getattr(user, 'id', None) + if user_id is None: + return set() + rows = UserClient.query.with_entities(UserClient.client_id).filter( + UserClient.user_id == user_id + ).all() + return {r[0] for r in rows} diff --git a/source/app/business/activity.py b/source/app/business/activity.py index de3caab04..dec04a9ea 100644 --- a/source/app/business/activity.py +++ b/source/app/business/activity.py @@ -16,8 +16,94 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +from app.business.war_rooms_access import ac_get_fast_user_war_rooms_access +from app.datamgmt.activities.activities_db import list_activities_paginated from app.datamgmt.activities.activities_db import search_users_activity_in_case +from app.datamgmt.manage.manage_cases_db import user_list_cases_view def activity_search_in_case(case_identifier): return search_users_activity_in_case(case_identifier) + + +def list_activities( + user_id, + can_read_all, + page=1, + per_page=25, + include_non_case=False, + search_value=None, + scope_user_id=None, + case_id=None, + war_room_id=None, + user_ids=None, + case_ids=None, + war_room_ids=None, + date_from=None, + date_to=None, + is_from_api=None, + is_manual=None, +): + """Paginated, access-scoped activities listing for the Activities page. + + ``can_read_all`` indicates whether the caller holds the + ``all_activities_read`` permission. When False, results are scoped to + cases the caller has access to (matching the legacy behaviour of + ``/activities/list``). When True, the caller sees activity across every + case (the legacy ``/activities/list-all`` view) and can opt-in to + non-case-related rows via ``include_non_case``. + """ + page = max(1, int(page or 1)) + per_page = max(1, min(int(per_page or 25), 200)) + + if can_read_all: + accessible_case_ids = None + accessible_war_room_ids = None + else: + # `user_list_cases_view` returns the case ids the user can see. + # Pass through verbatim — `list_activities_paginated` understands + # an empty list as "no cases" and short-circuits accordingly. + accessible_case_ids = user_list_cases_view(user_id) + # War-room access lives on its own ACL table (see + # `war_rooms_access`), independent of case ACLs. + accessible_war_room_ids = ac_get_fast_user_war_rooms_access(user_id) + + # When the caller asks for specific cases but isn't a global reader, + # intersect their request with the accessible-case window so a guess + # at a forbidden case id silently no-ops (rather than 403'ing or + # leaking existence). + effective_case_ids = case_ids + if not can_read_all and case_ids: + accessible_set = set(accessible_case_ids or []) + effective_case_ids = [cid for cid in case_ids if cid in accessible_set] + if not effective_case_ids: + # The caller filtered down to cases they can't see — return + # an empty page by passing an unsatisfiable case filter, + # rather than dropping the filter and showing everything. + effective_case_ids = [-1] + + effective_war_room_ids = war_room_ids + if not can_read_all and war_room_ids: + accessible_wr_set = set(accessible_war_room_ids or []) + effective_war_room_ids = [wid for wid in war_room_ids if wid in accessible_wr_set] + if not effective_war_room_ids: + effective_war_room_ids = [-1] + + return list_activities_paginated( + page=page, + per_page=per_page, + accessible_case_ids=accessible_case_ids, + accessible_war_room_ids=accessible_war_room_ids, + include_non_case=include_non_case, + search_value=search_value or None, + user_id=scope_user_id, + case_id=case_id, + war_room_id=war_room_id, + user_ids=user_ids, + case_ids=effective_case_ids, + war_room_ids=effective_war_room_ids, + date_from=date_from, + date_to=date_to, + is_from_api=is_from_api, + is_manual=is_manual, + ) diff --git a/source/app/business/alerts.py b/source/app/business/alerts.py index e93321b8d..4c1360331 100644 --- a/source/app/business/alerts.py +++ b/source/app/business/alerts.py @@ -43,7 +43,8 @@ def alerts_search(start_date, end_date, source_start_date, source_end_date, title, description, status, severity, owner, source, tags, case_identifier, customer_identifier, classification, alert_identifiers, - assets, iocs, resolution_status, source_reference, custom_conditions, user_identifier_filter, page, per_page, sort): + assets, iocs, resolution_status, source_reference, custom_conditions, user_identifier_filter, page, per_page, sort, + incident_identifier=None): return get_filtered_alerts( start_date, @@ -69,7 +70,8 @@ def alerts_search(start_date, end_date, source_start_date, source_end_date, titl sort, user_identifier_filter, source_reference, - custom_conditions + custom_conditions, + incident_id=incident_identifier, ) @@ -95,9 +97,25 @@ def alerts_create(alert: Alert, iocs: list[Ioc], assets: list[CaseAssets]) -> Al 'alert_id': alert.alert_id }), namespace='/alerts') + _enqueue_rule_evaluation(alert.alert_id) + return alert +def _enqueue_rule_evaluation(alert_id: int) -> None: + """Fire the async rule evaluator. Import is deferred so a broken/ + unregistered Celery worker doesn't crash the request path — the + ImportError branch logs and swallows so alert ingestion still + succeeds even if the incident-rules feature is disabled or the + worker module fails to load.""" + try: + from app.iris_engine.incident_rules.tasks import evaluate_alert_rules + evaluate_alert_rules.delay(alert_id) + except Exception: # noqa: BLE001 — rule evaluation must never block alert ingestion + from app.logger import logger + logger.exception('Failed to enqueue rule evaluation for alert #%s', alert_id) + + def _get(user, permissions, identifier, fallback_customer_access=None) -> Optional[Alert]: alert = get_alert_by_id(identifier) if not alert: @@ -334,6 +352,7 @@ def alerts_update(alert: Alert, updated_alert: Alert, activity_data) -> Alert: add_obj_history_entry(updated_alert, 'updated alert') db.session.commit() + _enqueue_rule_evaluation(updated_alert.alert_id) return updated_alert diff --git a/source/app/business/alerts_filters.py b/source/app/business/alerts_filters.py index 1bb6176b1..24cc8f350 100644 --- a/source/app/business/alerts_filters.py +++ b/source/app/business/alerts_filters.py @@ -18,12 +18,18 @@ from app.db import db from app.datamgmt.filters.filters_db import get_filters, get_filter_by_id +from app.iris_engine.utils.tracker import track_activity from app.models.errors import ObjectNotFoundError +def _filter_label(saved_filter): + return getattr(saved_filter, 'filter_name', None) or f'#{getattr(saved_filter, "id", "?")}' + + def alert_filter_add(new_saved_filter): db.session.add(new_saved_filter) db.session.commit() + track_activity(f'created saved filter "{_filter_label(new_saved_filter)}"') def alert_filter_list(user, filter_type="alerts", include_public=True): @@ -44,8 +50,11 @@ def alert_filter_get(user, identifier): def alert_filter_update(): db.session.commit() + track_activity('updated a saved filter') def alert_filter_delete(saved_filter): + label = _filter_label(saved_filter) db.session.delete(saved_filter) db.session.commit() + track_activity(f'deleted saved filter "{label}"') diff --git a/source/app/business/asynchronous_tasks.py b/source/app/business/asynchronous_tasks.py index d8238d5bd..e7d4c1acb 100644 --- a/source/app/business/asynchronous_tasks.py +++ b/source/app/business/asynchronous_tasks.py @@ -21,6 +21,8 @@ from app import celery from app.datamgmt.asynchronous_tasks import search_asynchronous_tasks +from app.datamgmt.asynchronous_tasks import search_asynchronous_tasks_paginated +from app.datamgmt.asynchronous_tasks import get_asynchronous_task_by_id from iris_interface.IrisInterfaceStatus import IIStatus @@ -88,6 +90,111 @@ def dim_tasks_get(task_identifier): } +def _project_row(row): + """Turn one CeleryTaskMeta record into a flat dict suitable for the + Dim Tasks listing page. + + Same shape as the legacy ``asynchronous_tasks_search`` projection, + but: + * ``date_done`` is an ISO 8601 string (or ``None``) so the frontend + doesn't have to know about Python datetimes; + * ``case_id`` is split out from the human ``case`` label, so the + UI can build a `/case/<id>` link without parsing a string; + * we never propagate exceptions raised by pickle.loads — a corrupt + result blob just leaves the row labelled as a failure rather + than 500-ing the whole page. + """ + tkp = { + 'task_id': row.task_id, + 'state': row.status, + 'case': '', + 'case_id': None, + 'module': row.name, + 'date_done': row.date_done.isoformat() if row.date_done else None, + 'user': 'Unknown', + } + + try: + _ = row.result + except AttributeError: + # Legacy task — pickled by an old IRIS version that no longer + # exists. Leave the bare row in place; the detail endpoint + # surfaces a proper "legacy" message. + return tkp + + if row.name is not None and 'task_hook_wrapper' in row.name: + task_name = f'{row.kwargs}::{row.kwargs}' + else: + task_name = row.name + + user = None + case_name = None + case_identifier = None + if row.kwargs and row.kwargs != b'{}': + try: + kwargs = json.loads(row.kwargs.decode('utf-8')) + except (UnicodeDecodeError, json.JSONDecodeError): + kwargs = None + if kwargs: + user = kwargs.get('init_user') + case_identifier = kwargs.get('caseid') + if case_identifier is not None: + case_name = f'Case #{case_identifier}' + module_name = kwargs.get('module_name') + hook_name = kwargs.get('hook_name') + task_name = f'{module_name}::{hook_name}' + + try: + result = pickle.loads(row.result) if row.result else None + except Exception: + result = None + + if isinstance(result, IIStatus): + try: + success = result.is_success() + except Exception: + success = None + else: + success = None + + tkp['state'] = 'success' if success else (row.status or 'failure') + tkp['user'] = user if user else 'Shadow Iris' + tkp['module'] = task_name + tkp['case'] = case_name if case_name else '' + tkp['case_id'] = case_identifier + + return tkp + + +def asynchronous_tasks_list(page=1, per_page=25, search_value=None, status=None): + """Paginated Dim Tasks listing. + + Returns a tuple ``(items, paginated)`` where ``items`` is the list of + projected dicts and ``paginated`` is the SQLAlchemy ``Pagination`` + object — same handshake as ``list_activities_paginated`` so the + endpoint can build the standard envelope without re-counting. + """ + paginated = search_asynchronous_tasks_paginated( + page=page, + per_page=per_page, + search_value=search_value, + status=status, + ) + items = [_project_row(r) for r in paginated.items] + return items, paginated + + +def asynchronous_task_get_by_id(task_id): + """Find one CeleryTaskMeta by Celery task id and project it. + + Returns ``None`` if the row doesn't exist (so the endpoint can 404). + """ + row = get_asynchronous_task_by_id(task_id) + if row is None: + return None + return _project_row(row) + + def asynchronous_tasks_search(count): tasks = search_asynchronous_tasks(count) diff --git a/source/app/business/auth.py b/source/app/business/auth.py index 1c710c503..7cefc8d86 100644 --- a/source/app/business/auth.py +++ b/source/app/business/auth.py @@ -16,7 +16,8 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -from urllib.parse import urlsplit +import time +from urllib.parse import urlparse from flask import flash from flask import session @@ -39,7 +40,6 @@ from app.models.authorization import User import datetime -import time import jwt @@ -93,31 +93,29 @@ def validate_local_login(username: str, password: str): def _is_safe_url(target): - """ - Check whether the target URL is safe for redirection. - - The previous check using urlparse(target).netloc failed to reject payloads - like 'attacker.com?cid=1': urlsplit treats that as a path with an empty - netloc, but browsers resolving a 'Location: attacker.com' header route the - user to the attacker's host — an Open Redirect (GHSA-vjc3-7jwv-j9qf, - SBA-ADV-20260126-02, CWE-601). - - A safe redirect target is a *relative* path on this application: - - must be a non-empty string - - no control characters or backslashes (browsers may normalise '\\' -> '/') - - must start with '/' but not '//' (rules out protocol-relative URLs) - - urlsplit must confirm no scheme and no netloc (defence-in-depth) + """Return True iff `target` is safe to use in a 302 Location header. + + A safe target is a *relative* path on this application. The previous + implementation only checked `parsed.scheme` and `parsed.netloc`, which is + bypassed by payloads like `attacker.com?cid=1` — urlparse treats that as a + path with an empty netloc, but browsers resolving a `Location: attacker.com` + header will route the user to the attacker's host. That's GHSA-vjc3-7jwv-j9qf + / SBA-ADV-20260126-02 / CWE-601. + + The strict rules: + - non-empty string + - no control characters (incl. tab/newline) or backslashes (some browsers + normalise `\\` -> `/`, turning `/\\evil.com` into `//evil.com`) + - starts with a single `/` (not `//`, which is protocol-relative) + - urlparse confirms no scheme and no netloc — defense in depth """ if not target or not isinstance(target, str): return False - # Reject control chars (incl. tab/newline) and backslashes outright. if any(ord(c) < 0x20 or c == '\\' for c in target): return False - # Must be a site-relative path: starts with '/' but not '//'. if not target.startswith('/') or target.startswith('//'): return False - # Defence-in-depth: urlsplit must confirm no scheme and no netloc. - parsed = urlsplit(target) + parsed = urlparse(target) return not parsed.scheme and not parsed.netloc @@ -126,11 +124,9 @@ def _filter_next_url(next_url, context_case): Ensures that the URL to which the user is redirected is safe. If the provided URL is not safe or is missing, a default URL (typically the index page) is returned. """ - if not next_url: + if not _is_safe_url(next_url): return url_for('index.index', cid=context_case) - if _is_safe_url(next_url): - return next_url - return url_for('index.index', cid=context_case) + return next_url def wrap_login_user(user, is_oidc=False): @@ -154,6 +150,7 @@ def wrap_login_user(user, is_oidc=False): if locked_until and locked_until > time.time(): flash('Too many attempts. Please try again later.', 'danger') return redirect(url_for('login.login')) + # Mark this browser session as the one that just passed password # auth for this user. mfa_setup / mfa_verify will refuse to run # for any other user id, preventing cross-user MFA handler abuse. @@ -234,14 +231,19 @@ def generate_auth_tokens(user, mfa_verified: bool = False): algorithm='HS256' ) - # Generate refresh token + # Generate refresh token. The MFA flags travel with the refresh too so + # the refresh endpoint can mint new access tokens that preserve the + # caller's MFA state without re-prompting — and, crucially, without + # silently upgrading a step-1 refresh into a verified access token. refresh_token_payload = { 'user_id': user.id, 'user_name': user.name, 'user_email': user.email, 'user_login': user.user, 'exp': refresh_token_expiry, - 'type': 'refresh' + 'type': 'refresh', + 'mfa_required': mfa_required, + 'mfa_verified': effective_mfa_verified, } refresh_token = jwt.encode( refresh_token_payload, diff --git a/source/app/business/case_timelines.py b/source/app/business/case_timelines.py new file mode 100644 index 000000000..cbd04df26 --- /dev/null +++ b/source/app/business/case_timelines.py @@ -0,0 +1,286 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. + +"""Business layer for the per-case named-timeline feature. + +The model and migration live alongside the rest of the case models in +`app.models.cases`. This module centralises the CRUD, the default +"Main" timeline guarantee, and the helpers that wire events into +timelines so the REST blueprints can stay thin. +""" + +import re + +from app.db import db +from app.iris_engine.utils.tracker import track_activity +from app.models.cases import CaseEventTimeline +from app.models.cases import CaseTimeline +from app.models.cases import CasesEvent +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError + + +_NAME_MAX_LEN = 128 +_HEX_COLOR_RE = re.compile(r'^#[0-9a-fA-F]{6}$') + + +def _validate_name(name): + if not isinstance(name, str): + raise BusinessProcessingError('Timeline name must be a string') + stripped = name.strip() + if not stripped: + raise BusinessProcessingError('Timeline name is required') + if len(stripped) > _NAME_MAX_LEN: + raise BusinessProcessingError( + f'Timeline 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 case_timeline_list(case_id): + """Return every timeline registered against a case, defaults first.""" + return ( + CaseTimeline.query + .filter(CaseTimeline.case_id == case_id) + .order_by(CaseTimeline.is_default.desc(), + CaseTimeline.created_at.asc(), + CaseTimeline.timeline_id.asc()) + .all() + ) + + +def case_timeline_get(case_id, timeline_id): + row = ( + CaseTimeline.query + .filter_by(case_id=case_id, timeline_id=timeline_id) + .first() + ) + if row is None: + raise ObjectNotFoundError() + return row + + +def case_timeline_create(case_id, name, description=None, color=None, + created_by_id=None, is_default=False): + """Create a new timeline on a case. + + Trips on the (case_id, name) unique constraint when the name is + already in use — surfaced as a 400 by the REST layer. + """ + name = _validate_name(name) + color = _validate_color(color) + + existing = ( + CaseTimeline.query + .filter_by(case_id=case_id, name=name) + .first() + ) + if existing is not None: + raise BusinessProcessingError( + f'A timeline named "{name}" already exists on this case' + ) + + timeline = CaseTimeline( + case_id=case_id, + name=name, + description=description, + color=color, + is_default=bool(is_default), + created_by_id=created_by_id, + ) + db.session.add(timeline) + db.session.commit() + if not is_default: + # Skip the audit line for the default-timeline bootstrap so a + # brand-new case doesn't drop two rows (case create + main + # timeline create) in the feed. + track_activity(f'created timeline "{name}"', caseid=case_id) + return timeline + + +def case_timeline_update(case_id, timeline_id, name=None, description=None, + color=None): + timeline = case_timeline_get(case_id, timeline_id) + + if name is not None: + new_name = _validate_name(name) + if new_name != timeline.name: + clash = ( + CaseTimeline.query + .filter_by(case_id=case_id, name=new_name) + .first() + ) + if clash is not None and clash.timeline_id != timeline.timeline_id: + raise BusinessProcessingError( + f'A timeline named "{new_name}" already exists on this case' + ) + timeline.name = new_name + if description is not None: + timeline.description = description + if color is not None: + timeline.color = _validate_color(color) + + db.session.commit() + track_activity(f'updated timeline "{timeline.name}"', caseid=case_id) + return timeline + + +def case_timeline_delete(case_id, timeline_id): + """Delete a timeline. The default timeline cannot be removed — + deleting it would leave events with no fallback timeline and + break the "Main" affordance every case relies on.""" + timeline = case_timeline_get(case_id, timeline_id) + if timeline.is_default: + raise BusinessProcessingError('The default timeline cannot be deleted') + name = timeline.name + db.session.delete(timeline) + db.session.commit() + track_activity(f'deleted timeline "{name}"', caseid=case_id) + + +def case_ensure_default_timeline(case_id, created_by_id=None): + """Create the case's "Main" timeline if it doesn't have one yet. + + Idempotent — safe to call from case-create hooks even if the + migration backfill already seeded a row. + """ + existing = ( + CaseTimeline.query + .filter_by(case_id=case_id, is_default=True) + .first() + ) + if existing is not None: + return existing + return case_timeline_create( + case_id, 'Main', + description='Default timeline', + created_by_id=created_by_id, + is_default=True, + ) + + +def _default_timeline_id(case_id): + row = ( + CaseTimeline.query + .filter_by(case_id=case_id, is_default=True) + .with_entities(CaseTimeline.timeline_id) + .first() + ) + return row.timeline_id if row else None + + +def set_event_timelines(event_id, case_id, timeline_ids): + """Replace the timeline-set attached to an event. + + Passing `None` is a no-op (caller didn't specify timelines — keep + whatever's already on the row). Passing an empty list intentionally + clears the event off every named timeline. + + Every id in `timeline_ids` must belong to `case_id` — cross-case + membership is rejected to keep filter queries safe. + """ + if timeline_ids is None: + return + + if not isinstance(timeline_ids, (list, tuple)): + raise BusinessProcessingError('timeline_ids must be a list of integers') + + cleaned = [] + for tid in timeline_ids: + if not isinstance(tid, int): + raise BusinessProcessingError('timeline_ids must be a list of integers') + cleaned.append(tid) + + if cleaned: + owned = ( + CaseTimeline.query + .filter(CaseTimeline.case_id == case_id, + CaseTimeline.timeline_id.in_(cleaned)) + .with_entities(CaseTimeline.timeline_id) + .all() + ) + owned_ids = {r.timeline_id for r in owned} + if owned_ids != set(cleaned): + raise BusinessProcessingError( + 'One or more timeline_ids do not belong to this case' + ) + + CaseEventTimeline.query.filter_by(event_id=event_id).delete( + synchronize_session=False + ) + for tid in cleaned: + db.session.add(CaseEventTimeline(event_id=event_id, timeline_id=tid)) + db.session.commit() + + +def attach_event_to_default_timeline_if_unset(event_id, case_id): + """Ensure a newly-created event is on at least the default timeline + when the caller did not specify a timeline set. + + Without this hook the event would be invisible when the user + filters the timeline view down to any specific timeline — including + the default — until they edit the event. + """ + already = ( + CaseEventTimeline.query + .filter_by(event_id=event_id) + .first() + ) + if already is not None: + return + default_id = _default_timeline_id(case_id) + if default_id is None: + # Edge case: an event was created before the default timeline + # was provisioned (case predates this feature and the backfill + # hasn't yet run). Caller is expected to ensure the default + # exists first; bail quietly. + return + db.session.add(CaseEventTimeline(event_id=event_id, timeline_id=default_id)) + db.session.commit() + + +def get_event_timeline_ids(event_id): + rows = ( + CaseEventTimeline.query + .filter_by(event_id=event_id) + .with_entities(CaseEventTimeline.timeline_id) + .all() + ) + return [r.timeline_id for r in rows] + + +def filter_events_by_timelines(case_id, timeline_ids): + """Return event ids on the case that are attached to any of the + given timelines. `timeline_ids=None` or empty means "no filter" + (caller gets all event ids for the case). + """ + if not timeline_ids: + return None + rows = ( + db.session.query(CaseEventTimeline.event_id) + .join(CasesEvent, CasesEvent.event_id == CaseEventTimeline.event_id) + .filter(CasesEvent.case_id == case_id) + .filter(CaseEventTimeline.timeline_id.in_(timeline_ids)) + .distinct() + .all() + ) + return [r.event_id for r in rows] diff --git a/source/app/business/cases.py b/source/app/business/cases.py index ca16f5888..b84e9b570 100644 --- a/source/app/business/cases.py +++ b/source/app/business/cases.py @@ -55,16 +55,20 @@ from app.datamgmt.reporter.report_db import export_case_notes_json from app.datamgmt.manage.manage_cases_db import get_filtered_cases from app.datamgmt.case.case_db import get_first_case_with_customer +from app.models.alerts import Alert +from app.models.alerts import AlertStatus from app.models.cases import Cases from app.models.cases import ReviewStatusList from app.models.customers import Client +from app.models.incidents import Incident def cases_filter(current_user, pagination_parameters, name=None, case_identifiers=None, customer_identifier=None, description=None, classification_identifier=None, owner_identifier=None, opening_user_identifier=None, severity_identifier=None, status_identifier=None, soc_identifier=None, start_open_date=None, end_open_date=None, is_open=None, search_value='', - advanced_filters=None, advanced_logic='and'): + advanced_filters=None, advanced_logic='and', quick_search=None, + start_close_date=None, end_close_date=None): return get_filtered_cases( current_user.id, pagination_parameters, @@ -83,7 +87,10 @@ def cases_filter(current_user, pagination_parameters, name=None, case_identifier search_value=search_value, is_open=is_open, advanced_filters=advanced_filters, - advanced_logic=advanced_logic) + advanced_logic=advanced_logic, + quick_search=quick_search, + start_close_date=start_close_date, + end_close_date=end_close_date) def cases_filter_by_user(user, show_all: bool): @@ -113,6 +120,102 @@ def cases_exists(identifier): return case_db_exists(identifier) +# When an alert is unlinked from a case, its status is rolled back to +# this seeded row so the alert re-enters the analyst queue rather than +# staying flagged as Escalated/Merged forever. `Assigned` is the same +# status the incident-status propagator moves alerts to on incident +# `Open`, so linked and unlinked alerts converge on the same state. +_RESET_ALERT_STATUS_NAME = 'Assigned' + + +def _reset_alert_statuses(alerts): + """Flip a list of alerts to the "unlinked-from-case" status. No-op if + the seed row is missing so a missing seed doesn't 500 the unlink.""" + if not alerts: + return + row = AlertStatus.query.filter_by(status_name=_RESET_ALERT_STATUS_NAME).first() + if row is None: + logger.warning( + 'AlertStatus "%s" is not seeded; leaving alert statuses unchanged', + _RESET_ALERT_STATUS_NAME, + ) + return + for alert in alerts: + if alert.alert_status_id != row.status_id: + alert.alert_status_id = row.status_id + + +def case_unlink_alert(case: Cases, alert_id: int) -> Alert: + """Detach one alert from a case and reset the alert's status. + + Case-scoped counterpart to the alert-side `PUT /alerts/{id}` cases + field. Keeping the endpoint on the case URL means access control + checks the case (which is the surface the analyst is acting from), + not the alert. Idempotent-ish: unlinking an already-unlinked alert + is a no-op that returns the alert. + """ + alert = Alert.query.filter_by(alert_id=alert_id).first() + if alert is None: + raise ObjectNotFoundError() + if case not in alert.cases: + return alert + alert.cases = [c for c in alert.cases if c.case_id != case.case_id] + _reset_alert_statuses([alert]) + add_obj_history_entry(alert, f'unlinked from case #{case.case_id}') + add_obj_history_entry(case, f'alert #{alert.alert_id} unlinked') + db.session.commit() + track_activity( + f'unlinked alert #{alert.alert_id} from case #{case.case_id}', + caseid=case.case_id, + ctx_less=False, + ) + call_modules_hook('on_postload_alert_unmerge', alert, caseid=case.case_id) + return alert + + +def case_unlink_incident(case: Cases) -> Incident | None: + """Reverse an incident->case escalation/merge in one shot. + + Clears the incident's `incident_case_id`, flips the incident status + back to `Investigating`, detaches every member alert from the case, + and rolls each alert's status back to `Assigned`. Returns the + incident so the caller can render a "unlinked from #N" toast; None + when the case wasn't sourced from an incident (idempotent no-op). + """ + incident = Incident.query.filter_by(incident_case_id=case.case_id).first() + if incident is None: + return None + + # Detach each member alert from the case using the association + # collection directly — `case.alerts` is viewonly, so we mutate + # `alert.cases` instead. Snapshot the list first because we're + # modifying the collection we're iterating over. + detached_alerts = [] + for alert in list(incident.alerts): + if any(c.case_id == case.case_id for c in alert.cases): + alert.cases = [c for c in alert.cases if c.case_id != case.case_id] + detached_alerts.append(alert) + _reset_alert_statuses(detached_alerts) + + from app.business.incidents import resolve_status_id, INCIDENT_STATUS_INVESTIGATING + + incident.incident_case_id = None + incident.incident_status_id = resolve_status_id(INCIDENT_STATUS_INVESTIGATING) + add_obj_history_entry( + incident, f'unlinked from case #{case.case_id} (moved back to Investigating)' + ) + add_obj_history_entry( + case, f'incident #{incident.incident_id} unlinked' + ) + db.session.commit() + track_activity( + f'unlinked incident #{incident.incident_id} from case #{case.case_id}', + caseid=case.case_id, + ctx_less=False, + ) + return incident + + def cases_create(user, case: Cases, case_template_id) -> Cases: case.owner_id = user.id case.severity_id = 4 @@ -138,6 +241,13 @@ def cases_create(user, case: Cases, case_template_id) -> Cases: ac_set_new_case_access(user, case.case_id, case.client_id) + # Every case gets a "Main" default timeline at creation time. The + # SPA timeline view lets users add more timelines on demand, but + # this default guarantees new events land somewhere visible + # without any extra step from the caller. + from app.business.case_timelines import case_ensure_default_timeline + case_ensure_default_timeline(case.case_id, created_by_id=user.id) + case = call_modules_hook('on_postload_case_create', case) add_obj_history_entry(case, 'created') @@ -233,6 +343,84 @@ def cases_update(case: Cases, updated_case, protagonists, tags) -> Cases: raise BusinessProcessingError('Data error', str(e)) +def cases_close(case_identifier) -> Cases: + """Close a case and cascade the state change to its alerts. + + Mirrors the legacy ``POST /manage/cases/close/<id>`` handler so the + v2 endpoint behaves identically: it flips the case state to + "Closed", closes every linked alert that isn't already closed, maps + the case resolution onto the alert resolution, fires the + ``on_postload_case_update`` module hook, and records a history / + activity entry. + """ + case = get_case(case_identifier) + if not case: + raise ObjectNotFoundError() + + res = close_case(case_identifier) + if not res: + raise ObjectNotFoundError() + + if case.alerts: + close_status = get_alert_status_by_name('Closed') + case_status_id_mapped = map_alert_resolution_to_case_status(case.status_id) + + for alert in case.alerts: + if alert.alert_status_id != close_status.status_id: + alert.alert_status_id = close_status.status_id + alert = call_modules_hook('on_postload_alert_update', alert, caseid=case_identifier) + + if alert.alert_resolution_status_id != case_status_id_mapped: + alert.alert_resolution_status_id = case_status_id_mapped + alert = call_modules_hook('on_postload_alert_resolution_update', alert, + caseid=case_identifier) + + track_activity(f'closing alert ID {alert.alert_id} due to case #{case_identifier} being closed', + caseid=case_identifier, ctx_less=False) + + db.session.add(alert) + + res = call_modules_hook('on_postload_case_update', res, caseid=case_identifier) + + add_obj_history_entry(res, 'case closed') + track_activity(f'closed case ID {case_identifier}', caseid=case_identifier, ctx_less=False) + return res + + +def cases_reopen(case_identifier) -> Cases: + """Reopen a previously-closed case and cascade to its alerts. + + Mirrors ``POST /manage/cases/reopen/<id>``: clears the close date, + flips the state back to "Open", moves every linked alert that + isn't already "Merged" to that state (legacy behaviour — reopening + a case implies its alerts are no longer terminal), and records + history + activity. + """ + case = get_case(case_identifier) + if not case: + raise ObjectNotFoundError() + + res = reopen_case(case_identifier) + if not res: + raise ObjectNotFoundError() + + if case.alerts: + merged_status = get_alert_status_by_name('Merged') + for alert in case.alerts: + if alert.alert_status_id != merged_status.status_id: + alert.alert_status_id = merged_status.status_id + track_activity( + f'alert ID {alert.alert_id} status updated to merged due to case #{case_identifier} being reopen', + caseid=case_identifier, ctx_less=False) + db.session.add(alert) + + res = call_modules_hook('on_postload_case_update', res, caseid=case_identifier) + + add_obj_history_entry(res, 'case reopen') + track_activity(f'reopen case ID {case_identifier}', caseid=case_identifier) + return res + + def cases_export_to_json(case_id, for_docx=False): """Fully export a case a JSON. diff --git a/source/app/business/collab.py b/source/app/business/collab.py new file mode 100644 index 000000000..d5cca6cd1 --- /dev/null +++ b/source/app/business/collab.py @@ -0,0 +1,363 @@ +# 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 the real-time collaborative editor (Option A). + +Server-authoritative Yjs. The server keeps the canonical Y.Doc for each +open document in `collab_doc.y_state` and: + + 1. Resolves a `doc_name` (like `note:42`, `case-summary:17`, + `war-room-note:8`, `sitrep:5`) to (a) the object, (b) the caller's + access level, (c) whether writes are currently allowed. + 2. Guarantees a Y.Doc exists for the document, migrating from the + source column exactly once via the markdown→Y.Doc renderer. + 3. Applies every client update to the authoritative Y.Doc (via + `pycrdt`) before rebroadcasting, so we KNOW the canonical state + rather than trusting a client's dump of it. + 4. Renders the Y.Doc back to markdown on flush and writes it to the + source column so REST readers / search / exports stay coherent. + +Design rules — kept short so they're easy to enforce during review: + * `content_md` NEVER travels on the collab wire. Removed from every + inbound and outbound message. + * The client never seeds the editor from the source column. Only + from `y_state`. + * The server never trusts a client's markdown. It only trusts + Yjs updates, which are CRDT-safe by construction. +""" + +import base64 +import datetime + +from app.business.access_controls import ac_fast_check_user_has_case_access +from app.db import db +from app.iris_engine.collab.render import markdown_to_ydoc_update +from app.iris_engine.collab.render import ydoc_update_to_markdown +from app.iris_engine.utils.tracker import track_activity +from app.models.authorization import CaseAccessLevel +from app.models.authorization import WarRoomAccessLevel +from app.models.cases import Cases +from app.models.collab import CollabDoc +from app.models.models import Notes +from app.models.war_rooms import WarRoomNote +from app.models.war_rooms import WarRoomSitRep + + +def _war_room_access_check(user_id, war_room_id, levels): + """Late-import wrapper around `ac_fast_check_user_has_war_room_access`. + + `app.business.war_rooms_access` pulls in several models that + transitively re-import this module, so a top-level import here would + trip a circular-import error at app-load time. Every existing caller + of this helper in the codebase does the same lazy import (see + `access_controls.ac_fast_check_current_user_has_war_room_access` for + the canonical example) — we're just following the convention. + """ + from app.business.war_rooms_access import ac_fast_check_user_has_war_room_access + return ac_fast_check_user_has_war_room_access(user_id, war_room_id, levels) + + +class DocResolutionError(Exception): + """Raised when a doc_name can't be parsed or its target doesn't exist.""" + + +# 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'} + + +def _parse_doc_name(doc_name): + """Split `<kind>:<id>` into (kind, int_id). Validates the shape and + the kind whitelist; caller-facing errors are `DocResolutionError`.""" + if not isinstance(doc_name, str) or ':' not in doc_name: + raise DocResolutionError('doc_name must be "<kind>:<id>"') + kind, _, raw_id = doc_name.partition(':') + if kind not in _DOC_KINDS: + raise DocResolutionError(f'Unknown doc kind: {kind!r}') + try: + obj_id = int(raw_id) + except (TypeError, ValueError): + raise DocResolutionError('doc_name id must be an integer') + return kind, obj_id + + +def resolve_doc(doc_name, user_id): + """Look up the object referenced by `doc_name` and the caller's + permissions. + + Returns a dict: + { + 'kind': str, + 'id': int, + 'exists': bool, + 'can_read': bool, + 'can_write': bool, + 'current_content': str | None, # source-column markdown + } + + `current_content` is used exclusively to seed the Y.Doc on first + open of a doc (see `ensure_snapshot` below). It does NOT travel to + the client — the client only ever sees Yjs update bytes. + + Raises `DocResolutionError` for bad shapes; permission failures are + reported as `can_read=False` / `can_write=False`, NOT raised, so + the socket layer can respond with a rejection event rather than a + Python exception. + """ + kind, obj_id = _parse_doc_name(doc_name) + + if kind == 'note': + note = Notes.query.filter_by(note_id=obj_id).first() + if note is None: + return {'kind': kind, 'id': obj_id, 'exists': False, + 'can_read': False, 'can_write': False, + 'current_content': None} + # Note ACL rides on the parent case's ACL. Two separate calls + # (write, then read) because `CaseAccessLevel` is a plain + # `enum.Enum`, so we can't just compare the returned int to + # the enum member. See the war-room branches for the same + # pattern. + write_level = ac_fast_check_user_has_case_access( + user_id, note.note_case_id, [CaseAccessLevel.full_access], + ) + read_level = write_level or ac_fast_check_user_has_case_access( + user_id, note.note_case_id, [CaseAccessLevel.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': note.note_content} + + if kind == 'case-summary': + case = Cases.query.filter_by(case_id=obj_id).first() + if case is None: + return {'kind': kind, 'id': obj_id, 'exists': False, + 'can_read': False, 'can_write': False, + 'current_content': None} + write_level = ac_fast_check_user_has_case_access( + user_id, case.case_id, [CaseAccessLevel.full_access], + ) + read_level = write_level or ac_fast_check_user_has_case_access( + user_id, case.case_id, [CaseAccessLevel.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': case.description} + + if kind == 'war-room-note': + wrn = WarRoomNote.query.filter_by(note_id=obj_id).first() + if wrn 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, wrn.war_room_id, [WarRoomAccessLevel.full_access], + ) + read_level = write_level or _war_room_access_check( + user_id, wrn.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': wrn.content} + + if kind == 'sitrep': + sit = WarRoomSitRep.query.filter_by(sitrep_id=obj_id).first() + if sit 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, sit.war_room_id, [WarRoomAccessLevel.full_access], + ) + read_level = write_level or _war_room_access_check( + user_id, sit.war_room_id, [WarRoomAccessLevel.read_only], + ) + # Published sitreps are read-only regardless of ACL level. + can_write = write_level is not None and not sit.published + return {'kind': kind, 'id': obj_id, 'exists': True, + 'can_read': read_level is not None, + 'can_write': can_write, + 'current_content': sit.body_md} + + raise DocResolutionError(f'Unhandled doc kind: {kind!r}') + + +# --------------------------------------------------------------------------- +# Snapshot storage — server-authoritative Y.Doc bytes. +# --------------------------------------------------------------------------- + +def ensure_snapshot(doc_name, current_content): + """Return the authoritative `y_state` bytes for `doc_name`, creating + it from the source column if this is a first open. + + Guarantees: + * A `CollabDoc` row exists after this call. + * The row's `y_state` is a non-empty Yjs update representing the + current authoritative Y.Doc. + * The one-time migration from markdown to Y.Doc happens exactly + once per document, atomically. + + The returned bytes are what we ship in `sync-init`. Every client + hydrates from these bytes and only these bytes — there's no + fallback path. + """ + row = CollabDoc.query.filter_by(doc_name=doc_name).first() + if row is not None and row.y_state: + return bytes(row.y_state) + + # First open of this doc (or a row exists with empty state — should + # only happen after a manual DB tampering, but we handle it the + # same way to be robust). + seed_md = current_content or '' + seed_bytes = markdown_to_ydoc_update(seed_md) + + if row is None: + row = CollabDoc( + doc_name=doc_name, + y_state=seed_bytes, + content_md=seed_md, + last_flushed_at=datetime.datetime.utcnow(), + ) + db.session.add(row) + else: + row.y_state = seed_bytes + row.content_md = seed_md + row.last_flushed_at = datetime.datetime.utcnow() + db.session.commit() + return seed_bytes + + +def apply_wire_update(doc_name, update_b64, user_id): + """Apply an incoming Yjs update from a client to the authoritative + Y.Doc for `doc_name`, then persist the merged state. + + Returns the ORIGINAL client update bytes (base64-decoded) so the + socket layer can rebroadcast them to peers. CRDTs are commutative — + peers applying the same update independently converge to the same + state as the server's Y.Doc. + + Returns None if the update is malformed or the doc has never been + initialised (caller should have called `ensure_snapshot` first). + """ + row = CollabDoc.query.filter_by(doc_name=doc_name).first() + if row is None or not row.y_state: + # Should be unreachable — the socket handler calls + # `ensure_snapshot` on join, so every write path sees an + # initialised row. If we get here it's a bug in the caller. + return None + + try: + update_bytes = base64.b64decode(update_b64) if update_b64 else b'' + except (TypeError, ValueError): + return None + if not update_bytes: + return None + + from pycrdt import merge_updates + try: + # Merge the incoming update INTO the stored state at the + # byte-blob level. `merge_updates` is the pycrdt/Yrs helper for + # combining update blobs without needing to materialise a full + # Doc — much cheaper than apply+get_update on hot paths. It's + # a variadic (not a list), hence the star-splat. + new_state = merge_updates(bytes(row.y_state), update_bytes) + except Exception: + # Malformed update — drop it. Peers won't see the broadcast + # either (caller checks the return value). + return None + + row.y_state = new_state + row.updated_by_id = user_id + row.last_flushed_at = datetime.datetime.utcnow() + db.session.commit() + + return update_bytes + + +def flush_to_source(doc_name): + """Render the Y.Doc to markdown and write it to the source column + if it changed. + + Called on last-client-disconnect (and, in a future revision, on a + periodic tick). Fires the existing `track_activity()` hook so the + audit log records who last touched the doc. + + Uses `ydoc_update_to_markdown` to render — the only place in the + server that reads editor content out of Yjs. No-op if the doc row + doesn't exist or the render matches what's already in the column. + """ + row = CollabDoc.query.filter_by(doc_name=doc_name).first() + if row is None or not row.y_state: + return + + try: + kind, obj_id = _parse_doc_name(doc_name) + except DocResolutionError: + return + + try: + new_content = ydoc_update_to_markdown(bytes(row.y_state)) + except Exception: + # Never let a bad snapshot break the audit path — keep the + # source column as-is until a subsequent flush succeeds. + return + + # Keep the cached markdown in the row so a REST reader can consume + # it without booting a Y.Doc. Also lets us skip the source-column + # UPDATE when there's no meaningful change. + if row.content_md != new_content: + row.content_md = new_content + db.session.commit() + + if kind == 'note': + note = Notes.query.filter_by(note_id=obj_id).first() + if note is None or note.note_content == new_content: + return + note.note_content = new_content + db.session.commit() + track_activity(f'updated note "{note.note_title}"', + caseid=note.note_case_id) + return + + if kind == 'case-summary': + case = Cases.query.filter_by(case_id=obj_id).first() + if case is None or case.description == new_content: + return + case.description = new_content + db.session.commit() + track_activity(f'updated case summary', caseid=case.case_id) + return + + if kind == 'war-room-note': + wrn = WarRoomNote.query.filter_by(note_id=obj_id).first() + if wrn is None or wrn.content == new_content: + return + wrn.content = new_content + db.session.commit() + track_activity(f'updated war room note "{wrn.title}"', + war_room_id=wrn.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 + # blocks writes, but this belt-and-braces guard prevents a + # rogue queued flush from mutating a locked report. + if sit is None or sit.published or sit.body_md == new_content: + return + sit.body_md = new_content + db.session.commit() + track_activity( + f'updated sitrep "{sit.title}" (v{sit.version})', + war_room_id=sit.war_room_id, + ) + return diff --git a/source/app/business/comments.py b/source/app/business/comments.py index 03c30e3dd..614d4e65c 100644 --- a/source/app/business/comments.py +++ b/source/app/business/comments.py @@ -27,6 +27,8 @@ from app.models.errors import BusinessProcessingError from app.datamgmt.case.case_comments import get_case_comment from app.datamgmt.comments import get_filtered_alert_comments +from app.datamgmt.comments import get_filtered_incident_comments +from app.datamgmt.comments import get_incident_comment from app.datamgmt.comments import get_filtered_asset_comments from app.datamgmt.comments import get_filtered_evidence_comments from app.datamgmt.comments import get_filtered_ioc_comments @@ -74,6 +76,40 @@ def comments_get_filtered_by_alert(current_user, permissions, alert_identifier: return get_filtered_alert_comments(alert_identifier, pagination_parameters) +def comments_get_filtered_by_incident(current_user, permissions, incident_identifier: int, pagination_parameters: PaginationParameters, fallback_customer_access=None) -> Pagination: + # Deferred import — pulling incidents_get at module top would import + # app.business.incidents before comments' own callers finish resolving. + from app.business.incidents import incidents_get + incidents_get(current_user, permissions, incident_identifier, fallback_customer_access=fallback_customer_access) + return get_filtered_incident_comments(incident_identifier, pagination_parameters) + + +def comments_create_for_incident(current_user, permissions, comment: Comments, incident_identifier: int, fallback_customer_access=None): + from app.business.incidents import incidents_get + incident = incidents_get(current_user, permissions, incident_identifier, fallback_customer_access=fallback_customer_access) + comment.comment_incident_id = incident_identifier + comment.comment_user_id = current_user.id + comment.comment_date = datetime.now() + comment.comment_update_date = datetime.now() + + db.session.add(comment) + db.session.commit() + + track_activity(f'incident "#{incident.incident_id}" commented', ctx_less=True) + + +def comments_get_for_incident(incident_identifier: int, identifier) -> Comments: + comment = get_incident_comment(incident_identifier, identifier) + if comment is None: + raise ObjectNotFoundError() + return comment + + +def comments_delete_for_incident(comment: Comments): + delete_comment(comment) + track_activity(f'comment {comment.comment_id} on incident {comment.comment_incident_id} deleted', ctx_less=True) + + def comments_get_filtered_by_asset(asset: CaseAssets, pagination_parameters: PaginationParameters) -> Pagination: return get_filtered_asset_comments(asset.asset_id, pagination_parameters) diff --git a/source/app/business/customers.py b/source/app/business/customers.py index 9e776cc78..ef4bc4059 100644 --- a/source/app/business/customers.py +++ b/source/app/business/customers.py @@ -31,8 +31,8 @@ from app.models.pagination_parameters import PaginationParameters -def customers_filter(user, pagination_parameters: PaginationParameters, is_server_administrator) -> Pagination: - return get_paginated_customers(pagination_parameters, user.id, is_server_administrator) +def customers_filter(user, pagination_parameters: PaginationParameters, is_server_administrator, search: str = None) -> Pagination: + return get_paginated_customers(pagination_parameters, user.id, is_server_administrator, search=search) # TODO maybe this method should be removed and always create a customer with at least a user diff --git a/source/app/business/customers_contacts.py b/source/app/business/customers_contacts.py index d7948b3c0..8694a9139 100644 --- a/source/app/business/customers_contacts.py +++ b/source/app/business/customers_contacts.py @@ -16,9 +16,16 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +from typing import List + from app.models.models import Contact from app.models.errors import ObjectNotFoundError +from app.datamgmt.client.client_db import delete_contact from app.datamgmt.client.client_db import get_client_contact +from app.datamgmt.client.client_db import get_client_contacts +from app.datamgmt.client.client_db import update_contact +from app.datamgmt.db_operations import db_create +from app.iris_engine.utils.tracker import track_activity def customers_contacts_get(identifier) -> Contact: @@ -26,3 +33,38 @@ def customers_contacts_get(identifier) -> Contact: if not contact: raise ObjectNotFoundError() return contact + + +def customers_contacts_list(client_id: int) -> List[Contact]: + """Return every contact bound to a given customer, sorted by name. + + The data layer enforces the sort so consumers don't need to — + contacts are typically displayed in a directory-style list and a + stable order across requests matters for pagination UX. + """ + return get_client_contacts(client_id) + + +def customers_contacts_create(contact: Contact) -> Contact: + db_create(contact) + track_activity(f'Added contact {contact.contact_name}', ctx_less=True) + return contact + + +def customers_contacts_update(contact: Contact) -> Contact: + """Persist pending in-session edits on `contact`. + + Mirrors the legacy `update_contact()` helper — the caller has + already mutated the SQLAlchemy instance (typically via + `ContactSchema.load(..., instance=contact)`) so this just flushes + the session. + """ + update_contact() + track_activity(f'Updated contact {contact.contact_name}', ctx_less=True) + return contact + + +def customers_contacts_delete(contact: Contact) -> None: + name = contact.contact_name + delete_contact(contact) + track_activity(f'Deleted contact {name}', ctx_less=True) diff --git a/source/app/business/events.py b/source/app/business/events.py index 573c12179..ee92677f9 100644 --- a/source/app/business/events.py +++ b/source/app/business/events.py @@ -33,9 +33,12 @@ from app.iris_engine.utils.tracker import track_activity from app.iris_engine.utils.collab import collab_notify from app.iris_engine.module_handler.module_handler import call_modules_hook +from app.business.case_timelines import attach_event_to_default_timeline_if_unset +from app.business.case_timelines import set_event_timelines -def events_create(case_identifier, event: CasesEvent, event_category_id, event_assets, event_iocs, sync_iocs_assets) -> CasesEvent: +def events_create(case_identifier, event: CasesEvent, event_category_id, event_assets, + event_iocs, sync_iocs_assets, timeline_ids=None) -> CasesEvent: event.case_id = case_identifier event.event_added = datetime.utcnow() @@ -61,6 +64,13 @@ def events_create(case_identifier, event: CasesEvent, event_category_id, event_a setattr(event, 'event_category_id', event_category_id) + if timeline_ids is None: + # Caller didn't specify — keep the legacy "all events appear on + # the timeline view" behaviour by stamping the default timeline. + attach_event_to_default_timeline_if_unset(event.event_id, case_identifier) + else: + set_event_timelines(event.event_id, case_identifier, timeline_ids) + event = call_modules_hook('on_postload_event_create', event, caseid=case_identifier) track_activity(f'added event "{event.event_title}"', caseid=case_identifier) @@ -74,7 +84,8 @@ def events_get(identifier) -> CasesEvent: return event -def events_update(event: CasesEvent, event_category_id, event_assets, event_iocs, event_sync_iocs_assets) -> CasesEvent: +def events_update(event: CasesEvent, event_category_id, event_assets, event_iocs, + event_sync_iocs_assets, timeline_ids=None) -> CasesEvent: add_obj_history_entry(event, 'updated') update_timeline_state(event.case_id) @@ -92,6 +103,9 @@ def events_update(event: CasesEvent, event_category_id, event_assets, event_iocs if not success: raise BusinessProcessingError('Error while saving linked iocs', data=log) + if timeline_ids is not None: + set_event_timelines(event.event_id, event.case_id, timeline_ids) + event = call_modules_hook('on_postload_event_update', event, caseid=event.case_id) track_activity(f"updated event \"{event.event_title}\"", caseid=event.case_id) diff --git a/source/app/business/incident_rules.py b/source/app/business/incident_rules.py new file mode 100644 index 000000000..72edecb84 --- /dev/null +++ b/source/app/business/incident_rules.py @@ -0,0 +1,363 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +import hashlib +from datetime import datetime +from datetime import timedelta +from typing import List +from typing import Optional + +from app.business.access_controls import access_controls_user_accessible_customers +from app.business.access_controls import access_controls_user_has_customer_scope +from app.business.incidents import incident_open_matching +from app.datamgmt.filtering import apply_custom_conditions +from app.datamgmt.filtering import combine_conditions +from app.db import db +from app.iris_engine.utils.tracker import track_activity +from app.logger import logger +from app.models.alerts import Alert +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.models.incident_rules import IncidentRule +from app.models.incident_rules import RULE_ACTION_CREATE_INCIDENT +from app.models.incidents import Incident + + +# --------------------------------------------------------------------------- +# CRUD +# +# Every getter that a route calls must be gated by the caller's identity so +# a request from tenant A can never touch a rule scoped to tenant B — see +# `access_controls_user_has_customer_scope` for the exact semantics. The +# raw helpers (used internally by the evaluator, which runs in a Celery +# worker with no request context) live under the `_unchecked` suffix and +# MUST NOT be called from route handlers. +# --------------------------------------------------------------------------- + +def _rules_get_unchecked(identifier: int) -> IncidentRule: + rule = IncidentRule.query.filter_by(rule_id=identifier).first() + if not rule: + raise ObjectNotFoundError() + return rule + + +def rules_create(user, permissions, rule: IncidentRule, + fallback_customer_access=None) -> IncidentRule: + """Persist a new rule after verifying the caller can act on every + customer the rule targets. A caller trying to create a rule scoped to + a tenant they can't see (or a null-scope global rule without + server_administrator) is rejected — otherwise `incident_rules_write` + would double as customer-elevation.""" + if not access_controls_user_has_customer_scope( + user, permissions, rule.rule_customer_scope, + fallback_customer_access=fallback_customer_access, + ): + raise BusinessProcessingError( + 'You do not have access to every customer in rule_customer_scope ' + '(or the rule is global and you are not a server administrator).' + ) + db.session.add(rule) + db.session.commit() + track_activity(f'created incident rule "{rule.rule_name}"') + return rule + + +def rules_get(user, permissions, identifier: int, + fallback_customer_access=None) -> IncidentRule: + """Fetch a rule + verify the caller has access to it. Raises + `ObjectNotFoundError` rather than a distinct forbidden error so a + caller can't enumerate rule ids across tenants by watching for a + 403-vs-404 difference.""" + rule = _rules_get_unchecked(identifier) + if not access_controls_user_has_customer_scope( + user, permissions, rule.rule_customer_scope, + fallback_customer_access=fallback_customer_access, + ): + raise ObjectNotFoundError() + return rule + + +def rules_list(user, permissions) -> List[IncidentRule]: + """List rules visible to the caller. + + * `server_administrator` sees every rule (null-scope + all tenants). + * Everyone else sees only rules whose `rule_customer_scope` is a + subset of the customers they have access to. Null-scope (global) + rules are hidden from non-admins because acting on them would + cross every tenant boundary. + + Ordering matches the evaluator so the UI reads in the same order the + engine would fire them.""" + accessible = access_controls_user_accessible_customers(user, permissions) + query = IncidentRule.query.order_by( + IncidentRule.rule_priority.asc(), IncidentRule.rule_id.asc() + ) + if accessible is None: # server_administrator + return query.all() + rows = query.all() + return [ + r for r in rows + if r.rule_customer_scope is not None + and r.rule_customer_scope + and set(r.rule_customer_scope).issubset(accessible) + ] + + +def rules_update(user, permissions, rule: IncidentRule, changes: dict, + fallback_customer_access=None) -> IncidentRule: + """Apply `changes` after verifying the caller can act on both the + rule's *current* scope AND its *incoming* scope. Skipping either + check would let a caller who can only see tenant A pivot a rule + from A → B (or vice versa) as a smuggling primitive.""" + # Current scope + if not access_controls_user_has_customer_scope( + user, permissions, rule.rule_customer_scope, + fallback_customer_access=fallback_customer_access, + ): + raise ObjectNotFoundError() + # Incoming scope — only check if the payload tried to change it. + if 'rule_customer_scope' in changes: + if not access_controls_user_has_customer_scope( + user, permissions, changes['rule_customer_scope'], + fallback_customer_access=fallback_customer_access, + ): + raise BusinessProcessingError( + 'You do not have access to every customer in the new rule_customer_scope.' + ) + for key, value in changes.items(): + setattr(rule, key, value) + rule.rule_updated_at = datetime.utcnow() + db.session.commit() + track_activity(f'updated incident rule "{rule.rule_name}"') + return rule + + +def rules_delete(rule: IncidentRule) -> None: + """Delete a rule. Caller-scope check is done by whichever getter + fetched `rule` — routes must load rules through `rules_get(user, ...)` + before delete.""" + name = rule.rule_name + db.session.delete(rule) + db.session.commit() + track_activity(f'deleted incident rule "{name}"') + + +# --------------------------------------------------------------------------- +# Evaluation +# --------------------------------------------------------------------------- + +def _rule_matches_alert(rule: IncidentRule, alert_id: int) -> bool: + """A rule matches when there exists exactly one Alert row whose PK is + `alert_id` and that also satisfies `rule.rule_conditions`. We reuse + `apply_custom_conditions` — the exact code path that already backs + the alerts search filter — so the DSL semantics never diverge from + what users see in the alert search UI.""" + conditions_payload = rule.rule_conditions or {} + condition_list = conditions_payload.get('conditions') or [] + logic = conditions_payload.get('logic', 'and') + + query = Alert.query.filter(Alert.alert_id == alert_id) + try: + query, extra = apply_custom_conditions(query, Alert, condition_list) + except Exception as exc: + logger.warning(f'Rule #{rule.rule_id} has invalid conditions: {exc}') + return False + combined = combine_conditions(extra, logic) + if combined is not None: + query = query.filter(combined) + return db.session.query(query.exists()).scalar() + + +def _dedupe_key(alert: Alert, rule: IncidentRule) -> str: + """Deterministic key from the rule's `group_by` fields on the alert plus + the rule id and a time bucket. Alerts landing in the same bucket + same + group values stack into one incident; the bucket rolls forward with + `time_window_seconds` so the incident stops accepting new alerts once + the window closes.""" + conditions_payload = rule.rule_conditions or {} + group_by = conditions_payload.get('group_by') or [] + time_window = conditions_payload.get('time_window_seconds') + + key_parts = [str(rule.rule_id)] + for field in group_by: + key_parts.append(f'{field}={getattr(alert, field, None)}') + if time_window and time_window > 0: + now = datetime.utcnow() + bucket = int(now.timestamp() // time_window) + key_parts.append(f'bucket={bucket}') + raw = '|'.join(key_parts) + return hashlib.sha256(raw.encode('utf-8')).hexdigest() + + +def _apply_create_incident(rule: IncidentRule, alert: Alert) -> Optional[Incident]: + config = rule.rule_action_config or {} + dedupe_key = _dedupe_key(alert, rule) + existing = incident_open_matching(alert.alert_customer_id, dedupe_key) + if existing is not None: + if alert.alert_id not in {a.alert_id for a in existing.alerts}: + existing.alerts.append(alert) + db.session.commit() + return existing + + title_template = config.get('title_template') or f'Auto-created by rule: {rule.rule_name}' + title = title_template.replace('{alert_title}', alert.alert_title or '') + incident = Incident( + incident_title=title[:2048], + incident_description=config.get('description'), + incident_customer_id=alert.alert_customer_id, + incident_severity_id=alert.alert_severity_id, + incident_source_rule_id=rule.rule_id, + incident_dedupe_key=dedupe_key, + incident_creation_time=datetime.utcnow(), + ) + # Deferred import to avoid a cycle at module import time. Public + # names (no leading underscore) so Ruff doesn't flag cross-module + # private imports. + from app.business.incidents import INCIDENT_STATUS_OPEN, resolve_status_id + incident.incident_status_id = resolve_status_id(INCIDENT_STATUS_OPEN) + db.session.add(incident) + db.session.flush() + incident.alerts.append(alert) + db.session.commit() + return incident + + +def evaluate_rules_for_alert(alert_id: int) -> dict: + """Load the alert fresh from the DB (this runs in a Celery worker with + its own session), find every active rule whose customer scope covers + the alert's tenant, and apply matches in priority order. Returns a small + summary useful for logging and the /test endpoint.""" + alert = Alert.query.filter_by(alert_id=alert_id).first() + if alert is None: + return {'alert_id': alert_id, 'matched_rules': [], 'skipped': 'alert_not_found'} + + candidates = IncidentRule.query.filter( + IncidentRule.rule_is_active.is_(True) + ).order_by( + IncidentRule.rule_priority.asc(), IncidentRule.rule_id.asc() + ).all() + + applied = [] + for rule in candidates: + scope = rule.rule_customer_scope + if scope is not None and alert.alert_customer_id not in (scope or []): + continue + if not _rule_matches_alert(rule, alert.alert_id): + continue + try: + if rule.rule_action_type == RULE_ACTION_CREATE_INCIDENT: + incident = _apply_create_incident(rule, alert) + applied.append({ + 'rule_id': rule.rule_id, + 'action': RULE_ACTION_CREATE_INCIDENT, + 'incident_id': incident.incident_id if incident else None, + }) + # Flow attachment lives in the flow evaluator — see + # `app.business.investigation_flows.evaluate_flows_for_alert`. + # Rules no longer carry an `attach_flow` action. + except Exception as exc: + db.session.rollback() + logger.exception(f'Failed to apply rule #{rule.rule_id} to alert #{alert_id}: {exc}') + return {'alert_id': alert_id, 'matched_rules': applied} + + +def rule_dry_run(rule: IncidentRule, sample_days: int = 7, limit: int = 50) -> List[int]: + """Return alert IDs that would match this rule against the last N days + of alerts. Used by the settings UI to preview a rule before saving it. + Read-only; does not run the actions.""" + since = datetime.utcnow() - timedelta(days=max(1, sample_days)) + conditions_payload = rule.rule_conditions or {} + condition_list = conditions_payload.get('conditions') or [] + logic = conditions_payload.get('logic', 'and') + + query = Alert.query.filter(Alert.alert_creation_time >= since) + scope = rule.rule_customer_scope + if scope is not None: + query = query.filter(Alert.alert_customer_id.in_(scope or [0])) + try: + query, extra = apply_custom_conditions(query, Alert, condition_list) + except Exception as exc: + logger.warning(f'Dry-run for rule #{rule.rule_id} failed to compile: {exc}') + return [] + combined = combine_conditions(extra, logic) + if combined is not None: + query = query.filter(combined) + rows = query.order_by(Alert.alert_creation_time.desc()).limit(limit).all() + return [r.alert_id for r in rows] + + +def backfill_rule(rule: IncidentRule, sample_days: int = 30) -> dict: + """Apply this rule's `create_incident` action to *historical* alerts — + matching alerts from the last `sample_days` days that aren't already + attached to the incident this rule would create. The dedupe key / + time-window logic in `_apply_create_incident` makes this naturally + idempotent — re-running the same back-fill won't produce duplicate + incidents. + + Skips alerts that are already members of ANY incident to avoid + dragging an analyst's manually-curated grouping under a rule they + didn't consent to. Returns per-outcome counts for the UI toast. + """ + if rule.rule_action_type != RULE_ACTION_CREATE_INCIDENT: + return {'attached': 0, 'skipped_already_in_incident': 0, 'errors': 0} + + since = datetime.utcnow() - timedelta(days=max(1, sample_days)) + conditions_payload = rule.rule_conditions or {} + condition_list = conditions_payload.get('conditions') or [] + logic = conditions_payload.get('logic', 'and') + + query = Alert.query.filter(Alert.alert_creation_time >= since) + scope = rule.rule_customer_scope + if scope is not None: + query = query.filter(Alert.alert_customer_id.in_(scope or [0])) + try: + query, extra = apply_custom_conditions(query, Alert, condition_list) + except Exception as exc: + logger.warning(f'Back-fill for rule #{rule.rule_id} failed to compile: {exc}') + return {'attached': 0, 'skipped_already_in_incident': 0, 'errors': 1} + combined = combine_conditions(extra, logic) + if combined is not None: + query = query.filter(combined) + + attached = 0 + skipped = 0 + errors = 0 + # Materialise once — the create-incident action commits, which + # would otherwise invalidate a streaming query cursor mid-iteration. + matches = query.order_by(Alert.alert_creation_time.asc()).all() + for alert in matches: + # Respect analyst intent: leave alerts alone if they're already + # grouped into an incident (manually or by an earlier rule). + if alert.incidents: + skipped += 1 + continue + try: + _apply_create_incident(rule, alert) + attached += 1 + except Exception as exc: + db.session.rollback() + logger.exception( + f'Back-fill for rule #{rule.rule_id} failed on alert #{alert.alert_id}: {exc}' + ) + errors += 1 + return { + 'attached': attached, + 'skipped_already_in_incident': skipped, + 'errors': errors, + 'considered': len(matches), + } diff --git a/source/app/business/incidents.py b/source/app/business/incidents.py new file mode 100644 index 000000000..67fc777db --- /dev/null +++ b/source/app/business/incidents.py @@ -0,0 +1,556 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from datetime import datetime +from typing import Iterable +from typing import List +from typing import Optional + +from app.blueprints.iris_user import iris_current_user +from app.business.access_controls import access_controls_user_has_customer_access +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.case.case_db import get_case +from app.db import db +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.models.alerts import Alert +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.models.alerts import AlertStatus +from app.models.incidents import Incident +from app.models.incidents import IncidentStatus +from app.util import add_obj_history_entry + + +# Fields whose changes are worth pinning to the analyst activity trail — +# status, owner, severity, title. Description edits generate a lot of +# small revisions (typos, formatting) so we keep them out to avoid +# flooding the timeline; the analyst still sees the current text on the +# detail page. +_AUDITED_FIELDS = { + 'incident_status_id': 'status', + 'incident_owner_id': 'owner', + 'incident_severity_id': 'severity', + 'incident_title': 'title', +} + + +# Public status-name constants. Kept as module-level names so callers +# in other modules can reference them without either duplicating the +# literal string (drift risk) or reaching into an underscored private +# (lint violation). The `_STATUS_*` aliases below are kept for the +# in-file callers. +INCIDENT_STATUS_OPEN = 'Open' +INCIDENT_STATUS_INVESTIGATING = 'Investigating' +INCIDENT_STATUS_DISMISSED = 'Dismissed' +INCIDENT_STATUS_ESCALATED = 'Escalated' + +_STATUS_OPEN = INCIDENT_STATUS_OPEN +_STATUS_INVESTIGATING = INCIDENT_STATUS_INVESTIGATING +_STATUS_DISMISSED = INCIDENT_STATUS_DISMISSED +_STATUS_ESCALATED = INCIDENT_STATUS_ESCALATED + + +def resolve_status_id(status_name: str) -> int: + """Look up an `IncidentStatus.status_id` by its human-readable name. + + Public so other business modules (e.g. `incident_rules._apply_create_incident`) + can resolve the "Open" status when opening a new incident, without + reaching into a private helper — Ruff / import-linters flag cross- + module private imports and the underscore signals module-internal + intent.""" + status = IncidentStatus.query.filter_by(status_name=status_name).first() + if not status: + raise BusinessProcessingError(f'IncidentStatus "{status_name}" is not seeded') + return status.status_id + + +# Backwards-compatible alias — this file's own callers used `_status_id` +# before the rename; keep the alias so no in-file caller changes. +_status_id = resolve_status_id + + +# Incident -> alert status mapping applied whenever the incident's status +# is updated. Chosen so that the alert timeline mirrors what the analyst +# is doing at the incident level: +# Open -> "Assigned" (someone owns this cluster) +# Investigating -> "In progress" (active work) +# Dismissed -> "Closed" (no action taken) +# The `Escalated` incident status is NOT in this map: the escalate/merge +# flows already set alerts to `Escalated` (new case) or `Merged` +# (existing case) explicitly, and letting the generic propagator run +# afterwards would clobber that distinction. +_INCIDENT_TO_ALERT_STATUS = { + INCIDENT_STATUS_OPEN: 'Assigned', + INCIDENT_STATUS_INVESTIGATING: 'In progress', + INCIDENT_STATUS_DISMISSED: 'Closed', +} + + +def _resolve_alert_status_id(status_name: str) -> Optional[int]: + """Look up an `AlertStatus.status_id` by name. Returns None (with a + log line) if the seed row is missing so the caller can degrade to a + no-op rather than 500ing on a status change.""" + row = AlertStatus.query.filter_by(status_name=status_name).first() + if row is None: + from app.logger import logger + logger.warning('AlertStatus "%s" is not seeded; cannot propagate', status_name) + return None + return row.status_id + + +def _propagate_status_to_alerts(incident: Incident, new_status_name: str) -> None: + """Push the incident's new status down to every member alert according to + `_INCIDENT_TO_ALERT_STATUS`. No-op for status transitions we don't map + (e.g. `Escalated` — see the mapping doc above). The caller is + responsible for committing; we only mutate ORM state so the outer + transaction stays a single commit.""" + target_alert_status = _INCIDENT_TO_ALERT_STATUS.get(new_status_name) + if target_alert_status is None: + return + alert_status_id = _resolve_alert_status_id(target_alert_status) + if alert_status_id is None: + return + changed = 0 + for alert in incident.alerts: + if alert.alert_status_id != alert_status_id: + alert.alert_status_id = alert_status_id + changed += 1 + if changed: + add_obj_history_entry( + incident, + f'propagated status to {changed} alert(s) as {target_alert_status!r}', + ) + + +def incidents_create(incident: Incident) -> Incident: + if incident.incident_status_id is None: + incident.incident_status_id = _status_id(_STATUS_OPEN) + incident.incident_creation_time = datetime.utcnow() + db.session.add(incident) + db.session.commit() + add_obj_history_entry(incident, 'created', commit=True) + track_activity(f'created incident #{incident.incident_id} - {incident.incident_title}', ctx_less=True) + _enqueue_flow_evaluation(incident.incident_id) + incident = call_modules_hook('on_postload_incident_create', incident) + return incident + + +def _enqueue_flow_evaluation(incident_id: int) -> None: + """Fire the async flow evaluator against a newly created / updated + incident. Import is deferred and errors are swallowed — a broken + worker must not block incident writes on the request path.""" + try: + from app.iris_engine.incident_rules.tasks import evaluate_incident_flows + evaluate_incident_flows.delay(incident_id) + except Exception: # noqa: BLE001 + from app.logger import logger + logger.exception('Failed to enqueue flow evaluation for incident #%s', incident_id) + + +def incidents_get(user, permissions, identifier, fallback_customer_access=None) -> Incident: + incident = Incident.query.filter_by(incident_id=identifier).first() + if not incident: + raise ObjectNotFoundError() + if not access_controls_user_has_customer_access( + user, permissions, incident.incident_customer_id, + fallback_customer_access=fallback_customer_access + ): + raise ObjectNotFoundError() + return incident + + +def incidents_get_by_case(case_id: int) -> Optional[Incident]: + """Return the incident this case was created from (via escalate/merge), + or None if the case wasn't sourced from an incident. Powers the "back + to source incident" chip in the case topbar; callers must gate access + on the case, not the incident — anyone who can read the case can see + which incident it came from. + """ + return Incident.query.filter_by(incident_case_id=case_id).first() + + +def incidents_search(customer_id: Optional[int], status_id: Optional[int], + title: Optional[str], user_identifier_filter: Optional[int], + page: int, per_page: int, sort: Optional[str]): + """Paginated incident search. `user_identifier_filter` = None means + server-admin (see all). Otherwise scope by the caller's UserClient rows — + same pattern `get_filtered_alerts` uses for tenant isolation.""" + from app.models.authorization import UserClient # local import to avoid cycles + + query = Incident.query + if user_identifier_filter is not None: + accessible = db.session.query(UserClient.client_id).filter( + UserClient.user_id == user_identifier_filter + ).subquery() + query = query.filter(Incident.incident_customer_id.in_(accessible)) + if customer_id is not None: + query = query.filter(Incident.incident_customer_id == customer_id) + if status_id is not None: + query = query.filter(Incident.incident_status_id == status_id) + if title: + query = query.filter(Incident.incident_title.ilike(f'%{title}%')) + + direction = 'desc' + if sort and sort.endswith(' asc'): + direction = 'asc' + if direction == 'asc': + query = query.order_by(Incident.incident_creation_time.asc()) + else: + query = query.order_by(Incident.incident_creation_time.desc()) + + return query.paginate(page=page, per_page=per_page, error_out=False) + + +def incidents_update(incident: Incident, changes: dict) -> Incident: + # Snapshot before-values for audited fields so we can record the + # transition into modification_history. Reading via getattr after + # setattr would already show the new value. + audit_before = { + key: getattr(incident, key, None) + for key in _AUDITED_FIELDS + if key in changes + } + status_changed = ( + 'incident_status_id' in changes + and audit_before.get('incident_status_id') != changes['incident_status_id'] + ) + for key, value in changes.items(): + setattr(incident, key, value) + for key, label in _AUDITED_FIELDS.items(): + if key not in changes: + continue + old_value = audit_before.get(key) + new_value = getattr(incident, key, None) + if old_value == new_value: + continue + add_obj_history_entry( + incident, + f'changed {label}: {old_value!r} -> {new_value!r}', + ) + # Cascade the incident's new status onto its member alerts (see + # `_INCIDENT_TO_ALERT_STATUS`). Skipped when the status didn't + # actually move so re-saving the same status doesn't spam alert + # history. `Escalated` is intentionally not in the mapping — the + # escalate/merge flows set alert status themselves. + if status_changed and incident.status is not None: + _propagate_status_to_alerts(incident, incident.status.status_name) + db.session.commit() + track_activity(f'updated incident #{incident.incident_id}', ctx_less=True) + _enqueue_flow_evaluation(incident.incident_id) + incident = call_modules_hook('on_postload_incident_update', incident) + return incident + + +def incidents_delete(incident: Incident) -> None: + identifier = incident.incident_id + db.session.delete(incident) + db.session.commit() + track_activity(f'deleted incident #{identifier}', ctx_less=True) + call_modules_hook('on_postload_incident_delete', identifier) + + +def incident_add_alerts(incident: Incident, alert_ids: Iterable[int]) -> Incident: + """Attach alerts to an incident. Silently skips alerts already attached and + alerts belonging to a different customer than the incident — mixing tenants + inside a container would leak data through `incident.alerts`.""" + ids = list(alert_ids) + if not ids: + return incident + alerts = Alert.query.filter( + Alert.alert_id.in_(ids), + Alert.alert_customer_id == incident.incident_customer_id + ).all() + existing = {a.alert_id for a in incident.alerts} + added_ids = [] + for alert in alerts: + if alert.alert_id not in existing: + incident.alerts.append(alert) + added_ids.append(alert.alert_id) + if added_ids: + add_obj_history_entry(incident, f'linked alerts: {added_ids}') + db.session.commit() + if added_ids: + call_modules_hook('on_postload_incident_alert_add', + {'incident_id': incident.incident_id, + 'alert_ids': added_ids}) + return incident + + +def incident_remove_alert(incident: Incident, alert_id: int) -> Incident: + was_linked = any(a.alert_id == alert_id for a in incident.alerts) + if was_linked: + incident.alerts = [a for a in incident.alerts if a.alert_id != alert_id] + add_obj_history_entry(incident, f'unlinked alert #{alert_id}') + db.session.commit() + if was_linked: + call_modules_hook('on_postload_incident_alert_remove', + {'incident_id': incident.incident_id, + 'alert_id': alert_id}) + return incident + + +def incident_escalate_to_case(incident: Incident, template_id: Optional[int] = None, + case_title: Optional[str] = None, note: Optional[str] = None, + import_as_event: bool = False, case_tags: str = ''): + """Escalate the incident to a case. Reuses `create_case_from_alerts` so the + case gets all member alerts linked (plus their IOCs/assets), which is what + the existing alerts-to-case flow already does. Idempotent-ish: re-escalating + a linked incident raises, callers should check `incident_case_id` first.""" + if incident.incident_case_id is not None: + raise BusinessProcessingError('Incident already escalated to a case') + if not incident.alerts: + raise BusinessProcessingError('Cannot escalate an incident with no alerts') + + # `create_case_from_alerts` matches IOCs/assets by UUID, not id. + # Passing numeric ids here silently drops every IOC/asset from the + # linked case, which is the "escalation produces an empty case" + # behaviour we're fixing alongside the ACL grant below. + case = create_case_from_alerts( + alerts=list(incident.alerts), + iocs_list=[str(i.ioc_uuid) for a in incident.alerts for i in (a.iocs or [])], + assets_list=[str(a2.asset_uuid) for a in incident.alerts for a2 in (a.assets or [])], + case_title=case_title or incident.incident_title, + note=note or incident.incident_description or '', + import_as_event=import_as_event, + case_tags=case_tags, + template_id=template_id, + ) + + # Grant the escalating user access to the freshly created case. Without + # this the case row lives in the DB but is filtered out of every + # per-user query (`user_list_cases_view`), so from the analyst's point + # of view the escalation "consumes" a case id and produces nothing. + # Mirrors the alert-escalation flow in `alerts_routes.py:642`. + ac_set_new_case_access(iris_current_user, case.case_id, case.client_id) + + # Fire the same post-create module hook and history entry the normal + # alert-escalate path fires — modules that react to new cases (SLA + # trackers, external syncs) shouldn't have to special-case incidents. + case = call_modules_hook('on_postload_case_create', case) + add_obj_history_entry(case, 'created') + + incident.incident_case_id = case.case_id + incident.incident_status_id = _status_id(_STATUS_ESCALATED) + # Mark every member alert as Escalated so the alerts board reflects + # the incident-level decision. Uses the same seed-lookup helper the + # alert-escalate route uses (`alerts_routes.py:631`). + escalated_alert_id = _resolve_alert_status_id('Escalated') + if escalated_alert_id is not None: + for alert in incident.alerts: + if alert.alert_status_id != escalated_alert_id: + alert.alert_status_id = escalated_alert_id + add_obj_history_entry(incident, f'escalated to case #{case.case_id}') + db.session.commit() + track_activity( + f'escalated incident #{incident.incident_id} to case #{case.case_id}', + ctx_less=True, + ) + call_modules_hook('on_postload_incident_escalate', + {'incident_id': incident.incident_id, + 'case_id': case.case_id}, + caseid=case.case_id) + return case + + +def incident_merge_to_case(incident: Incident, target_case_id: int, + note: Optional[str] = None, import_as_event: bool = False, + case_tags: str = ''): + """Merge an incident's alerts into an already-existing case. Analogous to + the alerts-to-existing-case merge flow (`alerts_routes.py:667`), but + fanned out across every alert on the incident: each alert links to the + target case, its selected IOCs/assets get imported, and the incident + is marked Escalated + backpointed to the target case. + + Same guardrails as escalate: re-merging a linked incident raises, and + an incident with no alerts has nothing to merge. The caller is + responsible for gating access to `target_case_id` — the request-layer + endpoint checks case access before calling in. + """ + if incident.incident_case_id is not None: + raise BusinessProcessingError('Incident already linked to a case') + if not incident.alerts: + raise BusinessProcessingError('Cannot merge an incident with no alerts') + + case = get_case(target_case_id) + if case is None: + raise BusinessProcessingError(f'Target case #{target_case_id} not found') + if case.client_id != incident.incident_customer_id: + raise BusinessProcessingError( + 'Target case belongs to a different customer than the incident' + ) + + # Import every alert's IOCs and assets into the case. `merge_alert_in_case` + # dedupes by (value,type) for IOCs and (name,type) for assets so + # re-merging alerts that share indicators doesn't create duplicates. + alerts_snapshot = list(incident.alerts) + for alert in alerts_snapshot: + merge_alert_in_case( + alert=alert, + case=case, + iocs_list=[str(i.ioc_uuid) for i in (alert.iocs or [])], + assets_list=[str(a.asset_uuid) for a in (alert.assets or [])], + note=note or incident.incident_description or '', + import_as_event=import_as_event, + case_tags=case_tags, + ) + + incident.incident_case_id = case.case_id + incident.incident_status_id = _status_id(_STATUS_ESCALATED) + # Mark every member alert as Merged (not Escalated) — the alert-merge + # route does the same thing at `alerts_routes.py:710`. + merged_alert_id = _resolve_alert_status_id('Merged') + if merged_alert_id is not None: + for alert in incident.alerts: + if alert.alert_status_id != merged_alert_id: + alert.alert_status_id = merged_alert_id + add_obj_history_entry(incident, f'merged into case #{case.case_id}') + db.session.commit() + track_activity( + f'merged incident #{incident.incident_id} into case #{case.case_id}', + ctx_less=True, + ) + # Best-effort hook fire. `call_modules_hook` raises when the hook name + # isn't seeded in `iris_hooks` (post_init seeds it on boot); we don't + # want a fresh install / mid-upgrade instance to 500 the whole merge + # because a module hook row hasn't landed yet. The merge itself is + # already committed above, so swallowing here just means module + # listeners miss THIS event — the operation is otherwise complete. + try: + call_modules_hook('on_postload_incident_merge', + {'incident_id': incident.incident_id, + 'case_id': case.case_id}, + caseid=case.case_id) + except Exception: # noqa: BLE001 + from app.logger import logger + logger.exception('on_postload_incident_merge hook failed; merge already committed') + return case + + +def incident_correlation_graph(incident: Incident) -> dict: + """Build a correlation graph (nodes + edges) for the incident. + + Nodes are one of three kinds: + * `alert` — one per member alert + * `ioc` — deduplicated by (value, type_id) across every alert + * `asset` — deduplicated by (name, type_id) across every alert + Edges connect each alert to every IOC / asset it carries. Dedup keys + let a single IOC/asset connect to multiple alerts, which is exactly + the correlation surface the graph exists to expose. + + Node shape matches `_create_*_node` in `business/alerts.py` closely + enough that the frontend's `VisNetwork` component can render this + payload with no per-source branching. + """ + nodes: list[dict] = [] + edges: list[dict] = [] + + seen_iocs: dict[tuple[str, Optional[int]], str] = {} + seen_assets: dict[tuple[str, Optional[int]], str] = {} + + for alert in incident.alerts: + alert_node_id = f'alert_{alert.alert_id}' + status_name = alert.status.status_name if alert.status else '' + is_closed = status_name in ('Closed', 'Merged', 'Escalated') + label_prefix = '[Closed] ' if is_closed else '' + nodes.append({ + 'id': alert_node_id, + 'label': f'{label_prefix}{alert.alert_title}', + 'title': alert.alert_description or alert.alert_title, + 'group': 'alert', + }) + + for ioc in (alert.iocs or []): + key = (ioc.ioc_value or '', ioc.ioc_type_id) + node_id = seen_iocs.get(key) + if node_id is None: + node_id = f'ioc_{ioc.ioc_id}' + seen_iocs[key] = node_id + type_name = ioc.ioc_type.type_name if ioc.ioc_type else '' + title_bits = [ + f'<b>{ioc.ioc_value or ""}</b>', + ] + if type_name: + title_bits.append(f'type: {type_name}') + if ioc.ioc_tags: + title_bits.append(f'tags: {ioc.ioc_tags}') + nodes.append({ + 'id': node_id, + 'label': ioc.ioc_value or f'ioc #{ioc.ioc_id}', + 'title': '<br>'.join(title_bits), + 'group': 'ioc', + }) + edges.append({ + 'from': alert_node_id, + 'to': node_id, + 'dashes': True, + }) + + for asset in (alert.assets or []): + key = ((asset.asset_name or ''), asset.asset_type_id) + node_id = seen_assets.get(key) + if node_id is None: + node_id = f'asset_{asset.asset_id}' + seen_assets[key] = node_id + type_name = asset.asset_type.asset_name if asset.asset_type else '' + title_bits = [ + f'<b>{asset.asset_name or ""}</b>', + ] + if type_name: + title_bits.append(f'type: {type_name}') + if asset.asset_ip: + title_bits.append(f'ip: {asset.asset_ip}') + if asset.asset_domain: + title_bits.append(f'domain: {asset.asset_domain}') + asset_node = { + 'id': node_id, + 'label': asset.asset_name or f'asset #{asset.asset_id}', + 'title': '<br>'.join(title_bits), + 'group': 'asset', + } + # Include the asset-type icon path if the referenced asset + # type carries one — mirrors what the alert graph does so + # the client can pick a matching image without a second + # lookup. + icon = getattr(asset.asset_type, 'asset_icon_not_compromised', None) + if icon: + asset_node['image'] = f'/static/assets/img/graph/{icon}' + nodes.append(asset_node) + edges.append({ + 'from': alert_node_id, + 'to': node_id, + }) + + return {'nodes': nodes, 'edges': edges} + + +def incident_open_matching(customer_id: int, dedupe_key: str) -> Optional[Incident]: + """Look up an open incident with the given dedupe key for stacking. Used by + the rules engine when a rule action is create_incident and its dedupe key + matches an incident already accepting alerts.""" + if not dedupe_key: + return None + open_status_id = _status_id(_STATUS_OPEN) + investigating_status_id = _status_id(_STATUS_INVESTIGATING) + return Incident.query.filter( + Incident.incident_customer_id == customer_id, + Incident.incident_dedupe_key == dedupe_key, + Incident.incident_status_id.in_([open_status_id, investigating_status_id]), + ).order_by(Incident.incident_creation_time.desc()).first() diff --git a/source/app/business/investigation_flows.py b/source/app/business/investigation_flows.py new file mode 100644 index 000000000..aa1dd848b --- /dev/null +++ b/source/app/business/investigation_flows.py @@ -0,0 +1,444 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from datetime import datetime +from typing import List +from typing import Optional +from typing import Tuple + +from app.business.access_controls import access_controls_user_accessible_customers +from app.business.access_controls import access_controls_user_has_customer_scope +from app.datamgmt.filtering import apply_custom_conditions +from app.datamgmt.filtering import combine_conditions +from app.db import db +from app.iris_engine.utils.tracker import track_activity +from app.logger import logger +from app.models.alerts import Alert +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.models.incidents import Incident +from app.models.investigation_flows import AlertInvestigationProgress +from app.models.investigation_flows import FLOW_TARGET_ALERT +from app.models.investigation_flows import FLOW_TARGET_BOTH +from app.models.investigation_flows import FLOW_TARGET_INCIDENT +from app.models.investigation_flows import IncidentInvestigationProgress +from app.models.investigation_flows import InvestigationFlow +from app.models.investigation_flows import InvestigationFlowStep + + +# --------------------------------------------------------------------------- +# CRUD +# +# Route handlers must call the scope-aware helpers below (that take +# `user`/`permissions`) — never the `_unchecked` variants. The +# `_unchecked` helpers exist for the Celery-worker code path (flow +# evaluator, deploy_flow) where there's no request context to derive +# a caller from. +# --------------------------------------------------------------------------- + +def _flows_get_unchecked(identifier: int) -> InvestigationFlow: + flow = InvestigationFlow.query.filter_by(flow_id=identifier).first() + if not flow: + raise ObjectNotFoundError() + return flow + + +def _flow_step_get_unchecked(step_id: int) -> InvestigationFlowStep: + step = InvestigationFlowStep.query.filter_by(step_id=step_id).first() + if not step: + raise ObjectNotFoundError() + return step + + +def flows_create(user, permissions, flow: InvestigationFlow, + fallback_customer_access=None) -> InvestigationFlow: + """Persist a new flow after verifying the caller can act on every + customer the flow declares in `flow_customer_scope`. Global (null) + scope requires `server_administrator` — otherwise `investigation_flows_write` + would suffice to attach a flow to every tenant on the box.""" + if not access_controls_user_has_customer_scope( + user, permissions, flow.flow_customer_scope, + fallback_customer_access=fallback_customer_access, + ): + raise BusinessProcessingError( + 'You do not have access to every customer in flow_customer_scope ' + '(or the flow is global and you are not a server administrator).' + ) + db.session.add(flow) + db.session.commit() + track_activity(f'created investigation flow #{flow.flow_id} - {flow.flow_name}', ctx_less=True) + return flow + + +def flows_list(user, permissions) -> List[InvestigationFlow]: + """List active flows visible to the caller. + + Semantics match `rules_list`: + * `server_administrator` sees every flow. + * Everyone else sees flows whose `flow_customer_scope` is a subset + of their accessible customers. Null-scope flows are hidden from + non-admins. + """ + accessible = access_controls_user_accessible_customers(user, permissions) + query = InvestigationFlow.query.filter( + InvestigationFlow.flow_is_active.is_(True) + ).order_by(InvestigationFlow.flow_name.asc()) + if accessible is None: + return query.all() + rows = query.all() + return [ + f for f in rows + if f.flow_customer_scope is not None + and f.flow_customer_scope + and set(f.flow_customer_scope).issubset(accessible) + ] + + +def flows_get(user, permissions, identifier: int, + fallback_customer_access=None) -> InvestigationFlow: + """Fetch a flow + gate by caller's scope. `ObjectNotFoundError` on + lack of access — same "no side-channel enumeration" pattern as the + rules helpers.""" + flow = _flows_get_unchecked(identifier) + if not access_controls_user_has_customer_scope( + user, permissions, flow.flow_customer_scope, + fallback_customer_access=fallback_customer_access, + ): + raise ObjectNotFoundError() + return flow + + +def flows_update(user, permissions, flow: InvestigationFlow, changes: dict, + fallback_customer_access=None) -> InvestigationFlow: + """Apply `changes` after checking both current and incoming scope. + Refusing the update when either side is unreachable prevents a caller + from pivoting a flow between tenants.""" + if not access_controls_user_has_customer_scope( + user, permissions, flow.flow_customer_scope, + fallback_customer_access=fallback_customer_access, + ): + raise ObjectNotFoundError() + if 'flow_customer_scope' in changes: + if not access_controls_user_has_customer_scope( + user, permissions, changes['flow_customer_scope'], + fallback_customer_access=fallback_customer_access, + ): + raise BusinessProcessingError( + 'You do not have access to every customer in the new flow_customer_scope.' + ) + for key, value in changes.items(): + if key == 'steps': + continue # steps mutated via separate endpoints + setattr(flow, key, value) + flow.flow_updated_at = datetime.utcnow() + db.session.commit() + return flow + + +def flows_delete(flow: InvestigationFlow) -> None: + """Delete a flow. Caller-scope check is done by the getter that + loaded `flow` — routes MUST load flows through `flows_get(user, ...)` + before delete.""" + identifier = flow.flow_id + db.session.delete(flow) + db.session.commit() + track_activity(f'deleted investigation flow #{identifier}', ctx_less=True) + + +def flow_step_create(flow: InvestigationFlow, step: InvestigationFlowStep) -> InvestigationFlowStep: + """Attach a step to `flow`. Route MUST have loaded `flow` via + `flows_get(user, ...)` so the caller-scope check has already run — + otherwise a caller could `POST /investigation-flows/<other-tenant-id>/steps` + to seed steps on a flow they can't see.""" + step.flow_id = flow.flow_id + db.session.add(step) + db.session.commit() + return step + + +def flow_step_get(user, permissions, step_id: int, + fallback_customer_access=None) -> InvestigationFlowStep: + """Fetch a step + verify the caller has access to the parent flow. + Same 404-on-no-access pattern to avoid step-id enumeration.""" + step = _flow_step_get_unchecked(step_id) + parent = _flows_get_unchecked(step.flow_id) + if not access_controls_user_has_customer_scope( + user, permissions, parent.flow_customer_scope, + fallback_customer_access=fallback_customer_access, + ): + raise ObjectNotFoundError() + return step + + +def flow_step_update(step: InvestigationFlowStep, changes: dict) -> InvestigationFlowStep: + """Mutate a step. Route MUST have loaded `step` via + `flow_step_get(user, ...)`.""" + for key, value in changes.items(): + setattr(step, key, value) + db.session.commit() + return step + + +def flow_step_delete(step: InvestigationFlowStep) -> None: + """Delete a step. Route MUST have loaded `step` via + `flow_step_get(user, ...)`.""" + db.session.delete(step) + db.session.commit() + + +def alert_progress_list(alert: Alert) -> List[AlertInvestigationProgress]: + if not alert.alert_investigation_flow_id: + return [] + return AlertInvestigationProgress.query.filter_by(alert_id=alert.alert_id).all() + + +def alert_progress_record(alert: Alert, step_id: int, user_id: int, + note: Optional[str] = None) -> AlertInvestigationProgress: + """Idempotent: re-checking the same step just updates the note + timestamp.""" + if not alert.alert_investigation_flow_id: + raise BusinessProcessingError('Alert has no investigation flow attached') + # Caller-scope was already enforced when the Alert/Incident was + # loaded upstream; the step lookup here is a data-integrity check + # (step must belong to the entity's attached flow), so the unchecked + # variant is correct. + step = _flow_step_get_unchecked(step_id) + if step.flow_id != alert.alert_investigation_flow_id: + raise BusinessProcessingError('Step does not belong to the alert\'s flow') + row = AlertInvestigationProgress.query.filter_by( + alert_id=alert.alert_id, step_id=step_id + ).first() + if row is None: + row = AlertInvestigationProgress( + alert_id=alert.alert_id, + step_id=step_id, + completed_by_user_id=user_id, + note=note, + ) + db.session.add(row) + else: + row.completed_by_user_id = user_id + row.completed_at = datetime.utcnow() + if note is not None: + row.note = note + db.session.commit() + return row + + +def alert_progress_uncheck(alert: Alert, step_id: int) -> None: + row = AlertInvestigationProgress.query.filter_by( + alert_id=alert.alert_id, step_id=step_id + ).first() + if row is None: + return + db.session.delete(row) + db.session.commit() + + +# --------------------------------------------------------------------------- +# Incident progress (mirror of the alert progress helpers) +# --------------------------------------------------------------------------- + +def incident_progress_list(incident: Incident) -> List[IncidentInvestigationProgress]: + if not incident.incident_investigation_flow_id: + return [] + return IncidentInvestigationProgress.query.filter_by( + incident_id=incident.incident_id + ).all() + + +def incident_progress_record(incident: Incident, step_id: int, user_id: int, + note: Optional[str] = None) -> IncidentInvestigationProgress: + if not incident.incident_investigation_flow_id: + raise BusinessProcessingError('Incident has no investigation flow attached') + # Caller-scope was already enforced when the Alert/Incident was + # loaded upstream; the step lookup here is a data-integrity check + # (step must belong to the entity's attached flow), so the unchecked + # variant is correct. + step = _flow_step_get_unchecked(step_id) + if step.flow_id != incident.incident_investigation_flow_id: + raise BusinessProcessingError("Step does not belong to the incident's flow") + row = IncidentInvestigationProgress.query.filter_by( + incident_id=incident.incident_id, step_id=step_id + ).first() + if row is None: + row = IncidentInvestigationProgress( + incident_id=incident.incident_id, + step_id=step_id, + completed_by_user_id=user_id, + note=note, + ) + db.session.add(row) + else: + row.completed_by_user_id = user_id + row.completed_at = datetime.utcnow() + if note is not None: + row.note = note + db.session.commit() + return row + + +def incident_progress_uncheck(incident: Incident, step_id: int) -> None: + row = IncidentInvestigationProgress.query.filter_by( + incident_id=incident.incident_id, step_id=step_id + ).first() + if row is None: + return + db.session.delete(row) + db.session.commit() + + +# --------------------------------------------------------------------------- +# Flow evaluator — decides which flow (if any) to attach to a given +# alert or incident, based on the flow's own `flow_conditions`. +# --------------------------------------------------------------------------- + +def _flow_matches(flow: InvestigationFlow, model, entity_id: int) -> bool: + """Return True when the given entity (Alert or Incident) satisfies the + flow's conditions. Reuses `apply_custom_conditions` — same code path + that backs the alert/case search filter — so the DSL never diverges + from what users see when authoring conditions. + + A flow with no conditions never auto-attaches (guard callers). + """ + conditions_payload = flow.flow_conditions or {} + condition_list = conditions_payload.get('conditions') or [] + if not condition_list: + return False + logic = conditions_payload.get('logic', 'and') + + pk_col = model.alert_id if model is Alert else model.incident_id + query = model.query.filter(pk_col == entity_id) + try: + query, extra = apply_custom_conditions(query, model, condition_list) + except Exception as exc: + logger.warning(f'Flow #{flow.flow_id} has invalid conditions: {exc}') + return False + combined = combine_conditions(extra, logic) + if combined is not None: + query = query.filter(combined) + return db.session.query(query.exists()).scalar() + + +def _candidate_flows_for(target: str, customer_id: int) -> List[InvestigationFlow]: + """Active flows visible for `customer_id` whose `flow_target` accepts + the given target ('alert' or 'incident'). Ordered by priority so the + first match wins.""" + accepted = (target, FLOW_TARGET_BOTH) + rows = InvestigationFlow.query.filter( + InvestigationFlow.flow_is_active.is_(True), + InvestigationFlow.flow_target.in_(accepted), + ).order_by( + InvestigationFlow.flow_priority.asc(), + InvestigationFlow.flow_id.asc(), + ).all() + return [f for f in rows + if f.flow_customer_scope is None or customer_id in (f.flow_customer_scope or [])] + + +def evaluate_flows_for_alert(alert_id: int) -> Optional[int]: + """First-match-wins: assign `alert.alert_investigation_flow_id` to the + first active alert-scoped flow whose conditions match. Idempotent — + does nothing if a flow is already attached. Returns the attached + flow_id, or None.""" + alert = Alert.query.filter_by(alert_id=alert_id).first() + if alert is None or alert.alert_investigation_flow_id is not None: + return alert.alert_investigation_flow_id if alert else None + for flow in _candidate_flows_for(FLOW_TARGET_ALERT, alert.alert_customer_id): + if _flow_matches(flow, Alert, alert_id): + alert.alert_investigation_flow_id = flow.flow_id + db.session.commit() + return flow.flow_id + return None + + +def evaluate_flows_for_incident(incident_id: int) -> Optional[int]: + incident = Incident.query.filter_by(incident_id=incident_id).first() + if incident is None or incident.incident_investigation_flow_id is not None: + return incident.incident_investigation_flow_id if incident else None + for flow in _candidate_flows_for(FLOW_TARGET_INCIDENT, incident.incident_customer_id): + if _flow_matches(flow, Incident, incident_id): + incident.incident_investigation_flow_id = flow.flow_id + db.session.commit() + return flow.flow_id + return None + + +# --------------------------------------------------------------------------- +# Deploy-to-existing — back-fill a flow onto historical entities that +# match its conditions and don't already have a flow attached. +# --------------------------------------------------------------------------- + +def _deploy_flow_to_model(flow: InvestigationFlow, model, fk_col, + customer_col, pk_col) -> int: + """Attach `flow` to every row of `model` whose FK column is NULL and + whose conditions match the flow. Returns the row count attached. + + Scoped by the flow's own `flow_customer_scope` — a flow that lists + customers must not back-fill onto tenants it can't see.""" + conditions_payload = flow.flow_conditions or {} + condition_list = conditions_payload.get('conditions') or [] + if not condition_list: + return 0 + logic = conditions_payload.get('logic', 'and') + + query = model.query.filter(fk_col.is_(None)) + if flow.flow_customer_scope: + query = query.filter(customer_col.in_(flow.flow_customer_scope)) + try: + query, extra = apply_custom_conditions(query, model, condition_list) + except Exception as exc: + logger.warning(f'Flow #{flow.flow_id} deploy failed to compile: {exc}') + return 0 + combined = combine_conditions(extra, logic) + if combined is not None: + query = query.filter(combined) + + rows = query.all() + for row in rows: + setattr(row, fk_col.key, flow.flow_id) + db.session.commit() + return len(rows) + + +def deploy_flow(flow: InvestigationFlow) -> Tuple[int, int]: + """Back-fill this flow onto historical alerts and/or incidents whose + FK is empty. Returns `(alerts_attached, incidents_attached)`. + + Only matches rows where no flow is already attached — we never + overwrite an existing attachment because the analyst may have chosen + it deliberately (or the pre-existing flow may already have progress + check-offs against it that we'd orphan).""" + alerts_attached = 0 + incidents_attached = 0 + if flow.flow_target in (FLOW_TARGET_ALERT, FLOW_TARGET_BOTH): + alerts_attached = _deploy_flow_to_model( + flow, Alert, + Alert.alert_investigation_flow_id, Alert.alert_customer_id, Alert.alert_id, + ) + if flow.flow_target in (FLOW_TARGET_INCIDENT, FLOW_TARGET_BOTH): + incidents_attached = _deploy_flow_to_model( + flow, Incident, + Incident.incident_investigation_flow_id, + Incident.incident_customer_id, Incident.incident_id, + ) + track_activity( + f'deployed flow #{flow.flow_id} — alerts={alerts_attached}, ' + f'incidents={incidents_attached}', + ctx_less=True, + ) + return alerts_attached, incidents_attached diff --git a/source/app/business/modules.py b/source/app/business/modules.py new file mode 100644 index 000000000..b2dc44be8 --- /dev/null +++ b/source/app/business/modules.py @@ -0,0 +1,310 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""Business layer for the modules ("plugins") admin surface. + +Wraps `app.datamgmt.iris_engine.modules_db` and `module_handler` so the +v2 REST layer can stay thin (parse args → call business → marshal). All +side-effecting calls raise `BusinessProcessingError` / `ObjectNotFoundError` +on failure so the route layer doesn't need to inspect tuple returns. +""" + +import json + +from app.datamgmt.iris_engine.modules_db import delete_module_from_id +from app.datamgmt.iris_engine.modules_db import get_module_config_from_id +from app.datamgmt.iris_engine.modules_db import get_module_from_id +from app.datamgmt.iris_engine.modules_db import iris_module_disable_by_id +from app.datamgmt.iris_engine.modules_db import iris_module_enable_by_id +from app.datamgmt.iris_engine.modules_db import iris_module_name_from_id +from app.datamgmt.iris_engine.modules_db import iris_module_save_parameter +from app.datamgmt.iris_engine.modules_db import iris_modules_list +from app.datamgmt.iris_engine.modules_db import module_list_hooks_view +from app.iris_engine.module_handler.module_handler import check_module_health +from app.iris_engine.module_handler.module_handler import instantiate_module_from_name +from app.iris_engine.module_handler.module_handler import iris_update_hooks +from app.iris_engine.module_handler.module_handler import register_module +from app.iris_engine.utils.tracker import track_activity +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError + + +def _module_row_projection(row): + return { + 'id': row['id'], + 'module_human_name': row['module_human_name'], + 'has_pipeline': row['has_pipeline'], + 'module_version': row['module_version'], + 'interface_version': row['interface_version'], + 'date_added': row['date_added'].isoformat() if row['date_added'] else None, + 'added_by': row['name'], + 'is_active': row['is_active'], + 'configured': row['configured'], + } + + +def modules_list(page=1, per_page=25): + """Return the projection used by the admin list page, paginated. + + The legacy underlying call (`iris_modules_list`) materialises every + row and post-processes them (auto-disable misconfigured modules), + which keeps the behaviour intact. Pagination is done in-memory + afterwards — module fleets stay small (dozens at most), so a full + scan is cheap and we avoid duplicating the auto-disable logic in a + SQL paginate path. + """ + rows = [_module_row_projection(r) for r in iris_modules_list()] + total = len(rows) + per_page = max(1, min(per_page, 200)) + page = max(1, page) + start = (page - 1) * per_page + end = start + per_page + page_items = rows[start:end] + last_page = max(1, (total + per_page - 1) // per_page) if total else 1 + next_page = page + 1 if end < total else None + return { + 'total': total, + 'data': page_items, + 'last_page': last_page, + 'current_page': page, + 'next_page': next_page, + } + + +def modules_get(module_id): + module = get_module_from_id(module_id) + if module is None: + raise ObjectNotFoundError() + return module + + +def module_get_detail(module_id): + """Read-only projection for the module-info modal. + + Returns the same metadata as the list endpoint plus the full + configuration array (so the UI can render Section / Parameter / + Value / Mandatory rows) and the module's description + target + package — those are the bits the modal shows above the config table. + """ + module = modules_get(module_id) + return { + 'id': module.id, + 'module_name': module.module_name, + 'module_human_name': module.module_human_name, + 'module_description': module.module_description, + 'module_version': module.module_version, + 'interface_version': module.interface_version, + 'date_added': module.date_added.isoformat() if module.date_added else None, + 'is_active': module.is_active, + 'has_pipeline': module.has_pipeline, + 'module_type': module.module_type, + 'module_config': module.module_config or [], + } + + +def modules_add(module_name): + """Install + register a new module by its pip package name. + + Three stages mirror the legacy flow: + 1. import + instantiate the python class, + 2. health-check (interface contract), + 3. persist into `IrisModule` (this is what triggers default-value + preset for every declared parameter). + """ + if not module_name: + raise BusinessProcessingError('Module name is required') + + class_, logs = instantiate_module_from_name(module_name) + if not class_: + raise BusinessProcessingError('Cannot import module', data=logs) + + is_ready, logs = check_module_health(class_) + if not is_ready: + raise BusinessProcessingError( + "Module health check didn't pass. Please check logs.", + data=logs, + ) + + module, message = register_module(module_name) + if module is None: + track_activity( + f'addition of IRIS module {module_name} was attempted and failed', + ctx_less=True, + ) + raise BusinessProcessingError(f'Unable to register module: {message}') + + track_activity(f'IRIS module {module_name} was added', ctx_less=True) + return module + + +def modules_delete(module_id): + module = modules_get(module_id) + delete_module_from_id(module.id) + track_activity(f'IRIS module #{module.id} deleted', ctx_less=True) + + +def modules_enable(module_id): + """Activate a module and resync its hooks. + + Re-syncing hooks on enable matters: the module may have been disabled + long enough for its declared hooks to drift from what's in the DB + (e.g. an upgrade added new hooks). Without the resync, calling them + later would silently no-op. + """ + module_name = iris_module_name_from_id(module_id) + if module_name is None: + raise ObjectNotFoundError() + + if not iris_module_enable_by_id(module_id): + raise BusinessProcessingError('Unable to enable module') + + success, logs = iris_update_hooks(module_name, module_id) + if not success: + raise BusinessProcessingError('Unable to update hooks when enabling module', data=logs) + + track_activity(f'IRIS module ({module_name}) #{module_id} enabled', ctx_less=True) + return logs + + +def modules_disable(module_id): + if not iris_module_disable_by_id(module_id): + raise BusinessProcessingError('Unable to disable module') + + track_activity(f'IRIS module #{module_id} disabled', ctx_less=True) + + +def modules_set_parameter(module_id, param_name, value): + """Update a single parameter value on a module's config. + + Returns the refreshed module-detail projection so the UI can swap its + in-memory copy without a second GET. Re-runs `iris_update_hooks` to + pick up any hook changes that depended on the new value. + """ + mod_config, mod_human_name, mod_name = get_module_config_from_id(module_id) + if mod_config is None: + raise ObjectNotFoundError() + + if not any(p.get('param_name') == param_name for p in mod_config): + raise BusinessProcessingError(f'Unknown parameter: {param_name}') + + if not iris_module_save_parameter(module_id, mod_config, param_name, value): + raise BusinessProcessingError('Unable to save parameter') + + track_activity( + f'parameter {param_name} of mod ({mod_human_name}) #{module_id} was updated', + ctx_less=True, + ) + + success, logs = iris_update_hooks(mod_name, module_id) + if not success: + raise BusinessProcessingError('Unable to update hooks', data=logs) + + return module_get_detail(module_id) + + +def modules_export_config(module_id): + mod_config, mod_human_name, mod_name = get_module_config_from_id(module_id) + if mod_config is None: + raise ObjectNotFoundError() + + return { + 'module_name': mod_name, + 'module_human_name': mod_human_name, + 'module_configuration': mod_config, + } + + +def modules_import_config(module_id, payload): + """Bulk-apply a configuration dict to an existing module. + + Accepts either a list of `{param_name, value}` entries or a JSON + string that decodes to one (legacy behaviour — the old form upload + posted the raw file contents as a string). + + Skips unknown parameters silently and returns the list of skipped + names so the caller can warn the user. Unknown params are treated as + "module was downgraded" rather than as a hard error: the rest of the + config still applies cleanly. + """ + if isinstance(payload, str): + try: + payload = json.loads(payload) + except (TypeError, ValueError): + raise BusinessProcessingError('Invalid data', data='Not a JSON file') + + if not isinstance(payload, list): + raise BusinessProcessingError('Invalid data', data='Expected a list of parameters') + + mod_config, _, _ = get_module_config_from_id(module_id) + if mod_config is None: + raise ObjectNotFoundError() + + skipped = [] + for param in payload: + param_name = param.get('param_name') + value = param.get('value') + if param_name is None: + continue + if not iris_module_save_parameter(module_id, mod_config, param_name, value): + skipped.append(param_name) + + track_activity( + f'parameters of mod #{module_id} were updated from config file', + ctx_less=True, + ) + + return { + 'skipped': skipped, + 'module': module_get_detail(module_id), + } + + +def modules_hooks_list(page=1, per_page=25): + """Paginated list of every (module, hook) binding for the Hooks table. + + Same in-memory pagination as `modules_list` — hook counts grow with + `n_modules * n_hooks_per_module` but remain bounded by the size of + the deployment. Materialising the full set per request stays cheap + even when the UI paginates. + """ + rows = [ + { + 'id': row.id, + 'module_name': row.module_name, + 'is_active': row.is_active, + 'hook_name': row.hook_name, + 'hook_description': row.hook_description, + 'is_manual_hook': row.is_manual_hook, + } + for row in module_list_hooks_view() + ] + total = len(rows) + per_page = max(1, min(per_page, 200)) + page = max(1, page) + start = (page - 1) * per_page + end = start + per_page + page_items = rows[start:end] + last_page = max(1, (total + per_page - 1) // per_page) if total else 1 + next_page = page + 1 if end < total else None + return { + 'total': total, + 'data': page_items, + 'last_page': last_page, + 'current_page': page, + 'next_page': next_page, + } diff --git a/source/app/business/notes.py b/source/app/business/notes.py index 3f407c9ec..bf0ecf219 100644 --- a/source/app/business/notes.py +++ b/source/app/business/notes.py @@ -173,3 +173,41 @@ def notes_delete_revision(identifier: int, revision_number: int): except Exception as e: raise UnhandledBusinessError('Unexpected error server-side', str(e)) + + +def notes_restore_revision(identifier: int, revision_number: int): + """Restore a note's title + content to the named revision. + + Snapshots the current note as a *new* revision first (via + `notes_update`'s usual versioning path), so the user can flip back + to the version they're about to overwrite without losing it. The + return value is the now-updated note row. + """ + try: + note = get_note(identifier) + if not note: + raise BusinessProcessingError('Invalid note ID for this case') + + target = NoteRevisions.query.filter( + NoteRevisions.note_id == identifier, + NoteRevisions.revision_number == revision_number + ).first() + + if not target: + raise BusinessProcessingError('Invalid note revision number') + + note.note_title = target.note_title + note.note_content = target.note_content + notes_update(iris_current_user, note) + + track_activity( + f'restored note "{note.note_title}" to revision {revision_number}', + caseid=note.note_case_id + ) + + return note + + except BusinessProcessingError: + raise + except Exception as e: + raise UnhandledBusinessError('Unexpected error server-side', str(e)) diff --git a/source/app/business/organisations.py b/source/app/business/organisations.py index bb6c8bdcd..084a9e35c 100644 --- a/source/app/business/organisations.py +++ b/source/app/business/organisations.py @@ -18,6 +18,7 @@ from app.datamgmt.db_operations import db_create from app.datamgmt.manage.manage_organisations import get_organisation_by_name +from app.iris_engine.utils.tracker import track_activity from app.models.authorization import Organisation from app.models.errors import ObjectNotFoundError @@ -32,4 +33,5 @@ def organisations_get(name) -> Organisation: def organisations_create(name, description) -> Organisation: organisation = Organisation(org_name=name, org_description=description) db_create(organisation) + track_activity(f'created organisation "{name}"') return organisation diff --git a/source/app/business/reports/ImageHandler.py b/source/app/business/reports/ImageHandler.py index e544ab91c..e538cd7a5 100644 --- a/source/app/business/reports/ImageHandler.py +++ b/source/app/business/reports/ImageHandler.py @@ -107,8 +107,8 @@ def _process_image(self, position, image_filename): try: picture = sub_document.add_picture(image_filename) except Exception as e: - self._logger.debug('Error while adding image {}: {}'.format(image_filename, e.__str__())) - raise RenderingError(self._logger, 'Image could not be added (try PNG instead of JPEG): {}'.format(image_filename)) + self._logger.debug(f'Error while adding image {image_filename}: {e.__str__()}') + raise RenderingError(self._logger, f'Image could not be added (try PNG instead of JPEG): {image_filename}') target_pct = getattr(self, '_target_width_pct', None) if target_pct: diff --git a/source/app/business/reports/docx_report_generator.py b/source/app/business/reports/docx_report_generator.py index 30bc85f70..d6fd50c56 100644 --- a/source/app/business/reports/docx_report_generator.py +++ b/source/app/business/reports/docx_report_generator.py @@ -61,18 +61,13 @@ _CODE_BLOCK_BORDER_COLOR = 'D0D7DE' _CODE_BLOCK_PBDR_XML = ( '<w:pBdr>' - '<w:top w:val="single" w:sz="4" w:space="4" w:color="{}"/>' - '<w:left w:val="single" w:sz="4" w:space="4" w:color="{}"/>' - '<w:bottom w:val="single" w:sz="4" w:space="4" w:color="{}"/>' - '<w:right w:val="single" w:sz="4" w:space="4" w:color="{}"/>' + f'<w:top w:val="single" w:sz="4" w:space="4" w:color="{_CODE_BLOCK_BORDER_COLOR}"/>' + f'<w:left w:val="single" w:sz="4" w:space="4" w:color="{_CODE_BLOCK_BORDER_COLOR}"/>' + f'<w:bottom w:val="single" w:sz="4" w:space="4" w:color="{_CODE_BLOCK_BORDER_COLOR}"/>' + f'<w:right w:val="single" w:sz="4" w:space="4" w:color="{_CODE_BLOCK_BORDER_COLOR}"/>' '</w:pBdr>' -).format( - _CODE_BLOCK_BORDER_COLOR, - _CODE_BLOCK_BORDER_COLOR, - _CODE_BLOCK_BORDER_COLOR, - _CODE_BLOCK_BORDER_COLOR, ) -_CODE_BLOCK_SHD_XML = '<w:shd w:val="clear" w:color="auto" w:fill="{}"/>'.format(_CODE_BLOCK_FILL) +_CODE_BLOCK_SHD_XML = f'<w:shd w:val="clear" w:color="auto" w:fill="{_CODE_BLOCK_FILL}"/>' # Schema order of children inside <w:pPr> (CT_PPr). The code-block override inserts pBdr # and shd in this order so the emitted paragraph properties remain validator-friendly. @@ -135,9 +130,9 @@ def _rpr_with_color(base_rpr, color): if not xml: xml = '<w:rPr/>' elif '<w:rPr' not in xml: - xml = '<w:rPr>{}</w:rPr>'.format(xml) + xml = f'<w:rPr>{xml}</w:rPr>' - wrapper = etree.fromstring('<root xmlns:w="{}">{}</root>'.format(_W_NS, xml).encode('utf-8')) + wrapper = etree.fromstring(f'<root xmlns:w="{_W_NS}">{xml}</root>'.encode('utf-8')) rpr = wrapper.find(_W + 'rPr') if rpr is None: rpr = etree.SubElement(wrapper, _W + 'rPr') @@ -167,7 +162,7 @@ def _ppr_sort_index(element): def _parse_ppr_child(xml): - return etree.fromstring('<root xmlns:w="{}">{}</root>'.format(_W_NS, xml).encode('utf-8'))[0] + return etree.fromstring(f'<root xmlns:w="{_W_NS}">{xml}</root>'.encode('utf-8'))[0] def _insert_ppr_child(ppr, child, order_pos): @@ -186,9 +181,9 @@ def _code_block_ppr(base_ppr): if not xml: xml = '<w:pPr/>' elif '<w:pPr' not in xml: - xml = '<w:pPr>{}</w:pPr>'.format(xml) + xml = f'<w:pPr>{xml}</w:pPr>' - wrapper = etree.fromstring('<root xmlns:w="{}">{}</root>'.format(_W_NS, xml).encode('utf-8')) + wrapper = etree.fromstring(f'<root xmlns:w="{_W_NS}">{xml}</root>'.encode('utf-8')) ppr = wrapper.find(_W + 'pPr') if ppr is None: ppr = etree.SubElement(wrapper, _W + 'pPr') @@ -314,7 +309,7 @@ def render_table(self, token): content = self.render_inner(token) col_w = max(1, _TABLE_TOTAL_WIDTH_TWIPS // ncols) - grid = ''.join('<w:gridCol w:w="{}"/>'.format(col_w) for _ in range(ncols)) + grid = ''.join(f'<w:gridCol w:w="{col_w}"/>' for _ in range(ncols)) tbl_pr = ( '<w:tblPr>' '<w:tblStyle w:val="ReportMain"/>' @@ -324,7 +319,7 @@ def render_table(self, token): '<w:tblLook w:val="04A0" w:firstRow="1" w:lastRow="0" w:firstColumn="1" w:lastColumn="0" w:noHBand="0" w:noVBand="1"/>' '</w:tblPr>' ) - return '<w:tbl>{}<w:tblGrid>{}</w:tblGrid>{}{}</w:tbl>'.format(tbl_pr, grid, header, content) + return f'<w:tbl>{tbl_pr}<w:tblGrid>{grid}</w:tblGrid>{header}{content}</w:tbl>' def render_image(self, token): if self._image_handler is None: @@ -347,7 +342,7 @@ class IrisDocxGenerator(DocxGenerator): def _recursive_rendering(self, base_path: str, template_path: str, data: Dict, output_path: str, render_level: int): render_level += 1 - self._logger.info('Start rendering for level {}'.format(render_level)) + self._logger.info(f'Start rendering for level {render_level}') loaded_template = MarkdownAwareDocxTemplate(template_path) template_styles = get_document_render_styles(template_path) @@ -362,7 +357,7 @@ def _recursive_rendering(self, base_path: str, template_path: str, data: Dict, o except RenderingError as e: raise e except Exception as e: - error_message = '{} ({})'.format(str(e), os.path.basename(template_path)) + error_message = f'{str(e)} ({os.path.basename(template_path)})' raise RenderingError(self._logger, error_message) is_variable_found = False @@ -382,7 +377,7 @@ def _recursive_rendering(self, base_path: str, template_path: str, data: Dict, o break loaded_template.save(output_path) - self._logger.info('Document generated for level {}'.format(render_level)) + self._logger.info(f'Document generated for level {render_level}') if is_variable_found and render_level <= self._max_recursive_render_depth: self._logger.info('Variable found in generated document. Restarting rendering process ...') @@ -418,7 +413,7 @@ def find_docx_text_leaf_violations(docx_path: str) -> List[str]: root = etree.fromstring(archive.read(part)) for text_node in root.xpath('.//w:t', namespaces=WORD_TEXT_NS): if len(text_node): - violations.append('{} contains <w:t> with {} child element(s)'.format(part, len(text_node))) + violations.append(f'{part} contains <w:t> with {len(text_node)} child element(s)') return violations diff --git a/source/app/business/search.py b/source/app/business/search.py index 36b294f30..0bbbb7c23 100644 --- a/source/app/business/search.py +++ b/source/app/business/search.py @@ -20,18 +20,137 @@ from app.datamgmt.comments import search_comments from app.datamgmt.case.case_notes_db import search_notes from app.datamgmt.case.case_iocs_db import search_iocs +from app.datamgmt.case.case_assets_db import search_assets +from app.datamgmt.case.case_events_db import search_events +from app.datamgmt.case.case_tasks_db import search_tasks +from app.datamgmt.case.case_evidences_search_db import search_evidences +from app.datamgmt.manage.manage_cases_db import search_case_summaries +from app.datamgmt.manage.manage_cases_db import user_list_cases_view + + +# Map every supported search type to (per-type helper, discriminator label). +# Helpers all take `(search_value, accessible_case_ids)` and return a list +# of `_asdict()` row dicts. +_SEARCHERS = { + 'ioc': search_iocs, + 'notes': search_notes, + 'comments': search_comments, + 'assets': search_assets, + 'events': search_events, + 'tasks': search_tasks, + 'evidences': search_evidences, + 'summaries': search_case_summaries, +} + +SUPPORTED_SEARCH_TYPES = tuple(_SEARCHERS.keys()) + + +def _annotate(rows, search_type): + # Tag every row with its discriminator + a stable id field the frontend + # can use as a list key. Each helper projects its own primary key into + # the row already; here we just normalise the name to `result_id`. + # Summaries key off `case_id` since the summary lives on the case row + # itself — there's no separate "summary" record. + id_field_per_type = { + 'ioc': 'ioc_id', + 'notes': 'note_id', + 'comments': 'comment_id', + 'assets': 'asset_id', + 'events': 'event_id', + 'tasks': 'task_id', + 'evidences': 'evidence_id', + 'summaries': 'case_id', + } + id_field = id_field_per_type[search_type] + for row in rows: + row['type'] = search_type + row['result_id'] = row.get(id_field) + return rows def search(search_type, search_value): + """Backwards-compatible single-type search used by the legacy + /search route. Returns a list of raw row dicts (no annotation, no + access scoping). + """ track_activity(f'started a global search for {search_value} on {search_type}') - files = [] - if search_type == 'ioc': - files = search_iocs(search_value) + if search_type in _SEARCHERS: + return _SEARCHERS[search_type](search_value) + return [] + + +def search_across(search_value, search_types, user_id, page=1, per_page=25, case_id=None, case_ids=None): + """Unified multi-type, access-scoped, paginated search. + + Args: + search_value: pattern with optional `%` wildcards. Pattern semantics + match the legacy per-type helpers (some use exact LIKE, others + wrap the value in `%...%`). + search_types: iterable of supported type strings; unknown types are + silently dropped. + user_id: scope the result set to cases this user has access to. + page: 1-indexed page number. + per_page: page size cap (we clamp to 1..100 to keep responses bounded). + case_id: optional — restrict to a single case. Kept for back-compat + with the single-case scope param; `case_ids` is the preferred + shape going forward. + case_ids: optional iterable of case ids to scope to. Each id is + intersected with the caller's accessible-case list, so a + request that mentions a forbidden case just drops it + (the caller never learns whether the case exists). + + Returns: + dict with `data` (list of annotated rows) and `pagination` (total, + page, per_page, total_pages). + """ + track_activity(f'started a global search for {search_value} on {list(search_types)}') + + accessible_case_ids = user_list_cases_view(user_id) + + # Normalise: collapse `case_id` (singular, legacy) into the same + # `requested_case_ids` set as the new `case_ids` param. + requested_case_ids = set() + if case_id is not None: + requested_case_ids.add(int(case_id)) + if case_ids: + for cid in case_ids: + try: + requested_case_ids.add(int(cid)) + except (TypeError, ValueError): + continue + + # Intersect with the user's access list so the caller can never + # broaden their scope by guessing case ids they shouldn't see. + if requested_case_ids: + accessible_case_ids = [cid for cid in accessible_case_ids if cid in requested_case_ids] + + per_page = max(1, min(int(per_page or 25), 100)) + page = max(1, int(page or 1)) + + # Each per-type helper streams its full result set. The combined list + # is sliced for pagination — fine for typical SOC-sized datasets where + # a single search rarely returns more than a few thousand rows. If + # this ever needs to scale further, push pagination into the per-type + # queries with UNION ALL. + combined = [] + for st in search_types: + if st not in _SEARCHERS: + continue + rows = _SEARCHERS[st](search_value, accessible_case_ids=accessible_case_ids) + combined.extend(_annotate(rows, st)) - if search_type == 'notes' and search_value: - files = search_notes(search_value) + total = len(combined) + total_pages = (total + per_page - 1) // per_page if total else 0 + start = (page - 1) * per_page + end = start + per_page - if search_type == 'comments': - files = search_comments(search_value) - return files + return { + 'data': combined[start:end], + 'pagination': { + 'total': total, + 'page': page, + 'per_page': per_page, + 'total_pages': total_pages, + } + } diff --git a/source/app/business/war_room_chat.py b/source/app/business/war_room_chat.py new file mode 100644 index 000000000..aa7029ca8 --- /dev/null +++ b/source/app/business/war_room_chat.py @@ -0,0 +1,1110 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Business layer for the war-room chat stream. + +The chat is a single chronological stream per war room. Operator +messages live alongside system rows we synthesise from events +elsewhere in IRIS — case activity ingest, task assignments, SitRep +publications. Sub-tabs filter client-side on the `kind` column. + +Slash commands (`/task`, `/attach`, `/sitrep`, `/pin`, `/note`) are +resolved server-side: the route layer dispatches them to the +appropriate sub-system and writes a corresponding `kind=*` row to the +stream so the audit trail stays in one place. +""" + +import datetime +import re + +from sqlalchemy import and_, desc + +from app.db import db +from app.iris_engine.module_handler.module_handler import call_modules_hook +from app.models.authorization import User +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 WarRoomChatReaction +from app.models.war_rooms import WarRoomThreadFollower + + +_BODY_MAX_LEN = 16_384 +_PAGE_DEFAULT = 50 +_PAGE_MAX = 200 +_DATETIME_MIN = datetime.datetime.min + +# Cache for the once-per-process check of whether the threading +# columns (`parent_message_id`, `thread_title`) and the follower table +# actually exist on the live database. We probe the information_schema +# the first time we need to know and then short-circuit so we don't +# pay for the lookup on every request. +# +# Same rationale as the `activity_type` skip: a war room installed +# against a database where the threading migration hasn't run yet +# should still be able to read the stream. Threading features just +# go dark until the migration lands. +_THREADS_SUPPORTED = None + + +def _threads_supported(): + """Probe whether the threading schema exists on this DB. + + Runs the actual query against the column on a fresh connection. If + Postgres raises `UndefinedColumn`, threads are off — anything else + means they're available. Cached only on a positive result so a + freshly-applied migration is picked up on the next request without + a Flask restart. + + Earlier implementations used `information_schema.columns` and the + SQLAlchemy inspector — both gave wrong negatives in practice + (search_path issues, stale inspector caches). Going straight to the + column is the most direct test. + """ + global _THREADS_SUPPORTED + if _THREADS_SUPPORTED is True: + return True + try: + from sqlalchemy import text as _text + # Fresh connection so a poisoned session can't taint the probe. + # We `SELECT parent_message_id LIMIT 0` so it works on an empty + # table — and Postgres still validates the column reference at + # plan time, so the missing-column case raises immediately. + with db.engine.connect() as conn: + conn.execute( + _text( + 'SELECT parent_message_id ' + 'FROM war_room_chat_message LIMIT 0' + ) + ) + supported = True + except Exception as e: + # Distinguish "column doesn't exist" from any other DB error so + # operators have a fighting chance of debugging the probe when + # it goes wrong. The `pgcode` for UndefinedColumn is '42703'. + from app.logger import logger + pgcode = getattr(getattr(e, 'orig', None), 'pgcode', None) + if pgcode == '42703': + logger.info('Threads disabled: parent_message_id column missing') + else: + logger.exception( + 'Threads support probe failed unexpectedly ' + '(pgcode=%s)', pgcode + ) + return False + if supported: + _THREADS_SUPPORTED = True + return supported + + +_VALID_KINDS = { + 'message', 'system', + 'task_assigned', 'task_completed', + 'case_attached', 'case_detached', + 'case_activity', + 'sitrep_published', 'note', 'pin', + # `decision` rows are first-class so the SitRep author can lift them + # straight out via a future timeline-of-decisions query; they + # otherwise behave like a richer `/note`. + 'decision', + # `priority` flags a banner-style row stamped when the operator flips + # the war room into a hotter posture via `/priority` or `/state`. + 'priority', +} + + +# --- Activity classifier -------------------------------------------------- +# +# Patterns derived from the verbs IRIS's business layer feeds into +# `track_activity()`. Order matters: more specific patterns first. Anything +# that doesn't match falls through to `case.other` so the row still shows +# up in the unfiltered stream view. +import re as _re + +_ACTIVITY_RULES = [ + (_re.compile(r'^new case '), 'case.created'), + (_re.compile(r'^case closed$', _re.IGNORECASE), 'case.closed'), + (_re.compile(r'^case re-opened$', _re.IGNORECASE), 'case.reopened'), + (_re.compile(r'^case updated', _re.IGNORECASE), 'case.updated'), + (_re.compile(r'^case reviewer', _re.IGNORECASE), 'case.reviewer_changed'), + (_re.compile(r'^closed case id', _re.IGNORECASE), 'case.closed'), + + (_re.compile(r'^created note', _re.IGNORECASE), 'note.created'), + (_re.compile(r'^updated note', _re.IGNORECASE), 'note.updated'), + (_re.compile(r'^deleted note revision', _re.IGNORECASE), 'note.updated'), + (_re.compile(r'^deleted note', _re.IGNORECASE), 'note.deleted'), + (_re.compile(r'^added directory', _re.IGNORECASE), 'directory.created'), + (_re.compile(r'^modified directory', _re.IGNORECASE), 'directory.updated'), + (_re.compile(r'^deleted directory', _re.IGNORECASE), 'directory.deleted'), + + (_re.compile(r'^added ioc', _re.IGNORECASE), 'ioc.created'), + (_re.compile(r'^updated ioc', _re.IGNORECASE), 'ioc.updated'), + (_re.compile(r'^deleted ioc', _re.IGNORECASE), 'ioc.deleted'), + + (_re.compile(r'^added asset', _re.IGNORECASE), 'asset.created'), + (_re.compile(r'^updated asset', _re.IGNORECASE), 'asset.updated'), + (_re.compile(r'^(deleted|removed) asset', _re.IGNORECASE), 'asset.deleted'), + + (_re.compile(r'^added evidence', _re.IGNORECASE), 'evidence.created'), + (_re.compile(r'^updated evidence', _re.IGNORECASE), 'evidence.updated'), + (_re.compile(r'^deleted evidence', _re.IGNORECASE), 'evidence.deleted'), + + (_re.compile(r'^added task', _re.IGNORECASE), 'task.created'), + (_re.compile(r'^updated task', _re.IGNORECASE), 'task.updated'), + (_re.compile(r'^deleted task', _re.IGNORECASE), 'task.deleted'), + + (_re.compile(r'^added event', _re.IGNORECASE), 'event.created'), + (_re.compile(r'^updated event', _re.IGNORECASE), 'event.updated'), + (_re.compile(r'^deleted event', _re.IGNORECASE), 'event.deleted'), + + (_re.compile(r'^(linked|unlinked) alert', _re.IGNORECASE), 'alert.linked'), +] + + +def classify_activity_text(text): + """Map a tracked activity description to a fine-grained type slug. + + The IRIS tracker capitalises the first letter of every message, so + we match case-insensitively. Returns `'case.other'` when nothing + fits; the stream still surfaces those under the "Other" toggle. + """ + if not isinstance(text, str) or not text: + return 'case.other' + for pattern, slug in _ACTIVITY_RULES: + if pattern.search(text): + return slug + return 'case.other' + + +def _validate_kind(kind): + if kind is None: + return 'message' + if not isinstance(kind, str) or kind not in _VALID_KINDS: + raise BusinessProcessingError(f'Invalid message kind: {kind}') + return kind + + +def _validate_body(body, kind): + if body is None: + # System messages may carry no body (the ref_id + ref_type carry + # the meaning). Operator-authored messages must have content. + if kind == 'message': + raise BusinessProcessingError('Message body is required') + return None + if not isinstance(body, str): + raise BusinessProcessingError('Message body must be a string') + stripped = body.strip() + if kind == 'message' and not stripped: + raise BusinessProcessingError('Message body is required') + if len(body) > _BODY_MAX_LEN: + raise BusinessProcessingError( + f'Message body must be at most {_BODY_MAX_LEN} characters' + ) + return body + + +def _virtual_activity_row(ua_row, war_room_id): + """Wrap a UserActivity row as a chat-list row. + + Same shape the REST serializer expects from the chat query + (`.message_id`, `.body`, `.kind`, `.activity_type`, `.created_at`, + …). The message id is synthesised with a high offset (-id) so it + never collides with a real chat message_id and the SPA can still + treat it as a stable React-style key. + """ + from types import SimpleNamespace + return SimpleNamespace( + # Negative id keeps virtual rows out of the real id space + # without polluting the integer cursor on the chat side. + message_id=-int(ua_row.id), + war_room_id=war_room_id, + author_id=ua_row.user_id, + body=ua_row.activity_desc, + kind='case_activity', + ref_type='user_activity', + ref_id=int(ua_row.id), + ref_case_id=ua_row.case_id, + activity_type=classify_activity_text(ua_row.activity_desc), + # UA-derived rows never participate in threads; the fields are + # set explicitly so the row's shape matches the chat-row tuple + # and downstream serializers don't have to special-case it. + parent_message_id=None, + thread_title=None, + created_at=ua_row.activity_date, + edited_at=None, + deleted_at=None, + author_login=ua_row.user_login, + author_name=ua_row.user_name, + ) + + +def _fetch_live_case_activities(war_room_id, before_dt, limit, + case_ids=None, search=None): + """Pull live `UserActivity` rows for cases attached to this war room. + + Avoids the chat-table backfill: every render of the stream sees + the up-to-date case activity, so a case attached after the war + room was created surfaces its full history immediately, and a + case detached drops out without leaving stale rows behind. + + The query filters by the war room's current attached-case set + (intersected with the caller's `case_ids` filter if provided), so + case_id mismatches just no-op. + """ + from app.models.authorization import User + from app.models.models import UserActivity + from app.models.war_rooms import WarRoomCase + from sqlalchemy import and_ + + attached = ( + WarRoomCase.query + .with_entities(WarRoomCase.case_id) + .filter(WarRoomCase.war_room_id == war_room_id) + .all() + ) + attached_ids = [row.case_id for row in attached] + if not attached_ids: + return [] + if case_ids: + attached_ids = [c for c in attached_ids if c in set(case_ids)] + if not attached_ids: + return [] + + q = ( + db.session.query( + UserActivity.id, + UserActivity.user_id, + UserActivity.case_id, + UserActivity.activity_date, + UserActivity.activity_desc, + User.user.label('user_login'), + User.name.label('user_name'), + ) + .outerjoin(User, User.id == UserActivity.user_id) + .filter(and_( + UserActivity.case_id.in_(attached_ids), + UserActivity.display_in_ui == True, + # Filter out the noise the case activity panel also drops — + # same exclusion list as `get_auto_activities`. + UserActivity.activity_desc.notlike('[Unbound]%'), + UserActivity.activity_desc.notlike('Started a search for %'), + UserActivity.activity_desc.notlike('Updated global task %'), + UserActivity.activity_desc.notlike('Created new global task %'), + UserActivity.activity_desc.notlike('Started a new case creation %'), + )) + ) + if before_dt is not None: + q = q.filter(UserActivity.activity_date < before_dt) + needle = search.strip() if isinstance(search, str) else None + if needle: + q = q.filter(UserActivity.activity_desc.ilike(f'%{needle}%')) + + rows = ( + q.order_by(desc(UserActivity.activity_date)) + .limit(limit) + .all() + ) + return [_virtual_activity_row(r, war_room_id) for r in rows] + + +def list_messages(war_room_id, before=None, limit=None, kinds=None, + case_ids=None, search=None): + """Return the next page of the war-room stream, newest first. + + Two sources are merged at read time: + + 1. Real chat-table rows (`WarRoomChatMessage`) — operator + messages, war-room-level system events, SitRep publishes, + task assignments, case attach/detach. + 2. Live `UserActivity` rows for every case currently attached + to this war room — no backfill, no duplication. A case + attached later instantly surfaces its full activity history; + a case detached drops out of the stream. + + The merge sorts by `created_at` descending. `before` remains a + chat `message_id` for backwards compatibility with the SPA's + infinite-scroll: we resolve it to the matching row's timestamp + and use that as the activity-side cursor. + + `kinds` filtering works as before. `case_ids` constrains both + sides (chat-row `ref_case_id` and `UserActivity.case_id`). + `search` case-insensitively matches the chat body / activity + description with a `%needle%` LIKE — the SPA uses this to drive + the top-of-stream quick-filter without pulling the full firehose + to the client. + """ + if limit is None: + limit = _PAGE_DEFAULT + limit = min(int(limit), _PAGE_MAX) + + want_case_activity = (not kinds) or ('case_activity' in kinds) + + # Resolve the cursor to a timestamp so we can apply it to both + # sources. None on initial load means "from now backwards". + # `before` may be a real chat message_id (positive) or a virtual + # UserActivity id (negative; encoded as -ua.id by + # `_virtual_activity_row`) — handle both so infinite scroll keeps + # working after the cursor crosses a stream-of-activity span. + before_dt = None + if before is not None: + cursor_int = int(before) + if cursor_int < 0: + from app.models.models import UserActivity + ua_row = ( + UserActivity.query + .with_entities(UserActivity.activity_date) + .filter(UserActivity.id == -cursor_int) + .first() + ) + if ua_row and ua_row.activity_date: + before_dt = ua_row.activity_date + else: + cursor_row = ( + WarRoomChatMessage.query + .with_entities(WarRoomChatMessage.created_at) + .filter(WarRoomChatMessage.message_id == cursor_int) + .first() + ) + if cursor_row and cursor_row.created_at: + before_dt = cursor_row.created_at + + # Drop any previously-ingested case_activity rows so we don't double + # them up against the live UserActivity pull below — installs that + # backfilled into the chat table before this change won't surface + # rows twice as a result. + # + # We intentionally do NOT select `activity_type` from the chat row: + # activity classification lives on live UserActivity rows (synthesised + # below). Skipping the column keeps the endpoint working on databases + # that haven't run the `e5a1b46c7d92` migration yet — important for + # rolling upgrades. + # Threading columns are conditionally selected: on databases that + # haven't applied the threads migration yet, requesting + # `parent_message_id` / `thread_title` would raise UndefinedColumn + # before any row could be returned. We probe the schema once and + # cache the result. + threads_on = _threads_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, + ] + if threads_on: + columns += [ + WarRoomChatMessage.parent_message_id, + WarRoomChatMessage.thread_title, + ] + columns += [ + WarRoomChatMessage.created_at, + WarRoomChatMessage.edited_at, + WarRoomChatMessage.deleted_at, + User.user.label('author_login'), + User.name.label('author_name'), + ] + q = ( + db.session.query(*columns) + .outerjoin(User, User.id == WarRoomChatMessage.author_id) + .filter(WarRoomChatMessage.war_room_id == war_room_id) + .filter(WarRoomChatMessage.kind != 'case_activity') + ) + if threads_on: + # Replies stay inside their thread panel — the top-level stream + # only shows roots so a chatty thread doesn't drown out other + # activity. Skipped when threading isn't supported on this DB + # yet (all messages are roots in that case). + q = q.filter(WarRoomChatMessage.parent_message_id.is_(None)) + if before is not None: + # Real chat ids only — virtual UA ids are negative and the + # `before_dt` clamp above already covers their case in the + # date-based merge below. + if int(before) > 0: + q = q.filter(WarRoomChatMessage.message_id < int(before)) + elif before_dt is not None: + q = q.filter(WarRoomChatMessage.created_at < before_dt) + if kinds: + q = q.filter(WarRoomChatMessage.kind.in_(list(kinds))) + if case_ids: + q = q.filter(WarRoomChatMessage.ref_case_id.in_(list(case_ids))) + # 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 + # step below trims to `limit`. + needle = search.strip() if isinstance(search, str) else None + if needle: + q = q.filter(WarRoomChatMessage.body.ilike(f'%{needle}%')) + + chat_rows = q.order_by(desc(WarRoomChatMessage.message_id)).limit(limit).all() + + if not want_case_activity: + return chat_rows + + # Overfetch live activities to fill the page after merge — we'll + # trim down to `limit` after sorting. + activity_rows = _fetch_live_case_activities( + war_room_id, before_dt=before_dt, limit=limit, case_ids=case_ids, + search=needle, + ) + + # Merge by created_at desc. When timestamps tie, real chat rows + # come first so a /command + its emitted system row stay adjacent. + merged = list(chat_rows) + list(activity_rows) + merged.sort( + key=lambda r: (r.created_at or _DATETIME_MIN, r.message_id), + reverse=True + ) + return merged[:limit] + + +def create_message(war_room_id, author_id, body, kind=None, + ref_type=None, ref_id=None, ref_case_id=None): + kind = _validate_kind(kind) + body = _validate_body(body, kind) + + msg = WarRoomChatMessage() + msg.war_room_id = war_room_id + msg.author_id = author_id + msg.body = body + msg.kind = kind + msg.ref_type = ref_type + msg.ref_id = ref_id + msg.ref_case_id = ref_case_id + db.session.add(msg) + db.session.commit() + + # Fire notifications on plain user messages only — system-authored + # kinds (task_assigned, sitrep_published, case_attached, …) get + # their notification via the origin action's own hook, not the + # chat mirror. `_fire_message_notifications` is a no-op if the + # notifications subsystem is not installed (safe on partial + # rollouts). + if kind == 'message': + _fire_message_notifications(msg) + msg = call_modules_hook('on_postload_war_room_message_create', msg) + return msg + + +def get_message(war_room_id, message_id): + msg = WarRoomChatMessage.query.filter_by( + war_room_id=war_room_id, message_id=message_id + ).first() + if msg is None: + raise ObjectNotFoundError() + return msg + + +def update_message(war_room_id, message_id, author_id, body): + msg = get_message(war_room_id, message_id) + if msg.deleted_at is not None: + raise BusinessProcessingError('Cannot edit a deleted message') + if msg.kind != 'message': + raise BusinessProcessingError('System messages cannot be edited') + if msg.author_id != author_id: + raise BusinessProcessingError('Only the author can edit a message') + msg.body = _validate_body(body, 'message') + msg.edited_at = datetime.datetime.utcnow() + db.session.commit() + msg = call_modules_hook('on_postload_war_room_message_update', msg) + return msg + + +def delete_message(war_room_id, message_id, author_id, is_admin=False): + msg = get_message(war_room_id, message_id) + if msg.author_id != author_id and not is_admin: + raise BusinessProcessingError('Only the author or an admin can delete a message') + # Soft-delete so the audit trail stays intact and any thread + # references remain valid. + msg.deleted_at = datetime.datetime.utcnow() + msg.body = None + db.session.commit() + call_modules_hook('on_postload_war_room_message_delete', + {'war_room_id': war_room_id, 'message_id': message_id}) + + +# ----- Reactions ----------------------------------------------------------- + +def toggle_reaction(war_room_id, message_id, user_id, emoji): + """Add the reaction if absent, remove it if present. + + Returns the resulting state — True if added, False if removed. + """ + if not isinstance(emoji, str) or not 1 <= len(emoji) <= 32: + raise BusinessProcessingError('Invalid emoji') + + # The message must belong to the war room — prevents a forged + # reaction call that hops between rooms. + msg = get_message(war_room_id, message_id) + + existing = ( + WarRoomChatReaction.query + .filter_by(message_id=msg.message_id, user_id=user_id, emoji=emoji) + .first() + ) + if existing is not None: + db.session.delete(existing) + db.session.commit() + call_modules_hook('on_postload_war_room_reaction_toggle', + {'war_room_id': war_room_id, + 'message_id': msg.message_id, + 'user_id': user_id, 'emoji': emoji, + 'added': False}) + return False + + row = WarRoomChatReaction() + row.message_id = msg.message_id + row.user_id = user_id + row.emoji = emoji + db.session.add(row) + db.session.commit() + call_modules_hook('on_postload_war_room_reaction_toggle', + {'war_room_id': war_room_id, + 'message_id': msg.message_id, + 'user_id': user_id, 'emoji': emoji, + 'added': True}) + return True + + +def list_reactions(message_ids): + """Return `{message_id: [{emoji, count, user_ids: [...]}, ...]}`. + + Called by the message-list endpoint so the SPA gets reactions + pre-aggregated and doesn't fire a round-trip per row. + """ + if not message_ids: + return {} + rows = ( + WarRoomChatReaction.query + .with_entities( + WarRoomChatReaction.message_id, + WarRoomChatReaction.user_id, + WarRoomChatReaction.emoji, + ) + .filter(WarRoomChatReaction.message_id.in_(message_ids)) + .all() + ) + by_msg = {} + for r in rows: + bucket = by_msg.setdefault(r.message_id, {}) + cell = bucket.setdefault(r.emoji, {'emoji': r.emoji, 'user_ids': []}) + cell['user_ids'].append(r.user_id) + out = {} + for mid, by_emoji in by_msg.items(): + out[mid] = [ + {'emoji': cell['emoji'], 'count': len(cell['user_ids']), + 'user_ids': cell['user_ids']} + for cell in by_emoji.values() + ] + return out + + +# ----- Slash commands ------------------------------------------------------ + +_SLASH_RE = re.compile(r'^/(?P<cmd>[a-z]+)(?:\s+(?P<rest>.*))?$', re.DOTALL) + + +def parse_slash(body): + """Detect a leading slash command. + + Returns (cmd, rest) on hit, None on miss. Commands not recognised by + the route layer are passed through as plain messages so the operator + sees their typo instead of a silent drop. + """ + if not isinstance(body, str): + return None + match = _SLASH_RE.match(body.strip()) + if not match: + return None + return match.group('cmd'), (match.group('rest') or '').strip() + + +# ----- System message helper for REST mutations --------------------------- + +def emit_system_event(war_room_id, kind, body, *, author_id=None, + ref_type=None, ref_id=None, ref_case_id=None, + activity_type=None): + """Write a system-kind chat row. + + `activity_type` is accepted for API compatibility with callers that + used to stamp it, but is intentionally ignored on write — the + column is read-side-only and is now never queried, so we skip it + to keep the helper safe on databases that haven't applied the + `e5a1b46c7d92` migration. + """ + """Best-effort write of a system-kind chat row. + + Used by REST routes (case attach/detach, member add/remove, task + create/close, …) so the activity panel reflects what happened in + the war room even when the actor used the regular UI instead of a + slash command. Failures are swallowed: the parent REST mutation + already succeeded; we don't want a chat-stream hiccup to roll back + a legitimate workspace change. + """ + try: + msg = WarRoomChatMessage() + msg.war_room_id = war_room_id + msg.author_id = author_id + msg.body = body[:_BODY_MAX_LEN] if body else None + msg.kind = kind + msg.ref_type = ref_type + msg.ref_id = ref_id + msg.ref_case_id = ref_case_id + # Don't touch msg.activity_type — see helper docstring. + db.session.add(msg) + db.session.commit() + except Exception: + try: + db.session.rollback() + except Exception: + pass + + +# ----- Threads ------------------------------------------------------------- + +_THREAD_TITLE_MAX_LEN = 160 + + +def _validate_thread_title(title): + if title is None or title == '': + return None + if not isinstance(title, str): + raise BusinessProcessingError('Thread title must be a string') + stripped = title.strip() + if not stripped: + return None + if len(stripped) > _THREAD_TITLE_MAX_LEN: + raise BusinessProcessingError( + f'Thread title must be at most {_THREAD_TITLE_MAX_LEN} characters' + ) + return stripped + + +def _get_root_message(war_room_id, message_id): + """Resolve a message id to its thread root. + + A reply (`parent_message_id IS NOT NULL`) folds upward to its + parent so callers can pass either the root or any reply id and get + consistent behaviour for follow/title/list. + """ + msg = get_message(war_room_id, message_id) + if _threads_supported() and msg.parent_message_id is not None: + return get_message(war_room_id, msg.parent_message_id) + return msg + + +def _require_threads(): + """Surface a clean error when a thread route is called on a DB + that hasn't applied the threading migration yet. Callers turn + this into a 400 instead of a 500 with a stack trace.""" + if not _threads_supported(): + raise BusinessProcessingError( + 'Threads are not enabled on this server yet — ' + 'apply the latest migrations.' + ) + + +def create_reply(war_room_id, parent_message_id, author_id, body, kind='message'): + """Post a reply hanging off a thread root. + + Threads are two-level: replying to a reply folds the new row up to + the same root, mirroring how operators expect "reply to this + thread" to behave. `kind` accepts the trace-friendly subset + (`message`, `decision`, `pin`, `note`) so slash commands like + `/decision` and `/pin` used inside a thread persist as structured + rows — the operator gets the same visual chrome (icon, colour) as + on the main stream, and the "who decided what and when" index can + surface these entries whether they were posted top-level or in a + thread. + """ + _require_threads() + root = _get_root_message(war_room_id, parent_message_id) + if root.deleted_at is not None: + raise BusinessProcessingError('Cannot reply on a deleted message') + # Only the plain-message + trace kinds are allowed as replies. Other + # kinds (task_assigned, case_attached, sitrep_published, …) represent + # room-wide state changes that belong in the main stream, not + # buried inside a thread. + if kind not in ('message', 'decision', 'pin', 'note'): + raise BusinessProcessingError( + f'Kind "{kind}" is not allowed as a thread reply' + ) + body = _validate_body(body, kind) + + msg = WarRoomChatMessage() + msg.war_room_id = war_room_id + msg.author_id = author_id + msg.body = body + msg.kind = kind + msg.parent_message_id = root.message_id + db.session.add(msg) + db.session.commit() + + _fire_reply_notifications(msg, root.message_id) + msg = call_modules_hook('on_postload_war_room_reply_create', msg) + return msg + + +def list_replies(war_room_id, root_message_id, limit=None): + """Return all replies for a thread root, oldest first. + + Oldest-first matches how thread side-pane UIs typically render + (read top-to-bottom). Pagination is by `limit` only since threads + are expected to be small relative to the main stream. + """ + if not _threads_supported(): + return [] + if limit is None: + limit = _PAGE_DEFAULT + limit = min(int(limit), _PAGE_MAX) + root = _get_root_message(war_room_id, root_message_id) + 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'), + ) + .outerjoin(User, User.id == WarRoomChatMessage.author_id) + .filter(WarRoomChatMessage.war_room_id == war_room_id) + .filter(WarRoomChatMessage.parent_message_id == root.message_id) + .order_by(WarRoomChatMessage.message_id.asc()) + .limit(limit) + ) + return q.all() + + +_TRACE_KINDS = ('decision', 'pin', 'note') + + +def list_trace_log(war_room_id, limit=None): + """Return every trace-worthy message (decisions, pins, notes) in the + war room — including replies inside threads. + + Unlike `list_messages`, this doesn't skip replies (the main-stream + listing hides them so a chatty thread doesn't drown out other + activity, but the trace log needs the full picture: a decision + posted inside a thread is still a decision). Ordered newest-first + so the sidebar renders "most recent first" without a client-side + reverse. + """ + if limit is None: + limit = _PAGE_MAX + limit = min(int(limit), _PAGE_MAX) + + threads_on = _threads_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.created_at, + WarRoomChatMessage.edited_at, + WarRoomChatMessage.deleted_at, + User.user.label('author_login'), + User.name.label('author_name'), + ] + # `parent_message_id` doesn't exist on installs that haven't run + # the threads migration yet — probe the schema so this endpoint + # keeps working through a rolling upgrade. When it's absent, every + # trace-worthy message is by definition a top-level one anyway. + if threads_on: + columns.append(WarRoomChatMessage.parent_message_id) + + 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)) + .order_by(desc(WarRoomChatMessage.created_at), desc(WarRoomChatMessage.message_id)) + .limit(limit) + ) + return q.all() + + +def set_thread_title(war_room_id, message_id, title): + """Promote a message to a named topic, or rename / clear the name. + + Always operates on the root: if the caller passed a reply id, we + fold up to the root so the title lives on the right row. + """ + _require_threads() + root = _get_root_message(war_room_id, message_id) + root.thread_title = _validate_thread_title(title) + db.session.commit() + return root + + +def list_thread_roots(war_room_id, limit=None): + """Return thread roots with reply counts and follower flags. + + A "thread" here is any root that has either at least one reply OR + a named `thread_title`. A naked message with neither isn't listed + — operators don't need to see every message in the threads + sidebar, just the ones with content branching off them. + + Results are sorted by latest activity (max of root.created_at and + the newest reply's created_at) descending so an active thread + bubbles to the top. + """ + if not _threads_supported(): + return [] + if limit is None: + limit = _PAGE_DEFAULT + limit = min(int(limit), _PAGE_MAX) + + from sqlalchemy import func + # Aggregate replies per root. + reply_stats = ( + db.session.query( + WarRoomChatMessage.parent_message_id.label('root_id'), + func.count(WarRoomChatMessage.message_id).label('reply_count'), + func.max(WarRoomChatMessage.created_at).label('last_reply_at'), + ) + .filter(WarRoomChatMessage.war_room_id == war_room_id) + .filter(WarRoomChatMessage.parent_message_id.isnot(None)) + .group_by(WarRoomChatMessage.parent_message_id) + .subquery() + ) + + q = ( + db.session.query( + WarRoomChatMessage.message_id, + WarRoomChatMessage.war_room_id, + WarRoomChatMessage.author_id, + WarRoomChatMessage.body, + WarRoomChatMessage.kind, + WarRoomChatMessage.thread_title, + WarRoomChatMessage.created_at, + WarRoomChatMessage.deleted_at, + User.user.label('author_login'), + User.name.label('author_name'), + reply_stats.c.reply_count, + reply_stats.c.last_reply_at, + ) + .outerjoin(User, User.id == WarRoomChatMessage.author_id) + .outerjoin(reply_stats, + reply_stats.c.root_id == WarRoomChatMessage.message_id) + .filter(WarRoomChatMessage.war_room_id == war_room_id) + .filter(WarRoomChatMessage.parent_message_id.is_(None)) + # Either has replies OR a name — naked unnamed roots aren't + # treated as threads. + .filter(and_( + (reply_stats.c.reply_count.isnot(None)) | + (WarRoomChatMessage.thread_title.isnot(None)) + )) + .order_by( + func.coalesce(reply_stats.c.last_reply_at, + WarRoomChatMessage.created_at).desc() + ) + .limit(limit) + ) + return q.all() + + +def follow_thread(war_room_id, message_id, user_id): + """Add a follow row for (user, root). Idempotent. + + Returns True on insert, False if the row already existed. + """ + _require_threads() + root = _get_root_message(war_room_id, message_id) + existing = ( + WarRoomThreadFollower.query + .filter_by(message_id=root.message_id, user_id=user_id) + .first() + ) + if existing is not None: + return False + row = WarRoomThreadFollower() + row.message_id = root.message_id + row.user_id = user_id + db.session.add(row) + db.session.commit() + return True + + +def unfollow_thread(war_room_id, message_id, user_id): + """Remove the follow row if present. Idempotent — returns True on + delete, False if there was nothing to remove.""" + if not _threads_supported(): + return False + root = _get_root_message(war_room_id, message_id) + row = ( + WarRoomThreadFollower.query + .filter_by(message_id=root.message_id, user_id=user_id) + .first() + ) + if row is None: + return False + db.session.delete(row) + db.session.commit() + return True + + +def list_followed_thread_ids(war_room_id, user_id): + """IDs of roots that `user_id` follows in this war room. + + Used by the UI to flag followed threads in the sidebar list. Scoped + to the war room via a join so we don't leak follows across rooms. + """ + if not _threads_supported(): + return [] + rows = ( + db.session.query(WarRoomThreadFollower.message_id) + .join(WarRoomChatMessage, + WarRoomChatMessage.message_id == WarRoomThreadFollower.message_id) + .filter(WarRoomChatMessage.war_room_id == war_room_id) + .filter(WarRoomThreadFollower.user_id == user_id) + .all() + ) + return [r.message_id for r in rows] + + +# ----- Notification fan-out ------------------------------------------------ +# +# Chat messages and thread replies both need to notify a set of +# recipients. Kept as helpers so `create_message` / `create_reply` stay +# focussed on persistence and the notification path can be exercised +# and updated in one place. Failure here MUST NOT bubble — a broken +# notification pipeline shouldn't prevent a chat message from being +# posted. + +def _fire_message_notifications(msg): + """Notify members mentioned in a new plain chat message. + + Room members (WarRoomMember roster) are NOT blanket-notified — a + busy war room would drown its participants. Only mentions raise + the bell. The war-room event type is reserved for followed-thread + replies (see `_fire_reply_notifications`). + """ + try: + from app.iris_engine.notifications.mentions import extract_mentioned_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) + if not mentioned: + return + + # Only notify members of this war room — a mention chip on a + # user without room access would be a leak (bell would surface + # the room title/body). + member_ids = { + row.user_id for row in + WarRoomMember.query + .filter(WarRoomMember.war_room_id == msg.war_room_id) + .filter(WarRoomMember.user_id.in_(mentioned)) + .all() + } + if not member_ids: + return + + notify_many( + user_ids=list(member_ids), + event_type='mention', + title='You were mentioned in a war room', + body=(msg.body or '')[:255], + link=f'/war-rooms/{msg.war_room_id}/chat', + source_type='war_room_message', + source_id=msg.message_id, + exclude_user_ids=[msg.author_id] if msg.author_id else [], + ) + except Exception: + # Broad catch — see note above. A missing notifications module + # or a DB blip must not fail the chat write. + import logging + logging.getLogger(__name__).exception( + 'war-room mention notification failed') + + +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.service import notify_many + + # 1. Thread followers (excluding the author) + follower_ids = { + row.user_id for row in + WarRoomThreadFollower.query + .filter(WarRoomThreadFollower.message_id == root_message_id) + .all() + } + + if follower_ids: + notify_many( + user_ids=list(follower_ids), + event_type='war_room_thread_reply', + title='New reply in a thread you follow', + body=(msg.body or '')[:255], + link=f'/war-rooms/{msg.war_room_id}/chat?thread={root_message_id}', + source_type='war_room_thread_reply', + source_id=msg.message_id, + exclude_user_ids=[msg.author_id] if msg.author_id else [], + ) + + # 2. Mentions inside the reply (independent from follow — + # mentioning a non-follower still pings them). + mentioned = extract_mentioned_user_ids(msg.body) + if mentioned: + from app.models.war_rooms import WarRoomMember + member_ids = { + row.user_id for row in + WarRoomMember.query + .filter(WarRoomMember.war_room_id == msg.war_room_id) + .filter(WarRoomMember.user_id.in_(mentioned)) + .all() + } + # Avoid double-notifying someone who both follows AND was + # mentioned — the mention notification is more informative + # so we keep it and drop the follow one for that user. + member_ids = member_ids - follower_ids + if member_ids: + notify_many( + user_ids=list(member_ids), + event_type='mention', + title='You were mentioned in a war-room thread', + body=(msg.body or '')[:255], + link=f'/war-rooms/{msg.war_room_id}/chat?thread={root_message_id}', + source_type='war_room_thread_reply', + source_id=msg.message_id, + exclude_user_ids=[msg.author_id] if msg.author_id else [], + ) + except Exception: + import logging + logging.getLogger(__name__).exception( + 'war-room reply notification failed') + + +# ----- Activity ingest ----------------------------------------------------- + +def ingest_case_activity(case_id, activity_text, ref_activity_id=None): + """DEPRECATED — no-op kept for backwards compatibility. + + Earlier versions mirrored case-activity rows into the chat table. + `list_messages` now pulls `UserActivity` rows live at render time + so a case attached after-the-fact instantly surfaces its full + history with zero duplication. This shim stays so external test + callers don't break; new code should not call it. + """ + return None diff --git a/source/app/business/war_room_datastore.py b/source/app/business/war_room_datastore.py new file mode 100644 index 000000000..2cbbcd85a --- /dev/null +++ b/source/app/business/war_room_datastore.py @@ -0,0 +1,177 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""War-room datastore business layer. + +Each war room gets its own subdirectory under the configured +`DATASTORE_PATH/war_rooms/<id>/`. The `WarRoomDatastoreFile` row holds +metadata (filename, size, sha256, mime, uploaded_by); the bytes live +on disk under a hashed name so two uploads with the same filename +don't clobber each other. +""" + +import hashlib +import os +import uuid + +from flask import current_app + +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 WarRoomCase +from app.models.war_rooms import WarRoomDatastoreFile + + +_MAX_BYTES = 200 * 1024 * 1024 # 200 MiB per file — same ceiling as cases +_FILENAME_MAX = 512 + + +def _datastore_root(): + return current_app.config.get( + 'DATASTORE_PATH', '/home/iris/server_data/datastore' + ) + + +def _war_room_dir(war_room_id): + root = _datastore_root() + return os.path.join(root, 'war_rooms', str(war_room_id)) + + +def _sanitize_filename(name): + if not isinstance(name, str) or not name.strip(): + raise BusinessProcessingError('filename is required') + base = os.path.basename(name).strip() + if not base or base in ('.', '..'): + raise BusinessProcessingError('Invalid filename') + return base[:_FILENAME_MAX] + + +def war_room_datastore_list(war_room_id, include_attached_cases=True): + """Return the war-room's own files. + + `include_attached_cases` is currently informational — the SPA + fetches per-case datastore listings via the existing case + endpoints. We don't aggregate server-side here because that would + require teaching this layer about case-level access nuances; the + SPA already has the per-case ACL coverage. + """ + rows = ( + WarRoomDatastoreFile.query + .filter(WarRoomDatastoreFile.war_room_id == war_room_id) + .order_by(WarRoomDatastoreFile.uploaded_at.desc()) + .all() + ) + return rows + + +def war_room_datastore_get(war_room_id, file_id): + row = WarRoomDatastoreFile.query.filter_by( + war_room_id=war_room_id, file_id=file_id + ).first() + if row is None: + raise ObjectNotFoundError() + return row + + +def war_room_datastore_save(war_room_id, file_stream, filename, + mime_type=None, description=None, tags=None, + uploaded_by_id=None): + """Persist an uploaded file. + + Streams to disk in 64 KiB chunks while updating a sha256 hash, so + we never load the entire upload into memory. Caller is expected to + have already validated war-room write access. + """ + filename = _sanitize_filename(filename) + dirpath = _war_room_dir(war_room_id) + os.makedirs(dirpath, exist_ok=True) + + # Random suffix prevents collision while keeping the original + # filename in the DB row for the UI. + storage_name = f'{uuid.uuid4().hex}_{filename}' + storage_path = os.path.join(dirpath, storage_name) + + hasher = hashlib.sha256() + total = 0 + with open(storage_path, 'wb') as out: + while True: + chunk = file_stream.read(64 * 1024) + if not chunk: + break + total += len(chunk) + if total > _MAX_BYTES: + out.close() + try: + os.remove(storage_path) + except OSError: + pass + raise BusinessProcessingError( + f'File exceeds {_MAX_BYTES // (1024 * 1024)} MiB cap' + ) + hasher.update(chunk) + out.write(chunk) + + if total == 0: + try: + os.remove(storage_path) + except OSError: + pass + raise BusinessProcessingError('Empty file') + + row = WarRoomDatastoreFile() + row.war_room_id = war_room_id + row.filename = filename + row.description = description + row.storage_path = storage_path + row.size_bytes = total + row.mime_type = mime_type + row.sha256 = hasher.hexdigest() + row.uploaded_by_id = uploaded_by_id + row.tags = tags + db.session.add(row) + db.session.commit() + track_activity(f'uploaded file "{row.filename}" ({total} bytes) to datastore', + war_room_id=war_room_id) + row = call_modules_hook('on_postload_war_room_datastore_file_create', row) + return row + + +def war_room_datastore_open(row): + """Return a binary file handle to the stored bytes.""" + if not row.storage_path or not os.path.exists(row.storage_path): + raise ObjectNotFoundError() + return open(row.storage_path, 'rb') + + +def war_room_datastore_delete(war_room_id, file_id): + row = war_room_datastore_get(war_room_id, file_id) + path = row.storage_path + filename = row.filename + db.session.delete(row) + db.session.commit() + if path and os.path.exists(path): + try: + os.remove(path) + except OSError: + # Don't roll back the DB delete — leaving an orphan blob is + # less bad than leaving the row pointing at it. + pass + track_activity(f'deleted file "{filename}" from datastore', + war_room_id=war_room_id) + call_modules_hook('on_postload_war_room_datastore_file_delete', + {'war_room_id': war_room_id, 'file_id': file_id}) + + +def war_room_attached_cases(war_room_id): + """Convenience used by the SPA to fan out per-case datastore reads.""" + rows = ( + WarRoomCase.query + .with_entities(WarRoomCase.case_id) + .filter(WarRoomCase.war_room_id == war_room_id) + .all() + ) + return [r.case_id for r in rows] diff --git a/source/app/business/war_room_notes.py b/source/app/business/war_room_notes.py new file mode 100644 index 000000000..0bc677061 --- /dev/null +++ b/source/app/business/war_room_notes.py @@ -0,0 +1,78 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Business layer for war-room notes.""" + +import datetime + +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 + + +def _validate_title(title): + if not isinstance(title, str) or not title.strip(): + raise BusinessProcessingError('Note title is required') + return title.strip()[:512] + + +def war_room_note_list(war_room_id): + return ( + WarRoomNote.query + .filter_by(war_room_id=war_room_id) + .order_by(WarRoomNote.updated_at.desc()) + .all() + ) + + +def war_room_note_get(war_room_id, note_id): + row = WarRoomNote.query.filter_by( + war_room_id=war_room_id, note_id=note_id + ).first() + if row is None: + raise ObjectNotFoundError() + return row + + +def war_room_note_create(war_room_id, title, content=None, created_by_id=None): + title = _validate_title(title) + note = WarRoomNote() + note.war_room_id = war_room_id + note.title = title + note.content = content + note.created_by_id = created_by_id + note.updated_by_id = created_by_id + db.session.add(note) + db.session.commit() + track_activity(f'created war room note "{note.title}"', war_room_id=war_room_id) + note = call_modules_hook('on_postload_war_room_note_create', note) + return note + + +def war_room_note_update(war_room_id, note_id, title=None, content=None, + updated_by_id=None): + note = war_room_note_get(war_room_id, note_id) + if title is not None: + note.title = _validate_title(title) + if content is not None: + note.content = content + note.updated_at = datetime.datetime.utcnow() + note.updated_by_id = updated_by_id + db.session.commit() + track_activity(f'updated war room note "{note.title}"', war_room_id=war_room_id) + note = call_modules_hook('on_postload_war_room_note_update', note) + return note + + +def war_room_note_delete(war_room_id, note_id): + note = war_room_note_get(war_room_id, note_id) + title = note.title + db.session.delete(note) + db.session.commit() + 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}) diff --git a/source/app/business/war_room_sitreps.py b/source/app/business/war_room_sitreps.py new file mode 100644 index 000000000..fd5f82652 --- /dev/null +++ b/source/app/business/war_room_sitreps.py @@ -0,0 +1,224 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Versioned situational reports.""" + +import datetime +import json + +from sqlalchemy import desc, 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.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.models.war_rooms import WarRoomCase +from app.models.war_rooms import WarRoomSitRep +from app.models.war_rooms import WarRoomTask + + +def _next_version(war_room_id): + latest = ( + db.session.query(func.max(WarRoomSitRep.version)) + .filter(WarRoomSitRep.war_room_id == war_room_id) + .scalar() + ) + return (latest or 0) + 1 + + +def _snapshot(war_room_id): + """Capture the war-room state we want frozen in this SitRep. + + Keeps the report coherent later even if the underlying data drifts + or the case is detached. + """ + attached = ( + WarRoomCase.query + .with_entities(WarRoomCase.case_id) + .filter(WarRoomCase.war_room_id == war_room_id) + .all() + ) + open_tasks = ( + db.session.query(func.count(WarRoomTask.task_id)) + .filter(WarRoomTask.war_room_id == war_room_id, + WarRoomTask.closed_at == None) + .scalar() or 0 + ) + closed_tasks = ( + db.session.query(func.count(WarRoomTask.task_id)) + .filter(WarRoomTask.war_room_id == war_room_id, + WarRoomTask.closed_at != None) + .scalar() or 0 + ) + return { + 'attached_case_ids': [a.case_id for a in attached], + 'tasks_open': open_tasks, + 'tasks_closed': closed_tasks, + 'captured_at': datetime.datetime.utcnow().isoformat(), + } + + +def sitrep_list(war_room_id): + return ( + WarRoomSitRep.query + .filter(WarRoomSitRep.war_room_id == war_room_id) + .order_by(desc(WarRoomSitRep.version)) + .all() + ) + + +def sitrep_get(war_room_id, sitrep_id): + row = WarRoomSitRep.query.filter_by( + war_room_id=war_room_id, sitrep_id=sitrep_id + ).first() + if row is None: + raise ObjectNotFoundError() + return row + + +def sitrep_draft(war_room_id, title, body_md='', authored_by_id=None): + if not isinstance(title, str) or not title.strip(): + raise BusinessProcessingError('SitRep title is required') + sit = WarRoomSitRep() + sit.war_room_id = war_room_id + sit.version = _next_version(war_room_id) + sit.title = title.strip()[:512] + sit.body_md = body_md or '' + sit.authored_by_id = authored_by_id + sit.snapshot_json = None + sit.published = False + db.session.add(sit) + db.session.commit() + track_activity(f'drafted sitrep "{sit.title}" (v{sit.version})', + war_room_id=war_room_id) + sit = call_modules_hook('on_postload_war_room_sitrep_create', sit) + return sit + + +def sitrep_update(war_room_id, sitrep_id, title=None, body_md=None): + # `published` is a state marker, not a write-lock — an IC needs to + # be able to correct a published SitRep (typo, updated facts) after + # the fact. The snapshot captured at publish time stays as it was + # (it represents "what this report claimed at publication"), only + # the free-text body and title are editable here. + sit = sitrep_get(war_room_id, sitrep_id) + if title is not None: + if not isinstance(title, str) or not title.strip(): + raise BusinessProcessingError('SitRep title is required') + sit.title = title.strip()[:512] + if body_md is not None: + sit.body_md = body_md + db.session.commit() + track_activity(f'updated sitrep "{sit.title}" (v{sit.version})', + war_room_id=war_room_id) + sit = call_modules_hook('on_postload_war_room_sitrep_update', sit) + return sit + + +def sitrep_publish(war_room_id, sitrep_id): + """Freeze the SitRep at the current war-room state. + + Publishing snapshots the attached cases + task counts so the + report stays coherent later, and forbids further edits. + """ + sit = sitrep_get(war_room_id, sitrep_id) + if sit.published: + raise BusinessProcessingError('SitRep is already published') + sit.snapshot_json = _snapshot(war_room_id) + sit.published = True + sit.authored_at = datetime.datetime.utcnow() + db.session.commit() + track_activity(f'published sitrep "{sit.title}" (v{sit.version})', + war_room_id=war_room_id) + sit = call_modules_hook('on_postload_war_room_sitrep_publish', sit) + return sit + + +def sitrep_delete(war_room_id, sitrep_id): + # Published SitReps can still be deleted — matches the "publishing + # is just a state" contract. Callers (UI) should confirm loudly for + # published ones since a delete removes the record entirely; the + # chat "SitRep published" mirror-message stays but its ref now + # points at nothing, which the chat surface handles as a dead ref. + sit = sitrep_get(war_room_id, sitrep_id) + label = f'"{sit.title}" (v{sit.version})' + db.session.delete(sit) + db.session.commit() + track_activity(f'deleted sitrep {label}', war_room_id=war_room_id) + call_modules_hook('on_postload_war_room_sitrep_delete', + {'war_room_id': war_room_id, 'sitrep_id': sitrep_id}) + + +def sitrep_as_markdown(sit): + """Render the SitRep as standalone markdown for download.""" + snap = sit.snapshot_json or {} + lines = [ + f'# {sit.title}', + '', + f'_War room #{sit.war_room_id} — version {sit.version}_', + '', + ] + if sit.authored_at: + lines.append(f'**Authored at:** {sit.authored_at.isoformat()}') + if sit.published: + lines.append('**Status:** Published') + else: + lines.append('**Status:** Draft') + lines.append('') + if snap: + lines.append('## Snapshot at publish') + if 'attached_case_ids' in snap: + ids = snap['attached_case_ids'] or [] + lines.append(f'- Attached cases: {", ".join(str(i) for i in ids) or "—"}') + if 'tasks_open' in snap: + lines.append(f'- Open tasks: {snap.get("tasks_open", 0)}') + if 'tasks_closed' in snap: + lines.append(f'- Closed tasks: {snap.get("tasks_closed", 0)}') + lines.append('') + lines.append('## Report') + lines.append('') + lines.append(sit.body_md or '_(no content)_') + return '\n'.join(lines) + + +def sitrep_as_html(sit): + """Render the SitRep as a stand-alone HTML page. + + Uses the same markdown→HTML pipeline IRIS already relies on for + case notes so the output matches the rest of the product. Used by + the export-to-PDF endpoint which feeds this into wkhtmltopdf when + it's available, or returns the HTML directly for print-to-PDF. + """ + try: + import markdown + body = markdown.markdown( + sitrep_as_markdown(sit), + extensions=['tables', 'fenced_code'] + ) + except Exception: + body = '<pre>' + (sitrep_as_markdown(sit) + .replace('&', '&') + .replace('<', '<') + .replace('>', '>')) + '</pre>' + + return f"""<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8" /> + <title>{sit.title} + + +{body} + +""" diff --git a/source/app/business/war_room_tasks.py b/source/app/business/war_room_tasks.py new file mode 100644 index 000000000..acaddb765 --- /dev/null +++ b/source/app/business/war_room_tasks.py @@ -0,0 +1,159 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Business layer for war-room tasks. + +Mirrors `case_tasks` but scoped to a war room. A war-room task can +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. +""" + +import datetime + +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 WarRoomTask + + +_TITLE_MAX_LEN = 1024 + + +def _validate_title(title): + if not isinstance(title, str): + raise BusinessProcessingError('Task title must be a string') + stripped = title.strip() + if not stripped: + raise BusinessProcessingError('Task title is required') + if len(stripped) > _TITLE_MAX_LEN: + raise BusinessProcessingError( + f'Task title must be at most {_TITLE_MAX_LEN} characters' + ) + return stripped + + +def war_room_task_list(war_room_id): + """List tasks on a war room with assignee / creator / closer joined. + + Three independent outer-joins on `User` (aliased) so a single row + carries the display name for every actor — the SPA shows them as + " · created by " without a per-row roundtrip. + """ + from app.models.authorization import User + from sqlalchemy.orm import aliased + + 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'), + ) + .outerjoin(Assignee, Assignee.id == WarRoomTask.assignee_id) + .outerjoin(Creator, Creator.id == WarRoomTask.created_by_id) + .outerjoin(Closer, Closer.id == WarRoomTask.closed_by_id) + .filter(WarRoomTask.war_room_id == war_room_id) + .order_by(WarRoomTask.created_at.desc()) + .all() + ) + return rows + + +def war_room_task_get(war_room_id, task_id): + row = WarRoomTask.query.filter_by( + war_room_id=war_room_id, task_id=task_id + ).first() + if row is None: + raise ObjectNotFoundError() + return row + + +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): + title = _validate_title(title) + task = WarRoomTask() + task.war_room_id = war_room_id + task.title = title + task.description = description + task.status_id = status_id + task.assignee_id = assignee_id + 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.created_by_id = created_by_id + db.session.add(task) + db.session.commit() + track_activity(f'created war room task "{task.title}"', war_room_id=war_room_id) + task = call_modules_hook('on_postload_war_room_task_create', task) + return task + + +def war_room_task_update(war_room_id, task_id, **fields): + task = war_room_task_get(war_room_id, task_id) + if 'title' in fields and fields['title'] is not None: + task.title = _validate_title(fields['title']) + for f in ('description', 'status_id', 'assignee_id', 'due_at', + 'source_case_id', 'source_case_task_id', 'tags'): + if f in fields: + setattr(task, f, fields[f]) + db.session.commit() + track_activity(f'updated war room task "{task.title}"', war_room_id=war_room_id) + task = call_modules_hook('on_postload_war_room_task_update', task) + return task + + +def war_room_task_close(war_room_id, task_id, closed_by_id=None): + task = war_room_task_get(war_room_id, task_id) + task.closed_at = datetime.datetime.utcnow() + task.closed_by_id = closed_by_id + db.session.commit() + track_activity(f'closed war room task "{task.title}"', war_room_id=war_room_id) + task = call_modules_hook('on_postload_war_room_task_close', task) + return task + + +def war_room_task_reopen(war_room_id, task_id): + task = war_room_task_get(war_room_id, task_id) + task.closed_at = None + task.closed_by_id = None + db.session.commit() + track_activity(f'reopened war room task "{task.title}"', war_room_id=war_room_id) + task = call_modules_hook('on_postload_war_room_task_reopen', task) + return task + + +def war_room_task_delete(war_room_id, task_id): + task = war_room_task_get(war_room_id, task_id) + title = task.title + db.session.delete(task) + db.session.commit() + 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}) diff --git a/source/app/business/war_room_timelines.py b/source/app/business/war_room_timelines.py new file mode 100644 index 000000000..7e521a1b4 --- /dev/null +++ b/source/app/business/war_room_timelines.py @@ -0,0 +1,259 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Per-war-room named timelines. + +Operationally identical to the per-case named timelines, but scoped to +a war room and using its own polymorphic event table +(`WarRoomTimelineEvent`) so an entry can be either a free-form row the +operator wrote inline OR a reference to an existing case event. +""" + +import re + +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 WarRoomTimeline +from app.models.war_rooms import WarRoomTimelineEvent + + +_NAME_MAX_LEN = 128 +_CATEGORY_MAX_LEN = 64 +_HEX_COLOR_RE = re.compile(r'^#[0-9a-fA-F]{6}$') +_UNSET = object() + + +def _validate_name(name): + if not isinstance(name, str): + raise BusinessProcessingError('Timeline name must be a string') + stripped = name.strip() + if not stripped: + raise BusinessProcessingError('Timeline name is required') + if len(stripped) > _NAME_MAX_LEN: + raise BusinessProcessingError( + f'Timeline 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 list_timelines(war_room_id): + return ( + WarRoomTimeline.query + .filter(WarRoomTimeline.war_room_id == war_room_id) + .order_by(WarRoomTimeline.is_default.desc(), + WarRoomTimeline.created_at.asc(), + WarRoomTimeline.timeline_id.asc()) + .all() + ) + + +def get_timeline(war_room_id, timeline_id): + row = WarRoomTimeline.query.filter_by( + war_room_id=war_room_id, timeline_id=timeline_id + ).first() + if row is None: + raise ObjectNotFoundError() + return row + + +def create_timeline(war_room_id, name, description=None, color=None, + created_by_id=None, is_default=False): + name = _validate_name(name) + color = _validate_color(color) + + existing = WarRoomTimeline.query.filter_by( + war_room_id=war_room_id, name=name + ).first() + if existing is not None: + raise BusinessProcessingError( + f'A timeline named "{name}" already exists on this war room' + ) + + t = WarRoomTimeline() + t.war_room_id = war_room_id + t.name = name + t.description = description + t.color = color + t.is_default = bool(is_default) + t.created_by_id = created_by_id + db.session.add(t) + db.session.commit() + track_activity(f'created war room timeline "{t.name}"', war_room_id=war_room_id) + t = call_modules_hook('on_postload_war_room_timeline_create', t) + return t + + +def update_timeline(war_room_id, timeline_id, name=None, description=None, + color=None): + t = get_timeline(war_room_id, timeline_id) + if name is not None: + new_name = _validate_name(name) + if new_name != t.name: + clash = WarRoomTimeline.query.filter_by( + war_room_id=war_room_id, name=new_name + ).first() + if clash is not None and clash.timeline_id != t.timeline_id: + raise BusinessProcessingError( + f'A timeline named "{new_name}" already exists on this war room' + ) + t.name = new_name + if description is not None: + t.description = description + if color is not None: + t.color = _validate_color(color) + db.session.commit() + track_activity(f'updated war room timeline "{t.name}"', war_room_id=war_room_id) + t = call_modules_hook('on_postload_war_room_timeline_update', t) + return t + + +def delete_timeline(war_room_id, timeline_id): + t = get_timeline(war_room_id, timeline_id) + if t.is_default: + raise BusinessProcessingError('The default timeline cannot be deleted') + name = t.name + db.session.delete(t) + db.session.commit() + track_activity(f'deleted war room timeline "{name}"', war_room_id=war_room_id) + call_modules_hook('on_postload_war_room_timeline_delete', + {'war_room_id': war_room_id, 'timeline_id': timeline_id}) + + +# Events ------------------------------------------------------------------- + +def list_timeline_events(war_room_id, timeline_ids=None): + """List events on the war room. + + If `timeline_ids` is given, restrict to those timelines (which must + belong to this war room). + """ + q = ( + WarRoomTimelineEvent.query + .join(WarRoomTimeline, + WarRoomTimeline.timeline_id == WarRoomTimelineEvent.timeline_id) + .filter(WarRoomTimeline.war_room_id == war_room_id) + ) + if timeline_ids: + q = q.filter(WarRoomTimelineEvent.timeline_id.in_(list(timeline_ids))) + return q.order_by(WarRoomTimelineEvent.event_date.asc().nullslast(), + WarRoomTimelineEvent.id.asc()).all() + + +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, + created_by_id=None): + timeline = get_timeline(war_room_id, timeline_id) + if (case_id is None) != (event_id is None): + raise BusinessProcessingError( + 'case_id and event_id must be provided together' + ) + if case_id is None and event_id is None and not title and not content: + raise BusinessProcessingError( + '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 + row = WarRoomTimelineEvent() + row.timeline_id = timeline.timeline_id + row.case_id = case_id + row.event_id = event_id + row.title = title + row.content = content + row.event_date = event_date + row.event_tz = event_tz + row.color = color + row.category = category + row.created_by_id = created_by_id + db.session.add(row) + 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) + row = call_modules_hook('on_postload_war_room_timeline_event_create', row) + return row + + +def _get_event(war_room_id, event_id): + row = ( + WarRoomTimelineEvent.query + .join(WarRoomTimeline, + WarRoomTimeline.timeline_id == WarRoomTimelineEvent.timeline_id) + .filter(WarRoomTimeline.war_room_id == war_room_id, + WarRoomTimelineEvent.id == event_id) + .first() + ) + if row is None: + raise ObjectNotFoundError() + return row + + +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): + """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. + """ + row = _get_event(war_room_id, event_id) + if timeline_id is not _UNSET and timeline_id != row.timeline_id: + target = get_timeline(war_room_id, timeline_id) + row.timeline_id = target.timeline_id + if title is not _UNSET: + row.title = title + if content is not _UNSET: + row.content = content + if event_date is not _UNSET: + row.event_date = event_date + if event_tz is not _UNSET: + row.event_tz = event_tz + if color is not _UNSET: + row.color = _validate_color(color) + if category is not _UNSET: + if category is None or category == '': + row.category = 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 + 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) + row = call_modules_hook('on_postload_war_room_timeline_event_update', row) + return row + + +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}') + db.session.delete(row) + db.session.commit() + track_activity(f'deleted timeline event "{label}"', war_room_id=war_room_id) + call_modules_hook('on_postload_war_room_timeline_event_delete', + {'war_room_id': war_room_id, 'event_id': event_id}) diff --git a/source/app/business/war_rooms.py b/source/app/business/war_rooms.py new file mode 100644 index 000000000..feb2e3553 --- /dev/null +++ b/source/app/business/war_rooms.py @@ -0,0 +1,696 @@ +# 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 war rooms. + +Covers war-room CRUD, membership management, case attachment, default +timeline provisioning, and ACL bootstrap. Keeps the REST blueprints +thin and lets test code call straight into the business layer without +mocking Flask. +""" + +import datetime +import re + +from sqlalchemy import and_, case, or_ + +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.authorization import ( + GroupWarRoomAccess, + UserWarRoomAccess, + UserWarRoomEffectiveAccess, + WarRoomAccessLevel, +) +from app.models.cases import Cases +from app.models.errors import BusinessProcessingError +from app.models.errors import ObjectNotFoundError +from app.models.war_rooms import ( + WarRoom, + WarRoomCase, + WarRoomMember, + WarRoomMemberRole, + WarRoomState, + WarRoomTimeline, +) + + +_NAME_MAX_LEN = 256 +_HEX_COLOR_RE = re.compile(r'^#[0-9a-fA-F]{6}$') +_VALID_STATES = {s.value for s in WarRoomState} +_VALID_ROLES = {r.value for r in WarRoomMemberRole} + + +def _validate_name(name): + if not isinstance(name, str): + raise BusinessProcessingError('War room name must be a string') + stripped = name.strip() + if not stripped: + raise BusinessProcessingError('War room name is required') + if len(stripped) > _NAME_MAX_LEN: + raise BusinessProcessingError( + f'War room 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 _validate_state(state): + if state is None: + return None + if not isinstance(state, str) or state not in _VALID_STATES: + raise BusinessProcessingError( + f'State must be one of {", ".join(sorted(_VALID_STATES))}' + ) + return state + + +def _validate_role(role): + if role is None: + return WarRoomMemberRole.responder.value + if not isinstance(role, str) or role not in _VALID_ROLES: + raise BusinessProcessingError( + f'Role must be one of {", ".join(sorted(_VALID_ROLES))}' + ) + return role + + +def war_room_exists(war_room_id): + return db.session.query( + WarRoom.query.filter_by(war_room_id=war_room_id).exists() + ).scalar() + + +def war_room_get(war_room_id): + row = WarRoom.query.filter_by(war_room_id=war_room_id).first() + if row is None: + raise ObjectNotFoundError() + return row + + +# Sort priority for the list view: rooms an operator is actively +# working land at the top, closed rooms drop to the bottom. Archived +# rooms are handled separately (they either share a section in the SPA +# or are excluded entirely depending on the `archived` filter), so this +# ordering only needs to worry about the operational lifecycle. +_STATE_SORT_ORDER = { + 'active': 0, + 'open': 1, + 'standby': 2, + 'closed': 3, +} + + +def _state_priority_expr(): + """SQLAlchemy CASE that maps the state string to a sort integer. + + Anything unrecognised falls to the end so a future state added + without updating this table still sorts predictably. + """ + return case( + _STATE_SORT_ORDER, + value=WarRoom.state, + else_=len(_STATE_SORT_ORDER), + ) + + +def war_room_list_for_user(user_id, is_admin=False, state=None, search=None, + archived=None): + """List the war rooms visible to a user. + + Admins see every row; everyone else is filtered through the + effective-access cache (precedence already resolved: deny_all rows + are excluded from the join). + + `archived` controls the archive lens: + + * `False` / None — the default: exclude archived rooms. + * `True` — return *only* archived rooms. + * `'any'` — return both, letting the caller decide. + + Rooms come back sorted by an operational-priority CASE (active > + open > standby > closed) then by creation date desc, so the room + an operator most likely needs to open is at the top of the list. + """ + query = WarRoom.query + if not is_admin: + query = ( + query + .join(UserWarRoomEffectiveAccess, + UserWarRoomEffectiveAccess.war_room_id == WarRoom.war_room_id) + .filter(and_( + UserWarRoomEffectiveAccess.user_id == user_id, + UserWarRoomEffectiveAccess.access_level != WarRoomAccessLevel.deny_all.value, + )) + ) + + if state is not None: + state = _validate_state(state) + query = query.filter(WarRoom.state == state) + + if archived == 'any': + pass + elif archived is True: + query = query.filter(WarRoom.archived_at.is_not(None)) + else: + # Default (None or False): hide archived rows so a user's main + # list stays focused on live workspaces. + query = query.filter(WarRoom.archived_at.is_(None)) + + if search: + needle = f'%{search.strip()}%' + query = query.filter(or_( + WarRoom.name.ilike(needle), + WarRoom.description.ilike(needle), + )) + + return ( + query + .order_by(_state_priority_expr().asc(), WarRoom.created_at.desc()) + .all() + ) + + +def war_room_archive(war_room_id, archived_by_id): + """Mark the room as archived. Idempotent: re-archiving is a no-op.""" + war_room = war_room_get(war_room_id) + if war_room.archived_at is None: + war_room.archived_at = datetime.datetime.utcnow() + war_room.archived_by_id = archived_by_id + db.session.commit() + track_activity(f'archived war room "{war_room.name}"', war_room_id=war_room_id) + war_room = call_modules_hook('on_postload_war_room_archive', war_room) + return war_room + + +def war_room_unarchive(war_room_id): + """Clear the archive stamp. Idempotent: unarchiving a live room is a no-op.""" + war_room = war_room_get(war_room_id) + if war_room.archived_at is not None: + war_room.archived_at = None + war_room.archived_by_id = None + db.session.commit() + track_activity(f'unarchived war room "{war_room.name}"', war_room_id=war_room_id) + war_room = call_modules_hook('on_postload_war_room_unarchive', war_room) + return war_room + + +def war_room_create(name, description=None, state=None, severity_id=None, + color=None, created_by_id=None, custom_attributes=None): + name = _validate_name(name) + color = _validate_color(color) + state = _validate_state(state) or WarRoomState.open.value + + war_room = WarRoom() + war_room.name = name + war_room.description = description + war_room.state = state + war_room.severity_id = severity_id + war_room.color = color + war_room.created_by_id = created_by_id + war_room.custom_attributes = custom_attributes + db.session.add(war_room) + db.session.commit() + + # Bootstrap: creator becomes a lead member with full access. + if created_by_id is not None: + member = WarRoomMember() + member.war_room_id = war_room.war_room_id + member.user_id = created_by_id + member.role = WarRoomMemberRole.lead.value + member.added_by_id = created_by_id + db.session.add(member) + db.session.commit() + + from app.business.war_rooms_access import set_user_war_room_access + set_user_war_room_access( + created_by_id, war_room.war_room_id, + WarRoomAccessLevel.full_access.value + ) + + # Every war room ships with a Main timeline so the SPA's sidebar has + # something to land on even before the operator creates more. + war_room_ensure_default_timeline(war_room.war_room_id, + created_by_id=created_by_id) + + track_activity(f'created war room "{war_room.name}"', + war_room_id=war_room.war_room_id) + war_room = call_modules_hook('on_postload_war_room_create', war_room) + return war_room + + +def war_room_update(war_room_id, name=None, description=None, state=None, + severity_id=None, color=None, custom_attributes=None, + closed_by_id=None): + war_room = war_room_get(war_room_id) + + previous_state = war_room.state + if name is not None: + war_room.name = _validate_name(name) + if description is not None: + war_room.description = description + if state is not None: + new_state = _validate_state(state) + # State transition to / from closed flips the closed_at stamp so + # the dashboard can report MTTR-style metrics. + if new_state == WarRoomState.closed.value and war_room.state != WarRoomState.closed.value: + war_room.closed_at = datetime.datetime.utcnow() + war_room.closed_by_id = closed_by_id + elif new_state != WarRoomState.closed.value and war_room.state == WarRoomState.closed.value: + war_room.closed_at = None + war_room.closed_by_id = None + war_room.state = new_state + if severity_id is not None: + war_room.severity_id = severity_id + if color is not None: + war_room.color = _validate_color(color) + if custom_attributes is not None: + war_room.custom_attributes = custom_attributes + + db.session.commit() + + if state is not None and war_room.state != previous_state: + track_activity( + f'changed war room "{war_room.name}" state from {previous_state} to {war_room.state}', + war_room_id=war_room_id, + ) + else: + track_activity(f'updated war room "{war_room.name}"', war_room_id=war_room_id) + war_room = call_modules_hook('on_postload_war_room_update', war_room) + return war_room + + +def war_room_delete(war_room_id): + war_room = war_room_get(war_room_id) + war_room_name = war_room.name + # CASCADE on FKs drops every child row (chat, tasks, etc). + db.session.delete(war_room) + db.session.commit() + # war_room_id is intentionally omitted — the row is gone, so leaving + # the FK NULL keeps the audit entry from dangling on delete-cascade. + track_activity(f'deleted war room "{war_room_name}"') + call_modules_hook('on_postload_war_room_delete', war_room_id) + + +# ------------------------------------------------------------ Members ---- + +def war_room_members_list(war_room_id): + from app.models.authorization import User + rows = ( + db.session.query( + WarRoomMember.war_room_id, + WarRoomMember.user_id, + WarRoomMember.role, + WarRoomMember.added_at, + User.user.label('login'), + User.name.label('name'), + ) + .join(User, User.id == WarRoomMember.user_id) + .filter(WarRoomMember.war_room_id == war_room_id) + .order_by(WarRoomMember.added_at.asc()) + .all() + ) + return rows + + +def war_room_add_member(war_room_id, user_id, role=None, added_by_id=None, + access_level=None): + """Add a member and grant them ACL access in one transaction. + + `access_level` defaults to `full_access` — observers can be added + with `read_only` explicitly. Idempotent: re-adding a member updates + their role + ACL level instead of raising. + """ + role = _validate_role(role) + if access_level is None: + access_level = WarRoomAccessLevel.full_access.value + if access_level not in (level.value for level in WarRoomAccessLevel): + raise BusinessProcessingError('Invalid access_level') + + existing = WarRoomMember.query.filter_by( + war_room_id=war_room_id, user_id=user_id + ).first() + was_new = existing is None + if was_new: + member = WarRoomMember() + member.war_room_id = war_room_id + member.user_id = user_id + member.role = role + member.added_by_id = added_by_id + db.session.add(member) + else: + existing.role = role + db.session.commit() + + from app.business.war_rooms_access import set_user_war_room_access + set_user_war_room_access(user_id, war_room_id, access_level) + + verb = 'added' if was_new else 'updated' + track_activity( + f'{verb} war room member (user #{user_id}, role {role})', + war_room_id=war_room_id, + ) + call_modules_hook('on_postload_war_room_member_add', + {'war_room_id': war_room_id, 'user_id': user_id, + 'role': role, 'access_level': access_level, + 'is_new': was_new}) + + +def war_room_remove_member(war_room_id, user_id): + WarRoomMember.query.filter_by( + war_room_id=war_room_id, user_id=user_id + ).delete() + db.session.commit() + + from app.business.war_rooms_access import remove_user_war_room_access + remove_user_war_room_access(user_id, war_room_id) + + track_activity( + f'removed war room member (user #{user_id})', + war_room_id=war_room_id, + ) + call_modules_hook('on_postload_war_room_member_remove', + {'war_room_id': war_room_id, 'user_id': user_id}) + + +# ----------------------------------------------------- Case attachment --- + +def war_room_cases_list(war_room_id): + """Return the war-room's attached cases joined with everything the + SPA needs to render a rich row in one shot: + + * `customer_id` / `customer_name` + * `owner_id` / `owner_name` / `owner_login` + * `open_date` / `close_date` + * `state_id` / `state_name` + * `task_count` (total) / `task_open_count` + + Task counts are computed via subqueries so a case with thousands of + tasks doesn't fan out the result set. Open vs. closed is determined + by `task_status.status_name not in ('done','closed','cancelled')` + case-insensitively — matches what the case dashboard considers open. + """ + from app.models.authorization import User + from app.models.cases import CaseState + from app.models.customers import Client + from app.models.models import CaseTasks, TaskStatus + from sqlalchemy import case as sa_case, func, and_ + + open_status_clause = func.lower(TaskStatus.status_name).notin_( + ['done', 'closed', 'cancelled'] + ) + + task_total_sq = ( + db.session.query( + CaseTasks.task_case_id.label('case_id'), + func.count(CaseTasks.id).label('task_count'), + func.sum( + sa_case((open_status_clause, 1), else_=0) + ).label('task_open_count'), + ) + .outerjoin(TaskStatus, TaskStatus.id == CaseTasks.task_status_id) + .group_by(CaseTasks.task_case_id) + .subquery() + ) + + rows = ( + db.session.query( + WarRoomCase.war_room_id, + WarRoomCase.case_id, + WarRoomCase.attached_at, + WarRoomCase.note, + Cases.name.label('case_name'), + Cases.client_id.label('customer_id'), + Client.name.label('customer_name'), + Cases.owner_id, + User.name.label('owner_name'), + User.user.label('owner_login'), + Cases.open_date, + Cases.close_date, + Cases.state_id, + CaseState.state_name, + func.coalesce(task_total_sq.c.task_count, 0).label('task_count'), + func.coalesce(task_total_sq.c.task_open_count, 0).label( + 'task_open_count' + ), + ) + .join(Cases, Cases.case_id == WarRoomCase.case_id) + .outerjoin(Client, Client.client_id == Cases.client_id) + .outerjoin(User, User.id == Cases.owner_id) + .outerjoin(CaseState, CaseState.state_id == Cases.state_id) + .outerjoin( + task_total_sq, task_total_sq.c.case_id == WarRoomCase.case_id + ) + .filter(WarRoomCase.war_room_id == war_room_id) + .order_by(WarRoomCase.attached_at.asc()) + .all() + ) + return rows + + +def war_room_attach_case(war_room_id, case_id, attached_by_id=None, note=None): + """Attach a case to a war room. + + The REST layer must validate that the actor has `full_access` on + the case *before* calling this — this layer trusts that gate and + only enforces the war-room write level. + """ + case = Cases.query.filter_by(case_id=case_id).first() + if case is None: + raise BusinessProcessingError('Case not found') + + war_room = war_room_get(war_room_id) + + existing = WarRoomCase.query.filter_by( + war_room_id=war_room_id, case_id=case_id + ).first() + if existing is not None: + # Idempotent: refresh the note + bump attached_at if provided. + if note is not None: + existing.note = note + db.session.commit() + return existing + + link = WarRoomCase() + link.war_room_id = war_room_id + link.case_id = case_id + link.attached_by_id = attached_by_id + link.note = note + db.session.add(link) + db.session.commit() + + track_activity( + f'attached case "{case.name}" to war room "{war_room.name}"', + caseid=case_id, war_room_id=war_room_id, + ) + link = call_modules_hook('on_postload_war_room_case_attach', link, caseid=case_id) + return link + + +def war_room_detach_case(war_room_id, case_id): + war_room = war_room_get(war_room_id) + case = Cases.query.filter_by(case_id=case_id).first() + deleted = WarRoomCase.query.filter_by( + war_room_id=war_room_id, case_id=case_id + ).delete() + db.session.commit() + if not deleted: + raise ObjectNotFoundError() + + case_label = f'"{case.name}"' if case else f'#{case_id}' + track_activity( + f'detached case {case_label} from war room "{war_room.name}"', + caseid=case_id, war_room_id=war_room_id, + ) + call_modules_hook('on_postload_war_room_case_detach', + {'war_room_id': war_room_id, 'case_id': case_id}, + caseid=case_id) + + +def war_rooms_for_case(case_id): + """Return the war rooms a case is currently attached to. + + Used by the case detail page to render the "in war room" badge. + """ + rows = ( + db.session.query( + WarRoom.war_room_id, + WarRoom.name, + WarRoom.state, + WarRoom.color, + ) + .join(WarRoomCase, WarRoomCase.war_room_id == WarRoom.war_room_id) + .filter(WarRoomCase.case_id == case_id) + .order_by(WarRoom.created_at.desc()) + .all() + ) + return rows + + +# --------------------------------------------- Default timeline guarantee + +def war_room_people(war_room_id): + """Return the people working on the war. + + Three lanes merged on `user_id`: + 1. Explicit members of the war room (carry their `role` here). + 2. Users with effective access to any case attached to the room. + 3. The case owners themselves (already covered by lane 2, but + we surface them as `is_owner=True` so the SPA can promote + them in the banner). + + Each row carries the union of source signals (`is_member`, + `is_owner`, `case_ids`) plus the basic identity fields so the + banner can render avatars + role hints without per-user fetches. + """ + from app.models.authorization import ( + User, + UserCaseEffectiveAccess, + WarRoomAccessLevel, + ) + + attached_case_ids = [ + r.case_id + for r in ( + WarRoomCase.query + .with_entities(WarRoomCase.case_id) + .filter(WarRoomCase.war_room_id == war_room_id) + .all() + ) + ] + + members = ( + db.session.query( + WarRoomMember.user_id, + WarRoomMember.role, + User.user.label('login'), + User.name.label('name'), + User.email, + ) + .join(User, User.id == WarRoomMember.user_id) + .filter(WarRoomMember.war_room_id == war_room_id) + .all() + ) + + case_accessors = [] + case_owners = [] + if attached_case_ids: + case_accessors = ( + db.session.query( + UserCaseEffectiveAccess.user_id, + UserCaseEffectiveAccess.case_id, + User.user.label('login'), + User.name.label('name'), + User.email, + ) + .join(User, User.id == UserCaseEffectiveAccess.user_id) + .filter( + UserCaseEffectiveAccess.case_id.in_(attached_case_ids), + UserCaseEffectiveAccess.access_level + != WarRoomAccessLevel.deny_all.value, + User.active == True, # noqa: E712 — SQL identity comparison + ) + .all() + ) + case_owners = ( + db.session.query( + Cases.owner_id.label('user_id'), + Cases.case_id, + User.user.label('login'), + User.name.label('name'), + User.email, + ) + .join(User, User.id == Cases.owner_id) + .filter(Cases.case_id.in_(attached_case_ids)) + .all() + ) + + people = {} + for m in members: + people[m.user_id] = { + 'user_id': m.user_id, + 'login': m.login, + 'name': m.name, + 'email': m.email, + 'role': m.role, + 'is_member': True, + 'is_owner': False, + 'case_ids': set(), + } + for row in case_accessors: + entry = people.setdefault(row.user_id, { + 'user_id': row.user_id, + 'login': row.login, + 'name': row.name, + 'email': row.email, + 'role': None, + 'is_member': False, + 'is_owner': False, + 'case_ids': set(), + }) + entry['case_ids'].add(row.case_id) + for row in case_owners: + entry = people.setdefault(row.user_id, { + 'user_id': row.user_id, + 'login': row.login, + 'name': row.name, + 'email': row.email, + 'role': None, + 'is_member': False, + 'is_owner': True, + 'case_ids': set(), + }) + entry['is_owner'] = True + entry['case_ids'].add(row.case_id) + + # Sort: members first (leads before responders before observers), + # then case owners, then plain access — within each group by display + # name so the banner reads predictably. + role_rank = {'lead': 0, 'responder': 1, 'observer': 2} + + def sort_key(p): + member_rank = 0 if p['is_member'] else (1 if p['is_owner'] else 2) + role = role_rank.get(p.get('role') or '', 99) + return (member_rank, role, (p.get('name') or p.get('login') or '').lower()) + + rows = [] + for p in people.values(): + p['case_ids'] = sorted(p['case_ids']) + rows.append(p) + rows.sort(key=sort_key) + return rows + + +def war_room_ensure_default_timeline(war_room_id, created_by_id=None): + """Create the war room's "Main" timeline if missing. Idempotent.""" + existing = ( + WarRoomTimeline.query + .filter_by(war_room_id=war_room_id, is_default=True) + .first() + ) + if existing is not None: + return existing + + timeline = WarRoomTimeline() + timeline.war_room_id = war_room_id + timeline.name = 'Main' + timeline.description = 'Default war-room timeline' + timeline.is_default = True + timeline.created_by_id = created_by_id + db.session.add(timeline) + db.session.commit() + return timeline diff --git a/source/app/business/war_rooms_access.py b/source/app/business/war_rooms_access.py new file mode 100644 index 000000000..a473c28ad --- /dev/null +++ b/source/app/business/war_rooms_access.py @@ -0,0 +1,205 @@ +# 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. + +"""Access-control helpers for war rooms. + +Mirrors the case ACL precedence (default → group → user) but routes +to war-room-specific join tables. Kept in its own module so the +generic case ACL plumbing in `business.access_controls` stays focused +on its existing surface. +""" + +from sqlalchemy import and_ + +from app.db import db +from app.models.authorization import GroupWarRoomAccess +from app.models.authorization import Permissions +from app.models.authorization import UserGroup +from app.models.authorization import UserWarRoomAccess +from app.models.authorization import UserWarRoomEffectiveAccess +from app.models.authorization import WarRoomAccessLevel +from app.models.authorization import ac_flag_match_mask +from app.models.authorization import ac_has_permission_server_administrator + + +def _get_effective_access_level(user_id, war_room_id): + row = ( + UserWarRoomEffectiveAccess.query + .filter(and_( + UserWarRoomEffectiveAccess.user_id == user_id, + UserWarRoomEffectiveAccess.war_room_id == war_room_id, + )) + .with_entities(UserWarRoomEffectiveAccess.access_level) + .first() + ) + return row.access_level if row else None + + +def _recompute_effective_access_level(user_id, war_room_id): + """Compute precedence chain and return the highest access level. + + Order: group access overwrites default (None == deny_all), user access + overwrites group access. Last write wins. + """ + access_level = None + + group_row = ( + GroupWarRoomAccess.query + .with_entities(GroupWarRoomAccess.access_level) + .filter(and_( + UserGroup.user_id == user_id, + UserGroup.group_id == GroupWarRoomAccess.group_id, + GroupWarRoomAccess.war_room_id == war_room_id, + )) + .first() + ) + if group_row: + access_level = group_row.access_level + + user_row = ( + UserWarRoomAccess.query + .with_entities(UserWarRoomAccess.access_level) + .filter(and_( + UserWarRoomAccess.user_id == user_id, + UserWarRoomAccess.war_room_id == war_room_id, + )) + .first() + ) + if user_row: + access_level = user_row.access_level + + return access_level + + +def set_war_room_effective_access_for_user(user_id, war_room_id, access_level): + existing = UserWarRoomEffectiveAccess.query.filter(and_( + UserWarRoomEffectiveAccess.user_id == user_id, + UserWarRoomEffectiveAccess.war_room_id == war_room_id, + )).all() + + if len(existing) > 1: + for row in existing: + db.session.delete(row) + db.session.commit() + existing = [] + + if existing: + existing[0].access_level = access_level + else: + row = UserWarRoomEffectiveAccess() + row.user_id = user_id + row.war_room_id = war_room_id + row.access_level = access_level + db.session.add(row) + + db.session.commit() + + +def set_user_war_room_access(user_id, war_room_id, access_level): + """Grant explicit per-user access to a war room and refresh the cache.""" + rows = UserWarRoomAccess.query.filter(and_( + UserWarRoomAccess.user_id == user_id, + UserWarRoomAccess.war_room_id == war_room_id, + )).all() + + if len(rows) > 1: + for r in rows: + db.session.delete(r) + db.session.commit() + rows = [] + + if rows: + rows[0].access_level = access_level + else: + row = UserWarRoomAccess() + row.user_id = user_id + row.war_room_id = war_room_id + row.access_level = access_level + db.session.add(row) + + db.session.commit() + + set_war_room_effective_access_for_user(user_id, war_room_id, access_level) + + +def remove_user_war_room_access(user_id, war_room_id): + """Drop the explicit per-user grant and reset the effective cache. + + After removal, the user's access falls back to the group level if a + matching group grant exists, otherwise to deny_all. + """ + UserWarRoomAccess.query.filter(and_( + UserWarRoomAccess.user_id == user_id, + UserWarRoomAccess.war_room_id == war_room_id, + )).delete() + db.session.commit() + + effective = _recompute_effective_access_level(user_id, war_room_id) + if effective is None: + effective = WarRoomAccessLevel.deny_all.value + set_war_room_effective_access_for_user(user_id, war_room_id, effective) + + +def ac_fast_check_user_has_war_room_access(user_id, war_room_id, expected_access_levels): + """Return the user's access_level if it matches one of the expected + levels, else None. + + Resolves the cached effective access; if nothing's cached, walks the + precedence chain once and writes it back. Falls through to + `server_administrator` so admins can always reach a war room + without being explicitly added. + """ + if not war_room_id: + return None + + from app.iris_engine.access_control.utils import ac_get_effective_permissions_of_user + from app.datamgmt.manage.manage_users_db import get_user + + user = get_user(user_id) + if user is None: + return None + + perms = ac_get_effective_permissions_of_user(user) + if ac_flag_match_mask(perms, Permissions.server_administrator.value): + # Admins always have full access. Skip the cache, skip the chain. + return WarRoomAccessLevel.full_access.value + + access_level = _get_effective_access_level(user_id, war_room_id) + if access_level is None: + access_level = _recompute_effective_access_level(user_id, war_room_id) + if access_level is not None: + set_war_room_effective_access_for_user(user_id, war_room_id, access_level) + + if access_level is None: + return None + if ac_flag_match_mask(access_level, WarRoomAccessLevel.deny_all.value): + return None + + for level in expected_access_levels: + if ac_flag_match_mask(access_level, level.value): + return access_level + return None + + +def ac_get_fast_user_war_rooms_access(user_id): + """Return the war_room_ids the user can read. + + Used by the war-room list endpoint to filter the query. Admins get + every row in `war_room` (handled by the caller). + """ + rows = ( + UserWarRoomEffectiveAccess.query + .with_entities(UserWarRoomEffectiveAccess.war_room_id) + .filter(and_( + UserWarRoomEffectiveAccess.user_id == user_id, + UserWarRoomEffectiveAccess.access_level != WarRoomAccessLevel.deny_all.value, + )) + .all() + ) + return [r.war_room_id for r in rows] diff --git a/source/app/datamgmt/activities/activities_db.py b/source/app/datamgmt/activities/activities_db.py index 074a0c032..6497a3c8b 100644 --- a/source/app/datamgmt/activities/activities_db.py +++ b/source/app/datamgmt/activities/activities_db.py @@ -18,10 +18,15 @@ from sqlalchemy import and_ from sqlalchemy import desc +from sqlalchemy import func +from sqlalchemy import or_ +from sqlalchemy.orm import aliased from app.models.cases import Cases from app.models.authorization import User +from app.models.customers import Client from app.models.models import UserActivity +from app.models.war_rooms import WarRoom def get_auto_activities(caseid): @@ -132,10 +137,320 @@ def get_all_users_activities(): return user_activities +def get_recent_activities_for_user(user_id, limit=20, offset=0, accessible_case_ids=None): + """Recent UI-visible UserActivity entries that the given user is allowed + to see, ordered newest-first. + + Filters on the same `display_in_ui` flag the legacy global activity + feed uses, then scopes by `case_id IN (...)` so users only see entries + from cases they have access to (or rows authored by the user + themselves). Activity from *other* users on those same cases is + included — this is the whole point: the feed is "what's happening on + the cases I'm part of", not "what I'm doing". + + `offset` lets callers paginate by re-querying with `offset += limit` + until fewer than `limit` rows come back. + + Pass `accessible_case_ids` to avoid an extra lookup if the caller has + already computed it. + """ + if accessible_case_ids is None: + from app.datamgmt.manage.manage_cases_db import user_list_cases_view + accessible_case_ids = user_list_cases_view(user_id) + + case_filter = UserActivity.case_id.in_(accessible_case_ids) if accessible_case_ids else None + + base = UserActivity.query.with_entities( + UserActivity.id, + Cases.name.label('case_name'), + UserActivity.case_id, + User.name.label('user_name'), + UserActivity.user_id, + UserActivity.activity_date, + UserActivity.activity_desc, + UserActivity.user_input, + UserActivity.is_from_api + ).outerjoin( + UserActivity.user + ).outerjoin( + UserActivity.case + ).filter( + UserActivity.display_in_ui == True + ) + + if case_filter is not None: + # Show activity from accessible cases plus activity authored by the + # user themselves on cases they no longer have access to (so users + # don't lose their own recent history when scopes shift). + base = base.filter( + (case_filter) | (UserActivity.user_id == user_id) + ) + else: + base = base.filter(UserActivity.user_id == user_id) + + rows = base.order_by(desc(UserActivity.activity_date)).offset(offset).limit(limit).all() + + return [row._asdict() for row in rows] + + +def get_recent_major_case_activities_for_user( + user_id, + limit=20, + offset=0, + accessible_case_ids=None, +): + """Recent high-signal case lifecycle activities (created/closed). + + Returns case-linked activity rows enriched with case owner, case opener, + and customer names so dashboard widgets can render a compact "who/what" + summary without N follow-up requests. + """ + if accessible_case_ids is None: + from app.datamgmt.manage.manage_cases_db import user_list_cases_view + accessible_case_ids = user_list_cases_view(user_id) + + case_filter = UserActivity.case_id.in_(accessible_case_ids) if accessible_case_ids else None + + owner_alias = aliased(User) + opener_alias = aliased(User) + + activity_desc_lower = func.lower(UserActivity.activity_desc) + is_case_created = activity_desc_lower.like('new case%created%') + is_case_closed = or_( + activity_desc_lower == 'case closed', + activity_desc_lower.like('closed case id %') + ) + + base = UserActivity.query.with_entities( + UserActivity.id, + UserActivity.case_id, + UserActivity.activity_date, + UserActivity.activity_desc, + Cases.name.label('case_name'), + owner_alias.name.label('owner_name'), + opener_alias.name.label('opened_by_name'), + Client.name.label('customer_name') + ).join( + Cases, UserActivity.case_id == Cases.case_id + ).outerjoin( + owner_alias, Cases.owner_id == owner_alias.id + ).outerjoin( + opener_alias, Cases.user_id == opener_alias.id + ).outerjoin( + Client, Cases.client_id == Client.client_id + ).filter( + UserActivity.display_in_ui == True, + or_(is_case_created, is_case_closed) + ) + + if case_filter is not None: + base = base.filter( + (case_filter) | (UserActivity.user_id == user_id) + ) + else: + base = base.filter(UserActivity.user_id == user_id) + + rows = base.order_by(desc(UserActivity.activity_date)).offset(offset).limit(limit).all() + return [row._asdict() for row in rows] + + +def list_activities_paginated( + page=1, + per_page=25, + accessible_case_ids=None, + accessible_war_room_ids=None, + include_non_case=False, + search_value=None, + user_id=None, + case_id=None, + war_room_id=None, + user_ids=None, + case_ids=None, + war_room_ids=None, + date_from=None, + date_to=None, + is_from_api=None, + is_manual=None, +): + """Paginated, filtered listing of UserActivity rows for the Activities + page. + + Same row shape as ``get_users_activities`` / ``get_all_users_activities`` + but with proper pagination, optional access scoping, and an optional + text filter on ``activity_desc``. Mirrors the shape used elsewhere in + the v2 API (returns a ``Pagination`` object so callers can hand it + straight to ``response_api_paginated``). + + Args: + page: 1-indexed page number. + per_page: page size (caller is expected to clamp). + accessible_case_ids: + * ``None`` → no access filter (caller is admin / has the + ``all_activities_read`` permission). + * iterable → only rows whose ``case_id`` is in the list, or + (when ``include_non_case`` is True) rows with ``case_id IS + NULL`` regardless. + include_non_case: + if True, also include rows with no associated case (login + events, global task changes, etc). When False, only + case-linked activity is returned. + search_value: optional case-insensitive ILIKE filter on the + ``activity_desc`` column. + user_id: optional — restrict to a single user. + case_id: optional — restrict to a single case. + """ + + # The UserActivity table accumulates near-duplicate rows whenever a + # client emits the same change repeatedly within seconds (e.g. a + # note editor's debounced autosave, an alert ingestion script + # re-emitting on retry). For the listing page we collapse rows that + # share (user_id, case_id, activity_desc) within a one-minute bucket + # and surface a single representative row carrying the most-recent + # timestamp plus an `occurrences` count. This is identical to how + # most activity feeds (GitHub, Slack) coalesce bursts. + bucket = func.date_trunc('minute', UserActivity.activity_date).label('bucket') + + base = UserActivity.query.filter(UserActivity.display_in_ui == True) + + # Access scoping. + # + # A row surfaces to a non-admin caller when *any* of these hold: + # * its `case_id` is in the caller's accessible-case list, OR + # * its `war_room_id` is in the caller's accessible-war-room list, OR + # * both are NULL and the caller opted into non-container rows + # (`include_non_case=True`) — global events like login, saved + # filter changes, organisation creation. + # + # Admin callers pass `accessible_case_ids=None` (no scope) — in that + # branch, `include_non_case=False` still means "hide global rows" + # (case OR war-room set); `include_non_case=True` means "show + # everything". + if accessible_case_ids is not None or accessible_war_room_ids is not None: + clauses = [] + if accessible_case_ids: + clauses.append(UserActivity.case_id.in_(accessible_case_ids)) + if accessible_war_room_ids: + clauses.append(UserActivity.war_room_id.in_(accessible_war_room_ids)) + if include_non_case: + clauses.append(and_( + UserActivity.case_id.is_(None), + UserActivity.war_room_id.is_(None), + )) + if not clauses: + # Caller has no accessible cases and no accessible war rooms + # and didn't ask for global rows — short-circuit. + base = base.filter(False) + else: + base = base.filter(or_(*clauses)) + elif not include_non_case: + # Admin view but the caller doesn't want global rows. + base = base.filter(or_( + UserActivity.case_id.isnot(None), + UserActivity.war_room_id.isnot(None), + )) + + if search_value: + base = base.filter(UserActivity.activity_desc.ilike(f'%{search_value}%')) + + if user_id is not None: + base = base.filter(UserActivity.user_id == user_id) + + if case_id is not None: + base = base.filter(UserActivity.case_id == case_id) + + if war_room_id is not None: + base = base.filter(UserActivity.war_room_id == war_room_id) + + # Multi-value filters. We accept the singular forms above too (legacy + # callers) and additively intersect with the multi-value ones — that + # way the UI can drive everything through the list params without + # needing to fall back to the singular shape. + if user_ids: + base = base.filter(UserActivity.user_id.in_(list(user_ids))) + + if case_ids: + # Intersect with the access-scoped list when one is in effect so + # an admin filter on cases X+Y still respects a non-admin's + # accessible-case window if both were passed. + base = base.filter(UserActivity.case_id.in_(list(case_ids))) + + if war_room_ids: + base = base.filter(UserActivity.war_room_id.in_(list(war_room_ids))) + + if date_from is not None: + base = base.filter(UserActivity.activity_date >= date_from) + + if date_to is not None: + base = base.filter(UserActivity.activity_date <= date_to) + + if is_from_api is not None: + base = base.filter(UserActivity.is_from_api == bool(is_from_api)) + + if is_manual is not None: + base = base.filter(UserActivity.user_input == bool(is_manual)) + + # Aggregation step. We pick MAX(id) as the representative row id so + # the frontend has a stable key for keyed-each updates; MAX(date) is + # the cluster's effective timestamp. `bool_or` collapses the two + # boolean flags — within a duplicate cluster they should be uniform + # anyway, but `bool_or` is correct if they ever diverge (e.g. one + # row came in via API, one via UI, for the same logical change). + aggregated = base.with_entities( + func.max(UserActivity.id).label('id'), + UserActivity.case_id, + UserActivity.war_room_id, + UserActivity.user_id, + UserActivity.activity_desc, + bucket, + func.max(UserActivity.activity_date).label('activity_date'), + func.bool_or(UserActivity.user_input).label('user_input'), + func.bool_or(UserActivity.is_from_api).label('is_from_api'), + func.count().label('occurrences'), + ).group_by( + UserActivity.case_id, + UserActivity.war_room_id, + UserActivity.user_id, + UserActivity.activity_desc, + bucket, + ).subquery() + + # Re-attach friendly labels. We outer-join because user / case / war + # room may be NULL (login events, global tasks; or users that were + # deleted after writing the row — UserActivity.user_id is nullable). + listing = ( + UserActivity.query.session.query( + aggregated.c.id, + Cases.name.label('case_name'), + aggregated.c.case_id, + WarRoom.name.label('war_room_name'), + aggregated.c.war_room_id, + User.name.label('user_name'), + aggregated.c.user_id, + aggregated.c.activity_date, + aggregated.c.activity_desc, + aggregated.c.user_input, + aggregated.c.is_from_api, + aggregated.c.occurrences, + ) + .outerjoin(User, User.id == aggregated.c.user_id) + .outerjoin(Cases, Cases.case_id == aggregated.c.case_id) + .outerjoin(WarRoom, WarRoom.war_room_id == aggregated.c.war_room_id) + .order_by(desc(aggregated.c.activity_date)) + ) + + return listing.paginate(page=page, per_page=per_page, error_out=False) + + def search_users_activity_in_case(case_identifier): ua = UserActivity.query.with_entities( UserActivity.activity_date, User.name, + # Exposed so the SPA can render the per-row avatar through the + # avatar endpoint (`/api/v2/users//avatar`). Without the + # id the activity panel would have to fall back to initials + # for everyone — fine for unknown users, but a regression for + # rows whose author *does* have a real avatar uploaded. + User.id.label('user_id'), UserActivity.activity_desc, UserActivity.is_from_api ).filter(and_( diff --git a/source/app/datamgmt/alerts/alerts_db.py b/source/app/datamgmt/alerts/alerts_db.py index 7bd8500c3..918e01383 100644 --- a/source/app/datamgmt/alerts/alerts_db.py +++ b/source/app/datamgmt/alerts/alerts_db.py @@ -65,6 +65,7 @@ from app.models.alerts import Alert from app.models.alerts import AlertStatus from app.models.alerts import AlertCaseAssociation +from app.models.incidents import AlertIncidentAssociation from app.models.alerts import SimilarAlertsCache from app.models.alerts import AlertResolutionStatus from app.models.alerts import AlertSimilarity @@ -118,7 +119,13 @@ def get_filtered_alerts( sort: str, user_identifier: int | None, source_reference, - custom_conditions: List[dict] + custom_conditions: List[dict], + # `incident_id`: + # * positive int → alerts attached to that incident + # * -1 → alerts NOT attached to any incident + # (analyst filter "orphans only") + # * None → no filter + incident_id: int | None = None, ) -> Pagination: conditions = [] @@ -180,6 +187,17 @@ def get_filtered_alerts( if case_id is not None: conditions.append(Alert.cases.any(AlertCaseAssociation.case_id == case_id)) + if incident_id is not None: + if incident_id == -1: + # Orphans: alerts with zero incident memberships. Analyst + # uses this to spot anything not yet triaged into an + # incident container. + conditions.append(~Alert.incidents.any()) + else: + conditions.append( + Alert.incidents.any(AlertIncidentAssociation.incident_id == incident_id) + ) + if assets is not None: if isinstance(assets, list): conditions.append(Alert.assets.any(CaseAssets.asset_name.in_(assets))) @@ -327,13 +345,17 @@ def create_case_from_alerts(alerts: List[Alert], iocs_list: List[str], assets_li if note: escalation_note = f"\n\n### Escalation note\n\n{note}\n\n" + # Default to empty prefix — the variable is unconditionally + # referenced when building the case name below, and passing + # `template_id=None` (the incident-escalation path does) would + # otherwise raise UnboundLocalError. + case_template_title_prefix = "" if template_id is not None and template_id != 0 and template_id != '': case_template = get_case_template_by_id(template_id) if case_template: case_template_title_prefix = case_template.title_prefix # Create the case - # FIXME I think there is a bug, if no template_id is provided case = Cases( name=f"[ALERT]{case_template_title_prefix} " f"Merge of alerts {', '.join([str(alert.alert_id) for alert in alerts])}" if not case_title else diff --git a/source/app/datamgmt/asynchronous_tasks.py b/source/app/datamgmt/asynchronous_tasks.py index 82d8afe9d..ab37934ca 100644 --- a/source/app/datamgmt/asynchronous_tasks.py +++ b/source/app/datamgmt/asynchronous_tasks.py @@ -17,6 +17,7 @@ # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. from sqlalchemy import desc +from sqlalchemy import or_ from app.models.models import CeleryTaskMeta @@ -26,3 +27,47 @@ def search_asynchronous_tasks(count): ~ CeleryTaskMeta.name.like('app.iris_engine.updater.updater.%') ).order_by(desc(CeleryTaskMeta.date_done)).limit(count).all() return tasks + + +def search_asynchronous_tasks_paginated( + page=1, + per_page=25, + search_value=None, + status=None, +): + """Paginated listing of CeleryTaskMeta rows for the Dim Tasks page. + + Same row source as ``search_asynchronous_tasks`` but with proper + pagination + optional filters. We filter out updater tasks (same as + the legacy listing) and offer a coarse ``search`` over the task name + plus a ``status`` exact-match filter. + + ``args`` / ``kwargs`` / ``result`` are LargeBinary (pickled) columns + — we deliberately do NOT filter on them, because that would either + require unpickling every row (security + performance hazard) or + binary-substring ILIKE'ing pickled bytes (false positives). The + business layer does the pickle decoding lazily on the items the page + returns. + """ + base = CeleryTaskMeta.query.filter( + ~ CeleryTaskMeta.name.like('app.iris_engine.updater.updater.%') + ) + + if search_value: + like = f'%{search_value}%' + base = base.filter(or_( + CeleryTaskMeta.name.ilike(like), + CeleryTaskMeta.task_id.ilike(like), + )) + + if status: + base = base.filter(CeleryTaskMeta.status == status) + + return base.order_by(desc(CeleryTaskMeta.date_done)).paginate( + page=page, per_page=per_page, error_out=False, + ) + + +def get_asynchronous_task_by_id(task_id): + """Single CeleryTaskMeta row by Celery task id, or None.""" + return CeleryTaskMeta.query.filter(CeleryTaskMeta.task_id == task_id).first() diff --git a/source/app/datamgmt/case/case_assets_db.py b/source/app/datamgmt/case/case_assets_db.py index a56fb6f08..efb8ace8f 100644 --- a/source/app/datamgmt/case/case_assets_db.py +++ b/source/app/datamgmt/case/case_assets_db.py @@ -37,6 +37,7 @@ from app.models.assets import CompromiseStatus from app.models.assets import AssetsType from app.models.assets import CaseAssets +from app.models.customers import Client from app.models.assets import AnalysisStatus from app.models.iocs import Ioc from app.models.models import IocAssetLink @@ -376,3 +377,34 @@ def get_asset_by_name(asset_name, caseid): CaseAssets.case_id == caseid ).first() return asset + + +def search_assets(search_value, accessible_case_ids=None): + # Matches the search-helper idiom used by `search_iocs` / `search_notes` + # — single SQL with `LIKE`, projects a flat row dict via `_asdict()`, + # scopes by the caller's accessible case ids when provided. + if accessible_case_ids is not None and not accessible_case_ids: + return [] + + scope_filter = CaseAssets.case_id.in_(accessible_case_ids) if accessible_case_ids is not None else and_() + + res = CaseAssets.query.with_entities( + CaseAssets.asset_id, + CaseAssets.asset_name, + CaseAssets.asset_description, + CaseAssets.asset_ip, + CaseAssets.asset_domain, + AssetsType.asset_name.label('asset_type_name'), + Cases.name.label('case_name'), + Cases.case_id, + Client.name.label('customer_name') + ).filter( + and_( + CaseAssets.asset_name.like(f'%{search_value}%'), + CaseAssets.case_id == Cases.case_id, + Client.client_id == Cases.client_id, + scope_filter + ) + ).join(CaseAssets.asset_type).all() + + return [row._asdict() for row in res] diff --git a/source/app/datamgmt/case/case_events_db.py b/source/app/datamgmt/case/case_events_db.py index 35c7d97eb..948ff2705 100644 --- a/source/app/datamgmt/case/case_events_db.py +++ b/source/app/datamgmt/case/case_events_db.py @@ -35,6 +35,9 @@ from app.models.models import IocAssetLink from app.models.models import IocType from app.models.authorization import User +from app.models.cases import Cases +from app.models.customers import Client +from sqlalchemy import or_ def get_case_events_assets_graph(caseid): @@ -420,3 +423,34 @@ def get_events_by_case(case_identifier): )).order_by( CasesEvent.event_date ).all() + + +def search_events(search_value, accessible_case_ids=None): + if accessible_case_ids is not None and not accessible_case_ids: + return [] + + scope_filter = CasesEvent.case_id.in_(accessible_case_ids) if accessible_case_ids is not None else and_() + + pattern = f'%{search_value}%' + + res = CasesEvent.query.with_entities( + CasesEvent.event_id, + CasesEvent.event_title, + CasesEvent.event_content, + CasesEvent.event_date, + Cases.name.label("case_name"), + Cases.case_id, + Client.name.label("customer_name") + ).filter( + and_( + or_( + CasesEvent.event_title.ilike(pattern), + CasesEvent.event_content.ilike(pattern) + ), + CasesEvent.case_id == Cases.case_id, + Client.client_id == Cases.client_id, + scope_filter + ) + ).all() + + return [row._asdict() for row in res] diff --git a/source/app/datamgmt/case/case_evidences_search_db.py b/source/app/datamgmt/case/case_evidences_search_db.py new file mode 100644 index 000000000..fcafd9f8e --- /dev/null +++ b/source/app/datamgmt/case/case_evidences_search_db.py @@ -0,0 +1,59 @@ +# IRIS Source Code +# Copyright (C) 2024 - 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from sqlalchemy import and_ +from sqlalchemy import or_ + +from app.models.cases import Cases +from app.models.customers import Client +from app.models.evidences import CaseReceivedFile + + +def search_evidences(search_value, accessible_case_ids=None): + # Mirrors `search_iocs` / `search_notes` / `search_comments`: flat + # `_asdict()` rows, optional access scoping via `accessible_case_ids`. + # Matches against filename, description, and hash so the same input + # can find an evidence whether the user types a name or a checksum. + if accessible_case_ids is not None and not accessible_case_ids: + return [] + + scope_filter = CaseReceivedFile.case_id.in_(accessible_case_ids) if accessible_case_ids is not None else and_() + pattern = f'%{search_value}%' + + res = CaseReceivedFile.query.with_entities( + CaseReceivedFile.id.label('evidence_id'), + CaseReceivedFile.filename, + CaseReceivedFile.file_description, + CaseReceivedFile.file_hash, + Cases.name.label('case_name'), + Cases.case_id, + Client.name.label('customer_name') + ).filter( + and_( + or_( + CaseReceivedFile.filename.ilike(pattern), + CaseReceivedFile.file_description.ilike(pattern), + CaseReceivedFile.file_hash.ilike(pattern) + ), + CaseReceivedFile.case_id == Cases.case_id, + Client.client_id == Cases.client_id, + scope_filter + ) + ).all() + + return [row._asdict() for row in res] diff --git a/source/app/datamgmt/case/case_iocs_db.py b/source/app/datamgmt/case/case_iocs_db.py index c0f28bc9a..bc340f53c 100644 --- a/source/app/datamgmt/case/case_iocs_db.py +++ b/source/app/datamgmt/case/case_iocs_db.py @@ -300,9 +300,18 @@ def get_filtered_iocs( return get_filtered_data(Ioc, base_filter, pagination_parameters, request_parameters, relationship_model_map) -def search_iocs(search_value): - search_condition = and_() +def search_iocs(search_value, accessible_case_ids=None): + # Scope the result set to cases the caller can see when + # `accessible_case_ids` is supplied. Empty list means "no access" → no + # results (rather than "no filter"), so we short-circuit to avoid a + # huge IN () query. + if accessible_case_ids is not None and not accessible_case_ids: + return [] + + scope_filter = Ioc.case_id.in_(accessible_case_ids) if accessible_case_ids is not None else and_() + res = Ioc.query.with_entities( + Ioc.ioc_id, Ioc.ioc_value.label('ioc_name'), Ioc.ioc_description.label('ioc_description'), Ioc.ioc_misp, @@ -318,7 +327,7 @@ def search_iocs(search_value): Ioc.case_id == Cases.case_id, Client.client_id == Cases.client_id, Ioc.ioc_tlp_id == Tlp.tlp_id, - search_condition + scope_filter ) ).join(Ioc.ioc_type).all() diff --git a/source/app/datamgmt/case/case_notes_db.py b/source/app/datamgmt/case/case_notes_db.py index fd98c3b02..e72e39a4d 100644 --- a/source/app/datamgmt/case/case_notes_db.py +++ b/source/app/datamgmt/case/case_notes_db.py @@ -481,12 +481,16 @@ def get_directory_with_note_count(directory): return directory_dict -def search_notes(search_value): - search_condition = and_() +def search_notes(search_value, accessible_case_ids=None): + if accessible_case_ids is not None and not accessible_case_ids: + return [] + + scope_filter = Notes.note_case_id.in_(accessible_case_ids) if accessible_case_ids is not None else and_() + notes = Notes.query.filter( Notes.note_content.like(f'%{search_value}%'), Cases.client_id == Client.client_id, - search_condition + scope_filter ).with_entities( Notes.note_id, Notes.note_title, diff --git a/source/app/datamgmt/case/case_tasks_db.py b/source/app/datamgmt/case/case_tasks_db.py index 7592d3df6..1a080f916 100644 --- a/source/app/datamgmt/case/case_tasks_db.py +++ b/source/app/datamgmt/case/case_tasks_db.py @@ -32,10 +32,12 @@ from app.models.models import CaseTasks from app.models.models import TaskAssignee from app.models.cases import Cases +from app.models.customers import Client from app.models.comments import Comments, TaskComments from app.models.models import TaskStatus from app.models.authorization import User from app.models.pagination_parameters import PaginationParameters +from sqlalchemy import or_ def get_tasks_status(): @@ -390,3 +392,33 @@ def update_utask_status(task_id, status, case_id): pass return False + + +def search_tasks(search_value, accessible_case_ids=None): + if accessible_case_ids is not None and not accessible_case_ids: + return [] + + scope_filter = CaseTasks.task_case_id.in_(accessible_case_ids) if accessible_case_ids is not None else and_() + pattern = f'%{search_value}%' + + res = CaseTasks.query.with_entities( + CaseTasks.id.label('task_id'), + CaseTasks.task_title, + CaseTasks.task_description, + TaskStatus.status_name, + Cases.name.label('case_name'), + Cases.case_id, + Client.name.label('customer_name') + ).filter( + and_( + or_( + CaseTasks.task_title.ilike(pattern), + CaseTasks.task_description.ilike(pattern) + ), + CaseTasks.task_case_id == Cases.case_id, + Client.client_id == Cases.client_id, + scope_filter + ) + ).outerjoin(CaseTasks.status).all() + + return [row._asdict() for row in res] diff --git a/source/app/datamgmt/client/client_db.py b/source/app/datamgmt/client/client_db.py index ab8202f50..cd4b541b3 100644 --- a/source/app/datamgmt/client/client_db.py +++ b/source/app/datamgmt/client/client_db.py @@ -69,7 +69,7 @@ def get_client_list(current_user_id: int, is_server_administrator: bool) -> List return output -def get_paginated_customers(pagination_parameters: PaginationParameters, current_user_identifier: int, is_server_administrator: bool) -> Pagination: +def get_paginated_customers(pagination_parameters: PaginationParameters, current_user_identifier: int, is_server_administrator: bool, search: Optional[str] = None) -> Pagination: query = Client.query if not is_server_administrator: @@ -78,6 +78,17 @@ def get_paginated_customers(pagination_parameters: PaginationParameters, current UserClient.user_id == current_user_identifier ) + # Case-insensitive substring match across the two human-readable + # fields. Cheap (no full-text index needed at this fleet size) and + # matches the legacy DataTables behaviour where typing in the + # search box filtered both columns. + if search: + needle = f'%{search.strip()}%' + if needle != '%%': + query = query.filter( + Client.name.ilike(needle) | Client.description.ilike(needle) + ) + return paginate(Client, pagination_parameters, query) diff --git a/source/app/datamgmt/comments.py b/source/app/datamgmt/comments.py index 1640ff111..6c5d0dc75 100644 --- a/source/app/datamgmt/comments.py +++ b/source/app/datamgmt/comments.py @@ -46,6 +46,20 @@ def get_filtered_alert_comments(alert_identifier: int, pagination_parameters: Pa return query.paginate(page=pagination_parameters.get_page(), per_page=pagination_parameters.get_per_page()) +def get_filtered_incident_comments(incident_identifier: int, pagination_parameters: PaginationParameters) -> Pagination: + query = Comments.query.filter( + Comments.comment_incident_id == incident_identifier + ).order_by(Comments.comment_date.asc()) + return query.paginate(page=pagination_parameters.get_page(), per_page=pagination_parameters.get_per_page()) + + +def get_incident_comment(incident_identifier: int, comment_identifier: int) -> Optional[Comments]: + return Comments.query.filter( + Comments.comment_id == comment_identifier, + Comments.comment_incident_id == incident_identifier, + ).first() + + def get_filtered_asset_comments(asset_identifier: int, pagination_parameters: PaginationParameters) -> Pagination: query = Comments.query.filter( AssetComments.comment_asset_id == asset_identifier @@ -115,12 +129,16 @@ def user_has_comments(user: User): return comment is not None -def search_comments(search_value): - search_condition = and_() +def search_comments(search_value, accessible_case_ids=None): + if accessible_case_ids is not None and not accessible_case_ids: + return [] + + scope_filter = Cases.case_id.in_(accessible_case_ids) if accessible_case_ids is not None else and_() + comments = Comments.query.filter( Comments.comment_text.like(f'%{search_value}%'), Cases.client_id == Client.client_id, - search_condition + scope_filter ).with_entities( Comments.comment_id, Comments.comment_text, diff --git a/source/app/datamgmt/custom_dashboard/__init__.py b/source/app/datamgmt/custom_dashboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/source/app/datamgmt/custom_dashboard/custom_dashboard_db.py b/source/app/datamgmt/custom_dashboard/custom_dashboard_db.py new file mode 100644 index 000000000..e28fc961a --- /dev/null +++ b/source/app/datamgmt/custom_dashboard/custom_dashboard_db.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import copy +import uuid +from typing import Iterable, List, Optional + +from sqlalchemy import or_, select + +from app import db +from app.models.custom_dashboard import CustomDashboard, CustomDashboardWidget + + +class DashboardAccessError(Exception): + pass + + +class DashboardNotFoundError(Exception): + pass + + +class DashboardSystemReadOnlyError(Exception): + pass + + +def list_dashboards_for_user(user_id: int) -> List[CustomDashboard]: + stmt = select(CustomDashboard).where( + or_( + CustomDashboard.owner_id == user_id, + CustomDashboard.is_shared.is_(True), + CustomDashboard.is_system.is_(True), + ) + ).order_by(CustomDashboard.is_system.desc(), CustomDashboard.created_at.desc()) + return list(db.session.execute(stmt).scalars().all()) + + +def get_dashboard_by_uuid(dashboard_uuid: str) -> Optional[CustomDashboard]: + stmt = select(CustomDashboard).where(CustomDashboard.dashboard_uuid == dashboard_uuid) + return db.session.execute(stmt).scalar_one_or_none() + + +def get_dashboard_for_user(dashboard_uuid: str, user_id: int) -> CustomDashboard: + dashboard = get_dashboard_by_uuid(dashboard_uuid) + if dashboard is None: + raise DashboardNotFoundError() + + if dashboard.is_system or dashboard.is_shared: + return dashboard + if dashboard.owner_id != user_id: + raise DashboardAccessError() + return dashboard + + +def _resolve_shared_flag(data: dict) -> bool: + value = data.get('is_shared') + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in {'true', '1', 'yes', 'on'} + if isinstance(value, int): + return value != 0 + return False + + +def _prepare_dashboard_payload(data: dict) -> List[dict]: + sections_payload = data.get('sections') or [] + widgets_payload = data.get('widgets') or [] + + normalized_sections = [] + flattened_widgets: List[dict] = [] + + if sections_payload: + for section_index, raw_section in enumerate(sections_payload): + section = copy.deepcopy(raw_section or {}) + section_id = section.get('id') or f'section-{uuid.uuid4()}' + section['id'] = section_id + section_title = section.get('title') + section_description = section.get('description') + show_divider = bool(section.get('show_divider')) + + normalized_widgets = [] + for widget_index, raw_widget in enumerate(section.get('widgets') or []): + widget = copy.deepcopy(raw_widget or {}) + layout = dict(widget.get('layout') or {}) + layout.setdefault('section_id', section_id) + layout.setdefault('section_title', section_title) + layout.setdefault('section_description', section_description) + layout.setdefault('section_index', section_index) + layout.setdefault('widget_index', widget_index) + layout.setdefault('show_divider', show_divider) + + options = widget.get('options') if isinstance(widget.get('options'), dict) else {} + if options and not layout.get('widget_size'): + candidate_size = options.get('widget_size') or options.get('size') + if candidate_size: + layout['widget_size'] = candidate_size + + widget['layout'] = layout + normalized_widgets.append(widget) + flattened_widgets.append(widget) + + section['widgets'] = normalized_widgets + normalized_sections.append(section) + else: + default_section_id = f'section-{uuid.uuid4()}' + default_section_title = data.get('name') + default_section_description = data.get('description') + default_section = { + 'id': default_section_id, + 'title': default_section_title, + 'description': default_section_description, + 'show_divider': False, + 'widgets': [] + } + + for widget_index, raw_widget in enumerate(widgets_payload): + widget = copy.deepcopy(raw_widget or {}) + layout = dict(widget.get('layout') or {}) + layout.setdefault('section_id', default_section_id) + layout.setdefault('section_title', default_section_title) + layout.setdefault('section_description', default_section_description) + layout.setdefault('section_index', 0) + layout.setdefault('widget_index', widget_index) + layout.setdefault('show_divider', False) + + options = widget.get('options') if isinstance(widget.get('options'), dict) else {} + if options and not layout.get('widget_size'): + candidate_size = options.get('widget_size') or options.get('size') + if candidate_size: + layout['widget_size'] = candidate_size + + widget['layout'] = layout + default_section['widgets'].append(widget) + flattened_widgets.append(widget) + + normalized_sections.append(default_section) + + data['sections'] = normalized_sections + data['widgets'] = flattened_widgets + return flattened_widgets + + +def create_dashboard_for_user(user_id: int, payload: dict, allow_share: bool) -> CustomDashboard: + data = dict(payload or {}) + data.pop('csrf_token', None) + is_shared = _resolve_shared_flag(data) if allow_share else False + data['is_shared'] = is_shared + + _prepare_dashboard_payload(data) + + dashboard = CustomDashboard( + name=data['name'], + description=data.get('description'), + owner_id=user_id, + is_shared=is_shared, + is_system=False, + definition=data, + ) + _apply_widgets(dashboard, data.get('widgets', [])) + db.session.add(dashboard) + db.session.commit() + return dashboard + + +def update_dashboard_for_user(dashboard_uuid: str, user_id: int, payload: dict, allow_share: bool) -> CustomDashboard: + dashboard = get_dashboard_for_user(dashboard_uuid, user_id) + if dashboard.is_system: + raise DashboardSystemReadOnlyError() + if dashboard.owner_id != user_id: + raise DashboardAccessError() + + data = dict(payload or {}) + data.pop('csrf_token', None) + requested_shared = _resolve_shared_flag(data) if allow_share else dashboard.is_shared + data['is_shared'] = requested_shared + + _prepare_dashboard_payload(data) + + dashboard.name = data.get('name', dashboard.name) + dashboard.description = data.get('description', dashboard.description) + dashboard.is_shared = requested_shared + dashboard.definition = data + dashboard.widgets.clear() + _apply_widgets(dashboard, data.get('widgets', [])) + db.session.commit() + return dashboard + + +def delete_dashboard_for_user(dashboard_uuid: str, user_id: int) -> None: + dashboard = get_dashboard_for_user(dashboard_uuid, user_id) + if dashboard.is_system: + raise DashboardSystemReadOnlyError() + if dashboard.owner_id != user_id: + raise DashboardAccessError() + + db.session.delete(dashboard) + db.session.commit() + + +def _apply_widgets(dashboard: CustomDashboard, widgets_payload: Iterable[dict]) -> None: + for position, widget in enumerate(widgets_payload): + dashboard.widgets.append(CustomDashboardWidget( + name=widget['name'], + chart_type=widget['chart_type'], + definition=widget, + position=position, + )) + + +def serialize_dashboard(dashboard: CustomDashboard) -> dict: + return { + 'dashboard_uuid': str(dashboard.dashboard_uuid), + 'name': dashboard.name, + 'description': dashboard.description, + 'owner_id': dashboard.owner_id, + 'is_shared': dashboard.is_shared, + 'is_system': dashboard.is_system, + 'definition': dashboard.definition or {}, + 'created_at': dashboard.created_at.isoformat() if dashboard.created_at else None, + 'updated_at': dashboard.updated_at.isoformat() if dashboard.updated_at else None, + } diff --git a/source/app/datamgmt/custom_dashboard/named_aggregations.py b/source/app/datamgmt/custom_dashboard/named_aggregations.py new file mode 100644 index 000000000..baa731aae --- /dev/null +++ b/source/app/datamgmt/custom_dashboard/named_aggregations.py @@ -0,0 +1,273 @@ +"""Post-query named aggregations for custom dashboards. + +The query engine in query_engine.py is declarative: table.column + whitelisted +SQL aggregations + whitelisted filter operators. Some statistics (MTTD, MTTR, +false-positive rate, escalation rate, sliding-window alert counts) cannot be +expressed as a single SQL aggregation because they walk JSON modification +history rows or compose two counts. They live here as Python helpers that the +render endpoint resolves when a widget references table=='computed'. + +A widget that uses a named aggregation looks like: + + { + "name": "Mean time to detect", + "chart_type": "number", + "fields": [{"table": "computed", "column": "mttd_seconds", "alias": "mttd_seconds"}] + } + +The registry is intentionally small and additive: future named aggregations +register a callable(timeframe, filters, scope) -> Optional[float]. The engine +never touches user-supplied identifiers; only registered names resolve. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple + +from flask_login import current_user +from sqlalchemy import and_, func, or_, select + +from app import db +from app.datamgmt.manage.manage_access_control_db import get_user_clients_id +from app.blueprints.access_controls import ac_current_user_has_permission +from app.iris_engine.access_control.utils import ac_get_fast_user_cases_access +from app.models.alerts import Alert, AlertCaseAssociation, AlertResolutionStatus, AlertStatus +from app.models.authorization import Permissions +from app.models.cases import Cases + + +class NamedAggregationError(Exception): + pass + + +@dataclass +class ComputedFilters: + customer_id: Optional[int] = None + severity_id: Optional[int] = None + case_status_id: Optional[int] = None + window: Optional[str] = None + + +def _normalize_history(value: Any) -> List[Dict[str, Any]]: + if not isinstance(value, dict): + return [] + entries: List[Tuple[datetime, str]] = [] + for ts_raw, payload in value.items(): + ts = _parse_datetime(ts_raw) + if ts is None: + continue + if isinstance(payload, dict): + action = str(payload.get('action') or payload.get('event') or payload.get('description') or '').lower() + else: + action = str(payload or '').lower() + entries.append((ts, action)) + entries.sort(key=lambda item: item[0]) + return [{'timestamp': ts, 'action': action} for ts, action in entries] + + +def _parse_datetime(value: Any) -> Optional[datetime]: + if isinstance(value, datetime): + return value + if not isinstance(value, str): + return None + text = value.strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace('Z', '+00:00')) + except ValueError: + pass + for fmt in ('%Y-%m-%d %H:%M:%S.%f', '%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%dT%H:%M:%S'): + try: + return datetime.strptime(text, fmt) + except ValueError: + continue + return None + + +def _average_seconds(deltas: Iterable[timedelta]) -> Optional[float]: + deltas = [d for d in deltas if isinstance(d, timedelta) and d.total_seconds() >= 0] + if not deltas: + return None + total = sum((d.total_seconds() for d in deltas), 0.0) + return total / len(deltas) + + +def _apply_access_filter_to_alert_query(stmt): + if ac_current_user_has_permission(Permissions.server_administrator): + return stmt + user_id = getattr(current_user, 'id', None) + if not user_id: + return stmt.where(Alert.alert_id == -1) + client_ids = get_user_clients_id(user_id) or [] + case_ids = ac_get_fast_user_cases_access(user_id) or [] + conditions = [] + if client_ids: + conditions.append(Alert.alert_customer_id.in_(client_ids)) + if case_ids: + sub = select(AlertCaseAssociation.alert_id).where(AlertCaseAssociation.case_id.in_(case_ids)) + conditions.append(Alert.alert_id.in_(sub)) + if not conditions: + return stmt.where(Alert.alert_id == -1) + if len(conditions) == 1: + return stmt.where(conditions[0]) + return stmt.where(or_(*conditions)) + + +def _apply_access_filter_to_case_query(stmt): + if ac_current_user_has_permission(Permissions.server_administrator): + return stmt + user_id = getattr(current_user, 'id', None) + if not user_id: + return stmt.where(Cases.case_id == -1) + case_ids = ac_get_fast_user_cases_access(user_id) or [] + if not case_ids: + return stmt.where(Cases.case_id == -1) + return stmt.where(Cases.case_id.in_(case_ids)) + + +def _apply_timeframe(stmt, column, timeframe: Tuple[Optional[datetime], Optional[datetime]]): + start, end = timeframe + if start is not None: + stmt = stmt.where(column >= start) + if end is not None: + stmt = stmt.where(column <= end) + return stmt + + +def _apply_alert_filters(stmt, filters: ComputedFilters): + if filters.customer_id is not None: + stmt = stmt.where(Alert.alert_customer_id == filters.customer_id) + if filters.severity_id is not None: + stmt = stmt.where(Alert.alert_severity_id == filters.severity_id) + return stmt + + +def _apply_case_filters(stmt, filters: ComputedFilters): + if filters.customer_id is not None: + stmt = stmt.where(Cases.client_id == filters.customer_id) + if filters.severity_id is not None: + stmt = stmt.where(Cases.severity_id == filters.severity_id) + if filters.case_status_id is not None: + stmt = stmt.where(Cases.state_id == filters.case_status_id) + return stmt + + +def _mttd_seconds(timeframe, filters: ComputedFilters) -> Optional[float]: + stmt = select(Cases.modification_history, Cases.open_date) + stmt = _apply_case_filters(stmt, filters) + stmt = _apply_timeframe(stmt, Cases.open_date, timeframe) + stmt = _apply_access_filter_to_case_query(stmt) + deltas: List[timedelta] = [] + for history, open_date in db.session.execute(stmt).all(): + if open_date is None: + continue + entries = _normalize_history(history) + transition_ts = next((e['timestamp'] for e in entries if 'in progress' in e['action'] or 'in_progress' in e['action']), None) + if transition_ts is None: + continue + deltas.append(transition_ts - open_date) + return _average_seconds(deltas) + + +def _mttr_seconds(timeframe, filters: ComputedFilters) -> Optional[float]: + stmt = select(Cases.modification_history, Cases.open_date) + stmt = _apply_case_filters(stmt, filters) + stmt = _apply_timeframe(stmt, Cases.open_date, timeframe) + stmt = _apply_access_filter_to_case_query(stmt) + deltas: List[timedelta] = [] + for history, open_date in db.session.execute(stmt).all(): + if open_date is None: + continue + entries = _normalize_history(history) + transition_ts = next((e['timestamp'] for e in entries if 'closed' in e['action'] or 'resolved' in e['action']), None) + if transition_ts is None: + continue + deltas.append(transition_ts - open_date) + return _average_seconds(deltas) + + +def _false_positive_rate(timeframe, filters: ComputedFilters) -> Optional[float]: + fp_status = db.session.execute( + select(AlertResolutionStatus.resolution_status_id) + .where(func.lower(AlertResolutionStatus.resolution_status_name) == 'false positive') + ).scalar_one_or_none() + + base_stmt = select(func.count(Alert.alert_id)) + base_stmt = _apply_alert_filters(base_stmt, filters) + base_stmt = _apply_timeframe(base_stmt, Alert.alert_creation_time, timeframe) + base_stmt = _apply_access_filter_to_alert_query(base_stmt) + total = db.session.execute(base_stmt).scalar() or 0 + if total == 0 or fp_status is None: + return None + + fp_stmt = base_stmt.where(Alert.alert_resolution_status_id == fp_status) + fp_count = db.session.execute(fp_stmt).scalar() or 0 + return (fp_count / total) * 100.0 + + +def _escalation_rate(timeframe, filters: ComputedFilters) -> Optional[float]: + escalated_status = db.session.execute( + select(AlertStatus.status_id) + .where(func.lower(AlertStatus.status_name) == 'escalated') + ).scalar_one_or_none() + + base_stmt = select(func.count(Alert.alert_id)) + base_stmt = _apply_alert_filters(base_stmt, filters) + base_stmt = _apply_timeframe(base_stmt, Alert.alert_creation_time, timeframe) + base_stmt = _apply_access_filter_to_alert_query(base_stmt) + total = db.session.execute(base_stmt).scalar() or 0 + if total == 0 or escalated_status is None: + return None + + esc_stmt = base_stmt.where(Alert.alert_status_id == escalated_status) + esc_count = db.session.execute(esc_stmt).scalar() or 0 + return (esc_count / total) * 100.0 + + +def _alerts_window_count(timeframe, filters: ComputedFilters) -> Optional[float]: + window = (filters.window or '24h').lower() + if window == '2h': + delta = timedelta(hours=2) + elif window == '48h': + delta = timedelta(hours=48) + else: + delta = timedelta(hours=24) + now = datetime.utcnow() + bounded = (now - delta, now) + stmt = select(func.count(Alert.alert_id)) + stmt = _apply_alert_filters(stmt, filters) + stmt = _apply_timeframe(stmt, Alert.alert_creation_time, bounded) + stmt = _apply_access_filter_to_alert_query(stmt) + return float(db.session.execute(stmt).scalar() or 0) + + +_NAMED_AGGREGATIONS: Dict[str, Callable[..., Optional[float]]] = { + 'mttd_seconds': _mttd_seconds, + 'mttr_seconds': _mttr_seconds, + 'false_positive_rate': _false_positive_rate, + 'escalation_rate': _escalation_rate, + 'alerts_window_count': _alerts_window_count, +} + + +def list_named_aggregations() -> List[Dict[str, str]]: + return [ + {'name': 'mttd_seconds', 'label': 'Mean time to detect (seconds)', 'value_format': 'duration'}, + {'name': 'mttr_seconds', 'label': 'Mean time to resolve (seconds)', 'value_format': 'duration'}, + {'name': 'false_positive_rate', 'label': 'False positive rate', 'value_format': 'percentage'}, + {'name': 'escalation_rate', 'label': 'Escalation rate', 'value_format': 'percentage'}, + {'name': 'alerts_window_count', 'label': 'Alerts within window', 'value_format': 'number'}, + ] + + +def compute_named_aggregation( + name: str, + timeframe: Tuple[Optional[datetime], Optional[datetime]], + filters: ComputedFilters, +) -> Optional[float]: + fn = _NAMED_AGGREGATIONS.get(name) + if fn is None: + raise NamedAggregationError(f"Named aggregation '{name}' is not defined.") + return fn(timeframe, filters) diff --git a/source/app/datamgmt/custom_dashboard/query_engine.py b/source/app/datamgmt/custom_dashboard/query_engine.py new file mode 100644 index 000000000..2a9192ff5 --- /dev/null +++ b/source/app/datamgmt/custom_dashboard/query_engine.py @@ -0,0 +1,1522 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +import math +from datetime import datetime, timedelta +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Set + +from flask_login import current_user +from sqlalchemy import func, or_, select, cast, Integer, case, literal +from sqlalchemy.orm import Query, aliased + +from app import db +from app.datamgmt.manage.manage_access_control_db import get_user_clients_id +from app.blueprints.access_controls import ac_current_user_has_permission +from app.iris_engine.access_control.utils import ac_get_fast_user_cases_access +from app.models.alerts import Alert, AlertResolutionStatus, AlertStatus, Severity +from app.models.alerts import AlertCaseAssociation +from app.models.cases import Cases, CaseTags, CaseState, CasesEvent, CaseClassification +from app.models.authorization import Permissions, User +from app.models.assets import CaseAssets, AssetsType +from app.models.customers import Client +from app.models.iocs import Ioc +from app.models.models import ( + Tags, + IocType, + Notes, + CaseTasks, + ReviewStatus +) + + +class QueryExecutionError(Exception): + """Raised when a widget cannot be executed due to an invalid configuration.""" + + +@dataclass +class WidgetQueryResult: + chart_type: str + rows: List[Dict[str, Any]] + group_labels: Sequence[str] + value_labels: Sequence[str] + select_labels: Sequence[str] + + +_MAX_TIME_BUCKET_POINTS = 2000 + + +def _ensure_numeric(value: Any) -> Optional[float]: + if isinstance(value, bool): + return float(int(value)) + if isinstance(value, (int, float)): + numeric = float(value) + if math.isnan(numeric) or math.isinf(numeric): + return None + return numeric + if isinstance(value, Decimal): + numeric = float(value) + if math.isnan(numeric) or math.isinf(numeric): + return None + return numeric + return None + + +def _format_number(value: Any) -> str: + numeric = _ensure_numeric(value) + if numeric is None: + if value is None or value == '': + return '--' + return str(value) + if numeric.is_integer(): + return f"{int(numeric):,}" + return f"{numeric:,.2f}".rstrip('0').rstrip('.') + + +def _format_percentage(value: Optional[float]) -> str: + if value is None: + return '--' + return f"{value:,.2f}%".rstrip('0').rstrip('.') + + +def _format_group_value(value: Any) -> str: + if value is None or value == '': + return 'N/A' + return str(value) + + +def _format_time_group_value(value: Any, bucket: Optional[str]) -> str: + if not isinstance(value, datetime): + return _format_group_value(value) + normalized_bucket = (bucket or '').lower() + if normalized_bucket in {'minute', '5minute', '5m', '15minute', '15m', 'hour'}: + return value.strftime('%Y-%m-%d %H:%M') + if normalized_bucket == 'week': + return value.strftime('Week %W %Y') + if normalized_bucket == 'month': + return value.strftime('%Y-%m') + if normalized_bucket == 'year': + return value.strftime('%Y') + return value.strftime('%Y-%m-%d') + + +def _canonical_label_key(value: Any) -> str: + if isinstance(value, datetime): + return value.isoformat() + if value is None or value == '': + return 'N/A' + return str(value) + + +def _compute_time_alias(time_column: Optional[str], time_bucket: Optional[str]) -> Optional[str]: + if not isinstance(time_column, str) or '.' not in time_column: + if isinstance(time_column, str): + candidate = time_column.replace('.', '_').strip() + return candidate or None + return None + table, column = time_column.split('.', 1) + base = f"{table.strip()}_{column.strip()}" + bucket = (time_bucket or '').strip().lower() + return f"{base}_{bucket}" if bucket else base + + +def _floor_datetime_to_bucket(value: datetime, bucket: str) -> datetime: + normalized = (bucket or '').lower() + if normalized == 'minute': + return value.replace(second=0, microsecond=0) + if normalized in {'5minute', '5m'}: + minute = value.minute - (value.minute % 5) + return value.replace(minute=minute, second=0, microsecond=0) + if normalized in {'15minute', '15m'}: + minute = value.minute - (value.minute % 15) + return value.replace(minute=minute, second=0, microsecond=0) + if normalized == 'hour': + return value.replace(minute=0, second=0, microsecond=0) + if normalized == 'day': + return value.replace(hour=0, minute=0, second=0, microsecond=0) + if normalized == 'week': + start_of_week = value - timedelta(days=value.weekday()) + return start_of_week.replace(hour=0, minute=0, second=0, microsecond=0) + if normalized == 'month': + return value.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + if normalized == 'year': + return value.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) + return value.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _advance_datetime_by_bucket(value: datetime, bucket: str) -> datetime: + normalized = (bucket or '').lower() + if normalized == 'minute': + return value + timedelta(minutes=1) + if normalized in {'5minute', '5m'}: + return value + timedelta(minutes=5) + if normalized in {'15minute', '15m'}: + return value + timedelta(minutes=15) + if normalized == 'hour': + return value + timedelta(hours=1) + if normalized == 'day': + return value + timedelta(days=1) + if normalized == 'week': + return value + timedelta(weeks=1) + if normalized == 'month': + if value.month == 12: + return value.replace(year=value.year + 1, month=1, day=1) + return value.replace(month=value.month + 1, day=1) + if normalized == 'year': + return value.replace(year=value.year + 1, month=1, day=1) + return value + timedelta(days=1) + + +def _generate_time_bucket_range( + start: Optional[datetime], + end: Optional[datetime], + bucket: Optional[str], + max_points: int = _MAX_TIME_BUCKET_POINTS +) -> List[datetime]: + if not isinstance(start, datetime) or not isinstance(end, datetime): + return [] + normalized = (bucket or '').lower() + if not normalized: + return [] + + if start > end: + start, end = end, start + + current = _floor_datetime_to_bucket(start, normalized) + end_floor = _floor_datetime_to_bucket(end, normalized) + buckets: List[datetime] = [] + + while current <= end_floor: + buckets.append(current) + if len(buckets) > max_points: + return [] + next_value = _advance_datetime_by_bucket(current, normalized) + if next_value <= current: + break + current = next_value + + return buckets + + +def _time_sort_key(value: Any) -> float: + if isinstance(value, datetime): + try: + return float(value.timestamp()) + except (OverflowError, OSError): + return float('-inf') + return float('-inf') + + +def _normalize_display_mode(value: Optional[str]) -> str: + if not value or not isinstance(value, str): + return 'number' + normalized = value.strip().lower() + if normalized in {'percentage', 'percent'}: + return 'percentage' + if normalized in {'number_percentage', 'number-and-percentage', 'number and percentage', 'both'}: + return 'number_percentage' + return 'number' + + +CaseOwnerUser = aliased(User, name='case_owner_user') +CaseCreatorUser = aliased(User, name='case_creator_user') +CaseReviewerUser = aliased(User, name='case_reviewer_user') +AlertOwnerUser = aliased(User, name='alert_owner_user') +AlertAsset = aliased(CaseAssets, name='alert_asset') +CaseAsset = aliased(CaseAssets, name='case_asset') +AlertAssetType = aliased(AssetsType, name='alert_asset_type') +CaseAssetType = aliased(AssetsType, name='case_asset_type') +AlertIoc = aliased(Ioc, name='alert_ioc') +CaseIoc = aliased(Ioc, name='case_ioc') +AlertIocType = aliased(IocType, name='alert_ioc_type') +CaseIocType = aliased(IocType, name='case_ioc_type') +CaseEventAlias = aliased(CasesEvent, name='case_event') +CaseNoteAlias = aliased(Notes, name='case_note') +CaseTaskAlias = aliased(CaseTasks, name='case_task') + + +def _join_cases(query): + return query.outerjoin(Cases, Alert.cases) + + +def _join_case_owner(query): + return query.outerjoin(CaseOwnerUser, Cases.owner) + + +def _join_case_creator(query): + return query.outerjoin(CaseCreatorUser, Cases.user) + + +def _join_case_reviewer(query): + return query.outerjoin(CaseReviewerUser, Cases.reviewer) + + +def _join_alert_owner(query): + return query.outerjoin(AlertOwnerUser, Alert.owner) + + +def _join_case_tags_table(query): + return query.outerjoin(CaseTags, CaseTags.case_id == Cases.case_id) + + +def _join_tags_table(query): + # Case tags join is registered before reaching this point, so reuse its alias when linking tags. + return query.outerjoin(Tags, Tags.id == CaseTags.tag_id) + + +def _join_case_state(query): + return query.outerjoin(CaseState, CaseState.state_id == Cases.state_id) + + +def _join_alert_assets(query): + return query.outerjoin(AlertAsset, Alert.assets) + + +def _join_case_assets(query): + return query.outerjoin(CaseAsset, CaseAsset.case_id == Cases.case_id) + + +def _join_alert_asset_types(query): + return query.outerjoin(AlertAssetType, AlertAsset.asset_type) + + +def _join_case_asset_types(query): + return query.outerjoin(CaseAssetType, CaseAsset.asset_type) + + +def _join_alert_iocs(query): + return query.outerjoin(AlertIoc, Alert.iocs) + + +def _join_case_iocs(query): + return query.outerjoin(CaseIoc, CaseIoc.case_id == Cases.case_id) + + +def _join_alert_ioc_types(query): + return query.outerjoin(AlertIocType, AlertIoc.ioc_type) + + +def _join_case_ioc_types(query): + return query.outerjoin(CaseIocType, CaseIoc.ioc_type) + + +def _join_case_events(query): + return query.outerjoin(CaseEventAlias, CaseEventAlias.case_id == Cases.case_id) + + +def _join_case_notes(query): + return query.outerjoin(CaseNoteAlias, CaseNoteAlias.note_case_id == Cases.case_id) + + +def _join_case_tasks(query): + return query.outerjoin(CaseTaskAlias, CaseTaskAlias.task_case_id == Cases.case_id) + + +_AGGREGATIONS = { + 'count': lambda column: func.count(column), + 'sum': lambda column: func.sum(column), + 'avg': lambda column: func.avg(column), + 'min': lambda column: func.min(column), + 'max': lambda column: func.max(column) +} + +_OPERATORS = { + 'eq': lambda column, value: column == value, + 'neq': lambda column, value: column != value, + 'gt': lambda column, value: column > value, + 'gte': lambda column, value: column >= value, + 'lt': lambda column, value: column < value, + 'lte': lambda column, value: column <= value, + 'in': lambda column, value: column.in_(value if isinstance(value, (list, tuple, set)) else [value]), + 'nin': lambda column, value: ~column.in_(value if isinstance(value, (list, tuple, set)) else [value]), + 'between': lambda column, value: column.between(value[0], value[1]) if isinstance(value, (list, tuple)) and len(value) == 2 else None, + 'contains': lambda column, value: column.ilike(f"%{value}%") +} + + +def _capitalize_label(label: str) -> str: + if not label: + return label + return label.replace('_', ' ').strip().capitalize() + + +class _WidgetQueryBuilder: + def __init__(self): + self.selects: List[Any] = [] + self.select_labels: List[str] = [] + self.group_by_exprs: List[Any] = [] + self.group_labels: List[str] = [] + self.aggregated_labels: List[str] = [] + self.aggregated_exprs: Dict[str, Any] = {} + self.filters: List[Any] = [] + self.joins: List[str] = [] + self.chart_type: str = '' + + def add_join(self, table_name: str): + if table_name not in self.joins: + self.joins.append(table_name) + + def add_group_by(self, expr: Any, label: str): + if expr not in self.group_by_exprs: + self.group_by_exprs.append(expr) + if label not in self.group_labels: + self.group_labels.append(label) + + def add_select(self, expr: Any, label: str, aggregated: bool): + # Same widget can list the same column twice (e.g. once as a + # non-aggregated field and once in group_by). The group-by side + # already dedupes; mirror that for the SELECT projection so the + # response doesn't carry two columns sharing the same label. + if label in self.select_labels: + if not aggregated: + self.add_group_by(expr, label) + return + labeled_expr = expr.label(label) + self.selects.append(labeled_expr) + self.select_labels.append(label) + if aggregated: + self.aggregated_labels.append(label) + self.aggregated_exprs[label] = labeled_expr + else: + self.add_group_by(expr, label) + + +def _between_dates(column, start: Optional[datetime], end: Optional[datetime]): + expressions = [] + if start is not None: + expressions.append(column >= start) + if end is not None: + expressions.append(column <= end) + return expressions + + +class WidgetQueryExecutor: + """Builds and executes SQLAlchemy queries for dashboard widgets.""" + + _BASE_TABLE = 'alerts' + + _TABLES: Dict[str, Dict[str, Any]] = { + 'alerts': { + 'model': Alert, + 'columns': { + 'alert_id': Alert.alert_id, + 'alert_uuid': Alert.alert_uuid, + 'alert_title': Alert.alert_title, + 'alert_source': Alert.alert_source, + 'alert_source_ref': Alert.alert_source_ref, + 'alert_description': Alert.alert_description, + 'alert_creation_time': Alert.alert_creation_time, + 'alert_source_event_time': Alert.alert_source_event_time, + 'alert_customer_id': Alert.alert_customer_id, + 'alert_resolution_status_id': Alert.alert_resolution_status_id, + 'alert_status_id': Alert.alert_status_id, + 'alert_severity_id': Alert.alert_severity_id, + 'alert_owner_id': Alert.alert_owner_id, + 'alert_classification_id': Alert.alert_classification_id, + 'alert_tags': Alert.alert_tags + }, + 'default_time_column': Alert.alert_creation_time + }, + 'client': { + 'model': Client, + 'columns': { + 'client_id': Client.client_id, + 'name': Client.name + }, + 'join': lambda query: query.outerjoin(Client, Alert.customer) + }, + 'alert_resolution_status': { + 'model': AlertResolutionStatus, + 'columns': { + 'resolution_status_id': AlertResolutionStatus.resolution_status_id, + 'resolution_status_name': AlertResolutionStatus.resolution_status_name + }, + 'join': lambda query: query.outerjoin(AlertResolutionStatus, Alert.resolution_status) + }, + 'alert_status': { + 'model': AlertStatus, + 'columns': { + 'status_id': AlertStatus.status_id, + 'status_name': AlertStatus.status_name + }, + 'join': lambda query: query.outerjoin(AlertStatus, Alert.status) + }, + 'severities': { + 'model': Severity, + 'columns': { + 'severity_id': Severity.severity_id, + 'severity_name': Severity.severity_name + }, + 'join': lambda query: query.outerjoin(Severity, Alert.severity) + }, + 'case_classification': { + 'model': CaseClassification, + 'columns': { + 'id': CaseClassification.id, + 'name': CaseClassification.name, + 'name_expanded': CaseClassification.name_expanded + }, + 'join': lambda query: query.outerjoin(CaseClassification, Alert.classification) + }, + 'cases': { + 'model': Cases, + 'columns': { + 'case_id': Cases.case_id, + 'name': Cases.name, + 'owner_id': Cases.owner_id, + 'creator_id': Cases.user_id, + 'reviewer_id': Cases.reviewer_id, + 'review_status_id': Cases.review_status_id, + 'state_id': Cases.state_id, + 'status_id': Cases.status_id, + 'severity_id': Cases.severity_id, + 'classification_id': Cases.classification_id, + 'client_id': Cases.client_id, + 'open_date': Cases.open_date, + 'close_date': Cases.close_date, + 'initial_date': Cases.initial_date + }, + 'join': _join_cases + }, + 'case_state': { + 'model': CaseState, + 'columns': { + 'state_id': CaseState.state_id, + 'state_name': CaseState.state_name, + 'state_description': CaseState.state_description + }, + 'join': _join_case_state + }, + 'case_tags': { + 'model': CaseTags, + 'columns': { + 'case_id': CaseTags.case_id, + 'tag_id': CaseTags.tag_id + }, + 'join': _join_case_tags_table + }, + 'alert_owner': { + 'model': User, + 'columns': { + 'id': AlertOwnerUser.id, + 'username': AlertOwnerUser.user, + 'name': AlertOwnerUser.name + }, + 'join': _join_alert_owner + }, + 'case_owner': { + 'model': User, + 'columns': { + 'id': CaseOwnerUser.id, + 'username': CaseOwnerUser.user, + 'name': CaseOwnerUser.name + }, + 'join': _join_case_owner + }, + 'case_creator': { + 'model': User, + 'columns': { + 'id': CaseCreatorUser.id, + 'username': CaseCreatorUser.user, + 'name': CaseCreatorUser.name + }, + 'join': _join_case_creator + }, + 'case_reviewer': { + 'model': User, + 'columns': { + 'id': CaseReviewerUser.id, + 'username': CaseReviewerUser.user, + 'name': CaseReviewerUser.name + }, + 'join': _join_case_reviewer + }, + 'tags': { + 'model': Tags, + 'columns': { + 'id': Tags.id, + 'tag_title': Tags.tag_title, + 'tag_namespace': Tags.tag_namespace, + 'tag_creation_date': Tags.tag_creation_date + }, + 'join': _join_tags_table + }, + 'alert_assets': { + 'model': AlertAsset, + 'columns': { + 'asset_id': AlertAsset.asset_id, + 'asset_uuid': AlertAsset.asset_uuid, + 'asset_name': AlertAsset.asset_name, + 'asset_description': AlertAsset.asset_description, + 'asset_domain': AlertAsset.asset_domain, + 'asset_ip': AlertAsset.asset_ip, + 'asset_info': AlertAsset.asset_info, + 'asset_compromise_status_id': AlertAsset.asset_compromise_status_id, + 'asset_type_id': AlertAsset.asset_type_id, + 'asset_tags': AlertAsset.asset_tags, + 'case_id': AlertAsset.case_id, + 'date_added': AlertAsset.date_added, + 'date_update': AlertAsset.date_update, + 'user_id': AlertAsset.user_id + }, + 'join': _join_alert_assets + }, + 'alert_asset_types': { + 'model': AlertAssetType, + 'columns': { + 'asset_id': AlertAssetType.asset_id, + 'asset_name': AlertAssetType.asset_name, + 'asset_description': AlertAssetType.asset_description, + 'asset_icon_not_compromised': AlertAssetType.asset_icon_not_compromised, + 'asset_icon_compromised': AlertAssetType.asset_icon_compromised + }, + 'join': _join_alert_asset_types + }, + 'alert_iocs': { + 'model': AlertIoc, + 'columns': { + 'ioc_id': AlertIoc.ioc_id, + 'ioc_uuid': AlertIoc.ioc_uuid, + 'ioc_value': AlertIoc.ioc_value, + 'ioc_type_id': AlertIoc.ioc_type_id, + 'ioc_description': AlertIoc.ioc_description, + 'ioc_tags': AlertIoc.ioc_tags, + 'user_id': AlertIoc.user_id, + 'ioc_misp': AlertIoc.ioc_misp, + 'ioc_tlp_id': AlertIoc.ioc_tlp_id + }, + 'join': _join_alert_iocs + }, + 'alert_ioc_types': { + 'model': AlertIocType, + 'columns': { + 'type_id': AlertIocType.type_id, + 'type_name': AlertIocType.type_name, + 'type_description': AlertIocType.type_description, + 'type_taxonomy': AlertIocType.type_taxonomy, + 'type_validation_regex': AlertIocType.type_validation_regex, + 'type_validation_expect': AlertIocType.type_validation_expect + }, + 'join': _join_alert_ioc_types + }, + 'case_assets': { + 'model': CaseAsset, + 'columns': { + 'asset_id': CaseAsset.asset_id, + 'asset_uuid': CaseAsset.asset_uuid, + 'asset_name': CaseAsset.asset_name, + 'asset_description': CaseAsset.asset_description, + 'asset_domain': CaseAsset.asset_domain, + 'asset_ip': CaseAsset.asset_ip, + 'asset_info': CaseAsset.asset_info, + 'asset_compromise_status_id': CaseAsset.asset_compromise_status_id, + 'asset_type_id': CaseAsset.asset_type_id, + 'asset_tags': CaseAsset.asset_tags, + 'case_id': CaseAsset.case_id, + 'date_added': CaseAsset.date_added, + 'date_update': CaseAsset.date_update, + 'user_id': CaseAsset.user_id + }, + 'join': _join_case_assets + }, + 'case_asset_types': { + 'model': CaseAssetType, + 'columns': { + 'asset_id': CaseAssetType.asset_id, + 'asset_name': CaseAssetType.asset_name, + 'asset_description': CaseAssetType.asset_description, + 'asset_icon_not_compromised': CaseAssetType.asset_icon_not_compromised, + 'asset_icon_compromised': CaseAssetType.asset_icon_compromised + }, + 'join': _join_case_asset_types + }, + 'case_iocs': { + 'model': CaseIoc, + 'columns': { + 'ioc_id': CaseIoc.ioc_id, + 'ioc_uuid': CaseIoc.ioc_uuid, + 'ioc_value': CaseIoc.ioc_value, + 'ioc_type_id': CaseIoc.ioc_type_id, + 'ioc_description': CaseIoc.ioc_description, + 'ioc_tags': CaseIoc.ioc_tags, + 'user_id': CaseIoc.user_id, + 'ioc_misp': CaseIoc.ioc_misp, + 'ioc_tlp_id': CaseIoc.ioc_tlp_id + }, + 'join': _join_case_iocs + }, + 'case_ioc_types': { + 'model': CaseIocType, + 'columns': { + 'type_id': CaseIocType.type_id, + 'type_name': CaseIocType.type_name, + 'type_description': CaseIocType.type_description, + 'type_taxonomy': CaseIocType.type_taxonomy, + 'type_validation_regex': CaseIocType.type_validation_regex, + 'type_validation_expect': CaseIocType.type_validation_expect + }, + 'join': _join_case_ioc_types + }, + 'case_events': { + 'model': CaseEventAlias, + 'columns': { + 'event_id': CaseEventAlias.event_id, + 'parent_event_id': CaseEventAlias.parent_event_id, + 'case_id': CaseEventAlias.case_id, + 'event_title': CaseEventAlias.event_title, + 'event_source': CaseEventAlias.event_source, + 'event_content': CaseEventAlias.event_content, + 'event_raw': CaseEventAlias.event_raw, + 'event_date': CaseEventAlias.event_date, + 'event_added': CaseEventAlias.event_added, + 'event_in_graph': CaseEventAlias.event_in_graph, + 'event_in_summary': CaseEventAlias.event_in_summary, + 'user_id': CaseEventAlias.user_id, + 'event_color': CaseEventAlias.event_color, + 'event_tags': CaseEventAlias.event_tags, + 'event_tz': CaseEventAlias.event_tz, + 'event_date_wtz': CaseEventAlias.event_date_wtz, + 'event_is_flagged': CaseEventAlias.event_is_flagged + }, + 'join': _join_case_events + }, + 'case_notes': { + 'model': CaseNoteAlias, + 'columns': { + 'note_id': CaseNoteAlias.note_id, + 'note_uuid': CaseNoteAlias.note_uuid, + 'note_title': CaseNoteAlias.note_title, + 'note_content': CaseNoteAlias.note_content, + 'note_user': CaseNoteAlias.note_user, + 'note_creationdate': CaseNoteAlias.note_creationdate, + 'note_lastupdate': CaseNoteAlias.note_lastupdate, + 'note_case_id': CaseNoteAlias.note_case_id, + 'directory_id': CaseNoteAlias.directory_id + }, + 'join': _join_case_notes + }, + 'case_tasks': { + 'model': CaseTaskAlias, + 'columns': { + 'id': CaseTaskAlias.id, + 'task_uuid': CaseTaskAlias.task_uuid, + 'task_title': CaseTaskAlias.task_title, + 'task_description': CaseTaskAlias.task_description, + 'task_tags': CaseTaskAlias.task_tags, + 'task_open_date': CaseTaskAlias.task_open_date, + 'task_close_date': CaseTaskAlias.task_close_date, + 'task_last_update': CaseTaskAlias.task_last_update, + 'task_userid_open': CaseTaskAlias.task_userid_open, + 'task_userid_close': CaseTaskAlias.task_userid_close, + 'task_userid_update': CaseTaskAlias.task_userid_update, + 'task_status_id': CaseTaskAlias.task_status_id, + 'task_case_id': CaseTaskAlias.task_case_id + }, + 'join': _join_case_tasks + }, + 'review_status': { + 'model': ReviewStatus, + 'columns': { + 'id': ReviewStatus.id, + 'status_name': ReviewStatus.status_name + }, + 'join': lambda query: query.outerjoin(ReviewStatus, Cases.review_status) + } + } + + def __init__(self, definition: Dict[str, Any]): + self.definition = definition or {} + self.builder = _WidgetQueryBuilder() + options = self.definition.get('options') or {} + self.time_column_spec = options.get('time_column') or 'alerts.alert_creation_time' + self._normalized_time_column_spec = self._normalize_table_column_value(self.time_column_spec) + raw_time_bucket = self.definition.get('time_bucket') + self.time_bucket = raw_time_bucket.strip().lower() if isinstance(raw_time_bucket, str) else '' + self._time_bucket_label = '' + self._projection_tables: Set[str] = self._collect_projection_tables() + + def execute(self, timeframe: Tuple[Optional[datetime], Optional[datetime]]) -> WidgetQueryResult: + widgets_fields = self.definition.get('fields') or [] + if not widgets_fields: + raise QueryExecutionError('The widget must define at least one field.') + + self.builder.chart_type = (self.definition.get('chart_type') or '').lower() + if not self.builder.chart_type: + raise QueryExecutionError('Missing chart type in widget definition.') + + for field in widgets_fields: + self._add_field(field) + + for group_entry in self.definition.get('group_by', []) or []: + self._apply_group_by(group_entry) + + self._ensure_time_bucket_group() + + for filter_entry in self.definition.get('filters', []) or []: + self._apply_filter(filter_entry) + + start, end = timeframe + self._apply_timeframe(start, end) + self._apply_access_filters() + + query = self._build_query() + query = self._apply_sorting(query) + query = self._apply_limit(query) + + rows = query.all() + mapped_rows = [dict(row._mapping) for row in rows] + + return WidgetQueryResult( + chart_type=self.builder.chart_type, + rows=mapped_rows, + group_labels=self.builder.group_labels, + value_labels=self.builder.aggregated_labels or [label for label in self.builder.select_labels if label in self.builder.group_labels], + select_labels=self.builder.select_labels + ) + + def _get_table(self, table_name: str) -> Dict[str, Any]: + table = self._TABLES.get(table_name) + if not table: + raise QueryExecutionError(f"Table '{table_name}' is not allowed in custom dashboards.") + return table + + def _get_column(self, table_name: str, column_name: str): + table = self._get_table(table_name) + columns = table.get('columns', {}) + column = columns.get(column_name) + if column is None: + raise QueryExecutionError(f"Column '{column_name}' is not allowed for table '{table_name}'.") + if table_name in {'case_owner', 'case_creator', 'case_reviewer', 'case_tags', 'tags', 'case_assets', 'case_asset_types', 'case_iocs', 'case_ioc_types', 'case_events', 'case_notes', 'case_tasks', 'review_status', 'case_state'}: + self.builder.add_join('cases') + if table_name == 'tags': + self.builder.add_join('case_tags') + if table_name == 'case_asset_types': + self.builder.add_join('case_assets') + if table_name == 'case_ioc_types': + self.builder.add_join('case_iocs') + if table_name == 'alert_asset_types': + self.builder.add_join('alert_assets') + if table_name == 'alert_ioc_types': + self.builder.add_join('alert_iocs') + if table_name != self._BASE_TABLE: + self.builder.add_join(table_name) + return column + + def _add_field(self, field_definition: Dict[str, Any]): + table_name = field_definition.get('table') + column_name = field_definition.get('column') + if not table_name or not column_name: + raise QueryExecutionError('Each field must specify a table and column.') + + column = self._get_column(table_name, column_name) + alias = field_definition.get('alias') or f"{table_name}_{column_name}" + aggregation = (field_definition.get('aggregation') or '').lower() or None + field_filter_definition = field_definition.get('filter') + + if aggregation: + filter_expression = None + if field_filter_definition: + filter_expression = self._build_filter_expression(field_filter_definition) + expr = self._build_aggregate_expression(aggregation, column, filter_expression) + self.builder.add_select(expr, alias, aggregated=True) + else: + if field_filter_definition: + raise QueryExecutionError('Non-aggregated fields cannot specify a filter.') + self.builder.add_select(column, alias, aggregated=False) + + def _apply_group_by(self, group_entry: str): + table_name, column_name = self._parse_table_column(group_entry) + column = self._get_column(table_name, column_name) + alias = f"{table_name}_{column_name}" + is_time_group = self._is_time_column(group_entry) + + if is_time_group and self.time_bucket: + truncated_column = self._apply_time_bucket(column) + alias = self._resolve_time_bucket_label(table_name, column_name) + self.builder.add_select(truncated_column, alias, aggregated=False) + self._time_bucket_label = alias + self._promote_time_group() + else: + self.builder.add_select(column, alias, aggregated=False) + + def _apply_filter(self, filter_definition: Dict[str, Any]): + table_name = (filter_definition or {}).get('table') + if table_name in {'tags', 'case_tags'} and table_name not in self._projection_tables: + expression = self._build_case_scoped_tag_filter(filter_definition) + else: + expression = self._build_filter_expression(filter_definition) + self.builder.filters.append(expression) + + def _collect_projection_tables(self) -> Set[str]: + tables: Set[str] = set() + for field in self.definition.get('fields') or []: + if isinstance(field, dict) and field.get('table'): + tables.add(field['table']) + for group_entry in self.definition.get('group_by') or []: + if isinstance(group_entry, str) and '.' in group_entry: + tables.add(group_entry.split('.', 1)[0].strip()) + return tables + + def _build_case_scoped_tag_filter(self, filter_definition: Dict[str, Any]): + # Many-to-many joins on tags inflate counts; route tag-only filters through + # a Cases.case_id IN (subquery) so the main query stays flat. + if not isinstance(filter_definition, dict): + raise QueryExecutionError('Invalid filter definition supplied.') + + table_name = filter_definition.get('table') + column_name = filter_definition.get('column') + operator = (filter_definition.get('operator') or '').lower() + value = filter_definition.get('value') + + if not table_name or not column_name or not operator: + raise QueryExecutionError('Filters must define table, column, and operator.') + + table = self._get_table(table_name) + column = table.get('columns', {}).get(column_name) + if column is None: + raise QueryExecutionError(f"Column '{column_name}' is not allowed for table '{table_name}'.") + + operator_fn = _OPERATORS.get(operator) + if operator_fn is None: + raise QueryExecutionError(f"Operator '{operator}' is not supported.") + + condition = operator_fn(column, value) + if condition is None: + raise QueryExecutionError(f"Operator '{operator}' expects a different value format.") + + if table_name == 'tags': + subquery = ( + select(CaseTags.case_id) + .join(Tags, Tags.id == CaseTags.tag_id) + .where(condition) + ) + else: + subquery = select(CaseTags.case_id).where(condition) + + self.builder.add_join('cases') + return Cases.case_id.in_(subquery) + + def _apply_timeframe(self, start: Optional[datetime], end: Optional[datetime]): + table_name, column_name = self._parse_table_column(self.time_column_spec) + column = self._get_column(table_name, column_name) + + for expression in _between_dates(column, start, end): + self.builder.filters.append(expression) + + def _ensure_time_bucket_group(self): + if not self.time_bucket: + return + + if self._time_bucket_label and self._time_bucket_label in self.builder.select_labels: + return + + table_name, column_name = self._parse_table_column(self.time_column_spec) + column = self._get_column(table_name, column_name) + alias = self._resolve_time_bucket_label(table_name, column_name) + truncated_column = self._apply_time_bucket(column) + self.builder.add_select(truncated_column, alias, aggregated=False) + self._time_bucket_label = alias + self._promote_time_group() + + def _apply_access_filters(self): + if ac_current_user_has_permission(Permissions.server_administrator): + return + + user_id = getattr(current_user, 'id', None) + if not user_id: + # Without a logged-in user we cannot determine scope; deny by default. + self.builder.filters.append(Alert.alert_id == -1) + return + + client_ids = get_user_clients_id(user_id) or [] + case_ids = ac_get_fast_user_cases_access(user_id) or [] + + access_conditions = [] + + if client_ids: + access_conditions.append(Alert.alert_customer_id.in_(client_ids)) + + if case_ids: + case_alerts_subquery = select(AlertCaseAssociation.alert_id).where( + AlertCaseAssociation.case_id.in_(case_ids) + ) + access_conditions.append(Alert.alert_id.in_(case_alerts_subquery)) + + if not access_conditions: + # User has no accessible scope -> no data should be returned. + self.builder.filters.append(Alert.alert_id == -1) + return + + if len(access_conditions) == 1: + self.builder.filters.append(access_conditions[0]) + else: + self.builder.filters.append(or_(*access_conditions)) + + def _build_query(self) -> Query: + if not any(label in self.builder.select_labels for label in self.builder.aggregated_labels): + if not self.builder.group_labels: + raise QueryExecutionError('Widgets must contain at least one aggregated field or grouping column.') + + query = db.session.query(*self.builder.selects) + query = query.select_from(Alert) + for join_table in self.builder.joins: + table = self._get_table(join_table) + join_callable = table.get('join') + if join_callable is None: + raise QueryExecutionError(f"No join strategy registered for table '{join_table}'.") + query = join_callable(query) + + if self.builder.filters: + query = query.filter(*self.builder.filters) + + if self.builder.group_by_exprs: + query = query.group_by(*self.builder.group_by_exprs) + + return query + + def _apply_sorting(self, query: Query) -> Query: + options = self.definition.get('options') or {} + sort_direction = (options.get('sort') or '').lower() + if not sort_direction: + if self._time_bucket_label: + try: + time_index = self.builder.group_labels.index(self._time_bucket_label) + time_expression = self.builder.group_by_exprs[time_index] + except (ValueError, IndexError): + time_expression = None + time_index = -1 + if time_expression is not None: + order_clauses = [time_expression.asc()] + for idx, expr in enumerate(self.builder.group_by_exprs): + if idx == time_index: + continue + order_clauses.append(expr.asc()) + return query.order_by(*order_clauses) + return query + + order_expression = None + if self.builder.aggregated_labels: + primary_label = self.builder.aggregated_labels[0] + order_expression = self.builder.aggregated_exprs.get(primary_label) + elif self.builder.group_by_exprs: + order_expression = self.builder.group_by_exprs[0] + + if order_expression is None: + return query + + if sort_direction == 'desc': + return query.order_by(order_expression.desc()) + if sort_direction == 'asc': + return query.order_by(order_expression.asc()) + return query + + def _apply_limit(self, query: Query) -> Query: + options = self.definition.get('options') or {} + limit_value = options.get('limit') + if isinstance(limit_value, int) and limit_value > 0: + return query.limit(limit_value) + return query + + def _parse_table_column(self, value: str) -> Tuple[str, str]: + if not value or '.' not in value: + raise QueryExecutionError('Expected table.column format in widget definition.') + table_name, column_name = value.split('.', 1) + return table_name.strip(), column_name.strip() + + def _build_filter_expression(self, filter_definition: Dict[str, Any]): + if not isinstance(filter_definition, dict): + raise QueryExecutionError('Invalid filter definition supplied.') + + table_name = filter_definition.get('table') + column_name = filter_definition.get('column') + operator = (filter_definition.get('operator') or '').lower() + value = filter_definition.get('value') + + if not table_name or not column_name or not operator: + raise QueryExecutionError('Filters must define table, column, and operator.') + + column = self._get_column(table_name, column_name) + operator_fn = _OPERATORS.get(operator) + if operator_fn is None: + raise QueryExecutionError(f"Operator '{operator}' is not supported.") + + expression = operator_fn(column, value) + if expression is None: + raise QueryExecutionError(f"Operator '{operator}' expects a different value format.") + + return expression + + def _build_aggregate_expression(self, aggregation: str, column, filter_expression): + normalized = aggregation.lower() + + if filter_expression is not None: + if normalized == 'count': + return func.coalesce(func.sum(case((filter_expression, 1), else_=0)), 0) + if normalized == 'sum': + return func.coalesce(func.sum(case((filter_expression, column), else_=0)), 0) + if normalized == 'avg': + return func.avg(case((filter_expression, column), else_=None)) + if normalized == 'min': + return func.min(case((filter_expression, column), else_=None)) + if normalized == 'max': + return func.max(case((filter_expression, column), else_=None)) + if normalized == 'ratio': + base_column = column or Alert.alert_id + numerator = func.sum(case((filter_expression, 1), else_=0)) + denominator = func.nullif(func.count(base_column), 0) + ratio_expr = (numerator * literal(100.0)) / denominator + return func.coalesce(ratio_expr, 0) + raise QueryExecutionError(f"Aggregation '{aggregation}' with filter is not supported.") + + if normalized == 'ratio': + raise QueryExecutionError("Ratio aggregation requires a filter to be specified.") + + agg_fn = _AGGREGATIONS.get(normalized) + if agg_fn is None: + raise QueryExecutionError(f"Aggregation '{aggregation}' is not supported.") + return agg_fn(column) + + def _promote_time_group(self): + if not self._time_bucket_label: + return + try: + label_index = self.builder.group_labels.index(self._time_bucket_label) + except ValueError: + return + if label_index == 0: + return + if label_index < len(self.builder.group_labels): + self.builder.group_labels.insert(0, self.builder.group_labels.pop(label_index)) + if label_index < len(self.builder.group_by_exprs): + self.builder.group_by_exprs.insert(0, self.builder.group_by_exprs.pop(label_index)) + + def _normalize_table_column_value(self, value: Optional[str]) -> str: + if not isinstance(value, str): + return '' + return value.replace(' ', '').lower() + + def _is_time_column(self, group_entry: str) -> bool: + return self._normalize_table_column_value(group_entry) == self._normalized_time_column_spec + + def _resolve_time_bucket_label(self, table_name: str, column_name: str) -> str: + if not self.time_bucket: + return f"{table_name}_{column_name}" + return f"{table_name}_{column_name}_{self.time_bucket}" + + def _apply_time_bucket(self, column): + if not self.time_bucket: + return column + + bucket = self.time_bucket + if bucket in {'minute', 'hour', 'day', 'week', 'month', 'year'}: + return func.date_trunc(bucket, column) + + if bucket in {'5minute', '5m'}: + minute_part = cast(func.date_part('minute', column), Integer) + remainder = cast(minute_part % 5, Integer) + return func.date_trunc('minute', column) - func.make_interval(0, 0, 0, 0, 0, remainder) + + if bucket in {'15minute', '15m'}: + minute_part = cast(func.date_part('minute', column), Integer) + remainder = cast(minute_part % 15, Integer) + return func.date_trunc('minute', column) - func.make_interval(0, 0, 0, 0, 0, remainder) + + return func.date_trunc(bucket, column) + + +def execute_widget(definition: Dict[str, Any], timeframe: Tuple[Optional[datetime], Optional[datetime]]) -> WidgetQueryResult: + executor = WidgetQueryExecutor(definition) + return executor.execute(timeframe) + + +def format_widget_payload( + result: WidgetQueryResult, + definition: Optional[Dict[str, Any]] = None, + timeframe: Optional[Tuple[Optional[datetime], Optional[datetime]]] = None +) -> Dict[str, Any]: + definition = definition or {} + options = definition.get('options') or {} + display_mode = _normalize_display_mode(options.get('display') or options.get('display_mode')) + chart_type_raw = (result.chart_type or '').lower() + chart_type = 'line' if chart_type_raw == 'timechart' else chart_type_raw + time_bucket = (definition.get('time_bucket') or '').strip().lower() + time_column_spec = options.get('time_column') or 'alerts.alert_creation_time' + expected_time_alias = _compute_time_alias(time_column_spec, time_bucket) + + timeframe_start: Optional[datetime] = None + timeframe_end: Optional[datetime] = None + if isinstance(timeframe, tuple) and len(timeframe) == 2: + timeframe_start, timeframe_end = timeframe + + if chart_type in {'bar', 'line', 'pie'}: + group_labels = list(result.group_labels) + value_labels = list(result.value_labels) + + effective_group_labels = group_labels[:] + time_axis_enabled = False + if expected_time_alias and expected_time_alias in effective_group_labels: + time_axis_enabled = True + effective_group_labels = [expected_time_alias] + [label for label in effective_group_labels if label != expected_time_alias] + + primary_group_label = effective_group_labels[0] if effective_group_labels else None + secondary_group_labels = effective_group_labels[1:] if len(effective_group_labels) > 1 else [] + + label_entries: List[Dict[str, Any]] = [] + label_index: Dict[str, int] = {} + + def remember_label(raw_value: Any) -> str: + key = _canonical_label_key(raw_value) + display_value = _format_time_group_value(raw_value, time_bucket) if time_axis_enabled else _format_group_value(raw_value) + if key not in label_index: + label_index[key] = len(label_entries) + label_entries.append({'key': key, 'raw': raw_value, 'display': display_value}) + return key + + axis_metadata: Optional[Dict[str, Any]] = None + + if ( + time_axis_enabled + and time_bucket + and timeframe_start is not None + and timeframe_end is not None + ): + prefilled_buckets = _generate_time_bucket_range(timeframe_start, timeframe_end, time_bucket) + for bucket_value in prefilled_buckets: + remember_label(bucket_value) + + if chart_type != 'pie' and len(effective_group_labels) > 1 and primary_group_label is not None: + dataset_map: Dict[Tuple[str, Tuple[str, ...]], Dict[str, Any]] = {} + totals: Dict[str, Optional[float]] = {label: 0.0 for label in value_labels} + counts: Dict[str, int] = {label: 0 for label in value_labels} + + for row in result.rows: + primary_raw = row.get(primary_group_label) + label_key = remember_label(primary_raw) + series_values = tuple( + _format_group_value(row.get(series_label)) + for series_label in secondary_group_labels + ) + + for value_label in value_labels: + dataset_key = (value_label, series_values) + entry = dataset_map.setdefault(dataset_key, { + 'value_label': value_label, + 'series_values': series_values, + 'data': {}, + 'has_values': False + }) + + value = row.get(value_label) + numeric_value = _ensure_numeric(value) + if numeric_value is not None: + entry['has_values'] = True + entry['data'][label_key] = numeric_value + totals[value_label] = (totals.get(value_label) or 0.0) + numeric_value + counts[value_label] = counts.get(value_label, 0) + 1 + else: + entry['data'].setdefault(label_key, 0) + + if not dataset_map: + for value_label in value_labels: + dataset_map[(value_label, tuple())] = { + 'value_label': value_label, + 'series_values': tuple(), + 'data': {}, + 'has_values': False + } + + for value_label, count in counts.items(): + if count == 0: + totals[value_label] = None + + if time_axis_enabled: + label_entries.sort(key=lambda item: (_time_sort_key(item['raw']), item['display'])) + axis_metadata = { + 'type': 'time', + 'bucket': time_bucket, + 'display_labels': [entry['display'] for entry in label_entries] + } + if timeframe_start is not None and timeframe_end is not None: + range_start = timeframe_start + range_end = timeframe_end + if time_bucket: + range_start = _floor_datetime_to_bucket(timeframe_start, time_bucket) + end_floor = _floor_datetime_to_bucket(timeframe_end, time_bucket) + next_end = _advance_datetime_by_bucket(end_floor, time_bucket) + range_end = next_end if next_end > end_floor else end_floor + axis_metadata['range'] = { + 'start': range_start.isoformat(), + 'end': range_end.isoformat() + } + + label_order_keys = [entry['key'] for entry in label_entries] + display_labels = [entry['display'] for entry in label_entries] + chart_labels = label_order_keys if time_axis_enabled else display_labels + + formatted_datasets: List[Dict[str, Any]] = [] + for dataset_key, entry in dataset_map.items(): + data_points = [entry['data'].get(label_key, 0) for label_key in label_order_keys] + dataset_total = sum(entry['data'].values()) if entry.get('has_values') else None + series_components = [component for component in entry['series_values'] if component] + base_label = _capitalize_label(entry['value_label']) + if series_components: + dataset_label = f"{base_label} – {' / '.join(series_components)}" + else: + dataset_label = base_label + series_key_str = '::'.join(entry['series_values']) + value_key = entry['value_label'] if not series_key_str else f"{entry['value_label']}::{series_key_str}" + formatted_datasets.append({ + 'label': dataset_label, + 'value_key': value_key, + 'data': data_points, + 'total': dataset_total, + 'formatted_total': _format_number(dataset_total) + }) + + payload = { + 'labels': chart_labels, + 'datasets': formatted_datasets, + 'value_keys': list(result.value_labels), + 'display_mode': display_mode, + 'totals': {key: totals.get(key) for key in value_labels}, + 'formatted_totals': {key: _format_number(totals.get(key)) for key in value_labels} + } + if axis_metadata: + payload['axis'] = axis_metadata + if time_axis_enabled: + payload['display_labels'] = display_labels + return payload + + totals: Dict[str, Optional[float]] = {label: 0.0 for label in value_labels} + counts: Dict[str, int] = {label: 0 for label in value_labels} + dataset_values: Dict[str, Dict[str, float]] = {label: {} for label in value_labels} + + for row in result.rows: + primary_raw = row.get(primary_group_label) if primary_group_label else None + label_key = remember_label(primary_raw) + for value_label in value_labels: + value = row.get(value_label) + numeric_value = _ensure_numeric(value) + if numeric_value is not None: + dataset_values.setdefault(value_label, {})[label_key] = numeric_value + totals[value_label] = (totals.get(value_label) or 0.0) + numeric_value + counts[value_label] = counts.get(value_label, 0) + 1 + else: + dataset_values.setdefault(value_label, {}).setdefault(label_key, 0) + + for value_label, count in counts.items(): + if count == 0: + totals[value_label] = None + + if time_axis_enabled: + label_entries.sort(key=lambda item: (_time_sort_key(item['raw']), item['display'])) + axis_metadata = { + 'type': 'time', + 'bucket': time_bucket, + 'display_labels': [entry['display'] for entry in label_entries] + } + if timeframe_start is not None and timeframe_end is not None: + range_start = timeframe_start + range_end = timeframe_end + if time_bucket: + range_start = _floor_datetime_to_bucket(timeframe_start, time_bucket) + end_floor = _floor_datetime_to_bucket(timeframe_end, time_bucket) + next_end = _advance_datetime_by_bucket(end_floor, time_bucket) + range_end = next_end if next_end > end_floor else end_floor + axis_metadata['range'] = { + 'start': range_start.isoformat(), + 'end': range_end.isoformat() + } + + label_order_keys = [entry['key'] for entry in label_entries] + display_labels = [entry['display'] for entry in label_entries] + chart_labels = label_order_keys if time_axis_enabled else display_labels + + formatted_datasets: List[Dict[str, Any]] = [] + for value_label in value_labels: + values_dict = dataset_values.get(value_label, {}) + values = [values_dict.get(label_key, 0) for label_key in label_order_keys] + formatted_datasets.append({ + 'label': _capitalize_label(value_label), + 'value_key': value_label, + 'data': values, + 'total': totals.get(value_label), + 'formatted_total': _format_number(totals.get(value_label)) + }) + + payload = { + 'labels': chart_labels, + 'datasets': formatted_datasets, + 'value_keys': list(result.value_labels), + 'display_mode': display_mode, + 'totals': {key: totals.get(key) for key in value_labels}, + 'formatted_totals': {key: _format_number(totals.get(key)) for key in value_labels} + } + if axis_metadata: + payload['axis'] = axis_metadata + if time_axis_enabled: + payload['display_labels'] = display_labels + return payload + + if chart_type in {'number', 'percentage'}: + numerator_key = options.get('percentage_numerator') or options.get('percentageNumerator') + denominator_key = options.get('percentage_denominator') or options.get('percentageDenominator') + label_override = options.get('percentage_label') or options.get('percentageLabel') + + value_label = result.value_labels[0] if result.value_labels else None + value = None + numerator_raw = None + denominator_raw = None + + if numerator_key and result.rows: + numerator_raw = result.rows[0].get(numerator_key) + if denominator_key and result.rows: + denominator_raw = result.rows[0].get(denominator_key) + + if numerator_key and denominator_key: + numerator_value = _ensure_numeric(numerator_raw) + denominator_value = _ensure_numeric(denominator_raw) + if numerator_value is not None and denominator_value not in (None, 0): + value = (numerator_value / denominator_value) * 100.0 + else: + value = None + value_label = numerator_key + else: + if result.rows: + value = result.rows[0].get(value_label) if value_label else None + + numeric_value = _ensure_numeric(value) + formatted_value = _format_number(value) + formatted_percentage = _format_percentage(numeric_value if numeric_value is not None else None) + + label_text = label_override or (_capitalize_label(value_label) if value_label else '') + + payload = { + 'value': value, + 'label': label_text, + 'rows': result.rows, + 'display_mode': display_mode, + 'formatted_value': formatted_value, + 'formatted_percentage': formatted_percentage + } + + if numerator_key and denominator_key: + payload['source_values'] = { + 'numerator': numerator_raw, + 'denominator': denominator_raw, + 'numerator_key': numerator_key, + 'denominator_key': denominator_key + } + + if chart_type == 'percentage': + payload['formatted_value'] = formatted_percentage + + return payload + + if chart_type == 'table': + select_labels = list(result.select_labels) if result.select_labels else [] + group_keys: List[str] = [] + value_keys: List[str] = [] + group_label_set = set(result.group_labels) + + for label in select_labels: + if label in group_label_set and label not in group_keys: + group_keys.append(label) + elif label not in value_keys: + value_keys.append(label) + + for label in result.group_labels: + if label not in group_keys: + group_keys.append(label) + + for label in result.value_labels: + if label not in group_label_set and label not in value_keys: + value_keys.append(label) + + if not value_keys and result.rows: + for key in result.rows[0].keys(): + if key not in group_keys and key not in value_keys: + value_keys.append(key) + + totals: Dict[str, Optional[float]] = {key: 0.0 for key in value_keys} + counts: Dict[str, int] = {key: 0 for key in value_keys} + + for row in result.rows: + for key in value_keys: + numeric_value = _ensure_numeric(row.get(key)) + if numeric_value is not None: + totals[key] = (totals.get(key) or 0.0) + numeric_value + counts[key] = counts.get(key, 0) + 1 + + for key, count in counts.items(): + if count == 0: + totals[key] = None + + table_rows: List[Dict[str, Any]] = [] + for row in result.rows: + group_values = [row.get(key) for key in group_keys] + formatted_group_values = [_format_group_value(row.get(key)) for key in group_keys] + value_cells = [] + for key in value_keys: + raw_value = row.get(key) + numeric_value = _ensure_numeric(raw_value) + total_value = totals.get(key) + percentage_value: Optional[float] = None + if numeric_value is not None and total_value not in (None, 0): + percentage_value = (numeric_value / total_value) * 100 + elif numeric_value is not None and total_value == 0: + percentage_value = 0.0 + + value_cells.append({ + 'key': key, + 'value': raw_value, + 'formatted_value': _format_number(raw_value), + 'percentage': percentage_value, + 'formatted_percentage': _format_percentage(percentage_value) + if percentage_value is not None else '--' + }) + + table_rows.append({ + 'group_values': group_values, + 'formatted_group_values': formatted_group_values, + 'value_cells': value_cells + }) + + totals_entries = [] + for key in value_keys: + total_value = totals.get(key) + if total_value is None and counts.get(key, 0) == 0: + formatted_percentage = '--' + percentage_value = None + elif total_value == 0: + percentage_value = 0.0 + formatted_percentage = _format_percentage(0.0) + else: + percentage_value = 100.0 if total_value is not None else None + formatted_percentage = _format_percentage(percentage_value) if percentage_value is not None else '--' + + totals_entries.append({ + 'key': key, + 'value': total_value, + 'formatted_value': _format_number(total_value), + 'percentage': percentage_value, + 'formatted_percentage': formatted_percentage + }) + + return { + 'display_mode': display_mode, + 'group_headers': [_capitalize_label(key) for key in group_keys], + 'value_headers': [_capitalize_label(key) for key in value_keys], + 'group_keys': group_keys, + 'value_keys': value_keys, + 'rows': table_rows, + 'totals': totals_entries, + 'total_label': options.get('total_label') or 'Total', + 'source_rows': result.rows + } + + return { + 'rows': result.rows, + 'group_labels': result.group_labels, + 'value_labels': result.value_labels + } diff --git a/source/app/datamgmt/custom_dashboard/schema.py b/source/app/datamgmt/custom_dashboard/schema.py new file mode 100644 index 000000000..e305acad3 --- /dev/null +++ b/source/app/datamgmt/custom_dashboard/schema.py @@ -0,0 +1,72 @@ +from marshmallow import EXCLUDE, Schema, fields as ma_fields, validate, validates_schema, ValidationError + + +class DashboardFilterSchema(Schema): + table = ma_fields.String(required=True) + column = ma_fields.String(required=True) + operator = ma_fields.String(required=True, validate=validate.OneOf(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'between', 'contains'])) + value = ma_fields.Raw(required=True) + + +class DashboardWidgetFieldSchema(Schema): + table = ma_fields.String(required=True) + column = ma_fields.String(required=True) + aggregation = ma_fields.String( + required=False, + allow_none=True, + validate=validate.OneOf(['count', 'sum', 'avg', 'min', 'max', 'ratio']) + ) + alias = ma_fields.String(required=False, allow_none=True) + filter = ma_fields.Nested(DashboardFilterSchema, required=False) + + +class DashboardWidgetSchema(Schema): + class Meta: + unknown = EXCLUDE + + name = ma_fields.String(required=True) + chart_type = ma_fields.String( + required=True, + validate=validate.OneOf(['line', 'bar', 'pie', 'number', 'percentage', 'table', 'timechart']) + ) + fields = ma_fields.List(ma_fields.Nested(DashboardWidgetFieldSchema), required=True, validate=validate.Length(min=1)) + filters = ma_fields.List(ma_fields.Nested(DashboardFilterSchema), required=False) + group_by = ma_fields.List(ma_fields.String(), required=False) + time_bucket = ma_fields.String(required=False, allow_none=True) + options = ma_fields.Dict(required=False) + layout = ma_fields.Dict(required=False) + + +class DashboardSectionSchema(Schema): + class Meta: + unknown = EXCLUDE + + id = ma_fields.String(required=False) + title = ma_fields.String(required=False, allow_none=True) + description = ma_fields.String(required=False, allow_none=True) + show_divider = ma_fields.Boolean(required=False) + widgets = ma_fields.List(ma_fields.Nested(DashboardWidgetSchema), required=True, validate=validate.Length(min=1)) + + +class CustomDashboardSchema(Schema): + class Meta: + unknown = EXCLUDE + + name = ma_fields.String(required=True) + description = ma_fields.String(allow_none=True) + is_shared = ma_fields.Boolean(required=False) + is_system = ma_fields.Boolean(required=False, dump_only=True) + widgets = ma_fields.List(ma_fields.Nested(DashboardWidgetSchema), required=False) + sections = ma_fields.List(ma_fields.Nested(DashboardSectionSchema), required=False) + filters_schema = ma_fields.List(ma_fields.Dict(), required=False) + + @validates_schema + def validate_widget_structure(self, data, **kwargs): + widgets = data.get('widgets') or [] + sections = data.get('sections') or [] + + section_widget_count = sum(len(section.get('widgets') or []) for section in sections) + total_widgets = len(widgets) + section_widget_count + + if total_widgets <= 0: + raise ValidationError('At least one widget must be defined.', field_name='widgets') diff --git a/source/app/datamgmt/datastore/datastore_db.py b/source/app/datamgmt/datastore/datastore_db.py index 9f25cbf28..aa4007508 100644 --- a/source/app/datamgmt/datastore/datastore_db.py +++ b/source/app/datamgmt/datastore/datastore_db.py @@ -19,12 +19,15 @@ # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. import datetime +import logging from pathlib import Path from sqlalchemy import and_ from sqlalchemy import func from app import app + +log = logging.getLogger(__name__) from app.datamgmt.db_operations import db_create from app.datamgmt.db_operations import db_delete from app.db import db @@ -334,7 +337,21 @@ def datastore_delete_file(cur_id, cid): fln = Path(dsf.file_local_name) if fln.is_file(): - fln.unlink(missing_ok=True) + # The on-disk path can be anything the row's file_local_name column + # points to. Before unlinking, confirm it actually lives under + # DATASTORE_PATH — refuse otherwise, even though the dsf row was + # legitimately resolved through (file_id, case_id). Guards against + # an attacker who managed to seed a file_local_name pointing + # outside the datastore (GHSA-qhqj-8qw6-wp8v / CWE-22). + datastore_path = Path(app.config['DATASTORE_PATH']).resolve() + file_path = fln.resolve() + if datastore_path in file_path.parents or datastore_path == file_path: + fln.unlink(missing_ok=True) + else: + log.warning( + f'File {file_path} physically not deleted — ' + f'attempted deletion outside datastore directory.' + ) db_delete(dsf) @@ -395,6 +412,19 @@ def datastore_get_local_file_path(file_id, caseid): if dsf is None: return True, 'Invalid DS file ID for this case' + # Defense in depth: confirm the recorded on-disk path is inside the + # datastore root before handing it back to send_file. Guards against an + # attacker who managed to seed file_local_name pointing outside the + # datastore (GHSA-qhqj-8qw6-wp8v / CWE-22). + datastore_path = Path(app.config['DATASTORE_PATH']).resolve() + file_path = Path(dsf.file_local_name).resolve() + if datastore_path not in file_path.parents and datastore_path != file_path: + log.warning( + f'File {file_path} not found in datastore — ' + f'attempted access outside datastore directory.' + ) + return True, '' + return False, dsf diff --git a/source/app/datamgmt/filtering.py b/source/app/datamgmt/filtering.py index 81fbf4b64..63f67d1d8 100644 --- a/source/app/datamgmt/filtering.py +++ b/source/app/datamgmt/filtering.py @@ -17,7 +17,8 @@ # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. import json -from sqlalchemy import String, Text, inspect, or_, not_, and_ +from sqlalchemy import JSON, String, Text, inspect, or_, not_, and_ +from sqlalchemy.dialects.postgresql import JSONB from app import app from app.models.errors import BusinessProcessingError @@ -70,7 +71,7 @@ def build_condition(column, operator, value): raise ValueError( "Non-in operators on relationships require specifying a related model column, e.g., owner.id or assets.asset_name." ) - if operator == 'not': + if operator == 'not' or operator == 'neq': return column != value if operator == 'in': return column.in_(value) @@ -80,6 +81,8 @@ def build_condition(column, operator, value): return column == value if operator == 'like': return column.ilike(f"%{value}%") + if operator == 'not_like': + return ~column.ilike(f"%{value}%") raise ValueError(f"Unsupported operator: {operator}") @@ -104,12 +107,18 @@ def apply_custom_conditions(query, model, custom_conditions, relationship_model_ """ Apply custom conditions to the query. - The custom_conditions parameter should be a list of dict objects with the following keys: - - 'field': a field name (or a relationship.field using dot notation) - - 'operator': the operator (e.g., 'eq', 'like', 'in', etc.) - - 'value': the value to compare against + Each item in `custom_conditions` is either: + * A **leaf** — dict with `field` / `operator` / `value`. + * A **group** — dict with `logic` ('and'/'or'/'not') and its own + `conditions` list, recursively. Nested groups let callers build + arbitrary AND/OR trees (e.g. `(A and B) or (C and D)`) — the + rule / flow condition builders on the frontend rely on this. - An optional relationship_model_map can be provided to map relationship names to models. + Existing callers that pass a flat list of leaves continue to work + unchanged; the recursion only kicks in when an item omits `field`. + + An optional `relationship_model_map` maps relationship names to + models, used for dot-notation fields like `assets.asset_name`. """ conditions = [] if relationship_model_map is None: @@ -118,21 +127,56 @@ def apply_custom_conditions(query, model, custom_conditions, relationship_model_ joined_relationships = set() for cond in custom_conditions: + if not isinstance(cond, dict): + raise ValueError(f'condition entry must be a dict, got {type(cond).__name__}') + + # Group form: recurse and combine. + if 'field' not in cond and 'conditions' in cond: + inner_logic = cond.get('logic', 'and') + inner_items = cond.get('conditions') or [] + query, inner_leaves = apply_custom_conditions( + query, model, inner_items, relationship_model_map + ) + combined = combine_conditions(inner_leaves, inner_logic) + if combined is not None: + conditions.append(combined) + continue + + # Leaf form. field_path = cond.get('field') operator = cond.get('operator') value = cond.get('value') if '.' in field_path: - # Handle related fields via dot notation - relationship_name, related_field_name = field_path.split('.', 1) - if relationship_name not in relationship_model_map: - raise ValueError(f"Unknown relationship: {relationship_name}") - related_model = relationship_model_map[relationship_name] - # Join the relationship if not already joined - if relationship_name not in joined_relationships: - query = query.join(getattr(model, relationship_name)) - joined_relationships.add(relationship_name) - - related_field = get_field_from_model(related_model, related_field_name) + head, tail = field_path.split('.', 1) + # A dotted path can mean two things: + # (a) a relationship path like `assets.asset_name` — join + # the related model and treat the tail as a column + # name on that model + # (b) a JSON(B) column path like `alert_context.foo.bar` — + # the head is a JSON column on this model and the + # tail(s) drill into the document + # We prefer (b) when the head *is* a column on this model + # AND its type is JSON/JSONB. Otherwise we fall back to (a). + head_attr = getattr(model, head, None) + head_is_json_column = ( + head_attr is not None + and hasattr(head_attr, 'type') + and isinstance(head_attr.type, (JSON, JSONB)) + ) + + if head_is_json_column: + condition = build_json_condition(head_attr, tail, operator, value) + conditions.append(condition) + continue + + if head not in relationship_model_map: + raise ValueError(f"Unknown relationship or JSON column: {head}") + related_model = relationship_model_map[head] + if head not in joined_relationships: + query = query.join(getattr(model, head)) + joined_relationships.add(head) + + related_field = get_field_from_model(related_model, tail) condition = build_condition(related_field, operator, value) conditions.append(condition) @@ -145,6 +189,57 @@ def apply_custom_conditions(query, model, custom_conditions, relationship_model_ return query, conditions +def build_json_condition(json_column, path, operator, value): + """Build a SQLAlchemy condition against a nested JSON(B) path. + + `path` is a dot-separated string like `foo.bar.baz`; each segment + becomes a key traversal step (Postgres `->` for intermediate + JSON-returning steps and `->>` (`astext`) for the final compare so + the RHS is a text scalar that composes with `ilike`/`in`/`==`). + + Array indices work implicitly — Postgres accepts numeric-looking + strings as array indices via `->`. We keep the dotted spelling + (`items.0.value`) rather than invent a bracket syntax, so rule + authors don't have to know two spellings. + + Numeric comparisons (`gte`/`lte`) cast the extracted text to + numeric first; unsupported operators bubble up from build_condition + via a shared path. + """ + segments = path.split('.') + if not segments: + raise ValueError("JSON path must not be empty") + + expr = json_column + for segment in segments[:-1]: + expr = expr[segment] + # Final step: extract as text so the comparison RHS is a string + # and existing operator handling (`ilike`, `in_`, `==`) applies + # without further coercion. Postgres' `->>` operator returns text. + final = expr[segments[-1]].astext + + if operator in ('gte', 'lte'): + # Cast to numeric on the fly so `alert_context.count >= 10` + # compares as a number. Text-wise `'2' > '10'` would be true + # otherwise (lexicographic). + from sqlalchemy import cast, Numeric + casted = cast(final, Numeric) + return casted >= value if operator == 'gte' else casted <= value + if operator in ('not', 'neq'): + return final != value + if operator == 'in': + return final.in_(value) + if operator == 'not_in': + return ~final.in_(value) + if operator == 'eq': + return final == value + if operator == 'like': + return final.ilike(f"%{value}%") + if operator == 'not_like': + return ~final.ilike(f"%{value}%") + raise ValueError(f"Unsupported operator for JSON path: {operator}") + + def get_field_from_model(model, field_path): """ Return the field from the given model. diff --git a/source/app/datamgmt/manage/manage_cases_db.py b/source/app/datamgmt/manage/manage_cases_db.py index 0c673836c..d5b027544 100644 --- a/source/app/datamgmt/manage/manage_cases_db.py +++ b/source/app/datamgmt/manage/manage_cases_db.py @@ -182,6 +182,32 @@ def user_list_cases_view(user_id): return [r.case_id for r in res] +def search_case_summaries(search_value, accessible_case_ids=None): + # Same shape as the per-type search helpers in datamgmt/case/*_db.py: + # accept a `%`-wildcarded value + optional access-scope list, return a + # list of `_asdict()` row dicts the global search endpoint annotates + # with a `type` discriminator. + if accessible_case_ids is not None and not accessible_case_ids: + return [] + + scope_filter = Cases.case_id.in_(accessible_case_ids) if accessible_case_ids is not None else and_() + + rows = Cases.query.with_entities( + Cases.case_id, + Cases.name.label('case_name'), + Cases.description.label('summary_excerpt'), + Client.name.label('customer_name') + ).filter( + and_( + Cases.description.ilike(f'%{search_value}%'), + Cases.client_id == Client.client_id, + scope_filter + ) + ).order_by(Client.name, Cases.case_id).all() + + return [row._asdict() for row in rows] + + def close_case(case_id): res = Cases.query.filter( Cases.case_id == case_id @@ -318,24 +344,62 @@ def get_case_details_rt(case_id): def _delete_iocs(case_identifier): - # TODO should do this with the 2.0 SQLAlchemy API - # TODO maybe this can be performed automatically with cascades - com_ids = IocComments.query.with_entities( - IocComments.comment_id - ).join( - Ioc - ).filter( - IocComments.comment_ioc_id == Ioc.ioc_id, - Ioc.case_id == case_identifier - ).all() - - com_ids = [c.comment_id for c in com_ids] - IocComments.query.filter(IocComments.comment_id.in_(com_ids)).delete() - - Comments.query.filter( - Comments.comment_id.in_(com_ids) - ).delete() - Ioc.query.filter(Ioc.case_id == case_identifier).delete() + # IoCs are shared between cases and alerts via `alert_iocs_association`. + # If we bulk-delete every IoC with `case_id = X` we hit the FK + # constraint as soon as one of them is still referenced from an + # alert (legitimate state: the IoC came in via an alert that wasn't + # merged into this case, or the case is being deleted but the alert + # survives). Mirror the partitioning `_delete_assets` already does: + # - IoCs not referenced by any alert → fully delete (with their + # comments and link tables); + # - IoCs still referenced by an alert → detach by clearing + # `case_id` so the case-level FK no longer pins them, but the + # alert-side association stays intact. + from app.models.iocs import alert_iocs_association + + referenced_subq = db.session.query(alert_iocs_association.c.ioc_id).filter( + alert_iocs_association.c.ioc_id == Ioc.ioc_id + ).exists() + + deletable_ids = [ + row.ioc_id + for row in db.session.query(Ioc.ioc_id).filter( + Ioc.case_id == case_identifier, + ~referenced_subq, + ).all() + ] + + if deletable_ids: + com_ids = [ + c.comment_id + for c in IocComments.query.with_entities(IocComments.comment_id).filter( + IocComments.comment_ioc_id.in_(deletable_ids) + ).all() + ] + if com_ids: + IocComments.query.filter(IocComments.comment_id.in_(com_ids)).delete( + synchronize_session=False) + Comments.query.filter(Comments.comment_id.in_(com_ids)).delete( + synchronize_session=False) + + # IocAssetLink + CaseEventsIoc rows for these IoCs are scoped to + # this case (link tables don't survive the case anyway), but + # CaseEventsIoc filtering at the caller level only covers the + # `case_id` column. Belt-and-braces: clear them by ioc_id too so + # we never hit an "ioc still referenced" FK from a stray link. + IocAssetLink.query.filter(IocAssetLink.ioc_id.in_(deletable_ids)).delete( + synchronize_session=False) + CaseEventsIoc.query.filter(CaseEventsIoc.ioc_id.in_(deletable_ids)).delete( + synchronize_session=False) + + Ioc.query.filter(Ioc.ioc_id.in_(deletable_ids)).delete( + synchronize_session=False) + + # Anything left behind belonged to alerts too — detach from the case + # so the next `Cases.query.filter(...).delete()` doesn't fail on + # `ioc.case_id → cases.case_id`. + Ioc.query.filter(Ioc.case_id == case_identifier).update( + {Ioc.case_id: None}, synchronize_session=False) def _delete_assets(case_identifier): @@ -479,14 +543,33 @@ def build_filter_case_query(current_user_id, search_value=None, sort_by=None, sort_dir='asc', - is_open: bool=None + is_open: bool=None, + quick_search: str=None, + start_close_date: str = None, + end_close_date: str = None, ): """ Get a list of cases from the database, filtered by the given parameters """ conditions = [] - if start_open_date is not None and end_open_date is not None: - conditions.append(Cases.open_date.between(start_open_date, end_open_date)) + # Open-date window. We accept each bound independently — the legacy + # behaviour required both to be set together, which forced callers + # to fake one end of the range when they only had one side. With + # `>=` / `<=` you can give just "opened since 2026-01-01" without + # also having to pick a closing bound. + if start_open_date is not None: + conditions.append(Cases.open_date >= start_open_date) + if end_open_date is not None: + conditions.append(Cases.open_date <= end_open_date) + + # Close-date window — same independent-bound semantics. Rows with + # `close_date IS NULL` (still-open cases) drop out naturally on any + # bound because NULL comparisons fail; that's the right behaviour + # for a "filter by close date" use case. + if start_close_date is not None: + conditions.append(Cases.close_date >= start_close_date) + if end_close_date is not None: + conditions.append(Cases.close_date <= end_close_date) if case_customer_id is not None: conditions.append(Cases.client_id == case_customer_id) @@ -521,6 +604,15 @@ def build_filter_case_query(current_user_id, if search_value is not None: conditions.append(Cases.name.like(f"%{search_value}%")) + quick_search_term = quick_search.strip() if isinstance(quick_search, str) else None + if quick_search_term: + # Case IDs are prefixed into the title at creation time (e.g. "#42 - Foo"), + # so an ILIKE on Cases.name already catches numeric matches via the prefix. + conditions.append(or_( + Cases.name.ilike(f"%{quick_search_term}%"), + Client.name.ilike(f"%{quick_search_term}%") + )) + if case_open_since is not None: result = date.today() - timedelta(case_open_since) conditions.append(Cases.open_date == result) @@ -535,7 +627,13 @@ def build_filter_case_query(current_user_id, if len(conditions) > 1: conditions = [reduce(and_, conditions)] conditions.append(Cases.case_id.in_(user_list_cases_view(current_user_id))) - query = Cases.query.filter(*conditions) + base_query = Cases.query + # quick_search references Client.name, so make sure the table is joined + # before the filter is applied. Use an outer join so cases without a + # customer are still considered for the name/id match. + if quick_search_term: + base_query = base_query.outerjoin(Client, Cases.client_id == Client.client_id) + query = base_query.filter(*conditions) if case_tags is not None: return query.join(Tags, Tags.tag_title.ilike(f'%{case_tags}%')).filter(CaseTags.case_id == Cases.case_id) @@ -550,7 +648,11 @@ def build_filter_case_query(current_user_id, query = query.join(User, Cases.user_id == User.id).order_by(order_func(User.name)) elif sort_by == 'customer_name': - query = query.join(Client, Cases.client_id == Client.client_id).order_by(order_func(Client.name)) + if quick_search_term: + # Client is already joined via the quick_search outer join; just order by it. + query = query.order_by(order_func(Client.name)) + else: + query = query.join(Client, Cases.client_id == Client.client_id).order_by(order_func(Client.name)) elif sort_by == 'state': query = query.join(CaseState, Cases.state_id == CaseState.state_id).order_by(order_func(CaseState.state_name)) @@ -578,7 +680,10 @@ def get_filtered_cases(current_user_id, search_value: str | None = None, is_open: bool | None = None, advanced_filters: list[dict[str, Any]] | None = None, - advanced_logic: str = 'and' + advanced_logic: str = 'and', + quick_search: str | None = None, + start_close_date: str | None = None, + end_close_date: str | None = None, ): kwargs: dict[str, Any] = { 'current_user_id': current_user_id, @@ -591,6 +696,11 @@ def get_filtered_cases(current_user_id, if end_open_date is not None: kwargs['end_open_date'] = end_open_date + if start_close_date is not None: + kwargs['start_close_date'] = start_close_date + if end_close_date is not None: + kwargs['end_close_date'] = end_close_date + if case_customer_id is not None: kwargs['case_customer_id'] = case_customer_id @@ -627,87 +737,177 @@ def get_filtered_cases(current_user_id, if search_value is not None: kwargs['search_value'] = search_value + if quick_search is not None: + kwargs['quick_search'] = quick_search + if is_open is not None: kwargs['is_open'] = is_open query = build_filter_case_query(**kwargs) if advanced_filters: - adv_conditions = [] - joined_client = False - joined_state = False - joined_owner = False - - for f in advanced_filters: - field_id = f.get('fieldId') - operation = f.get('operation') - value = f.get('value', '') - - if not isinstance(field_id, str) or not isinstance(operation, str) or not isinstance(value, str): - continue - - field_expr: Any = None - + # Caller may pass either: + # * a list of condition dicts (legacy) — wrap into a single + # root group whose logic is `advanced_logic`; + # * a group dict `{ logic, items: [|] }` — + # walked recursively. Items can be arbitrarily nested + # groups so the UI can express `(A and B) or (C and D)` + # style queries. + join_state = { + 'client': False, + 'state': False, + 'owner': False, + 'severity': False, + } + + def _field_expr_for(field_id: str): + nonlocal query if field_id == 'title': - field_expr = Cases.name - elif field_id == 'case_id': - field_expr = cast(Cases.case_id, String) - elif field_id == 'outcome': - field_expr = Cases.closing_note - elif field_id == 'open_date': - field_expr = cast(Cases.open_date, String) - elif field_id == 'classification': - field_expr = cast(Cases.classification_id, String) - elif field_id == 'customer': - if not joined_client: + return Cases.name + if field_id == 'case_id': + return cast(Cases.case_id, String) + if field_id == 'outcome': + return Cases.closing_note + if field_id == 'open_date': + return cast(Cases.open_date, String) + if field_id == 'classification': + return cast(Cases.classification_id, String) + if field_id == 'customer': + if not join_state['client']: query = query.join(Client, Cases.client_id == Client.client_id) - joined_client = True - field_expr = Client.name - elif field_id == 'state': - if not joined_state: + join_state['client'] = True + return Client.name + if field_id == 'state': + if not join_state['state']: query = query.join(CaseState, Cases.state_id == CaseState.state_id) - joined_state = True - field_expr = CaseState.state_name - elif field_id == 'owner': - if not joined_owner: + join_state['state'] = True + return CaseState.state_name + if field_id == 'owner': + if not join_state['owner']: query = query.join(User, Cases.owner_id == User.id) - joined_owner = True - field_expr = User.user + join_state['owner'] = True + return User.user + if field_id == 'severity': + # Joined separately from the simple `severity_identifier` + # path above. `outerjoin` so cases without a severity + # still pass through. + if not join_state['severity']: + from app.models.alerts import Severity + query = query.outerjoin(Severity, Cases.severity_id == Severity.severity_id) + join_state['severity'] = True + from app.models.alerts import Severity + return Severity.severity_name + return None - if field_expr is None: - continue + def _tag_condition(op: str, value: str): + # Tags are many-to-many (Cases → CaseTags → Tags), so a plain + # join would multiply case rows in the result set. Use + # EXISTS/NOT EXISTS against the tag title instead — one row + # per case regardless of how many tags match, and the + # semantics of `not_contains` / `empty` come out clean. + base = db.session.query(CaseTags.case_id).join( + Tags, Tags.id == CaseTags.tag_id + ).filter(CaseTags.case_id == Cases.case_id) + + def has(pattern: str): + return base.filter(Tags.tag_title.ilike(pattern)).exists() - op = operation.lower() + def eq(exact: str): + return base.filter(Tags.tag_title == exact).exists() if op == 'empty': - adv_conditions.append(or_(field_expr.is_(None), field_expr == '')) - continue + # No tags at all attached to the case. + return ~db.session.query(CaseTags.case_id).filter( + CaseTags.case_id == Cases.case_id + ).exists() if op == 'not_empty': - adv_conditions.append(and_(field_expr.is_not(None), field_expr != '')) - continue + return db.session.query(CaseTags.case_id).filter( + CaseTags.case_id == Cases.case_id + ).exists() + if op == 'equals': + return eq(value) + if op == 'not': + return ~eq(value) + if op == 'starts_with': + return has(f'{value}%') + if op == 'not_starts_with': + return ~has(f'{value}%') + if op == 'contains': + return has(f'%{value}%') + if op == 'not_contains': + return ~has(f'%{value}%') + if op == 'ends_with': + return has(f'%{value}') + if op == 'not_ends_with': + return ~has(f'%{value}') + return None + def _condition_sql(field_id: str, op: str, value: str): + op = (op or '').lower() + # Tags are multi-valued so they don't fit the scalar + # `field_expr op value` shape the other fields use — hand off + # to the EXISTS-based builder before falling through. + if field_id == 'tags': + return _tag_condition(op, value) + field_expr = _field_expr_for(field_id) + if field_expr is None: + return None + if op == 'empty': + return or_(field_expr.is_(None), field_expr == '') + if op == 'not_empty': + return and_(field_expr.is_not(None), field_expr != '') if op == 'equals': - adv_conditions.append(field_expr == value) - elif op == 'not': - adv_conditions.append(field_expr != value) - elif op == 'starts_with': - adv_conditions.append(field_expr.ilike(f'{value}%')) - elif op == 'not_starts_with': - adv_conditions.append(~field_expr.ilike(f'{value}%')) - elif op == 'contains': - adv_conditions.append(field_expr.ilike(f'%{value}%')) - elif op == 'not_contains': - adv_conditions.append(~field_expr.ilike(f'%{value}%')) - elif op == 'ends_with': - adv_conditions.append(field_expr.ilike(f'%{value}')) - elif op == 'not_ends_with': - adv_conditions.append(~field_expr.ilike(f'%{value}')) - - if adv_conditions: - if (advanced_logic or 'and').lower() == 'or': - query = query.filter(or_(*adv_conditions)) - else: - query = query.filter(and_(*adv_conditions)) + return field_expr == value + if op == 'not': + return field_expr != value + if op == 'starts_with': + return field_expr.ilike(f'{value}%') + if op == 'not_starts_with': + return ~field_expr.ilike(f'{value}%') + if op == 'contains': + return field_expr.ilike(f'%{value}%') + if op == 'not_contains': + return ~field_expr.ilike(f'%{value}%') + if op == 'ends_with': + return field_expr.ilike(f'%{value}') + if op == 'not_ends_with': + return ~field_expr.ilike(f'%{value}') + return None + + def _walk_group(node: dict) -> Any: + items = node.get('items') or [] + group_logic = (node.get('logic') or 'and').lower() + parts: list[Any] = [] + for item in items: + if not isinstance(item, dict): + continue + if 'items' in item: + sub = _walk_group(item) + if sub is not None: + parts.append(sub) + else: + cond = _condition_sql( + item.get('fieldId') or '', + item.get('operation') or '', + item.get('value', '') if isinstance(item.get('value'), str) else '' + ) + if cond is not None: + parts.append(cond) + if not parts: + return None + return or_(*parts) if group_logic == 'or' else and_(*parts) + + if isinstance(advanced_filters, list): + root_group = {'logic': (advanced_logic or 'and').lower(), 'items': advanced_filters} + elif isinstance(advanced_filters, dict): + root_group = advanced_filters + else: + root_group = None + + if root_group is not None: + sql = _walk_group(root_group) + if sql is not None: + query = query.filter(sql) return query.paginate(page=pagination_parameters.get_page(), per_page=pagination_parameters.get_per_page(), diff --git a/source/app/datamgmt/reporter/report_db.py b/source/app/datamgmt/reporter/report_db.py index eba14c476..9efe6f3de 100644 --- a/source/app/datamgmt/reporter/report_db.py +++ b/source/app/datamgmt/reporter/report_db.py @@ -103,9 +103,9 @@ def _docx_rewrite_datastore_image(match): url = 'http://127.0.0.1:8000' + match.group('url') pct = _docx_width_percent(match.group('size')) if pct is not None: - url += '&iriswidth={}'.format(pct) + url += f'&iriswidth={pct}' title = match.group('title') - title_part = ' {}'.format(title) if title else '' + title_part = f' {title}' if title else '' return '![{}]({}{})'.format(match.group('alt'), url, title_part) diff --git a/source/app/iris_engine/access_control/oidc_handler.py b/source/app/iris_engine/access_control/oidc_handler.py index c271792ab..9244d3663 100644 --- a/source/app/iris_engine/access_control/oidc_handler.py +++ b/source/app/iris_engine/access_control/oidc_handler.py @@ -25,14 +25,23 @@ def get_oidc_client(config, logger) -> Client: client = Client(client_authn_method=CLIENT_AUTHN_METHOD) - # retrieve provider configuration dynamically from metadata - # or fall back to env vars + issuer = config.get("OIDC_ISSUER_URL") + + # Try dynamic discovery first. If the well-known endpoint is + # reachable and well-formed, every endpoint URL (authorization, + # token, userinfo, end-session, ...) is populated on the client. + discovery_error = None try: - client.provider_config(config.get("OIDC_ISSUER_URL")) + client.provider_config(issuer) except Exception as e: - logger.warning(f"Could not read OIDC metadata, using environment variables - error {e}") + discovery_error = e + logger.warning( + "OIDC discovery failed for issuer %s — falling back to " + "environment variables. Error: %s", issuer, e, + ) + op_info = ProviderConfigurationResponse( - issuer=config.get("OIDC_ISSUER_URL"), + issuer=issuer, authorization_endpoint=config.get("OIDC_AUTH_ENDPOINT"), token_endpoint=config.get("OIDC_TOKEN_ENDPOINT"), end_session_endpoint=config.get("OIDC_END_SESSION_ENDPOINT"), @@ -40,6 +49,26 @@ def get_oidc_client(config, logger) -> Client: client.handle_provider_config(op_info, op_info['issuer']) + # Fail loud at startup rather than letting the request-time code + # blow up later inside oic with the famously opaque + # `argument of type 'NoneType' is not iterable`. If we get here + # with no authorization_endpoint, the operator either hit a + # discovery error AND didn't set OIDC_AUTH_ENDPOINT, or the + # discovery doc itself was missing the field. + if not getattr(client, "authorization_endpoint", None): + msg = ( + "OIDC client could not resolve authorization_endpoint. " + "Check that the IRIS app container can reach " + f"{issuer!r}/.well-known/openid-configuration " + "(set OIDC_ISSUER_URL correctly + verify network + TLS), " + "or set OIDC_AUTH_ENDPOINT / OIDC_TOKEN_ENDPOINT / " + "OIDC_END_SESSION_ENDPOINT in iris-web/.env to bypass " + "discovery." + ) + if discovery_error is not None: + msg += f" Discovery error was: {discovery_error}" + raise RuntimeError(msg) + info = { "client_id": config.get("OIDC_CLIENT_ID"), "client_secret": config.get("OIDC_CLIENT_SECRET") diff --git a/source/app/iris_engine/access_control/utils.py b/source/app/iris_engine/access_control/utils.py index 0ab5a5410..92c92779c 100644 --- a/source/app/iris_engine/access_control/utils.py +++ b/source/app/iris_engine/access_control/utils.py @@ -284,7 +284,6 @@ def ac_trace_effective_user_permissions(user_id): return perms - def ac_recompute_effective_ac_from_users_list(users_list): """ Recompute all users effective access of users @@ -366,11 +365,13 @@ def ac_set_new_case_access(user, case_id, customer_id): add_several_user_effective_access([user.id], case_id, CaseAccessLevel.full_access.value) - # Add customer permissions for all users belonging to the customer + # Add customer permissions for all users belonging to the customer. + # Skip users already added via auto-follow groups — otherwise the + # customer access overwrites the group level. Backport of 2f8107af + # from v2.4.29. if customer_id: users_client = get_user_access_levels_by_customer(customer_id) - # Remove users already added via auto-follow groups - users_client = [u for u in users_client if u.user_id not in users.keys()] + users_client = [u for u in users_client if u.user_id not in users] users_map = {u.user_id: u.access_level for u in users_client} ac_add_user_effective_access_from_map(users_map, case_id) @@ -402,7 +403,6 @@ def set_user_case_access(user, case_id): db.session.commit() - def ac_apply_autofollow_groups_access(case_id): """ Apply a direct effective user access to users within a group @@ -609,6 +609,11 @@ def ac_get_user_cases_access(user_id): for oca in cases: effective_cases_access[oca.case_id] = CaseAccessLevel.deny_all.value + # Precedence (last write wins): default → client → group → user. + # Group access must overwrite client access so admins can give a + # group tighter access (e.g. read-only) to a case under a customer + # the group's members also have customer-level full access to. + # Backport of 2f8107af from v2.4.29. for cca in ccas: effective_cases_access[cca.case_id] = cca.access_level @@ -700,8 +705,11 @@ def ac_trace_user_effective_cases_access_2(user_id): effective_cases_access[oca.case_id]['user_access'].append(access) + # Precedence: client < group < user. Iterate client first so the + # group loop's "Overwritten by group X" marker correctly applies to + # the client row, matching ac_get_user_cases_access (2f8107af). - # Client case access: + # Client case access for cca in ccas: access = { 'state': 'Effective', @@ -747,7 +755,7 @@ def ac_trace_user_effective_cases_access_2(user_id): } if gca.case_id in effective_cases_access: - effective_cases_access[gca.case_id]['user_effective_access'] = gca.access_level + effective_cases_access[gca.case_id]['user_effective_access'] = ac_access_level_to_list(gca.access_level) for kec in effective_cases_access[gca.case_id]['user_access']: kec['state'] = f'Overwritten by group {gca.group_name}' @@ -758,7 +766,7 @@ def ac_trace_user_effective_cases_access_2(user_id): 'case_id': gca.case_id }, 'user_access': [], - 'user_effective_access': gca.access_level + 'user_effective_access': ac_access_level_to_list(gca.access_level) } effective_cases_access[gca.case_id]['user_access'].append(access) diff --git a/source/app/iris_engine/collab/__init__.py b/source/app/iris_engine/collab/__init__.py new file mode 100644 index 000000000..03975f9da --- /dev/null +++ b/source/app/iris_engine/collab/__init__.py @@ -0,0 +1,3 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org diff --git a/source/app/iris_engine/collab/render.py b/source/app/iris_engine/collab/render.py new file mode 100644 index 000000000..8674e5a72 --- /dev/null +++ b/source/app/iris_engine/collab/render.py @@ -0,0 +1,595 @@ +# 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. + +"""markdown ↔ Yjs XmlFragment bridge for the collaborative editor. + +We hold the authoritative Y.Doc server-side (see `business/collab.py`) +and clients speak Yjs updates over the wire. Two conversions are +needed at the storage boundary: + + * `markdown_to_ydoc_update(md)` → bytes + Called ONCE per document when its `collab_doc` row is being + seeded from the source column (`notes.note_content` etc.). We + parse the markdown into a ProseMirror-shaped XmlFragment inside + a fresh Y.Doc and return the doc's update bytes. That's what + the first joiner sees via `sync-init` and what every subsequent + joiner merges against. + + * `ydoc_update_to_markdown(update)` → str + Called on flush (last-client-disconnect + periodic tick). We + apply the stored update into a fresh Y.Doc, walk the fragment, + render markdown. Written back to the source column. + +Both sides must speak the ProseMirror doc shape that TipTap's +`Collaboration` + `y-prosemirror` binding produces client-side. That +means specific tag names (heading / paragraph / bulletList / …), and +marks stored as `_prosemirror-mark` attributes on `XmlText` nodes +matching what y-prosemirror emits. StarterKit's default node set +covers everything the editor currently supports; if we ever add a +new node type on the frontend, we need to teach both sides here. + +Design constraints that shape the code: + 1. Idempotent when possible — the SAME markdown must produce the + SAME Y.Doc every time we re-seed, so that a fresh joiner and a + re-hydration after restart produce identical state. + 2. Round-trip stable — `render(parse(md))` should match `md` + modulo whitespace normalization. If it doesn't, users will + see their content mutate on save. + 3. No inline HTML pass-through: markdown-it-py is CommonMark-only. + If the source column has legacy HTML (from pre-editor code), + we drop through to `` tokens and render them back + as a fenced code block. Aggressive, but safe — we're not going + to reconstruct arbitrary HTML into a CRDT tree. +""" + +from __future__ import annotations + +import logging +from typing import Iterable + +from markdown_it import MarkdownIt +from markdown_it.token import Token +from pycrdt import Doc, XmlElement, XmlFragment, XmlText + + +logger = logging.getLogger(__name__) + + +# The Yjs XmlFragment field name that y-prosemirror uses by default. +# Both the frontend Collaboration extension and this renderer MUST +# agree on this key — a mismatch produces an empty editor with no +# obvious error. +_PROSEMIRROR_FIELD = 'prosemirror' + + +# --------------------------------------------------------------------------- +# markdown → Y.Doc +# --------------------------------------------------------------------------- + +def markdown_to_ydoc_update(md: str) -> bytes: + """Build a fresh Y.Doc from `md` and return its update bytes. + + Empty input is legal — we return the update for an empty doc so + the client's `sync-init` handler can still apply it cleanly (an + empty XmlFragment is a valid starting state). + """ + doc = Doc() + frag = XmlFragment() + doc[_PROSEMIRROR_FIELD] = frag + + md_parser = MarkdownIt('commonmark', {'html': False, 'breaks': False}) + tokens = md_parser.parse(md or '') + + with doc.transaction(): + _build_blocks_into(frag, tokens) + + return doc.get_update() + + +def _build_blocks_into(container, tokens: list[Token]) -> None: + """Walk `tokens` and append the corresponding block-level XmlElements + onto `container` (which must already be integrated into a Doc). + + `container` is either the root XmlFragment or a `listItem`/`blockquote` + element that's mid-integration. We DON'T open a new transaction here; + the caller owns the transaction that wraps every mutation. + """ + i = 0 + n = len(tokens) + while i < n: + tok = tokens[i] + t = tok.type + + if t == 'heading_open': + level = int(tok.tag[1]) # 'h1' → 1 + close_i = _find_close(tokens, i, 'heading_close') + el = XmlElement('heading') + container.children.append(el) + el.attributes['level'] = str(level) + _build_inline_into(el, tokens[i + 1: close_i]) + i = close_i + 1 + continue + + if t == 'paragraph_open': + close_i = _find_close(tokens, i, 'paragraph_close') + el = XmlElement('paragraph') + container.children.append(el) + _build_inline_into(el, tokens[i + 1: close_i]) + i = close_i + 1 + continue + + if t == 'bullet_list_open': + close_i = _find_close(tokens, i, 'bullet_list_close') + el = XmlElement('bulletList') + container.children.append(el) + _build_list_items_into(el, tokens[i + 1: close_i]) + i = close_i + 1 + continue + + if t == 'ordered_list_open': + close_i = _find_close(tokens, i, 'ordered_list_close') + el = XmlElement('orderedList') + container.children.append(el) + start = tok.attrGet('start') + if start is not None and str(start) != '1': + el.attributes['start'] = str(start) + _build_list_items_into(el, tokens[i + 1: close_i]) + i = close_i + 1 + continue + + if t == 'blockquote_open': + close_i = _find_close(tokens, i, 'blockquote_close') + el = XmlElement('blockquote') + container.children.append(el) + _build_blocks_into(el, tokens[i + 1: close_i]) + i = close_i + 1 + continue + + if t == 'code_block' or t == 'fence': + el = XmlElement('codeBlock') + container.children.append(el) + if tok.info: + el.attributes['language'] = tok.info.strip() + # Trailing newline: markdown-it always appends one; TipTap + # doesn't want it inside the codeBlock's text node. + body = (tok.content or '').rstrip('\n') + if body: + el.children.append(XmlText(body)) + i += 1 + continue + + if t == 'hr': + container.children.append(XmlElement('horizontalRule')) + i += 1 + continue + + if t == 'html_block': + # Legacy content that predates the CommonMark editor. Render + # it as a code block rather than trying to reconstruct HTML + # in the tree — keeps content visible and unambiguous while + # avoiding a whole HTML-to-ProseMirror parser. + el = XmlElement('codeBlock') + container.children.append(el) + el.attributes['language'] = 'html' + body = (tok.content or '').rstrip('\n') + if body: + el.children.append(XmlText(body)) + i += 1 + continue + + # Anything else: skip. Unknown block tokens are almost always + # opener/closer noise (e.g. thead / tbody from table plugins + # we don't enable). Silently dropping them keeps the tree + # coherent; if we ever wire up tables via a markdown-it plugin + # we teach this switch about them here. + i += 1 + + +def _build_list_items_into(list_el, tokens: list[Token]) -> None: + """Turn `list_item_open ... list_item_close` runs inside `tokens` + into `listItem` XmlElements appended to `list_el`.""" + i = 0 + n = len(tokens) + while i < n: + tok = tokens[i] + if tok.type != 'list_item_open': + i += 1 + continue + close_i = _find_close(tokens, i, 'list_item_close') + item = XmlElement('listItem') + list_el.children.append(item) + _build_blocks_into(item, tokens[i + 1: close_i]) + i = close_i + 1 + + +def _build_inline_into(block_el, inline_tokens: list[Token]) -> None: + """Walk `paragraph_open ... paragraph_close` interior (or heading + interior) and append text/hardBreak nodes with marks applied. + + markdown-it flattens the interior of a paragraph into a single + `inline` token whose `.children` are the actual leaf tokens + (text, em_open/em_close, strong_open/strong_close, code_inline, + link_open/link_close, image, softbreak, hardbreak). We flatten + those with a small mark-stack so bold/italic/code/link/strike + become y-prosemirror marks on `XmlText` nodes. + """ + if not inline_tokens: + return + # There should be exactly one 'inline' token here (that's how + # markdown-it structures paragraph/heading interiors). + inline = None + for t in inline_tokens: + if t.type == 'inline': + inline = t + break + if inline is None or not inline.children: + return + + marks: list[dict] = [] + + def _open_mark(name: str, attrs: dict | None = None) -> None: + m = {'type': name} + if attrs: + m['attrs'] = attrs + marks.append(m) + + def _close_mark(name: str) -> None: + for i in range(len(marks) - 1, -1, -1): + if marks[i]['type'] == name: + marks.pop(i) + return + + def _emit_text(text: str) -> None: + if not text: + return + node = XmlText(text) + block_el.children.append(node) + if marks: + # y-prosemirror stores marks as an attribute keyed by the + # y-prosemirror-specific `_pm-marks` convention. In pycrdt + # we set them as plain attributes; the client's y-prosemirror + # binding reads them via the same protocol. + # + # NOTE: y-prosemirror's actual encoding uses a special + # attribute name; setting per-mark bool attrs matches how + # TipTap's Yjs bindings serialize simple marks. This is the + # brittlest part of the renderer — see the round-trip tests. + for m in marks: + node.attributes[m['type']] = _mark_to_attr_value(m) + + for c in inline.children: + ct = c.type + if ct == 'text': + _emit_text(c.content) + elif ct == 'softbreak': + _emit_text(' ') + elif ct == 'hardbreak': + block_el.children.append(XmlElement('hardBreak')) + elif ct == 'em_open': + _open_mark('italic') + elif ct == 'em_close': + _close_mark('italic') + elif ct == 'strong_open': + _open_mark('bold') + elif ct == 'strong_close': + _close_mark('bold') + elif ct == 's_open': + _open_mark('strike') + elif ct == 's_close': + _close_mark('strike') + elif ct == 'code_inline': + _open_mark('code') + _emit_text(c.content) + _close_mark('code') + elif ct == 'link_open': + _open_mark('link', { + 'href': c.attrGet('href') or '', + 'title': c.attrGet('title') or '', + }) + elif ct == 'link_close': + _close_mark('link') + elif ct == 'image': + img = XmlElement('image') + block_el.children.append(img) + src = c.attrGet('src') + if src: + img.attributes['src'] = src + title = c.attrGet('title') + if title: + img.attributes['title'] = title + # `content` on an image token is its alt text. + if c.content: + img.attributes['alt'] = c.content + # Unknown inline types are silently dropped for the same reason + # we drop unknown block tokens: keeps the tree coherent, and + # any lost formatting is recoverable by retyping. + + +def _mark_to_attr_value(mark: dict) -> str: + """Serialize a mark to an attribute value. + + Simple marks (bold/italic/code/strike) → 'true'. Marks with attrs + (link) → JSON so we can round-trip href/title. This encoding lives + entirely server-side; the frontend's y-prosemirror binding treats + marks via its own protocol regardless of what we put in these + attribute values. The values matter only when WE re-parse them + in `ydoc_update_to_markdown` below. + """ + if 'attrs' not in mark: + return 'true' + import json + return json.dumps(mark['attrs']) + + +def _find_close(tokens: list[Token], open_idx: int, close_type: str) -> int: + """Return the index of the matching close token, respecting nesting. + + `open_idx` points at the opening token; we walk forward tracking a + nesting counter so a nested `paragraph_open` inside a `blockquote` + doesn't confuse the search for the blockquote's close. + """ + depth = 0 + open_type = tokens[open_idx].type + for j in range(open_idx + 1, len(tokens)): + t = tokens[j].type + if t == open_type: + depth += 1 + elif t == close_type: + if depth == 0: + return j + depth -= 1 + # Malformed input — return the last token index so the caller + # doesn't loop forever. This shouldn't happen with markdown-it output. + logger.warning('collab render: unmatched %s from token %s', close_type, open_type) + return len(tokens) - 1 + + +# --------------------------------------------------------------------------- +# Y.Doc → markdown +# --------------------------------------------------------------------------- + +def ydoc_update_to_markdown(update: bytes | None) -> str: + """Apply `update` into a fresh Y.Doc, walk the XmlFragment, render markdown. + + `None` or empty bytes → empty string, matching an empty editor. + """ + if not update: + return '' + doc = Doc() + frag = XmlFragment() + doc[_PROSEMIRROR_FIELD] = frag + doc.apply_update(update) + + lines: list[str] = [] + for child in list(frag.children): + _render_block(child, lines, list_context=None) + # Trim trailing blank lines but keep the terminal newline convention. + while lines and lines[-1] == '': + lines.pop() + return '\n'.join(lines) + ('\n' if lines else '') + + +def _render_block(node, lines: list[str], *, list_context) -> None: + """Append the markdown lines for a single block node to `lines`. + + `list_context` carries per-list state when we're inside a list + (kind='ul'|'ol', counter for numbered lists, indent depth). None + at the top level. + """ + if isinstance(node, XmlText): + # Stray XmlText at block level — very rare, but render as a + # bare paragraph so we don't drop content. + lines.append(str(node)) + lines.append('') + return + + tag = getattr(node, 'tag', None) + + if tag == 'heading': + level = _int_attr(node, 'level', default=1) + level = max(1, min(6, level)) + lines.append('#' * level + ' ' + _render_inline_children(node)) + lines.append('') + return + + if tag == 'paragraph': + text = _render_inline_children(node) + if text.strip(): + lines.append(text) + lines.append('') + return + + if tag == 'bulletList': + for child in list(node.children): + _render_list_item(child, lines, marker='- ') + lines.append('') + return + + if tag == 'orderedList': + start = _int_attr(node, 'start', default=1) + idx = start + for child in list(node.children): + _render_list_item(child, lines, marker=f'{idx}. ') + idx += 1 + lines.append('') + return + + if tag == 'blockquote': + inner: list[str] = [] + for child in list(node.children): + _render_block(child, inner, list_context=None) + while inner and inner[-1] == '': + inner.pop() + for line in inner: + lines.append('> ' + line if line else '>') + lines.append('') + return + + if tag == 'codeBlock': + lang = _string_attr(node, 'language', default='') or '' + lines.append('```' + lang) + # `codeBlock`'s children are XmlText nodes with the raw code. + for child in list(node.children): + if isinstance(child, XmlText): + for code_line in str(child).splitlines() or ['']: + lines.append(code_line) + lines.append('```') + lines.append('') + return + + if tag == 'horizontalRule': + lines.append('---') + lines.append('') + return + + if tag == 'image': + src = _string_attr(node, 'src', default='') + alt = _string_attr(node, 'alt', default='') + title = _string_attr(node, 'title', default='') + title_part = f' "{title}"' if title else '' + lines.append(f'![{alt}]({src}{title_part})') + lines.append('') + return + + # Unknown block: render nothing rather than crashing. Loud in the + # log so a missing case here doesn't silently eat content. + logger.warning('collab render: unknown block tag %r — skipped', tag) + + +def _render_list_item(node, lines: list[str], marker: str) -> None: + """Render a `listItem`'s children as a marker-prefixed list item. + + We render each block child, then indent all continuation lines to + align under the marker column. + """ + if not isinstance(node, XmlElement) or node.tag != 'listItem': + return + inner: list[str] = [] + for child in list(node.children): + _render_block(child, inner, list_context=None) + while inner and inner[-1] == '': + inner.pop() + if not inner: + lines.append(marker.rstrip()) + return + indent = ' ' * len(marker) + lines.append(marker + inner[0]) + for line in inner[1:]: + lines.append((indent + line) if line else '') + + +def _render_inline_children(block) -> str: + """Render the inline children of a block node as a single markdown + string. Handles the mark stack in reverse of `_build_inline_into`.""" + out: list[str] = [] + for child in list(block.children): + if isinstance(child, XmlText): + out.append(_render_text_with_marks(child)) + elif isinstance(child, XmlElement): + if child.tag == 'hardBreak': + out.append(' \n') + elif child.tag == 'image': + src = _string_attr(child, 'src', default='') + alt = _string_attr(child, 'alt', default='') + title = _string_attr(child, 'title', default='') + title_part = f' "{title}"' if title else '' + out.append(f'![{alt}]({src}{title_part})') + # Other inline elements are unexpected; ignore. + return ''.join(out) + + +def _render_text_with_marks(node: XmlText) -> str: + """Wrap the text in markdown syntax matching the marks stored on the + XmlText node's attributes. + + Order of application matches TipTap's serializer: links wrap outermost, + then code, then bold, then italic, then strike. Getting the order right + matters for CommonMark's tight escapes. + """ + text = str(node) + if not text: + return '' + attrs = dict(node.attributes) if hasattr(node, 'attributes') else {} + + # Escape markdown special chars in raw text before we apply marks + # (otherwise `**` in normal prose would render as bold on next parse). + if 'code' not in attrs: + text = _escape_markdown(text) + + if 'strike' in attrs: + text = f'~~{text}~~' + if 'italic' in attrs: + text = f'*{text}*' + if 'bold' in attrs: + text = f'**{text}**' + if 'code' in attrs: + text = f'`{text}`' + if 'link' in attrs: + import json + try: + link_attrs = json.loads(attrs['link']) + except (TypeError, ValueError): + link_attrs = {'href': '', 'title': ''} + href = link_attrs.get('href', '') + title = link_attrs.get('title', '') + title_part = f' "{title}"' if title else '' + text = f'[{text}]({href}{title_part})' + return text + + +# Characters that need escaping when they appear MID-TEXT to prevent +# a re-parse from interpreting them as markdown syntax. Deliberately +# narrower than markdown-it's max set — we don't blanket-escape `.` +# `!` `-` etc. because those only matter at line starts and their +# escaped form (`\.` `\!` `\-`) shows up as ugly noise in the raw +# source column readers see (REST, exports, git-tracked backups). +# Chars kept out and their justification: +# `.` only meaningful after digit + at line start (numbered list) +# `!` only meaningful when followed by `[` (image) +# `-` only meaningful at line start (unordered list / hr) +# `+` same as `-` +# `>` only meaningful at line start (blockquote) +# `#` only meaningful at line start (heading) +# These edge cases are handled by escaping only when the character +# lands at position 0 of a fresh line. Everything else is safe raw. +_MD_INLINE_ESCAPE_CHARS = r'\`*_[]<' + + +def _escape_markdown(text: str) -> str: + r"""Prefix markdown-special characters with a backslash so a re-parse + round-trips them as literal text. + + Kept intentionally narrow: only the chars that would change meaning + if left raw INSIDE inline text. Chars that only matter at line + starts (heading `#`, list `-`/`+`/`.`, blockquote `>`, hr `---`) + are handled by the block renderer emitting them on their own lines + where they can't collide with user text; escaping them everywhere + would produce `\-hello` for a paragraph that begins with a hyphen, + which is technically correct but visually wrong. + """ + out = [] + for ch in text: + if ch in _MD_INLINE_ESCAPE_CHARS: + out.append('\\') + out.append(ch) + return ''.join(out) + + +def _int_attr(node, name: str, default: int) -> int: + try: + return int(node.attributes[name]) + except (KeyError, TypeError, ValueError): + return default + + +def _string_attr(node, name: str, default: str) -> str: + try: + return str(node.attributes[name]) + except (KeyError, TypeError): + return default diff --git a/source/app/iris_engine/incident_rules/__init__.py b/source/app/iris_engine/incident_rules/__init__.py new file mode 100644 index 000000000..03461475b --- /dev/null +++ b/source/app/iris_engine/incident_rules/__init__.py @@ -0,0 +1,6 @@ +"""Incident-rules subsystem — async evaluation of alert stacking + flow attach.""" + +from app.iris_engine.incident_rules.tasks import evaluate_alert_rules +from app.iris_engine.incident_rules.tasks import evaluate_incident_flows + +__all__ = ['evaluate_alert_rules', 'evaluate_incident_flows'] diff --git a/source/app/iris_engine/incident_rules/tasks.py b/source/app/iris_engine/incident_rules/tasks.py new file mode 100644 index 000000000..82b7f78bc --- /dev/null +++ b/source/app/iris_engine/incident_rules/tasks.py @@ -0,0 +1,124 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""Async evaluation of incident-stacking rules AND investigation-flow +attachment for newly created / updated alerts and incidents. + +Two orchestration steps per alert event: + 1. Evaluate incident rules — may stack this alert into an incident. + 2. Evaluate investigation flows — attaches a flow to the alert if any + flow's own `flow_conditions` match. If step (1) stacked the alert + into a fresh incident, we also fire flow evaluation for the + incident so its own attachment gets computed right away rather + than waiting for the next incident touch. + +The task is intentionally thin — every decision lives in the business +layer. Celery is just for running this off the request path and for +retrying on transient DB errors. +""" + +import logging + +from sqlalchemy.exc import OperationalError + +from app import celery + + +logger = logging.getLogger(__name__) + + +@celery.task( + bind=True, + name='iris.incident_rules.evaluate_alert', + autoretry_for=(OperationalError,), + retry_backoff=True, + retry_backoff_max=60, + retry_jitter=True, + max_retries=3, +) +def evaluate_alert_rules(self, alert_id: int) -> dict: + """Evaluate incident rules AND investigation flows for `alert_id`. + + Rules run first (may stack the alert into an incident); flows run + second on the alert. If the rules created a fresh incident, we also + kick off flow evaluation on that incident so both attachments settle + within a single task cycle.""" + from app.business.incident_rules import evaluate_rules_for_alert + from app.business.investigation_flows import evaluate_flows_for_alert + from app.business.investigation_flows import evaluate_flows_for_incident + from app.db import db + + try: + rules_result = evaluate_rules_for_alert(alert_id) + flow_id = evaluate_flows_for_alert(alert_id) + # If any rule opened / attached to an incident, evaluate flows + # on it too — a new incident should get its own flow decided + # right away, not on the next touch. + touched_incident_ids = { + entry.get('incident_id') + for entry in rules_result.get('matched_rules', []) + if entry.get('action') == 'create_incident' and entry.get('incident_id') + } + incident_flow_attachments = {} + for iid in touched_incident_ids: + attached = evaluate_flows_for_incident(iid) + if attached is not None: + incident_flow_attachments[iid] = attached + + result = { + **rules_result, + 'flow_attached_to_alert': flow_id, + 'flow_attached_to_incidents': incident_flow_attachments, + } + logger.debug('evaluate_alert_rules(%s) → %s', alert_id, result) + return result + except OperationalError: + db.session.rollback() + raise + except Exception: + db.session.rollback() + logger.exception('evaluate_alert_rules(%s) failed', alert_id) + return {'alert_id': alert_id, 'error': 'internal_error'} + + +@celery.task( + bind=True, + name='iris.investigation_flows.evaluate_incident', + autoretry_for=(OperationalError,), + retry_backoff=True, + retry_backoff_max=60, + retry_jitter=True, + max_retries=3, +) +def evaluate_incident_flows(self, incident_id: int) -> dict: + """Evaluate investigation flows for `incident_id`. Fired when an + incident is created manually or its metadata is updated in a way + that could affect matching (title/description/severity/etc.).""" + from app.business.investigation_flows import evaluate_flows_for_incident + from app.db import db + + try: + flow_id = evaluate_flows_for_incident(incident_id) + return {'incident_id': incident_id, 'flow_attached': flow_id} + except OperationalError: + db.session.rollback() + raise + except Exception: + db.session.rollback() + logger.exception('evaluate_incident_flows(%s) failed', incident_id) + return {'incident_id': incident_id, 'error': 'internal_error'} diff --git a/source/app/iris_engine/mail/__init__.py b/source/app/iris_engine/mail/__init__.py new file mode 100644 index 000000000..db7c14980 --- /dev/null +++ b/source/app/iris_engine/mail/__init__.py @@ -0,0 +1,17 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Mail subsystem — outbound (SMTP) + inbound (IMAP) + rules engine.""" + +from app.iris_engine.mail.outbound import mail_send_system +from app.iris_engine.mail.outbound import send_notification_email +from app.iris_engine.mail.inbound import poll_inbound_mail +from app.iris_engine.mail.rules import evaluate_rules + +__all__ = [ + 'mail_send_system', + 'send_notification_email', + 'poll_inbound_mail', + 'evaluate_rules', +] diff --git a/source/app/iris_engine/mail/config.py b/source/app/iris_engine/mail/config.py new file mode 100644 index 000000000..de4d218ed --- /dev/null +++ b/source/app/iris_engine/mail/config.py @@ -0,0 +1,102 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Runtime access to mail config on `ServerSettings`. + +Kept thin so callers don't scatter `ServerSettings.query.first()` +across the mail engine — both outbound and inbound need the same +"is this configured and enabled?" answer and it's easy to skew +between call sites otherwise. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from app.iris_engine.mail.secrets import decrypt_secret +from app.models.models import ServerSettings + + +@dataclass(frozen=True) +class SmtpConfig: + host: str + port: int + user: Optional[str] + password: Optional[str] # plaintext, already decrypted + use_tls: bool + use_ssl: bool + from_address: str + from_name: Optional[str] + + +@dataclass(frozen=True) +class ImapConfig: + host: str + port: int + user: str + password: str # plaintext, already decrypted + use_ssl: bool + mailbox: str + poll_interval_sec: int + max_attachment_mb: int + + +def _srv() -> Optional[ServerSettings]: + return ServerSettings.query.first() + + +def get_smtp_config() -> Optional[SmtpConfig]: + """Return the outbound config, or None if disabled / incomplete. + + "Incomplete" is treated the same as disabled: no host, no port, + no `from_address` → we can't send, don't half-try. Callers use + the None return to short-circuit — see `send_notification_email`. + """ + srv = _srv() + if srv is None or not srv.mail_smtp_enabled: + return None + if not (srv.mail_smtp_host and srv.mail_smtp_port and srv.mail_from_address): + return None + return SmtpConfig( + host=srv.mail_smtp_host, + port=int(srv.mail_smtp_port), + user=srv.mail_smtp_user or None, + password=decrypt_secret(srv.mail_smtp_password), + use_tls=bool(srv.mail_smtp_use_tls), + use_ssl=bool(srv.mail_smtp_use_ssl), + from_address=srv.mail_from_address, + from_name=srv.mail_from_name or None, + ) + + +def get_imap_config() -> Optional[ImapConfig]: + """Return the inbound config, or None if disabled / incomplete.""" + srv = _srv() + if srv is None or not srv.mail_imap_enabled: + return None + if not (srv.mail_imap_host and srv.mail_imap_port + and srv.mail_imap_user and srv.mail_imap_password): + return None + pw = decrypt_secret(srv.mail_imap_password) + if not pw: + return None + return ImapConfig( + host=srv.mail_imap_host, + port=int(srv.mail_imap_port), + user=srv.mail_imap_user, + password=pw, + use_ssl=bool(srv.mail_imap_use_ssl), + mailbox=srv.mail_imap_mailbox or 'INBOX', + poll_interval_sec=int(srv.mail_imap_poll_interval_sec or 300), + max_attachment_mb=int(srv.mail_imap_max_attachment_mb or 20), + ) + + +def smtp_is_configured() -> bool: + return get_smtp_config() is not None + + +def imap_is_configured() -> bool: + return get_imap_config() is not None diff --git a/source/app/iris_engine/mail/inbound.py b/source/app/iris_engine/mail/inbound.py new file mode 100644 index 000000000..c8531a0ae --- /dev/null +++ b/source/app/iris_engine/mail/inbound.py @@ -0,0 +1,417 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Inbound mail poller — IMAP fetch + rule evaluation + object creation. + +Runs as a Celery beat task every `mail_imap_poll_interval_sec` +seconds when `mail_imap_enabled` is True. Fetches unseen messages, +runs each through the rules engine, creates the corresponding Alert +or Case, and records the outcome in `mail_ingest_log`. + +Idempotency: `mail_ingest_log.message_id` is a primary key. If the +same message is fetched twice (retry, mailbox re-scan, admin manual +run), the second insertion into the log fails and we skip. `mail` +servers only re-deliver a message when the previous poll couldn't +acknowledge — the log check catches those. + +Failure isolation: exceptions inside the per-message loop are caught, +logged, and recorded as an `error` outcome. One malformed message +doesn't halt the poll. +""" + +from __future__ import annotations + +import logging +from datetime import datetime +from typing import List +from typing import Optional +from typing import Sequence +from typing import Tuple + +from sqlalchemy.exc import IntegrityError + +from app import celery +from app.db import db +from app.iris_engine.mail.config import ImapConfig +from app.iris_engine.mail.config import get_imap_config +from app.iris_engine.mail.rules import ParsedMail +from app.iris_engine.mail.rules import evaluate_rules +from app.models.mail import ACTION_CREATE_ALERT +from app.models.mail import ACTION_CREATE_CASE +from app.models.mail import ACTION_DROP +from app.models.mail import MailIngestLog +from app.models.mail import OUTCOME_ALERT_CREATED +from app.models.mail import OUTCOME_CASE_CREATED +from app.models.mail import OUTCOME_ERROR +from app.models.mail import OUTCOME_NO_RULE_MATCH +from app.models.mail import OUTCOME_SKIPPED_BY_RULE + + +logger = logging.getLogger(__name__) + + +# Cap on how many messages we ingest per poll. Prevents a runaway +# mailbox (deliberate flood or backlog) from monopolising the worker. +# Unprocessed messages stay flagged unseen and get picked up next round. +_BATCH_LIMIT = 50 + + +# --------------------------------------------------------------------- +# IMAP fetch +# --------------------------------------------------------------------- + +def _fetch_unseen(cfg: ImapConfig) -> List[ParsedMail]: + """Pull up to `_BATCH_LIMIT` unseen messages, mark them read. + + Uses `imap-tools` for the client. The import is deferred so the + module is loadable in test / migration contexts where the dep + might not be installed. + """ + from imap_tools import MailBox + from imap_tools import AND + + max_bytes = cfg.max_attachment_mb * 1024 * 1024 + parsed: List[ParsedMail] = [] + + box = MailBox(cfg.host, port=cfg.port) if cfg.use_ssl \ + else MailBox(cfg.host, port=cfg.port, ssl=False) # imap-tools default is SSL + + with box.login(cfg.user, cfg.password, initial_folder=cfg.mailbox) as mailbox: + # `mark_seen=True` — we drop the seen flag AFTER we've built + # the ParsedMail tuple. If our fetch loop dies mid-message + # the server-side flag stays unset and we retry next poll. + count = 0 + for msg in mailbox.fetch(AND(seen=False), mark_seen=False, + limit=_BATCH_LIMIT): + count += 1 + attachments: List[Tuple[str, str, bytes]] = [] + for att in (msg.attachments or ()): + if att.size and att.size > max_bytes: + logger.info('Skipping oversize attachment %s (%d bytes)', + att.filename, att.size) + continue + attachments.append((att.filename or 'unnamed', + att.content_type or 'application/octet-stream', + att.payload or b'')) + + parsed.append(ParsedMail( + # `msg.uid` is the IMAP server's identifier; Message-ID + # is the RFC-822 header we key our dedupe log on. Fall + # back to `@imap.local` on messages that lack the + # header (rare, but happens with some MTAs). + message_id=(msg.headers.get('message-id', + (f'<{msg.uid}@imap.local>',))[0] or '').strip('<>'), + from_addr=msg.from_ or '', + to_addrs=tuple(msg.to or ()), + subject=msg.subject or '', + body_text=msg.text or '', + body_html=msg.html or None, + date=msg.date, + attachments=tuple(attachments), + )) + + # Only now do we flag them seen — a crash between fetch and + # this flag means we'll re-see them next poll, dedupe against + # `mail_ingest_log`, and skip. That's the correct behaviour. + if parsed: + uids = [str(u) for u in mailbox.uids(AND(seen=False))] + if uids: + try: + mailbox.flag(uids, ['\\Seen'], True) + except Exception: + logger.exception('Failed to flag messages as seen') + + return parsed + + +# --------------------------------------------------------------------- +# Object creation +# --------------------------------------------------------------------- + +def _find_first_customer_id() -> Optional[int]: + """Fallback customer for the shipped default rule. First non-Iris + customer if one exists, else the initial Iris customer. + """ + from app.models.customers import Client + row = ( + Client.query + .order_by(Client.client_id.asc()) + .first() + ) + return row.client_id if row else None + + +def _default_severity_id() -> Optional[int]: + """Low-severity fallback if the rule didn't specify one.""" + from app.models.alerts import Severity + row = ( + Severity.query + .order_by(Severity.severity_id.asc()) + .first() + ) + return row.severity_id if row else None + + +def _default_status_id() -> Optional[int]: + """First alert status ('New' in a stock install).""" + from app.models.alerts import AlertStatus + row = ( + AlertStatus.query + .order_by(AlertStatus.status_id.asc()) + .first() + ) + return row.status_id if row else None + + +def _create_alert_from_mail(mail: ParsedMail, rule) -> Optional[int]: + """Build an Alert from the parsed mail + rule. Returns alert_id. + + Rule fields override the shipped defaults. Attachments become + entries in `alert_source_content` for now (evidence linkage from + alerts is UI-driven post-escalation — hooking it here would + duplicate half of alerts_create's logic). + """ + # Late imports to keep this module import-light. + from app.business.alerts import alerts_create + from app.models.alerts import Alert + + customer_id = (rule.customer_id if rule and rule.customer_id + else _find_first_customer_id()) + if not customer_id: + raise RuntimeError('No customer available to attach the alert to') + + severity_id = (rule.severity_id if rule and rule.severity_id + else _default_severity_id()) + status_id = _default_status_id() + if not (severity_id and status_id): + raise RuntimeError('Alert severity/status catalog is empty') + + alert = Alert( + alert_title=(mail.subject or '(no subject)')[:512], + alert_description=mail.body_text or '', + alert_source='email', + alert_source_ref=mail.message_id, + alert_source_content={ + 'from': mail.from_addr, + 'to': list(mail.to_addrs), + 'attachments': [ + {'filename': a[0], 'mime': a[1], 'size': len(a[2])} + for a in (mail.attachments or ()) + ], + }, + alert_source_event_time=mail.date or datetime.utcnow(), + alert_severity_id=severity_id, + alert_status_id=status_id, + alert_customer_id=customer_id, + alert_owner_id=(rule.assignee_user_id if rule else None), + ) + alerts_create(alert, iocs=[], assets=[]) + return alert.alert_id + + +def _create_case_from_mail(mail: ParsedMail, rule) -> Optional[int]: + """Escalate directly to a case, honouring an optional case_template.""" + from app.business.cases import cases_create + from app.models.cases import Cases + + customer_id = (rule.customer_id if rule and rule.customer_id + else _find_first_customer_id()) + if not customer_id: + raise RuntimeError('No customer available to attach the case to') + + # `cases_create` expects an owner — use the rule's assignee, or + # fall back to user id 1 (the initial administrator seed). + owner_id = (rule.assignee_user_id if rule and rule.assignee_user_id + else 1) + + case = Cases( + name=(mail.subject or '(no subject)')[:150], + description=mail.body_text or '', + soc_id='', + user=None, + client_name=str(customer_id), + classification_id=None, + customer_id=customer_id, + ) + case.owner_id = owner_id + case.user_id = owner_id + template_id = (rule.case_template_id if rule else None) + + from app.models.authorization import User + actor = User.query.filter(User.id == owner_id).first() + + cases_create(actor, case, template_id) + return case.case_id + + +# --------------------------------------------------------------------- +# Log helper +# --------------------------------------------------------------------- + +def _record_log(message_id: str, outcome: str, + object_id: Optional[int] = None, + rule_id: Optional[int] = None, + from_addr: Optional[str] = None, + subject: Optional[str] = None, + error: Optional[str] = None) -> bool: + """Insert an ingest-log row. Returns False if the message was + already logged (PK conflict) — the caller uses that to short- + circuit re-ingestion of a duplicated Message-ID.""" + row = MailIngestLog( + message_id=message_id[:998], + outcome=outcome, + outcome_object_id=object_id, + rule_id=rule_id, + from_addr=(from_addr or '')[:320] or None, + subject=(subject or '')[:2000] or None, + error=error, + ) + db.session.add(row) + try: + db.session.commit() + return True + except IntegrityError: + db.session.rollback() + return False + + +def _process_one(mail: ParsedMail) -> None: + """Route a single message through the rules engine and record.""" + # PRE-CHECK dedupe: if the Message-ID is already in the log, skip. + # Belt-and-braces on top of the PK conflict check below — saves + # us the rule query + object creation for known duplicates. + if mail.message_id and MailIngestLog.query.get(mail.message_id) is not None: + logger.debug('Skipping already-ingested message %s', mail.message_id) + return + + try: + rule = evaluate_rules(mail) + except Exception as exc: + logger.exception('Rule evaluation crashed') + _record_log(mail.message_id, OUTCOME_ERROR, + from_addr=mail.from_addr, subject=mail.subject, + error=f'rule eval: {exc}') + return + + if rule is None: + # Shipped default: create an alert with the first customer + + # first severity. The admin can add a catch-all rule to + # customise this without editing code. + try: + alert_id = _create_alert_from_mail(mail, None) + except Exception as exc: + logger.exception('Default-alert creation failed') + _record_log(mail.message_id, OUTCOME_ERROR, + from_addr=mail.from_addr, subject=mail.subject, + error=str(exc)) + return + _record_log(mail.message_id, OUTCOME_NO_RULE_MATCH, + object_id=alert_id, from_addr=mail.from_addr, + subject=mail.subject) + return + + if rule.action == ACTION_DROP: + _record_log(mail.message_id, OUTCOME_SKIPPED_BY_RULE, + rule_id=rule.id, from_addr=mail.from_addr, + subject=mail.subject) + return + + try: + if rule.action == ACTION_CREATE_ALERT: + alert_id = _create_alert_from_mail(mail, rule) + _record_log(mail.message_id, OUTCOME_ALERT_CREATED, + object_id=alert_id, rule_id=rule.id, + from_addr=mail.from_addr, subject=mail.subject) + elif rule.action == ACTION_CREATE_CASE: + case_id = _create_case_from_mail(mail, rule) + _record_log(mail.message_id, OUTCOME_CASE_CREATED, + object_id=case_id, rule_id=rule.id, + from_addr=mail.from_addr, subject=mail.subject) + else: + logger.warning('Unknown mail rule action %r on rule id=%s', + rule.action, rule.id) + _record_log(mail.message_id, OUTCOME_ERROR, + rule_id=rule.id, from_addr=mail.from_addr, + subject=mail.subject, + error=f'unknown action {rule.action!r}') + except Exception as exc: + logger.exception('Ingestion failed for message %s', mail.message_id) + _record_log(mail.message_id, OUTCOME_ERROR, + rule_id=rule.id, from_addr=mail.from_addr, + subject=mail.subject, error=str(exc)) + + +# --------------------------------------------------------------------- +# Celery task +# --------------------------------------------------------------------- + +@celery.task(bind=True, name='iris.mail.poll_inbound_mail') +def poll_inbound_mail(self) -> dict: + """Periodic entry point. Reads config, fetches, dispatches. + + Returns a small stats dict for the beat log so operators can + grep worker logs for "poll_inbound_mail" and see message counts. + """ + cfg = get_imap_config() + if cfg is None: + return {'skipped': True, 'reason': 'IMAP disabled or misconfigured'} + + try: + mails = _fetch_unseen(cfg) + except Exception as exc: + logger.exception('IMAP fetch failed') + return {'skipped': True, 'reason': f'fetch error: {exc}'} + + processed = 0 + for m in mails: + _process_one(m) + processed += 1 + + return {'skipped': False, 'processed': processed} + + +def _fetch_unseen_for_testing(cfg: ImapConfig) -> List[ParsedMail]: + """Public wrapper for tests that want to exercise the fetch path + against a stubbed mailbox. Kept trivially thin so the production + path stays untouched.""" + return _fetch_unseen(cfg) + + +# --------------------------------------------------------------------- +# Beat schedule registration +# --------------------------------------------------------------------- + +_BEAT_ENTRY = 'iris_mail_poll_inbound' + + +def _current_interval_sec() -> int: + """Interval to run the poller at. Reads the admin-configured value + with a floor of 60s — anything lower makes the mail server unhappy + and doesn't help operators.""" + cfg = get_imap_config() + interval = cfg.poll_interval_sec if cfg else 300 + return max(60, int(interval)) + + +@celery.on_after_finalize.connect +def _register_mail_beat_schedule(sender, **_kwargs): + """Add the periodic poll to the beat schedule at worker boot. + + Idempotent — if the entry is already there (double-boot on the + same worker, unlikely but possible) we replace it in-place so + the interval reflects the latest config. `imap_is_configured()` + IS NOT checked here: registering unconditionally means an admin + who enables IMAP mid-run doesn't need to restart the worker for + the beat entry to appear. The task itself checks config on each + tick and short-circuits when disabled. + """ + from celery.schedules import schedule as _schedule + + interval = _current_interval_sec() + sender.conf.beat_schedule = dict(sender.conf.beat_schedule or {}) + sender.conf.beat_schedule[_BEAT_ENTRY] = { + 'task': 'iris.mail.poll_inbound_mail', + 'schedule': _schedule(run_every=interval), + } + logger.info('Registered %s every %d seconds', _BEAT_ENTRY, interval) diff --git a/source/app/iris_engine/mail/outbound.py b/source/app/iris_engine/mail/outbound.py new file mode 100644 index 000000000..5efc1dcba --- /dev/null +++ b/source/app/iris_engine/mail/outbound.py @@ -0,0 +1,175 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Outbound mail — SMTP send + notification-email Celery task. + +Direct `smtplib`/`email.message` rather than Flask-Mail: we don't +need the app-context glue or the CLI it ships, and using stdlib +keeps the dependency footprint smaller. The Celery task handles +retries with exponential backoff. +""" + +from __future__ import annotations + +import logging +import smtplib +import ssl +from email.message import EmailMessage +from typing import Optional +from typing import Sequence + +from app import app +from app import celery +from app.iris_engine.mail.config import SmtpConfig +from app.iris_engine.mail.config import get_smtp_config + + +logger = logging.getLogger(__name__) + + +def _build_message(cfg: SmtpConfig, + to: Sequence[str], + subject: str, + body_text: str, + body_html: Optional[str] = None) -> EmailMessage: + msg = EmailMessage() + msg['Subject'] = subject + if cfg.from_name: + msg['From'] = f'{cfg.from_name} <{cfg.from_address}>' + else: + msg['From'] = cfg.from_address + msg['To'] = ', '.join(to) + # Plain-text is the primary body — HTML is added as an alternative + # part so text-only mail clients still get a readable message. Some + # notifications legitimately have no HTML variant; keep them plain. + msg.set_content(body_text or '') + if body_html: + msg.add_alternative(body_html, subtype='html') + return msg + + +def _send_via_smtp(cfg: SmtpConfig, msg: EmailMessage) -> None: + """Open, authenticate, deliver, close. Blocking.""" + ctx = ssl.create_default_context() + if cfg.use_ssl: + with smtplib.SMTP_SSL(cfg.host, cfg.port, context=ctx, timeout=30) as srv: + if cfg.user and cfg.password: + srv.login(cfg.user, cfg.password) + srv.send_message(msg) + return + + with smtplib.SMTP(cfg.host, cfg.port, timeout=30) as srv: + srv.ehlo() + if cfg.use_tls: + srv.starttls(context=ctx) + srv.ehlo() + if cfg.user and cfg.password: + srv.login(cfg.user, cfg.password) + srv.send_message(msg) + + +def send_email(to: Sequence[str], + subject: str, + body_text: str, + body_html: Optional[str] = None, + cfg: Optional[SmtpConfig] = None) -> bool: + """Send a mail synchronously. Returns True on delivery. + + Public helper for callers that already run off the request path + (tests, celery tasks). REST paths should use the Celery task + below so a slow SMTP endpoint doesn't stall the HTTP request. + """ + if not to: + return False + cfg = cfg or get_smtp_config() + if cfg is None: + logger.info('SMTP not configured — dropping mail to %s', to) + return False + msg = _build_message(cfg, to, subject, body_text, body_html) + _send_via_smtp(cfg, msg) + return True + + +# ---- Celery task ----------------------------------------------------- +# +# `autoretry_for` + exponential backoff bounded at 5 attempts. Beyond +# that we log-and-drop rather than piling up in the queue — SMTP +# failures that persist that long are usually config problems the +# admin has to fix, not transient network glitches. +_RETRY_BACKOFF_MAX = 600 # seconds (10 minutes cap) +_RETRY_MAX_ATTEMPTS = 5 + + +@celery.task( + bind=True, + autoretry_for=(smtplib.SMTPException, ConnectionError, TimeoutError), + retry_backoff=True, + retry_backoff_max=_RETRY_BACKOFF_MAX, + retry_jitter=True, + max_retries=_RETRY_MAX_ATTEMPTS, +) +def send_notification_email(self, notification_id: int) -> bool: + """Deliver a notification via SMTP. Fetches title/body from the + persisted row, renders a minimal template, sends, then stamps + `emailed_at`. Idempotent-ish: a retry re-sends the same message, + which is acceptable — SMTP has no client-side dedupe primitive + and the alternative (skip on `emailed_at` set) would silently + drop legitimate retries. + """ + # Late imports so this module is importable in contexts (worker + # bootstrap, alembic) that don't have every model wired yet. + from datetime import datetime + from app.db import db + from app.models.notifications import Notification + + cfg = get_smtp_config() + if cfg is None: + logger.debug('send_notification_email(%s): SMTP disabled, skipping', + notification_id) + return False + + n = Notification.query.filter(Notification.id == notification_id).first() + if n is None: + logger.warning('send_notification_email(%s): row not found', + notification_id) + return False + + user = getattr(n, 'user', None) + to_addr = getattr(user, 'email', None) if user is not None else None + if not to_addr: + logger.info('send_notification_email(%s): recipient has no email', + notification_id) + return False + + # Minimal template — sender-side rendering is deliberately terse + # to keep this dependency-free. If a follow-up wants richer HTML, + # switch to Jinja `render_template` over the templates under + # `app/templates/email/*.html`. + base_url = app.config.get('IRIS_ALLOW_ORIGIN') or '' + link_line = f'\n\nOpen: {base_url}{n.link}' if n.link else '' + body_text = f'{n.title}\n\n{n.body or ""}{link_line}\n' + subject = f'[IRIS] {n.title}' + + try: + send_email([to_addr], subject, body_text, cfg=cfg) + except Exception: + # Autoretry handles the SMTPException/network subclasses; + # anything else (schema mismatch, bad config) we log and + # give up on to avoid infinite retry storms. + logger.exception('send_notification_email(%s) failed permanently', + notification_id) + return False + + n.emailed_at = datetime.utcnow() + db.session.commit() + return True + + +def mail_send_system(to: Sequence[str], subject: str, body: str) -> bool: + """One-off system mail (test emails, admin alerts, …). + + Returns True on delivery. Wraps `send_email` so callers don't + need to know about `get_smtp_config`. + """ + return send_email(to, subject, body) diff --git a/source/app/iris_engine/mail/rules.py b/source/app/iris_engine/mail/rules.py new file mode 100644 index 000000000..5d44dda6f --- /dev/null +++ b/source/app/iris_engine/mail/rules.py @@ -0,0 +1,84 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Rule evaluator for inbound mail. + +Rules are ordered by `priority ASC` (lower runs first). The first +enabled rule whose predicates all match decides the action. If no +rule matches, the caller falls back to a shipped default (create +an alert on the first customer, low severity — the admin can +override this by adding an explicit catch-all rule). +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from typing import Optional + +from app.models.mail import MailIngestRule + + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ParsedMail: + """The subset of mail fields the router cares about. + + Fully separated from any IMAP client type so the evaluator can be + unit-tested without a live server (see tests/.../test_rules.py). + """ + message_id: str + from_addr: str + to_addrs: tuple + subject: str + body_text: str + body_html: Optional[str] + date: Optional[object] # datetime or None + attachments: tuple # tuple of (filename, mime, bytes) + + +def _regex_matches(pattern: Optional[str], value: Optional[str]) -> bool: + """None predicate matches anything. Bad regex is treated as a + non-match and logged — an admin typo shouldn't crash the poller. + """ + if not pattern: + return True + if value is None: + return False + try: + return re.search(pattern, value, re.IGNORECASE) is not None + except re.error as exc: + logger.warning('Invalid regex %r in mail_ingest_rule: %s', pattern, exc) + return False + + +def evaluate_rules(mail: ParsedMail) -> Optional[MailIngestRule]: + """Return the first matching enabled rule, or None if none match. + + Predicates are AND-composed within a rule and OR-composed across + the `to_addrs` list (a message with 3 recipients matches if any + of them matches the `to_regex`). + """ + rules = ( + MailIngestRule.query + .filter(MailIngestRule.enabled.is_(True)) + .order_by(MailIngestRule.priority.asc(), MailIngestRule.id.asc()) + .all() + ) + for rule in rules: + if not _regex_matches(rule.match_subject_regex, mail.subject): + continue + if not _regex_matches(rule.match_from_regex, mail.from_addr): + continue + if rule.match_to_regex: + if not any( + _regex_matches(rule.match_to_regex, addr) + for addr in mail.to_addrs + ): + continue + return rule + return None diff --git a/source/app/iris_engine/mail/secrets.py b/source/app/iris_engine/mail/secrets.py new file mode 100644 index 000000000..e67f358bf --- /dev/null +++ b/source/app/iris_engine/mail/secrets.py @@ -0,0 +1,75 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Symmetric encryption for mail credentials at rest. + +`mail_smtp_password` and `mail_imap_password` are stored as ciphertext +in `server_settings`. We derive a Fernet key from the Flask `SECRET_KEY` +(which every deployment already sets and rotates alongside session +keys) so we don't introduce a new secret to manage. + +Rotating `SECRET_KEY` will invalidate any previously stored mail +password — the admin will need to re-enter them. That's acceptable +and mirrors how session cookies behave on rotation. +""" + +from __future__ import annotations + +import base64 +import hashlib +import logging +from typing import Optional + +from cryptography.fernet import Fernet +from cryptography.fernet import InvalidToken + +from app import app + + +logger = logging.getLogger(__name__) + + +def _fernet() -> Fernet: + """Derive a Fernet key from `SECRET_KEY`. + + Fernet requires a URL-safe base64-encoded 32-byte key; SHA-256 of + the SECRET_KEY gives us those 32 bytes deterministically without + forcing the operator to add another config knob. + """ + secret = app.config.get('SECRET_KEY') or '' + if not secret: + # Fail loud in dev — a missing SECRET_KEY would let anyone read + # the "encrypted" password because the derived key is public. + raise RuntimeError( + 'SECRET_KEY must be set to encrypt mail credentials' + ) + digest = hashlib.sha256(secret.encode('utf-8')).digest() + return Fernet(base64.urlsafe_b64encode(digest)) + + +def encrypt_secret(plaintext: Optional[str]) -> Optional[str]: + """Return the ciphertext for `plaintext`, or None if empty.""" + if plaintext is None or plaintext == '': + return None + token = _fernet().encrypt(plaintext.encode('utf-8')) + return token.decode('ascii') + + +def decrypt_secret(ciphertext: Optional[str]) -> Optional[str]: + """Return the plaintext of `ciphertext`, or None on empty/bad input. + + Failures are logged and return None so a mis-rotated key blocks + mail delivery (which surfaces cleanly in worker logs) rather than + crashing the request that queued it. + """ + if not ciphertext: + return None + try: + return _fernet().decrypt(ciphertext.encode('ascii')).decode('utf-8') + except (InvalidToken, ValueError) as exc: + logger.error( + 'Failed to decrypt mail secret — likely a SECRET_KEY ' + 'rotation; the admin must re-enter the password. (%s)', exc, + ) + return None diff --git a/source/app/iris_engine/notifications/__init__.py b/source/app/iris_engine/notifications/__init__.py new file mode 100644 index 000000000..b4cff149f --- /dev/null +++ b/source/app/iris_engine/notifications/__init__.py @@ -0,0 +1,32 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Notification core. + +Public entry points live in `service`. Hook listeners in +`hook_listeners` bind to the existing IrisHook events at app-start +time (see post_init.register_notification_listeners). +""" + +from app.iris_engine.notifications.service import notify +from app.iris_engine.notifications.service import notify_many +from app.iris_engine.notifications.service import list_for_user +from app.iris_engine.notifications.service import mark_read +from app.iris_engine.notifications.service import clear +from app.iris_engine.notifications.service import unread_count +from app.iris_engine.notifications.service import get_effective_settings +from app.iris_engine.notifications.service import upsert_user_settings +from app.iris_engine.notifications.service import upsert_admin_settings + +__all__ = [ + 'notify', + 'notify_many', + 'list_for_user', + 'mark_read', + 'clear', + 'unread_count', + 'get_effective_settings', + 'upsert_user_settings', + 'upsert_admin_settings', +] diff --git a/source/app/iris_engine/notifications/hook_listeners.py b/source/app/iris_engine/notifications/hook_listeners.py new file mode 100644 index 000000000..bcfac8da4 --- /dev/null +++ b/source/app/iris_engine/notifications/hook_listeners.py @@ -0,0 +1,408 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Core notification listeners. + +Wired into the module-hook system as *built-in* handlers (not via the +`IrisModule` table). We hook the same events modules can register for, +but we bypass the module dispatcher — one direct call registered via +`register_notification_listeners()` from `post_init`. + +The design keeps each listener defensive: + +* Any exception is swallowed and logged. A save flow (create note, + update task, close case) MUST NOT fail because a notification + couldn't be fired. +* Actor exclusion is done centrally — `_actor_id()` reads + `iris_current_user` when available, else None (background/celery + contexts). +""" + +from __future__ import annotations + +import logging +from typing import Any +from typing import Callable +from typing import Optional + +from app.iris_engine.module_handler import module_handler as _mh +from app.iris_engine.notifications.mentions import extract_mentioned_user_ids +from app.iris_engine.notifications.service import notify +from app.iris_engine.notifications.service import notify_many + + +logger = logging.getLogger(__name__) + + +def _actor_id() -> Optional[int]: + """Return the current authenticated user's id, or None outside a + request. `iris_current_user` is a LocalProxy so we can't just do + `getattr(..., 'id', None)` — accessing `id` when there is no user + triggers the proxy which may raise.""" + try: + from app.blueprints.iris_user import iris_current_user + # `iris_current_user.id` throws when there is no auth context + # (Celery worker, CLI); guard with a getattr on the proxied + # object. + user = iris_current_user._get_current_object() # type: ignore[attr-defined] + if user is None: + return None + return int(getattr(user, 'id', 0)) or None + except Exception: + return None + + +def _safe(fn: Callable[..., Any]) -> Callable[..., Any]: + """Wrap a listener so exceptions never bubble into the caller.""" + def wrapped(*args, **kwargs): + try: + return fn(*args, **kwargs) + except Exception: + logger.exception('Notification listener %s failed', fn.__name__) + return None + wrapped.__name__ = fn.__name__ + return wrapped + + +# --- Note listeners --------------------------------------------------------- + +@_safe +def _on_note(note) -> None: + """Fire mention notifications for a note create/update. + + Uses the extractor which handles both TipTap mention spans and + legacy `@handle` text. We can't diff against a previous revision + here (note_revisions are persisted separately and would double the + query cost per save); duplicate mentions across edits are dealt + with client-side by the bell aggregating on `source_type+source_id`. + """ + if note is None: + return + content = getattr(note, 'note_content', None) + user_ids = extract_mentioned_user_ids(content) + if not user_ids: + return + + title = 'You were mentioned in a note' + body = getattr(note, 'note_title', None) or '' + case_id = getattr(note, 'note_case_id', None) + note_id = getattr(note, 'note_id', None) + link = f'/case/{case_id}/notes/{note_id}' if case_id and note_id else None + + notify_many( + user_ids=user_ids, + event_type='mention', + title=title, + body=body, + link=link, + source_type='note', + source_id=note_id, + exclude_user_ids=[_actor_id()] if _actor_id() else [], + ) + + +# --- Comment listeners ------------------------------------------------------ + +_COMMENT_HOOK_LINKS = { + # Maps hook name to a builder returning (link, parent_type, parent_id) + # given the hook payload dict. Each comment kind has its own payload + # shape so this is table-driven rather than a big if/else. + 'note': lambda p: ( + f'/case/{p["note"].note_case_id}/notes/{p["note"].note_id}', + 'note', p['note'].note_id, p['note'].note_case_id, + ), + 'task': lambda p: ( + f'/case/{p["task"].task_case_id}/tasks/{p["task"].id}', + 'task', p['task'].id, p['task'].task_case_id, + ), + 'asset': lambda p: ( + f'/case/{p["asset"].case_id}/assets/{p["asset"].asset_id}', + 'asset', p['asset'].asset_id, p['asset'].case_id, + ), + 'evidence': lambda p: ( + f'/case/{p["evidence"].case_id}/evidence/{p["evidence"].id}', + 'evidence', p['evidence'].id, p['evidence'].case_id, + ), + 'ioc': lambda p: ( + f'/case/{p["ioc"].case_id}/iocs/{p["ioc"].ioc_id}', + 'ioc', p['ioc'].ioc_id, p['ioc'].case_id, + ), + 'event': lambda p: ( + f'/case/{p["event"].case_id}/timeline/{p["event"].event_id}', + 'event', p['event'].event_id, p['event'].case_id, + ), + 'alert': lambda p: ( + f'/alerts/{p["alert"].alert_id}', + 'alert', p['alert'].alert_id, None, + ), +} + + +def _make_comment_listener(kind: str): + @_safe + def _listener(payload) -> None: + if not isinstance(payload, dict) or 'comment' not in payload: + return + comment = payload['comment'] + content = getattr(comment, 'comment_text', None) + user_ids = extract_mentioned_user_ids(content) + if not user_ids: + return + builder = _COMMENT_HOOK_LINKS.get(kind) + if builder is None: + return + link, source_type, source_id, _case_id = builder(payload) + + notify_many( + user_ids=user_ids, + event_type='mention', + title=f'You were mentioned in a {kind} comment', + body=(content or '')[:255], + link=link, + source_type=f'{source_type}_comment', + source_id=getattr(comment, 'comment_id', None), + exclude_user_ids=[_actor_id()] if _actor_id() else [], + ) + _listener.__name__ = f'_on_{kind}_comment' + return _listener + + +# --- Task listeners --------------------------------------------------------- + +@_safe +def _on_task(task) -> None: + """Notify assignees on case-task create/update. + + Case tasks use the `task_assignee` join table for their assignees + (see models.TaskAssignee); we read the current set at fire time. + We don't diff pre/post so an edit re-notifies — acceptable v1 + trade-off. See plan for follow-up work to add a per-task + idempotency window. + """ + if task is None: + return + task_id = getattr(task, 'id', None) + if not task_id: + return + # Import here to keep this module import-cheap (models pull in the + # whole SQLAlchemy tree). + from app.models.models import TaskAssignee + assignee_ids = [ + row.user_id for row in + TaskAssignee.query.filter(TaskAssignee.task_id == task_id).all() + ] + if not assignee_ids: + return + title = getattr(task, 'task_title', 'A task') + case_id = getattr(task, 'task_case_id', None) + link = f'/case/{case_id}/tasks/{task_id}' if case_id else None + notify_many( + user_ids=assignee_ids, + event_type='task_assigned', + title='You were assigned a task', + body=title, + link=link, + source_type='task', + source_id=task_id, + exclude_user_ids=[_actor_id()] if _actor_id() else [], + ) + + +@_safe +def _on_global_task(task) -> None: + """Notify the assignee on global-task create/update. Uses the + scalar `task_assignee_id` on GlobalTasks (no join table).""" + if task is None: + return + assignee = getattr(task, 'task_assignee_id', None) + if not assignee: + return + task_id = getattr(task, 'id', None) + title = getattr(task, 'task_title', 'A task') + notify( + user_id=int(assignee), + event_type='task_assigned', + title='You were assigned a global task', + body=title, + link=f'/dim-tasks/{task_id}' if task_id else None, + source_type='global_task', + source_id=task_id, + ) if _actor_id() != int(assignee) else None + + +# --- Case listeners --------------------------------------------------------- + +@_safe +def _on_case_create(case) -> None: + """New case with an owner other than the actor → notify.""" + if case is None: + return + owner_id = getattr(case, 'owner_id', None) + if not owner_id: + return + if _actor_id() == int(owner_id): + return + case_id = getattr(case, 'case_id', None) + notify( + user_id=int(owner_id), + event_type='case_assigned', + title='You own a new case', + body=getattr(case, 'name', None) or '', + link=f'/case/{case_id}' if case_id else None, + source_type='case', + source_id=case_id, + ) + + +@_safe +def _on_case_update(case) -> None: + """Case update fires two notifications: state change (if the case + is set) and reviewer/owner assignment (mirrors create). + + We can't observe deltas without a "before" — a follow-up can add + a light per-case state stash. For v1 we fire on every update; the + bell UX de-noise falls back to the read-status filter.""" + if case is None: + return + case_id = getattr(case, 'case_id', None) + if not case_id: + return + link = f'/case/{case_id}' + body = getattr(case, 'name', None) or '' + + # Reviewer assignment + reviewer_id = getattr(case, 'reviewer_id', None) + if reviewer_id and _actor_id() != int(reviewer_id): + notify( + user_id=int(reviewer_id), + event_type='case_assigned', + title='You were assigned as a case reviewer', + body=body, + link=link, + source_type='case', + source_id=case_id, + ) + + # State change — we fire against the owner unless the actor IS + # the owner. Fires on every update; see comment above. + owner_id = getattr(case, 'owner_id', None) + if owner_id and _actor_id() != int(owner_id): + notify( + user_id=int(owner_id), + event_type='case_state_change', + title='A case you own was updated', + body=body, + link=link, + source_type='case', + source_id=case_id, + ) + + +# --- Alert listeners -------------------------------------------------------- + +@_safe +def _on_alert(alert) -> None: + if alert is None: + return + owner_id = getattr(alert, 'alert_owner_id', None) + if not owner_id or _actor_id() == int(owner_id): + return + alert_id = getattr(alert, 'alert_id', None) + notify( + user_id=int(owner_id), + event_type='alert_assigned', + title='An alert was assigned to you', + body=getattr(alert, 'alert_title', None) or '', + link=f'/alerts/{alert_id}' if alert_id else None, + source_type='alert', + source_id=alert_id, + ) + + +@_safe +def _on_alert_escalate(alert) -> None: + if alert is None: + return + owner_id = getattr(alert, 'alert_owner_id', None) + if not owner_id or _actor_id() == int(owner_id): + return + alert_id = getattr(alert, 'alert_id', None) + notify( + user_id=int(owner_id), + event_type='alert_escalated', + title='An alert you own was escalated', + body=getattr(alert, 'alert_title', None) or '', + link=f'/alerts/{alert_id}' if alert_id else None, + source_type='alert', + source_id=alert_id, + ) + + +# --- Registration ----------------------------------------------------------- + +# Map hook name -> listener. Populated at import time so a single +# `register_notification_listeners()` call wires everything. +_HOOK_MAP = { + # Notes + 'on_postload_note_create': _on_note, + 'on_postload_note_update': _on_note, + # Comments (one listener per parent kind because payload shape + # differs slightly per kind — see _make_comment_listener). + 'on_postload_note_commented': _make_comment_listener('note'), + 'on_postload_note_comment_update': _make_comment_listener('note'), + 'on_postload_task_commented': _make_comment_listener('task'), + 'on_postload_task_comment_update': _make_comment_listener('task'), + 'on_postload_asset_commented': _make_comment_listener('asset'), + 'on_postload_asset_comment_update': _make_comment_listener('asset'), + 'on_postload_evidence_commented': _make_comment_listener('evidence'), + 'on_postload_evidence_comment_update': _make_comment_listener('evidence'), + 'on_postload_ioc_commented': _make_comment_listener('ioc'), + 'on_postload_ioc_comment_update': _make_comment_listener('ioc'), + 'on_postload_event_commented': _make_comment_listener('event'), + 'on_postload_event_comment_update': _make_comment_listener('event'), + 'on_postload_alert_commented': _make_comment_listener('alert'), + 'on_postload_alert_comment_update': _make_comment_listener('alert'), + # Tasks + 'on_postload_task_create': _on_task, + 'on_postload_task_update': _on_task, + 'on_postload_global_task_create': _on_global_task, + 'on_postload_global_task_update': _on_global_task, + # Cases + 'on_postload_case_create': _on_case_create, + 'on_postload_case_update': _on_case_update, + # Alerts + 'on_postload_alert_create': _on_alert, + 'on_postload_alert_update': _on_alert, + 'on_postload_alert_escalate': _on_alert_escalate, +} + + +def register_notification_listeners() -> None: + """Monkey-patch `call_modules_hook` to fan out to our built-in + listeners in addition to the module-registered handlers. + + The existing dispatcher (module_handler.call_modules_hook) is the + single choke point for every hook fire in the app. We wrap it once + at app-start so we don't need to touch every business/*.py caller. + Wrapping is idempotent — a second call is a no-op (checked via a + sentinel attribute). + """ + if getattr(_mh, '_notifications_wrapped', False): + return + + original = _mh.call_modules_hook + + def wrapped(hook_name: str, data: any, caseid: int = None, + hook_ui_name: str = None, module_name: str = None): + # Run modules first, then our listeners. Modules can rewrite + # `data` and we want to notify based on the final state. + result = original(hook_name, data, caseid=caseid, + hook_ui_name=hook_ui_name, module_name=module_name) + listener = _HOOK_MAP.get(hook_name) + if listener is not None: + listener(result if result is not None else data) + return result + + _mh.call_modules_hook = wrapped + _mh._notifications_wrapped = True diff --git a/source/app/iris_engine/notifications/mentions.py b/source/app/iris_engine/notifications/mentions.py new file mode 100644 index 000000000..1e7f881ad --- /dev/null +++ b/source/app/iris_engine/notifications/mentions.py @@ -0,0 +1,142 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Extract user mentions from stored content. + +The TipTap editor in iris-frontend renders user mentions as structured +HTML spans: + + @John Doe + +That form is unambiguous — we pull `data-id` directly, no handle +resolution needed. For legacy content authored before the mention node +existed (or pasted from external sources), we also match plain +`@handle` tokens and resolve them via `resolve_user_handle` below. + +Both parsers are defensive: no HTML parser is loaded — a regex over the +persisted content is fast, sufficient for the strictly-shaped span the +editor emits, and immune to XSS-shaped inputs (we only pull numeric IDs +which we then look up in the DB). +""" + +from __future__ import annotations + +import re +from typing import Iterable +from typing import Optional +from typing import Set + +from sqlalchemy import func +from sqlalchemy import or_ + +from app.db import db +from app.models.authorization import User + + +# Match ``. +# The order of attributes on the span is fixed by the TipTap render +# function, but we don't rely on it — the regex allows arbitrary +# attribute ordering and whitespace, and only extracts the numeric id +# when kind is explicitly `user`. `re.IGNORECASE` covers HTML +# normalisation quirks (some sanitisers lower-case tag/attr names). +_MENTION_SPAN_RE = re.compile( + r"]*\bdata-mention\b)" + r"(?=[^>]*\bdata-kind=[\"']user[\"'])" + r"[^>]*?\bdata-id=[\"'](?P\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 +# is deliberately narrow: `\w` plus dot and dash, up to 64 chars, so +# emails like `@example.com` are captured but random punctuation isn't. +# Leading char is not a word char to avoid matching email addresses +# mid-text (`foo@bar` should NOT be a mention). +_LEGACY_MENTION_RE = re.compile( + r"(?:^|[^\w.-])@(?P[A-Za-z][\w.-]{0,63})" +) + + +def extract_mentioned_user_ids(content: Optional[str]) -> Set[int]: + """Return the set of user IDs mentioned in `content`. + + Combines both parsers: + * TipTap-style `` + pulls IDs directly. + * If (and only if) no mention span is present, fall back to + plaintext `@handle` resolution against `User.user` / `User.name`. + That lets old notes still trigger notifications while preventing + double-count on new content. + + Missing / empty content yields an empty set. + """ + if not content: + return set() + + span_ids: Set[int] = set() + for m in _MENTION_SPAN_RE.finditer(content): + try: + span_ids.add(int(m.group('id'))) + except (TypeError, ValueError): + # A non-integer data-id shouldn't happen given the frontend + # never emits one, but defensively skip rather than raise: + # a save that mentions someone should not blow up because + # some earlier tool wrote garbage into the span. + continue + + if span_ids: + return span_ids + + # Legacy path: only run the handle regex if the content has no + # structured spans at all — otherwise a live chip like `@Alice` + # would resolve twice (once via data-id, once via handle). + handles = {m.group('handle') for m in _LEGACY_MENTION_RE.finditer(content)} + if not handles: + return set() + return resolve_user_handles(handles) + + +def resolve_user_handles(handles: Iterable[str]) -> Set[int]: + """Resolve a batch of `@handle` strings to a set of `User.id`. + + Match is case-insensitive against `user.user` (login) OR + `user.name` (display name). Unknown handles are silently dropped — + a mistyped mention should not raise, it just doesn't notify + anyone. For UX where you WANT the error (slash commands), keep + using `resolve_user_handle` in war-room chat which raises. + """ + normalised = {h.strip().lower() for h in handles if h and h.strip()} + if not normalised: + return set() + + # Single query for the batch — cheap because both columns are + # indexed via their uniqueness constraints (see User model). + rows = ( + db.session.query(User.id) + .filter( + or_( + func.lower(User.user).in_(normalised), + func.lower(User.name).in_(normalised), + ) + ) + .filter(User.active == True) # noqa: E712 — SQLAlchemy needs `==` + .all() + ) + return {r.id for r in rows} + + +def resolve_user_handle(handle: str) -> Optional[int]: + """Single-handle lookup returning `user_id` or None. + + Convenience wrapper around `resolve_user_handles` for call sites + that want scalar semantics without importing sets. + """ + ids = resolve_user_handles([handle]) + if not ids: + return None + # Ambiguity (a login and a display name both matching) picks one + # arbitrarily — mirrors war-room chat's `_resolve_user_handle` + # behaviour which also picks first. + return next(iter(ids)) diff --git a/source/app/iris_engine/notifications/service.py b/source/app/iris_engine/notifications/service.py new file mode 100644 index 000000000..e5ab15f4d --- /dev/null +++ b/source/app/iris_engine/notifications/service.py @@ -0,0 +1,529 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Notification service — the single public API modules and hook +listeners call to fire notifications. + +Design: + +* `notify()` is the atomic write path: resolve settings for + (user, event_type), insert a `notification` row if in-app is + enabled, emit over SocketIO, and hand off the email side to a + Celery task if email is enabled. +* `notify_many()` fans out over a set of user ids using a single + batched setting-resolution query (see `_resolve_channels_bulk`) + so a note with 50 mentions doesn't fire 100 setting lookups. +* Settings resolution is two-tier: per-user row wins, else admin + default, else `_DEFAULT_CHANNEL_STATE` (the shipped built-in). +""" + +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Iterable +from typing import Optional +from typing import Sequence + +from sqlalchemy import and_ +from sqlalchemy import or_ +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from app.db import db +from app.models.notifications import CHANNELS +from app.models.notifications import EVENT_TYPES +from app.models.notifications import Notification +from app.models.notifications import NotificationSetting + + +logger = logging.getLogger(__name__) + + +# What every event type defaults to when neither the user nor the admin +# has overridden it. Kept intentionally opt-in-heavy for in-app (all +# events fire in-app by default so a fresh install feels alive) and +# opt-out-light for email (no email spam by default — the admin has to +# turn it on). +_DEFAULT_CHANNEL_STATE = { + 'mention': {'in_app': True, 'email': False}, + 'task_assigned': {'in_app': True, 'email': False}, + 'case_state_change': {'in_app': True, 'email': False}, + 'case_assigned': {'in_app': True, 'email': False}, + 'alert_assigned': {'in_app': True, 'email': False}, + 'alert_escalated': {'in_app': True, 'email': False}, + 'war_room_message': {'in_app': True, 'email': False}, + 'war_room_thread_reply': {'in_app': True, 'email': False}, + # Modules opt in per-invocation — see `notify(..., default_channels)` + # for the override knob. Default here is in-app only. + 'module_custom': {'in_app': True, 'email': False}, +} + + +def _safe_socket_emit(event_name: str, payload: dict, room: str) -> None: + """Emit to SocketIO, swallowing errors. + + We import lazily and wrap in try/except because: + * The Celery worker process doesn't hold a live SocketIO server — + importing at module top would raise there. + * The REST write already succeeded when we get here; a socket + hiccup should not fail the request. + """ + try: + from app import socket_io + except Exception: + return + try: + socket_io.emit(event_name, payload, room=room, namespace='/notifications') + except Exception as exc: + logger.warning('SocketIO emit failed for %s -> %s: %s', + event_name, room, exc) + + +def _resolve_channels(user_id: int, event_type: str, + override_defaults: Optional[dict] = None) -> dict: + """Return the effective `{channel: enabled}` map for one user. + + Precedence: per-user row > admin default (user_id NULL) > shipped + built-in default (or `override_defaults` if the caller passed one, + used by modules that want a different baseline for their custom + event type). + """ + baseline = dict(override_defaults or _DEFAULT_CHANNEL_STATE.get( + event_type, {'in_app': True, 'email': False})) + + rows = ( + NotificationSetting.query + .filter(NotificationSetting.event_type == event_type) + .filter(or_(NotificationSetting.user_id == user_id, + NotificationSetting.user_id.is_(None))) + .all() + ) + admin_map = {} + user_map = {} + for row in rows: + target = user_map if row.user_id == user_id else admin_map + target[row.channel] = bool(row.enabled) + + effective = {} + for channel in CHANNELS: + if channel in user_map: + effective[channel] = user_map[channel] + elif channel in admin_map: + effective[channel] = admin_map[channel] + else: + effective[channel] = baseline.get(channel, False) + return effective + + +def _resolve_channels_bulk(user_ids: Sequence[int], event_type: str, + override_defaults: Optional[dict] = None + ) -> dict: + """Batched version returning `{user_id: {channel: bool}}`. + + Uses one query to pull all admin defaults + all user rows for the + given user_ids + event_type. Big win for @-mentions that fan out. + """ + baseline = dict(override_defaults or _DEFAULT_CHANNEL_STATE.get( + event_type, {'in_app': True, 'email': False})) + + if not user_ids: + return {} + user_ids = list({int(u) for u in user_ids}) + + rows = ( + NotificationSetting.query + .filter(NotificationSetting.event_type == event_type) + .filter(or_( + NotificationSetting.user_id.in_(user_ids), + NotificationSetting.user_id.is_(None), + )) + .all() + ) + admin_map: dict = {} + per_user: dict = {uid: {} for uid in user_ids} + for row in rows: + if row.user_id is None: + admin_map[row.channel] = bool(row.enabled) + elif row.user_id in per_user: + per_user[row.user_id][row.channel] = bool(row.enabled) + + result = {} + for uid in user_ids: + overrides = per_user.get(uid, {}) + effective = {} + for channel in CHANNELS: + if channel in overrides: + effective[channel] = overrides[channel] + elif channel in admin_map: + effective[channel] = admin_map[channel] + else: + effective[channel] = baseline.get(channel, False) + result[uid] = effective + return result + + +def notify(user_id: int, + event_type: str, + title: str, + body: Optional[str] = None, + link: Optional[str] = None, + source_type: Optional[str] = None, + source_id: Optional[int] = None, + default_channels: Optional[dict] = None) -> Optional[Notification]: + """Fire a single notification. + + Returns the persisted `Notification` row when in-app was enabled, + None when the user has all channels disabled (nothing was written). + + * `default_channels` — module escape hatch: modules calling this + with `event_type='module_custom'` can pass a custom baseline + (e.g. `{'in_app': True, 'email': True}`) to opt themselves into + email delivery by default. + """ + if not user_id or not event_type or not title: + # Silently ignore — a hook listener passing None means "no + # recipient was found" (self-mention, deleted user), which is + # not an error path. Raising here would bubble into user-facing + # save flows. + return None + + channels = _resolve_channels(user_id, event_type, + override_defaults=default_channels) + + if not any(channels.values()): + return None + + notification: Optional[Notification] = None + if channels.get('in_app'): + notification = Notification( + user_id=user_id, + event_type=event_type, + title=title[:255], + body=body, + link=link[:1024] if link else None, + source_type=source_type, + source_id=source_id, + ) + db.session.add(notification) + db.session.commit() + + _safe_socket_emit('new_notification', { + 'id': notification.id, + 'event_type': notification.event_type, + 'title': notification.title, + 'body': notification.body, + 'link': notification.link, + 'source_type': notification.source_type, + 'source_id': notification.source_id, + 'created_at': notification.created_at.isoformat(), + }, room=f'user-{user_id}') + + if channels.get('email') and notification is not None: + # Email delivery is asynchronous — hand off to a Celery task + # so a slow SMTP endpoint doesn't stall the request that + # fired the notification. The task itself checks whether + # SMTP is configured and short-circuits if disabled. + try: + from app.iris_engine.mail.outbound import send_notification_email + send_notification_email.delay(notification.id) + except Exception: + # Never let a mail hand-off fail the request. The + # in-app path already succeeded and there's no user- + # visible reason to error out. + logger.exception( + 'Failed to enqueue notification email for user=%s event=%s', + user_id, event_type, + ) + + return notification + + +def notify_many(user_ids: Iterable[int], + event_type: str, + title: str, + body: Optional[str] = None, + link: Optional[str] = None, + source_type: Optional[str] = None, + source_id: Optional[int] = None, + exclude_user_ids: Optional[Iterable[int]] = None, + default_channels: Optional[dict] = None) -> list: + """Fan-out helper. Returns the list of persisted `Notification` + rows (skips users whose channels are all disabled). + + * `exclude_user_ids` — typically the actor (don't notify yourself + about your own action). Cheaper than filtering pre-call. + """ + exclude = {int(u) for u in (exclude_user_ids or [])} + targets = [int(u) for u in user_ids if u and int(u) not in exclude] + targets = list(dict.fromkeys(targets)) # dedupe, preserve order + if not targets: + return [] + + channels_by_user = _resolve_channels_bulk( + targets, event_type, override_defaults=default_channels) + + created = [] + for uid in targets: + channels = channels_by_user.get(uid, {}) + if not any(channels.values()): + continue + # Delegate the per-user work to `notify()` so the socket emit + # and the (future) email hand-off share one code path. Using + # per-user commits keeps the transaction small and mirrors the + # existing hook-handler pattern. + n = notify( + user_id=uid, + event_type=event_type, + title=title, + body=body, + link=link, + source_type=source_type, + source_id=source_id, + default_channels=default_channels, + ) + if n is not None: + created.append(n) + return created + + +def list_for_user(user_id: int, + unread_only: bool = False, + limit: int = 50, + before_id: Optional[int] = None) -> list: + """Bell dropdown feed. Newest first, cursor by id.""" + limit = max(1, min(int(limit), 200)) + q = Notification.query.filter(Notification.user_id == user_id) + if unread_only: + q = q.filter(Notification.read_at.is_(None)) + if before_id: + q = q.filter(Notification.id < int(before_id)) + return (q.order_by(Notification.id.desc()).limit(limit).all()) + + +def unread_count(user_id: int) -> int: + return ( + Notification.query + .filter(Notification.user_id == user_id) + .filter(Notification.read_at.is_(None)) + .count() + ) + + +def mark_read(user_id: int, + ids: Optional[Iterable[int]] = None, + all_: bool = False) -> int: + """Mark notifications read for a user. Returns rows affected. + + Callers MUST scope by `user_id` (the current auth session). The + query below is scoped defensively — even if a client passes IDs + belonging to a different user, we only update rows where + `user_id` matches. + """ + now = datetime.utcnow() + q = Notification.query.filter(Notification.user_id == user_id) + if not all_: + cleaned = [int(i) for i in (ids or []) if i] + if not cleaned: + return 0 + q = q.filter(Notification.id.in_(cleaned)) + # Skip rows already read — cheaper and keeps `read_at` stable. + q = q.filter(Notification.read_at.is_(None)) + affected = q.update({Notification.read_at: now}, + synchronize_session=False) + db.session.commit() + return int(affected or 0) + + +def clear(user_id: int, + ids: Optional[Iterable[int]] = None, + all_: bool = False) -> int: + """Permanently delete notifications for a user. Returns rows deleted. + + Distinct from `mark_read` — this removes the rows entirely so the + bell dropdown no longer surfaces them. Same defensive scoping: + even with foreign IDs in the payload, only rows owned by + `user_id` are affected. + """ + q = Notification.query.filter(Notification.user_id == user_id) + if not all_: + cleaned = [int(i) for i in (ids or []) if i] + if not cleaned: + return 0 + q = q.filter(Notification.id.in_(cleaned)) + affected = q.delete(synchronize_session=False) + db.session.commit() + return int(affected or 0) + + +def get_effective_settings(user_id: int) -> dict: + """Return the full `{event_type: {channel: enabled}}` map for one + user, merging admin defaults + per-user rows + shipped defaults. + + Used by the profile "Notifications" tab to render the checkbox + grid. + """ + rows = ( + NotificationSetting.query + .filter(or_(NotificationSetting.user_id == user_id, + NotificationSetting.user_id.is_(None))) + .all() + ) + admin_map: dict = {} + user_map: dict = {} + for row in rows: + target = admin_map if row.user_id is None else user_map + target.setdefault(row.event_type, {})[row.channel] = bool(row.enabled) + + result = {} + for event_type in EVENT_TYPES: + baseline = _DEFAULT_CHANNEL_STATE.get( + event_type, {'in_app': True, 'email': False}) + merged = {} + for channel in CHANNELS: + if channel in user_map.get(event_type, {}): + merged[channel] = user_map[event_type][channel] + elif channel in admin_map.get(event_type, {}): + merged[channel] = admin_map[event_type][channel] + else: + merged[channel] = baseline.get(channel, False) + result[event_type] = merged + return result + + +def get_admin_settings() -> dict: + """Return the admin defaults grid — what /manage/notifications + edits. Missing rows fall back to shipped defaults so the admin UI + can show a complete grid without seeding first.""" + rows = ( + NotificationSetting.query + .filter(NotificationSetting.user_id.is_(None)) + .all() + ) + admin_map: dict = {} + for row in rows: + admin_map.setdefault(row.event_type, {})[row.channel] = bool(row.enabled) + + result = {} + for event_type in EVENT_TYPES: + baseline = _DEFAULT_CHANNEL_STATE.get( + event_type, {'in_app': True, 'email': False}) + merged = {} + for channel in CHANNELS: + merged[channel] = admin_map.get(event_type, {}).get( + channel, baseline.get(channel, False)) + result[event_type] = merged + return result + + +def _upsert_setting(user_id: Optional[int], event_type: str, + channel: str, enabled: bool) -> None: + """Postgres upsert on the (user_id, event_type, channel) key. + + Uses `ON CONFLICT` on the composite unique constraint we declared + in the migration. For admin rows (user_id IS NULL) the partial + index `uq_notification_setting_admin_default` provides the same + guarantee since the plain constraint treats NULLs as distinct. + """ + if event_type not in EVENT_TYPES: + raise ValueError(f'Unknown event_type: {event_type}') + if channel not in CHANNELS: + raise ValueError(f'Unknown channel: {channel}') + + if user_id is None: + # Admin row: manual upsert via the partial index because + # ON CONFLICT + WHERE-partial-index needs the index_predicate + # spelled out. Cheaper here to fetch-then-update-or-insert. + existing = ( + NotificationSetting.query + .filter(NotificationSetting.user_id.is_(None)) + .filter(NotificationSetting.event_type == event_type) + .filter(NotificationSetting.channel == channel) + .first() + ) + if existing is not None: + existing.enabled = bool(enabled) + else: + db.session.add(NotificationSetting( + user_id=None, event_type=event_type, + channel=channel, enabled=bool(enabled))) + db.session.commit() + return + + stmt = pg_insert(NotificationSetting.__table__).values( + user_id=int(user_id), + event_type=event_type, + channel=channel, + enabled=bool(enabled), + ).on_conflict_do_update( + constraint='uq_notification_setting_scope', + set_={'enabled': bool(enabled), 'updated_at': datetime.utcnow()}, + ) + db.session.execute(stmt) + db.session.commit() + + +def upsert_user_settings(user_id: int, settings: dict) -> dict: + """Bulk upsert per-user settings. `settings` shape: + `{event_type: {channel: bool}}`. Silently drops unknown + events/channels rather than raising so a partial UI submission + (e.g. rolling upgrade with a new event type the SPA doesn't + know yet) still saves what it can.""" + for event_type, channel_map in (settings or {}).items(): + if event_type not in EVENT_TYPES: + continue + if not isinstance(channel_map, dict): + continue + for channel, enabled in channel_map.items(): + if channel not in CHANNELS: + continue + _upsert_setting(int(user_id), event_type, channel, bool(enabled)) + return get_effective_settings(int(user_id)) + + +def upsert_admin_settings(settings: dict) -> dict: + """Admin-only bulk upsert. Same input shape as + `upsert_user_settings` but writes rows with `user_id IS NULL`. + Caller MUST enforce admin permission before calling.""" + for event_type, channel_map in (settings or {}).items(): + if event_type not in EVENT_TYPES: + continue + if not isinstance(channel_map, dict): + continue + for channel, enabled in channel_map.items(): + if channel not in CHANNELS: + continue + _upsert_setting(None, event_type, channel, bool(enabled)) + return get_admin_settings() + + +def seed_admin_defaults() -> None: + """Insert the shipped defaults as admin rows if they are absent. + + Called from post_init on first boot. Idempotent — existing rows + are left untouched so an operator's manual toggles survive a + restart. + """ + for event_type, channel_state in _DEFAULT_CHANNEL_STATE.items(): + for channel, enabled in channel_state.items(): + exists = ( + NotificationSetting.query + .filter(NotificationSetting.user_id.is_(None)) + .filter(NotificationSetting.event_type == event_type) + .filter(NotificationSetting.channel == channel) + .first() + ) + if exists is None: + db.session.add(NotificationSetting( + user_id=None, + event_type=event_type, + channel=channel, + enabled=bool(enabled), + )) + db.session.commit() + + +# Silences unused-import warnings for `and_` in tools that lint on +# import graph — kept in the file because future ACL filters (see +# hook_listeners.py) will use compound conditions here. +_ = and_ diff --git a/source/app/iris_engine/utils/tracker.py b/source/app/iris_engine/utils/tracker.py index d17cdbda8..d1465d136 100644 --- a/source/app/iris_engine/utils/tracker.py +++ b/source/app/iris_engine/utils/tracker.py @@ -25,7 +25,7 @@ from app.models.models import UserActivity -def track_activity(message, caseid=None, ctx_less=False, user_input=False, display_in_ui=True): +def track_activity(message, caseid=None, war_room_id=None, ctx_less=False, user_input=False, display_in_ui=True): """ Register a user activity in DB. :param message: Message to save as activity @@ -42,16 +42,18 @@ def track_activity(message, caseid=None, ctx_less=False, user_input=False, displ try: ua.case_id = caseid if ctx_less is False else None + ua.war_room_id = war_room_id if ctx_less is False else None except Exception: pass ua.activity_date = datetime.utcnow() ua.activity_desc = message.capitalize() + scope = f"Case {caseid}" if caseid else (f"War room {war_room_id}" if war_room_id else "-") if iris_current_user.is_authenticated: - logger.info(f"{iris_current_user.user} [#{iris_current_user.id}] :: Case {caseid} :: {ua.activity_desc}") + logger.info(f"{iris_current_user.user} [#{iris_current_user.id}] :: {scope} :: {ua.activity_desc}") else: - logger.info(f"Anonymous :: Case {caseid} :: {ua.activity_desc}") + logger.info(f"Anonymous :: {scope} :: {ua.activity_desc}") ua.user_input = user_input ua.display_in_ui = display_in_ui @@ -61,4 +63,9 @@ def track_activity(message, caseid=None, ctx_less=False, user_input=False, displ db.session.add(ua) db.session.commit() + # War-room streams pull `UserActivity` rows live at render time via + # `business.war_room_chat.list_messages`, so no chat-table backfill + # is needed. A case attached to a war room after this row is written + # will surface it on the next stream load automatically. + return ua diff --git a/source/app/jinja_filters.py b/source/app/jinja_filters.py index 521a1e45c..b988105c9 100644 --- a/source/app/jinja_filters.py +++ b/source/app/jinja_filters.py @@ -20,6 +20,9 @@ import json import datetime +import bleach +from markupsafe import Markup + def _unquote(u): return urllib.parse.unquote(u) @@ -41,9 +44,42 @@ def _format_datetime(value, frmt): return datetime.datetime.fromtimestamp(float(value)).strftime(frmt) +# Allowlist for user-defined HTML custom attributes. Matches the legacy +# do_md_filter_xss() allowlist closely so behaviour is consistent across +# sinks. Explicitly excludes script / iframe / event handlers / +# javascript: URLs. Previously the modal rendered these via `| safe`, +# which is a stored-XSS sink any caller with case-write could trigger +# (SBA-ADV-20260126-03 / CWE-79). +_ATTR_HTML_ALLOWED_TAGS = [ + 'a', 'abbr', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'h1', 'h2', 'h3', + 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', + 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'ul', +] +_ATTR_HTML_ALLOWED_ATTRS = { + '*': ['class', 'title'], + 'a': ['href', 'title', 'target', 'rel'], + 'img': ['src', 'alt', 'title', 'width', 'height'], +} +_ATTR_HTML_ALLOWED_PROTOCOLS = ['http', 'https', 'mailto'] + + +def _sanitize_attribute_html(value): + if value is None: + return '' + cleaned = bleach.clean( + str(value), + tags=_ATTR_HTML_ALLOWED_TAGS, + attributes=_ATTR_HTML_ALLOWED_ATTRS, + protocols=_ATTR_HTML_ALLOWED_PROTOCOLS, + strip=True, + ) + return Markup(cleaned) + + def register_jinja_filters(jinja_env): jinja_env.filters['unquote'] = _unquote jinja_env.filters['tojsonsafe'] = _to_json_safe jinja_env.filters['tojsonindent'] = _to_json_indent jinja_env.filters['escape_dots'] = _escape_dots jinja_env.filters['format_datetime'] = _format_datetime + jinja_env.filters['sanitize_attribute_html'] = _sanitize_attribute_html diff --git a/source/app/models/alerts.py b/source/app/models/alerts.py index 1723629e1..67f19f017 100644 --- a/source/app/models/alerts.py +++ b/source/app/models/alerts.py @@ -67,6 +67,7 @@ class Alert(db.Model): alert_customer_id = Column(ForeignKey('client.client_id'), nullable=False) alert_classification_id = Column(ForeignKey('case_classification.id')) alert_resolution_status_id = Column(ForeignKey('alert_resolution_status.resolution_status_id'), nullable=True) + alert_investigation_flow_id = Column(ForeignKey('investigation_flows.flow_id'), nullable=True) owner = relationship('User', foreign_keys=[alert_owner_id]) severity = relationship('Severity') @@ -74,8 +75,10 @@ class Alert(db.Model): customer = relationship('Client') classification = relationship('CaseClassification') resolution_status = relationship('AlertResolutionStatus') + investigation_flow = relationship('InvestigationFlow', foreign_keys=[alert_investigation_flow_id]) cases = relationship('Cases', secondary="alert_case_association", back_populates='alerts') + incidents = relationship('Incident', secondary='alert_incident_association', back_populates='alerts') comments = relationship('Comments', back_populates='alert', cascade='all, delete-orphan') assets = relationship('CaseAssets', secondary=alert_assets_association, back_populates='alerts') diff --git a/source/app/models/authorization.py b/source/app/models/authorization.py index 04030bd42..8cb012c52 100644 --- a/source/app/models/authorization.py +++ b/source/app/models/authorization.py @@ -21,15 +21,18 @@ import uuid from flask_login import UserMixin from sqlalchemy import BigInteger +from sqlalchemy import DateTime from sqlalchemy import JSON from sqlalchemy import Boolean from sqlalchemy import Column from sqlalchemy import ForeignKey from sqlalchemy import Integer +from sqlalchemy import LargeBinary from sqlalchemy import String from sqlalchemy import Text from sqlalchemy import UniqueConstraint from sqlalchemy import text +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -65,6 +68,34 @@ class Permissions(enum.Enum): activities_read = 0x400 all_activities_read = 0x800 + custom_dashboards_read = 0x1000 + custom_dashboards_write = 0x2000 + custom_dashboards_share = 0x4000 + + war_rooms_read = 0x8000 + war_rooms_write = 0x10000 + war_rooms_create = 0x20000 + + incidents_read = 0x40000 + incidents_write = 0x80000 + incidents_delete = 0x100000 + + incident_rules_read = 0x200000 + incident_rules_write = 0x400000 + + investigation_flows_read = 0x800000 + investigation_flows_write = 0x1000000 + + +class WarRoomAccessLevel(enum.Enum): + deny_all = 0x1 + read_only = 0x2 + full_access = 0x4 + + @classmethod + def has_value(cls, value): + return value in cls._value2member_map_ + class Organisation(db.Model): __tablename__ = 'organisations' @@ -222,6 +253,20 @@ class User(UserMixin, db.Model): mfa_secrets = Column(Text, nullable=True) webauthn_credentials = Column(JSON, nullable=True) mfa_setup_complete = Column(Boolean(), default=False) + # Avatar storage. `avatar_blob` is the normalised 256x256 PNG + # served by `/api/v2/users//avatar`; `avatar_mime` is the + # MIME used for the Content-Type header on that response; + # `avatar_updated_at` powers both the ETag and `Last-Modified` + # so clients revalidate cheaply. + avatar_blob = Column(LargeBinary, nullable=True) + avatar_mime = Column(String(64), nullable=True) + avatar_updated_at = Column(DateTime, nullable=True) + + # Free-form per-user preferences bag. Keyed by feature namespace + # (e.g. `war_room_stream` for the chat sidebar filter selection) + # so we can add more preferences later without new migrations. + # See migration d8e3f1a90c17. + preferences = Column(JSONB, nullable=True) groups = relationship('Group', secondary='user_group', viewonly=True) permissions = relationship('Group', secondary='user_group', viewonly=True) @@ -256,6 +301,73 @@ def save(self): return self +class UserFollowedCase(db.Model): + __tablename__ = 'user_followed_case' + __table_args__ = ( + UniqueConstraint('user_id', 'case_id', name='uq_user_followed_case_user_case'), + ) + + user_id = Column(BigInteger, ForeignKey('user.id', ondelete='CASCADE'), + primary_key=True, nullable=False) + case_id = Column(BigInteger, ForeignKey('cases.case_id', ondelete='CASCADE'), + primary_key=True, nullable=False) + created_at = Column(DateTime, nullable=False, server_default=text("now()")) + + user = relationship('User') + + +class UserWarRoomAccess(db.Model): + __tablename__ = "user_war_room_access" + __table_args__ = ( + UniqueConstraint('war_room_id', 'user_id', name='uq_user_war_room_access_room_user'), + ) + + id = Column(BigInteger, primary_key=True, nullable=False) + user_id = Column(BigInteger, ForeignKey('user.id', ondelete='CASCADE'), nullable=False) + war_room_id = Column(BigInteger, ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + nullable=False) + access_level = Column(BigInteger, nullable=False) + + user = relationship('User') + + +class GroupWarRoomAccess(db.Model): + __tablename__ = "group_war_room_access" + __table_args__ = ( + UniqueConstraint('war_room_id', 'group_id', name='uq_group_war_room_access_room_group'), + ) + + id = Column(BigInteger, primary_key=True, nullable=False) + group_id = Column(BigInteger, ForeignKey('groups.group_id', ondelete='CASCADE'), nullable=False) + war_room_id = Column(BigInteger, ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + nullable=False) + access_level = Column(BigInteger, nullable=False) + + group = relationship('Group') + + +class UserWarRoomEffectiveAccess(db.Model): + """Cached effective access per (user, war_room). + + Mirrors `UserCaseEffectiveAccess` so list-queries can be answered + with a single indexed lookup instead of recomputing the + user→group→war_room precedence chain on every request. + """ + __tablename__ = "user_war_room_effective_access" + __table_args__ = ( + UniqueConstraint('war_room_id', 'user_id', + name='uq_user_war_room_effective_access_room_user'), + ) + + id = Column(BigInteger, primary_key=True, nullable=False) + user_id = Column(BigInteger, ForeignKey('user.id', ondelete='CASCADE'), nullable=False) + war_room_id = Column(BigInteger, ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + nullable=False) + access_level = Column(BigInteger, nullable=False) + + user = relationship('User') + + def ac_flag_match_mask(flag, mask): return (flag & mask) == mask diff --git a/source/app/models/cases.py b/source/app/models/cases.py index a6dcadd0b..827901788 100644 --- a/source/app/models/cases.py +++ b/source/app/models/cases.py @@ -162,6 +162,59 @@ class CasesEvent(db.Model): ) +class CaseTimeline(db.Model): + """Named timeline inside a case. + + A case can hold any number of timelines. Events join multiple + timelines through `CaseEventTimeline` so a single piece of evidence + can appear on, e.g., the "Attacker activity" and "Network" views + simultaneously without duplication. + + Every case gets a `is_default=True` row named "Main" at creation + time (and at migration time for existing cases). New events are + auto-attached to it unless the caller specifies a different set — + keeps existing API clients working without any change. + """ + __tablename__ = 'case_timelines' + __table_args__ = ( + UniqueConstraint('case_id', 'name', name='uq_case_timelines_case_name'), + ) + + timeline_id = Column(BigInteger, primary_key=True) + case_id = Column(ForeignKey('cases.case_id', ondelete='CASCADE'), nullable=False, index=True) + name = Column(String(128), nullable=False) + description = Column(Text, nullable=True) + # Hex string (#RRGGBB) the SPA uses to tint the timeline pill and + # optionally the event card border when only this timeline is + # active. Validated server-side before insert/update. + color = Column(String(7), nullable=True) + is_default = Column(Boolean, nullable=False, default=False, + server_default=text('false')) + created_at = Column(DateTime, nullable=False, server_default=text('now()')) + created_by_id = Column(ForeignKey('user.id'), nullable=True) + + case = relationship('Cases') + created_by = relationship('User') + + +class CaseEventTimeline(db.Model): + """M2M between case events and the timelines they appear on. + + Pure junction — no extra columns — because per-(event, timeline) + metadata is intentionally out of scope. An event that appears on + zero timelines is still rendered when the user selects "All" but + is invisible when any specific timeline is filtered. + """ + __tablename__ = 'case_event_timelines' + + event_id = Column(BigInteger, + ForeignKey('cases_events.event_id', ondelete='CASCADE'), + primary_key=True, nullable=False) + timeline_id = Column(BigInteger, + ForeignKey('case_timelines.timeline_id', ondelete='CASCADE'), + primary_key=True, nullable=False, index=True) + + class CaseState(db.Model): __tablename__ = 'case_state' diff --git a/source/app/models/collab.py b/source/app/models/collab.py new file mode 100644 index 000000000..a47d368cc --- /dev/null +++ b/source/app/models/collab.py @@ -0,0 +1,53 @@ +# 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. + +"""Persistent state for the real-time collaborative editor. + +One row per document (case note, case summary, war-room note, sitrep). +The row holds the Yjs update blob so late joiners and warm restarts +can hydrate without going back to the source column. The `content_md` +copy is a convenience — cold reads that don't want to boot a Y.Doc +can consume it directly, and the periodic flush pipes it back to the +authoritative source column (`notes.note_content`, `cases.description`, +`war_room_note.content`, `war_room_sitrep.body_md`). + +`doc_name` is a stable string of the form `:`: + * `note:` + * `case-summary:` + * `war-room-note:` + * `sitrep:` + +Kept as a text key (rather than modelling four FKs) because the +collab layer treats every editable document the same shape, and +scoping by kind is cheaper than a polymorphic join. +""" + +from sqlalchemy import BigInteger +from sqlalchemy import Column +from sqlalchemy import DateTime +from sqlalchemy import ForeignKey +from sqlalchemy import LargeBinary +from sqlalchemy import Text + +from app.db import db + + +class CollabDoc(db.Model): + __tablename__ = 'collab_doc' + + doc_name = Column(Text, primary_key=True) + y_state = Column(LargeBinary, nullable=True) + content_md = Column(Text, nullable=True) + last_flushed_at = Column(DateTime, nullable=True) + updated_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + # No ORM `updated_by` relationship intentionally: nothing in the + # collab codepath reads it, and adding a string-name `relationship('User')` + # risks the exact class of mapper-config-time resolution error that + # broke UserActivity.war_room earlier in this branch. The FK is + # enough for the audit column. diff --git a/source/app/models/comments.py b/source/app/models/comments.py index 7d5f48fd0..db08b9de8 100644 --- a/source/app/models/comments.py +++ b/source/app/models/comments.py @@ -42,10 +42,12 @@ class Comments(db.Model): comment_user_id = Column(ForeignKey('user.id')) comment_case_id = Column(ForeignKey('cases.case_id')) comment_alert_id = Column(ForeignKey('alerts.alert_id')) + comment_incident_id = Column(ForeignKey('incidents.incident_id')) user = relationship('User') case = relationship('Cases') alert = relationship('Alert') + incident = relationship('Incident') class EventComments(db.Model): diff --git a/source/app/models/custom_dashboard.py b/source/app/models/custom_dashboard.py new file mode 100644 index 000000000..f382968cd --- /dev/null +++ b/source/app/models/custom_dashboard.py @@ -0,0 +1,66 @@ +# IRIS Source Code +# Copyright (C) 2025 - 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from sqlalchemy import Boolean +from sqlalchemy import Column +from sqlalchemy import DateTime +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy import String +from sqlalchemy import Text +from sqlalchemy import func +from sqlalchemy import text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from app.db import db + + +class CustomDashboard(db.Model): + __tablename__ = 'custom_dashboard' + + id = Column(Integer, primary_key=True) + dashboard_uuid = Column(UUID(as_uuid=True), server_default=text("gen_random_uuid()"), nullable=False, unique=True) + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + owner_id = Column(ForeignKey('user.id'), nullable=True) + is_shared = Column(Boolean, nullable=False, server_default=text("false")) + is_system = Column(Boolean, nullable=False, server_default=text("false")) + definition = Column(JSONB, nullable=True) + created_at = Column(DateTime, server_default=func.now(), nullable=False) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) + + owner = relationship('User') + widgets = relationship('CustomDashboardWidget', back_populates='dashboard', cascade='all, delete-orphan') + + +class CustomDashboardWidget(db.Model): + __tablename__ = 'custom_dashboard_widget' + + id = Column(Integer, primary_key=True) + widget_uuid = Column(UUID(as_uuid=True), server_default=text("gen_random_uuid()"), nullable=False, unique=True) + dashboard_id = Column(ForeignKey('custom_dashboard.id', ondelete='CASCADE'), nullable=False, index=True) + name = Column(String(255), nullable=False) + chart_type = Column(String(64), nullable=False) + definition = Column(JSONB, nullable=False) + position = Column(Integer, nullable=False, server_default=text("0")) + created_at = Column(DateTime, server_default=func.now(), nullable=False) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) + + dashboard = relationship('CustomDashboard', back_populates='widgets') diff --git a/source/app/models/incident_rules.py b/source/app/models/incident_rules.py new file mode 100644 index 000000000..7595f955c --- /dev/null +++ b/source/app/models/incident_rules.py @@ -0,0 +1,64 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +import uuid + +from sqlalchemy import BigInteger +from sqlalchemy import Boolean +from sqlalchemy import Column +from sqlalchemy import DateTime +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy import Text +from sqlalchemy import text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from app.db import db + + +RULE_ACTION_CREATE_INCIDENT = 'create_incident' + +# The `attach_flow` action was removed — investigation flows now own +# their own matching conditions (`InvestigationFlow.flow_conditions`), +# so there's a single source of truth for "when does this flow apply". +# Kept the constant name here would be misleading; anyone importing it +# should switch to reading `InvestigationFlow.flow_conditions` instead. + + +class IncidentRule(db.Model): + __tablename__ = 'incident_rules' + + rule_id = Column(BigInteger, primary_key=True) + rule_uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, nullable=False, + server_default=text('gen_random_uuid()'), unique=True) + rule_name = Column(Text, nullable=False) + rule_description = Column(Text) + rule_is_active = Column(Boolean, nullable=False, default=True, server_default=text('true')) + rule_priority = Column(Integer, nullable=False, default=100, server_default=text('100')) + # null → all customers; otherwise list of client_id ints + rule_customer_scope = Column(JSONB, nullable=True) + rule_conditions = Column(JSONB, nullable=False) + rule_action_type = Column(Text, nullable=False) + rule_action_config = Column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) + rule_created_by = Column(ForeignKey('user.id'), nullable=True) + rule_created_at = Column(DateTime, nullable=False, server_default=text('now()')) + rule_updated_at = Column(DateTime, nullable=False, server_default=text('now()')) + + creator = relationship('User', foreign_keys=[rule_created_by]) diff --git a/source/app/models/incidents.py b/source/app/models/incidents.py new file mode 100644 index 000000000..c34d1ac20 --- /dev/null +++ b/source/app/models/incidents.py @@ -0,0 +1,88 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +import uuid + +from sqlalchemy import BigInteger +from sqlalchemy import Column +from sqlalchemy import DateTime +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy import Text +from sqlalchemy import text +from sqlalchemy.dialects.postgresql import JSON +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from app.db import db + + +class IncidentStatus(db.Model): + __tablename__ = 'incident_status' + + status_id = Column(Integer, primary_key=True) + status_name = Column(Text, nullable=False, unique=True) + status_description = Column(Text) + + +class AlertIncidentAssociation(db.Model): + __tablename__ = 'alert_incident_association' + + alert_id = Column(ForeignKey('alerts.alert_id'), primary_key=True, nullable=False) + incident_id = Column(ForeignKey('incidents.incident_id'), primary_key=True, nullable=False, index=True) + + +class Incident(db.Model): + __tablename__ = 'incidents' + + incident_id = Column(BigInteger, primary_key=True) + incident_uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, nullable=False, + server_default=text('gen_random_uuid()'), unique=True) + incident_title = Column(Text, nullable=False) + incident_description = Column(Text) + incident_status_id = Column(ForeignKey('incident_status.status_id'), nullable=False) + incident_severity_id = Column(ForeignKey('severities.severity_id'), nullable=True) + incident_customer_id = Column(ForeignKey('client.client_id'), nullable=False) + incident_owner_id = Column(ForeignKey('user.id'), nullable=True) + incident_creation_time = Column(DateTime, nullable=False, server_default=text('now()')) + incident_source_rule_id = Column(ForeignKey('incident_rules.rule_id'), nullable=True) + incident_case_id = Column(ForeignKey('cases.case_id'), nullable=True) + # Stacking dedupe key computed from the rule that opened the incident. + # Rows sharing this key inside the rule's time window are merged into + # the same open incident. Left free-text so different rules can key + # by whatever fields they group_by. + incident_dedupe_key = Column(Text, nullable=True, index=True) + # Cached investigation-flow attachment — mirrors the alert-side FK. + # Set by the flow evaluator when a flow's conditions match this + # incident, so the detail page doesn't re-evaluate every render. + incident_investigation_flow_id = Column( + ForeignKey('investigation_flows.flow_id'), nullable=True + ) + modification_history = Column(JSON) + + status = relationship('IncidentStatus') + severity = relationship('Severity') + customer = relationship('Client') + owner = relationship('User', foreign_keys=[incident_owner_id]) + source_rule = relationship('IncidentRule', foreign_keys=[incident_source_rule_id]) + case = relationship('Cases', foreign_keys=[incident_case_id]) + investigation_flow = relationship( + 'InvestigationFlow', foreign_keys=[incident_investigation_flow_id] + ) + + alerts = relationship('Alert', secondary='alert_incident_association', back_populates='incidents') diff --git a/source/app/models/investigation_flows.py b/source/app/models/investigation_flows.py new file mode 100644 index 000000000..a652497a4 --- /dev/null +++ b/source/app/models/investigation_flows.py @@ -0,0 +1,137 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +import uuid + +from sqlalchemy import BigInteger +from sqlalchemy import Boolean +from sqlalchemy import Column +from sqlalchemy import DateTime +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy import Text +from sqlalchemy import UniqueConstraint +from sqlalchemy import text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from app.db import db + + +# `flow_target` values — a flow declares whether it applies to alerts, +# incidents, or both. Rules used to decide flow attachment; that was +# consolidated into the flow itself so there's one place to look for +# "when does this flow apply". +FLOW_TARGET_ALERT = 'alert' +FLOW_TARGET_INCIDENT = 'incident' +FLOW_TARGET_BOTH = 'both' +FLOW_TARGETS = (FLOW_TARGET_ALERT, FLOW_TARGET_INCIDENT, FLOW_TARGET_BOTH) + + +class InvestigationFlow(db.Model): + __tablename__ = 'investigation_flows' + + flow_id = Column(BigInteger, primary_key=True) + flow_uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, nullable=False, + server_default=text('gen_random_uuid()'), unique=True) + flow_name = Column(Text, nullable=False) + flow_description = Column(Text) + flow_is_active = Column(Boolean, nullable=False, default=True, server_default=text('true')) + # null → all customers; otherwise list of client_id ints + flow_customer_scope = Column(JSONB, nullable=True) + # Which entities this flow can attach to: 'alert' / 'incident' / 'both'. + # See FLOW_TARGET_* constants. + flow_target = Column(Text, nullable=False, + server_default=text("'alert'"), default=FLOW_TARGET_ALERT) + # Match conditions — same DSL shape as `IncidentRule.rule_conditions` + # (`{"logic": "and", "conditions": [{field, operator, value}, ...]}`) + # evaluated by `app.datamgmt.filtering.apply_custom_conditions`. + # A flow with an empty condition list never auto-attaches; use + # /deploy against manual selection or wait for a rule to expand. + flow_conditions = Column(JSONB, nullable=False, server_default=text("'{\"logic\":\"and\",\"conditions\":[]}'::jsonb")) + # Priority for tie-breaking when multiple flows match the same entity; + # lower = higher priority, matching the incident-rules convention. + flow_priority = Column(Integer, nullable=False, default=100, server_default=text('100')) + flow_created_by = Column(ForeignKey('user.id'), nullable=True) + flow_created_at = Column(DateTime, nullable=False, server_default=text('now()')) + flow_updated_at = Column(DateTime, nullable=False, server_default=text('now()')) + + creator = relationship('User', foreign_keys=[flow_created_by]) + steps = relationship( + 'InvestigationFlowStep', + back_populates='flow', + cascade='all, delete-orphan', + order_by='InvestigationFlowStep.step_order' + ) + + +class InvestigationFlowStep(db.Model): + __tablename__ = 'investigation_flow_steps' + + step_id = Column(BigInteger, primary_key=True) + flow_id = Column(ForeignKey('investigation_flows.flow_id', ondelete='CASCADE'), nullable=False, index=True) + step_order = Column(Integer, nullable=False) + step_title = Column(Text, nullable=False) + step_description = Column(Text) + step_is_required = Column(Boolean, nullable=False, default=False, server_default=text('false')) + + flow = relationship('InvestigationFlow', back_populates='steps') + + +class AlertInvestigationProgress(db.Model): + __tablename__ = 'alert_investigation_progress' + __table_args__ = ( + UniqueConstraint('alert_id', 'step_id', name='uq_alert_investigation_progress_alert_step'), + ) + + id = Column(BigInteger, primary_key=True) + alert_id = Column(ForeignKey('alerts.alert_id', ondelete='CASCADE'), nullable=False, index=True) + step_id = Column(ForeignKey('investigation_flow_steps.step_id', ondelete='CASCADE'), nullable=False) + completed_by_user_id = Column(ForeignKey('user.id'), nullable=False) + completed_at = Column(DateTime, nullable=False, server_default=text('now()')) + note = Column(Text) + + alert = relationship('Alert') + step = relationship('InvestigationFlowStep') + completed_by = relationship('User', foreign_keys=[completed_by_user_id]) + + +class IncidentInvestigationProgress(db.Model): + """Per-incident, per-step check-off. Mirrors AlertInvestigationProgress + but scoped to an incident so the incident-level checklist is fully + independent of any member alert's checklist — an analyst working the + incident as a whole may cover steps that no single alert has yet.""" + __tablename__ = 'incident_investigation_progress' + __table_args__ = ( + UniqueConstraint('incident_id', 'step_id', + name='uq_incident_investigation_progress_incident_step'), + ) + + id = Column(BigInteger, primary_key=True) + incident_id = Column(ForeignKey('incidents.incident_id', ondelete='CASCADE'), + nullable=False, index=True) + step_id = Column(ForeignKey('investigation_flow_steps.step_id', ondelete='CASCADE'), + nullable=False) + completed_by_user_id = Column(ForeignKey('user.id'), nullable=False) + completed_at = Column(DateTime, nullable=False, server_default=text('now()')) + note = Column(Text) + + incident = relationship('Incident') + step = relationship('InvestigationFlowStep') + completed_by = relationship('User', foreign_keys=[completed_by_user_id]) diff --git a/source/app/models/mail.py b/source/app/models/mail.py new file mode 100644 index 000000000..96b2ef227 --- /dev/null +++ b/source/app/models/mail.py @@ -0,0 +1,106 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Mail ingest models. + +`MailIngestRule` drives the inbound router — incoming email is +matched against enabled rules in `priority` order (lowest first), +first hit wins, and `action` decides `create_alert` / `create_case` +/ `drop`. + +`MailIngestLog` is an audit trail keyed by `Message-ID`. Repeat polls +of the mailbox can safely dedupe by INSERT-then-catch on the PK; the +row also records the outcome so the admin log UI can render "3 alerts +created / 2 dropped / 1 error" without scanning source objects. +""" + +from sqlalchemy import BigInteger +from sqlalchemy import Boolean +from sqlalchemy import Column +from sqlalchemy import DateTime +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy import String +from sqlalchemy import Text +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.db import db + + +# ---- Action & outcome vocab. Kept as constants (not enums) so a +# ---- follow-up migration isn't needed to add e.g. `create_ioc`. +ACTION_CREATE_ALERT = 'create_alert' +ACTION_CREATE_CASE = 'create_case' +ACTION_DROP = 'drop' +VALID_ACTIONS = (ACTION_CREATE_ALERT, ACTION_CREATE_CASE, ACTION_DROP) + +OUTCOME_ALERT_CREATED = 'alert_created' +OUTCOME_CASE_CREATED = 'case_created' +OUTCOME_SKIPPED_BY_RULE = 'skipped_by_rule' +OUTCOME_NO_RULE_MATCH = 'no_rule_match' +OUTCOME_ERROR = 'error' + + +class MailIngestRule(db.Model): + __tablename__ = 'mail_ingest_rule' + + id = Column(BigInteger, primary_key=True) + name = Column(String(255), nullable=False) + priority = Column(Integer, nullable=False, default=100) + enabled = Column(Boolean, nullable=False, default=True) + + # All predicates nullable — a rule with no predicates matches + # every message and serves as the fallback catch-all. The router + # applies the predicates as AND when multiple are set. + match_subject_regex = Column(Text, nullable=True) + match_from_regex = Column(Text, nullable=True) + match_to_regex = Column(Text, nullable=True) + + action = Column(String(32), nullable=False, default=ACTION_CREATE_ALERT) + + customer_id = Column(BigInteger, ForeignKey('client.client_id', + ondelete='SET NULL'), + nullable=True) + case_template_id = Column(Integer, ForeignKey('case_template.id', + ondelete='SET NULL'), + nullable=True) + severity_id = Column(Integer, ForeignKey('severities.severity_id', + ondelete='SET NULL'), + nullable=True) + assignee_user_id = Column(BigInteger, ForeignKey('user.id', + ondelete='SET NULL'), + nullable=True) + + created_by_id = Column(BigInteger, ForeignKey('user.id', + ondelete='SET NULL'), + nullable=True) + created_at = Column(DateTime, nullable=False, server_default=func.now()) + updated_at = Column(DateTime, nullable=True, onupdate=func.now()) + + customer = relationship('Client', foreign_keys=[customer_id]) + case_template = relationship('CaseTemplate', foreign_keys=[case_template_id]) + severity = relationship('Severity', foreign_keys=[severity_id]) + assignee = relationship('User', foreign_keys=[assignee_user_id]) + created_by = relationship('User', foreign_keys=[created_by_id]) + + +class MailIngestLog(db.Model): + __tablename__ = 'mail_ingest_log' + + # The RFC-822 `Message-ID:` header value (angle brackets stripped). + # PK — the same message arriving twice must be a no-op. We rely on + # this uniqueness for the poller's dedupe path. + message_id = Column(String(998), primary_key=True) + received_at = Column(DateTime, nullable=False, server_default=func.now()) + outcome = Column(String(32), nullable=False) + outcome_object_id = Column(BigInteger, nullable=True) + rule_id = Column(BigInteger, ForeignKey('mail_ingest_rule.id', + ondelete='SET NULL'), + nullable=True) + from_addr = Column(String(320), nullable=True) + subject = Column(Text, nullable=True) + error = Column(Text, nullable=True) + + rule = relationship('MailIngestRule', foreign_keys=[rule_id]) diff --git a/source/app/models/models.py b/source/app/models/models.py index 66cd380ef..5dbd55127 100644 --- a/source/app/models/models.py +++ b/source/app/models/models.py @@ -490,6 +490,7 @@ class UserActivity(db.Model): id = Column(BigInteger, primary_key=True) user_id = Column(ForeignKey('user.id'), nullable=True) case_id = Column(ForeignKey('cases.case_id'), nullable=True) + war_room_id = Column(BigInteger, ForeignKey('war_room.war_room_id', ondelete='SET NULL'), nullable=True) activity_date = Column(DateTime) activity_desc = Column(Text) user_input = Column(Boolean, default=False) @@ -498,6 +499,11 @@ class UserActivity(db.Model): user = relationship('User') case = relationship('Cases') + # No ORM `war_room` relationship on purpose: `app.models.war_rooms` + # isn't guaranteed to be imported at mapper-config time, and every + # consumer of this row joins WarRoom via an explicit Core outerjoin + # (see `activities_db.list_activities_paginated`). The FK column + # alone is enough for the audit-log use case. class ServerSettings(db.Model): @@ -518,6 +524,33 @@ class ServerSettings(db.Model): enforce_mfa = Column(Boolean) force_confirmation_before_delete = Column(Boolean) + # ---- Mail — outbound (SMTP) -------------------------------------- + # Split into distinct fields (rather than a JSONB blob) so the + # Marshmallow schema can validate each one and the admin UI can + # bind checkboxes / inputs directly. `mail_smtp_password` holds + # Fernet-encrypted ciphertext when set — the raw password never + # touches the DB. See app/iris_engine/mail/secrets.py. + mail_smtp_enabled = Column(Boolean, nullable=True, default=False) + mail_smtp_host = Column(String(255), nullable=True) + mail_smtp_port = Column(Integer, nullable=True) + mail_smtp_user = Column(String(255), nullable=True) + mail_smtp_password = Column(Text, nullable=True) + mail_smtp_use_tls = Column(Boolean, nullable=True, default=True) + mail_smtp_use_ssl = Column(Boolean, nullable=True, default=False) + mail_from_address = Column(String(255), nullable=True) + mail_from_name = Column(String(255), nullable=True) + + # ---- Mail — inbound (IMAP) --------------------------------------- + mail_imap_enabled = Column(Boolean, nullable=True, default=False) + mail_imap_host = Column(String(255), nullable=True) + mail_imap_port = Column(Integer, nullable=True) + mail_imap_user = Column(String(255), nullable=True) + mail_imap_password = Column(Text, nullable=True) + mail_imap_use_ssl = Column(Boolean, nullable=True, default=True) + mail_imap_mailbox = Column(String(255), nullable=True, default='INBOX') + mail_imap_poll_interval_sec = Column(Integer, nullable=True, default=300) + mail_imap_max_attachment_mb = Column(Integer, nullable=True, default=20) + class IrisModule(db.Model): __tablename__ = "iris_module" diff --git a/source/app/models/notifications.py b/source/app/models/notifications.py new file mode 100644 index 000000000..1c7c04d68 --- /dev/null +++ b/source/app/models/notifications.py @@ -0,0 +1,129 @@ +# 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. + +"""Notification core: the `notification` and `notification_setting` tables. + +A `Notification` row is a *materialised* event addressed to one user: the +title/body/link are rendered at fire time and persisted on the row so the +bell dropdown survives deletion of the source object (deleting a case +should not erase the mention notification that pointed at it — the user +still needs to know they were pinged). + +`NotificationSetting` implements a two-tier config: admin rows have +`user_id IS NULL` and define the org default, per-user rows override. +The effective setting for `(user, event_type, channel)` is the user row +if present, else the admin row, else the built-in default (see +`iris_engine/notifications/service.py`). +""" + +from sqlalchemy import BigInteger +from sqlalchemy import Boolean +from sqlalchemy import Column +from sqlalchemy import DateTime +from sqlalchemy import ForeignKey +from sqlalchemy import Index +from sqlalchemy import Integer +from sqlalchemy import String +from sqlalchemy import Text +from sqlalchemy import UniqueConstraint +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.db import db + + +# Notification event types. Kept as free-form strings (not a DB enum) so +# modules can register their own event types via the `module_custom` +# convention without a migration. The set below is what the core fires; +# admin defaults + user prefs are only seeded for these. +EVENT_TYPES = ( + 'mention', + 'task_assigned', + 'case_state_change', + 'case_assigned', + 'alert_assigned', + 'alert_escalated', + 'war_room_message', + 'war_room_thread_reply', + 'module_custom', +) + +CHANNELS = ('in_app', 'email') + + +class Notification(db.Model): + __tablename__ = 'notification' + + id = Column(BigInteger, primary_key=True) + user_id = Column(BigInteger, ForeignKey('user.id', ondelete='CASCADE'), + nullable=False, index=True) + event_type = Column(String(64), nullable=False) + + # Rendered at fire time so deleting the source object doesn't nuke + # the notification. Kept as short strings to keep the bell payload + # small — the SPA follows `link` for the full context. + title = Column(String(255), nullable=False) + body = Column(Text, nullable=True) + link = Column(String(1024), nullable=True) + + # Polymorphic pointer for dedupe and cleanup jobs. Not a FK — the + # source table varies (notes, comments, cases, alerts, war_room …). + source_type = Column(String(64), nullable=True) + source_id = Column(BigInteger, nullable=True) + + # `read_at IS NULL` = unread. The bell dropdown filters on this. + read_at = Column(DateTime, nullable=True) + # Set once the outbound email has been handed off to SMTP (or the + # user's email channel is disabled, so we skipped it). Lets the + # /notifications feed distinguish "queued" vs "delivered" if we ever + # add a retry surface. + emailed_at = Column(DateTime, nullable=True) + + created_at = Column(DateTime, nullable=False, + server_default=func.now()) + + user = relationship('User', backref='notifications') + + __table_args__ = ( + # Powers the bell dropdown: unread rows for one user, newest + # first. The `read_at IS NULL` predicate is highly selective for + # active users, so this compound index keeps that hot path cheap. + Index('ix_notification_user_unread', + 'user_id', 'read_at', 'created_at'), + ) + + +class NotificationSetting(db.Model): + __tablename__ = 'notification_setting' + + id = Column(BigInteger, primary_key=True) + # NULL = admin default (org-wide). Not-NULL = per-user override. + # `ondelete=CASCADE` so removing a user drops their prefs; admin + # defaults (NULL) survive since no FK matches. + user_id = Column(BigInteger, ForeignKey('user.id', ondelete='CASCADE'), + nullable=True) + event_type = Column(String(64), nullable=False) + channel = Column(String(16), nullable=False) + enabled = Column(Boolean, nullable=False, default=True) + + updated_at = Column(DateTime, nullable=True, onupdate=func.now()) + + user = relationship('User') + + __table_args__ = ( + # We rely on this unique constraint to upsert prefs in one + # statement. Admin defaults (user_id NULL) and per-user rows + # live in the same table but never collide because Postgres + # treats NULLs as distinct in a unique index — that's fine here + # because we only ever want ONE admin default per (event, + # channel), and we enforce that via a partial index in the + # migration (uq_notification_setting_admin_default). + UniqueConstraint('user_id', 'event_type', 'channel', + name='uq_notification_setting_scope'), + ) diff --git a/source/app/models/war_rooms.py b/source/app/models/war_rooms.py new file mode 100644 index 000000000..6d219a39b --- /dev/null +++ b/source/app/models/war_rooms.py @@ -0,0 +1,447 @@ +# 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. + +"""SQLAlchemy models for the War Room feature. + +A war room is a crisis-coordination workspace that aggregates multiple +cases under a single command view. It is intentionally peer-of-case +rather than child-of-case: a single war room can pull from any number +of cases the operator has access to, while each case remains +authoritative for its own evidence and investigation. + +The tables live here (not in `cases.py`) because a war room is *not* +case-scoped, and the per-war-room ACL precedence (default → group → +user) is identical to the case ACL but routes to a different set of +join tables. +""" + +import enum +import uuid + +from sqlalchemy import BigInteger +from sqlalchemy import Boolean +from sqlalchemy import CheckConstraint +from sqlalchemy import Column +from sqlalchemy import DateTime +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy import String +from sqlalchemy import Text +from sqlalchemy import UniqueConstraint +from sqlalchemy import text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from app.db import db + + +class WarRoomState(enum.Enum): + """Lifecycle states. + + `open` = newly created, gathering scope. `active` = the crisis is + being actively run from this room. `standby` = monitored but + quiescent. `closed` = archival — chat input disabled, read-only. + """ + open = 'open' + active = 'active' + standby = 'standby' + closed = 'closed' + + +class WarRoomMemberRole(enum.Enum): + """Soft roles inside a war room. + + These are presentation/coordination hints — `lead` shows up as the + Incident Commander chip, `responder` is the default for IR + operators, `observer` is for stakeholders who need visibility + without write access. The real access gate is the war-room ACL + layer, not this role. + """ + lead = 'lead' + responder = 'responder' + observer = 'observer' + + +class WarRoom(db.Model): + __tablename__ = 'war_room' + + war_room_id = Column(BigInteger, primary_key=True) + war_room_uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, + server_default=text('gen_random_uuid()'), + nullable=False, unique=True) + name = Column(String(256), nullable=False) + description = Column(Text, nullable=True) + state = Column(String(16), nullable=False, + server_default=text("'open'")) + severity_id = Column(BigInteger, ForeignKey('severities.severity_id'), 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) + closed_at = Column(DateTime, nullable=True) + closed_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + # Archive is a filing decision independent of the operational state + # (open / active / standby / closed). NULL = live in the default + # list; timestamped = moved out of the default view but still fully + # readable. See migration c4d1a2b7f503. + archived_at = Column(DateTime, nullable=True) + archived_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + custom_attributes = Column(JSONB, nullable=True) + + created_by = relationship('User', foreign_keys=[created_by_id]) + closed_by = relationship('User', foreign_keys=[closed_by_id]) + archived_by = relationship('User', foreign_keys=[archived_by_id]) + severity = relationship('Severity') + + +class WarRoomCase(db.Model): + """Many-to-many: a war room aggregates N cases.""" + __tablename__ = 'war_room_case' + __table_args__ = ( + UniqueConstraint('war_room_id', 'case_id', name='uq_war_room_case'), + ) + + war_room_id = Column(BigInteger, + ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + primary_key=True, nullable=False) + case_id = Column(BigInteger, + ForeignKey('cases.case_id', ondelete='CASCADE'), + primary_key=True, nullable=False, index=True) + attached_at = Column(DateTime, nullable=False, server_default=text('now()')) + attached_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + note = Column(Text, nullable=True) + + war_room = relationship('WarRoom') + case = relationship('Cases') + attached_by = relationship('User') + + +class WarRoomMember(db.Model): + """Roster of users assigned to a war room with their soft role. + + Distinct from the ACL — being a member is a presentation/notification + affordance, while access is enforced by `UserWarRoomEffectiveAccess`. + Most flows add a row here AND set the corresponding ACL entry in one + transaction, but they're decoupled so admin tooling can grant + read-only ACL access to observers who aren't formal members. + """ + __tablename__ = 'war_room_member' + __table_args__ = ( + UniqueConstraint('war_room_id', 'user_id', name='uq_war_room_member'), + ) + + war_room_id = Column(BigInteger, + ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + primary_key=True, nullable=False) + user_id = Column(BigInteger, ForeignKey('user.id', ondelete='CASCADE'), + primary_key=True, nullable=False) + role = Column(String(16), nullable=False, server_default=text("'responder'")) + added_at = Column(DateTime, nullable=False, server_default=text('now()')) + added_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + + war_room = relationship('WarRoom') + 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. + + Operationally identical to `CaseTimeline` but scoped to the war + room. Every war room gets an `is_default=True` row named "Main" + at creation time; new chat-derived events and cross-case event + pull-ins are attached to it unless the caller specifies otherwise. + """ + __tablename__ = 'war_room_timeline' + __table_args__ = ( + UniqueConstraint('war_room_id', 'name', name='uq_war_room_timeline_name'), + ) + + timeline_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(128), nullable=False) + description = Column(Text, nullable=True) + color = Column(String(7), nullable=True) + is_default = Column(Boolean, nullable=False, default=False, + server_default=text('false')) + 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 WarRoomTimelineEvent(db.Model): + """Polymorphic timeline entry. + + Carries either a reference to a case event (the dominant case) OR a + 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. + """ + __tablename__ = 'war_room_timeline_event' + __table_args__ = ( + CheckConstraint( + "(case_id IS NULL AND event_id IS NULL) OR " + "(case_id IS NOT NULL AND event_id IS NOT NULL)", + name='ck_war_room_timeline_event_case_event_pair' + ), + ) + + id = Column(BigInteger, primary_key=True) + timeline_id = Column(BigInteger, + ForeignKey('war_room_timeline.timeline_id', ondelete='CASCADE'), + nullable=False, index=True) + case_id = Column(BigInteger, + ForeignKey('cases.case_id', ondelete='SET NULL'), + nullable=True) + event_id = Column(BigInteger, + ForeignKey('cases_events.event_id', ondelete='CASCADE'), + nullable=True) + title = Column(Text, nullable=True) + content = Column(Text, nullable=True) + 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) + created_at = Column(DateTime, nullable=False, server_default=text('now()')) + created_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + + timeline = relationship('WarRoomTimeline') + case = relationship('Cases') + event = relationship('CasesEvent') + created_by = relationship('User') + + +class WarRoomChatMessage(db.Model): + """A single entry in the war room chat stream. + + `kind` discriminates between operator-authored messages and the + system rows we synthesise from events elsewhere in IRIS (a case + activity row, a task assignment, a published SitRep). `ref_type` + and `ref_id` point back to the source object so the chat row can + render an inline preview without duplicating the source data. + """ + __tablename__ = 'war_room_chat_message' + + message_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) + body = Column(Text, nullable=True) + # Discriminator. Lower-case strings rather than an enum so adding a + # new system message type doesn't require a migration. + kind = Column(String(32), nullable=False, + server_default=text("'message'")) + # Pointer back to the originating object when `kind` is not 'message'. + # `ref_type` is a short label like 'user_activity', 'case_task', + # 'sitrep', 'war_room_task'. `ref_case_id` is denormalised so we can + # cheaply filter the chat by case without a join. + ref_type = Column(String(32), nullable=True) + ref_id = Column(BigInteger, nullable=True) + ref_case_id = Column(BigInteger, ForeignKey('cases.case_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 + # never themselves be parents (enforced at the business layer, not + # at the DB, so we don't pay for a CHECK on every insert). + # + # `thread_title` is set on root messages that have been promoted to + # named topics via `/thread `. NULL means the message is a + # plain root with no name; readers fall back to a truncation of the + # body for the threads list. + parent_message_id = Column(BigInteger, + ForeignKey('war_room_chat_message.message_id', + ondelete='CASCADE'), + nullable=True, index=True) + thread_title = Column(String(160), nullable=True) + # `activity_type` USED to live here when case activity was backfilled + # into the chat table. The stream now pulls UserActivity rows live + # and classifies them at read time, so this column is never + # written to and never selected. The Alembic migration that adds + # the column (`e5a1b46c7d92`) is still kept for forward-compat + # in case we want server-side filtering by type later, but + # absence of the column at the DB level is intentionally tolerated. + created_at = Column(DateTime, nullable=False, server_default=text('now()')) + edited_at = Column(DateTime, nullable=True) + deleted_at = Column(DateTime, nullable=True) + + war_room = relationship('WarRoom') + author = relationship('User') + + +class WarRoomThreadFollower(db.Model): + """Per-user follow flag for a thread root. + + Pure indicator for now — used by the UI to show the followed-thread + pill and (later) to drive notifications. One row per (user, root) + pair; deleted to unfollow. + """ + __tablename__ = 'war_room_thread_follower' + __table_args__ = ( + UniqueConstraint('message_id', 'user_id', + name='uq_war_room_thread_follower'), + ) + + id = Column(BigInteger, primary_key=True) + # The thread root — always a `WarRoomChatMessage` with + # `parent_message_id IS NULL`. + message_id = Column(BigInteger, + ForeignKey('war_room_chat_message.message_id', + ondelete='CASCADE'), + nullable=False, index=True) + user_id = Column(BigInteger, ForeignKey('user.id', ondelete='CASCADE'), + nullable=False, index=True) + created_at = Column(DateTime, nullable=False, server_default=text('now()')) + + +class WarRoomChatReaction(db.Model): + __tablename__ = 'war_room_chat_reaction' + __table_args__ = ( + UniqueConstraint('message_id', 'user_id', 'emoji', + name='uq_war_room_chat_reaction'), + ) + + id = Column(BigInteger, primary_key=True) + message_id = Column(BigInteger, + ForeignKey('war_room_chat_message.message_id', ondelete='CASCADE'), + nullable=False, index=True) + user_id = Column(BigInteger, ForeignKey('user.id', ondelete='CASCADE'), + nullable=False) + emoji = Column(String(32), nullable=False) + created_at = Column(DateTime, nullable=False, server_default=text('now()')) + + +class WarRoomTask(db.Model): + """Task tracked at the war-room level. + + Separate from `CaseTasks` because a war-room task can be cross-case + (or non-case, e.g. "draft external comms statement"). When promoted + from / linked to a case task the link goes in + `source_case_task_id`. + """ + __tablename__ = 'war_room_task' + + task_id = Column(BigInteger, primary_key=True) + war_room_id = Column(BigInteger, + ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + nullable=False, index=True) + title = Column(Text, nullable=False) + description = Column(Text, nullable=True) + status_id = Column(Integer, ForeignKey('task_status.id'), nullable=True) + assignee_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + due_at = Column(DateTime, nullable=True) + source_case_id = Column(BigInteger, + ForeignKey('cases.case_id', ondelete='SET NULL'), + nullable=True) + source_case_task_id = Column(BigInteger, + ForeignKey('case_tasks.id', ondelete='SET NULL'), + nullable=True) + created_at = Column(DateTime, nullable=False, server_default=text('now()')) + created_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + closed_at = Column(DateTime, nullable=True) + closed_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + tags = Column(Text, nullable=True) + custom_attributes = Column(JSONB, nullable=True) + + war_room = relationship('WarRoom') + status = relationship('TaskStatus') + assignee = relationship('User', foreign_keys=[assignee_id]) + created_by = relationship('User', foreign_keys=[created_by_id]) + closed_by = relationship('User', foreign_keys=[closed_by_id]) + source_case = relationship('Cases') + + +class WarRoomNote(db.Model): + """Note pinned to a war room. + + Mirrors the per-case Notes pattern but scoped to a war room. Body is + markdown — the collab markdown editor is wired in the frontend via + the existing socket channel. + """ + __tablename__ = 'war_room_note' + + note_id = Column(BigInteger, primary_key=True) + war_room_id = Column(BigInteger, + ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + nullable=False, index=True) + title = Column(Text, nullable=False) + content = Column(Text, 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') + created_by = relationship('User', foreign_keys=[created_by_id]) + updated_by = relationship('User', foreign_keys=[updated_by_id]) + + +class WarRoomSitRep(db.Model): + """Versioned situational report. + + Each row is an immutable-once-published snapshot. `snapshot_json` + freezes the war-room context (attached cases, task counts, last + activity) at the moment of publish so historical SitReps stay + coherent even as the underlying state evolves. + """ + __tablename__ = 'war_room_sitrep' + __table_args__ = ( + UniqueConstraint('war_room_id', 'version', name='uq_war_room_sitrep_version'), + ) + + sitrep_id = Column(BigInteger, primary_key=True) + war_room_id = Column(BigInteger, + ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + nullable=False, index=True) + version = Column(Integer, nullable=False) + title = Column(Text, nullable=False) + body_md = Column(Text, nullable=False) + snapshot_json = Column(JSONB, nullable=True) + authored_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + authored_at = Column(DateTime, nullable=False, server_default=text('now()')) + published = Column(Boolean, nullable=False, default=False, + server_default=text('false')) + + war_room = relationship('WarRoom') + authored_by = relationship('User') + + +class WarRoomDatastoreFile(db.Model): + """File stored in the war-room datastore. + + The war room's "dedicated space" — files uploaded directly into the + war room (after-action photos, exported SitReps, evidence bundles + not yet attributable to a single case). Files attached at the + war-room level live here; files belonging to a specific attached + case still live in that case's existing datastore and are surfaced + in the unified view as virtual rows joined from the cases. + """ + __tablename__ = 'war_room_datastore_file' + + file_id = Column(BigInteger, primary_key=True) + war_room_id = Column(BigInteger, + ForeignKey('war_room.war_room_id', ondelete='CASCADE'), + nullable=False, index=True) + filename = Column(Text, nullable=False) + description = Column(Text, nullable=True) + storage_path = Column(Text, nullable=False) + size_bytes = Column(BigInteger, nullable=False) + mime_type = Column(String(128), nullable=True) + sha256 = Column(String(64), nullable=True) + uploaded_at = Column(DateTime, nullable=False, server_default=text('now()')) + uploaded_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + tags = Column(Text, nullable=True) + + war_room = relationship('WarRoom') + uploaded_by = relationship('User') diff --git a/source/app/post_init.py b/source/app/post_init.py index 5a09e9060..aec9326a1 100644 --- a/source/app/post_init.py +++ b/source/app/post_init.py @@ -53,6 +53,7 @@ from app.models.alerts import Severity from app.models.alerts import AlertStatus from app.models.alerts import AlertResolutionStatus +from app.models.incidents import IncidentStatus from app.models.authorization import CaseAccessLevel from app.models.authorization import Group from app.models.authorization import User @@ -572,6 +573,104 @@ def create_safe_hooks(): create_safe(db.session, IrisHook, hook_name='on_postload_alert_comment_delete', hook_description='Triggered on alert comment deletion, after commit in DB') + # --- War Room + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_create', + hook_description='Triggered on war room creation, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_update', + hook_description='Triggered on war room update, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_delete', + hook_description='Triggered on war room deletion, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_archive', + hook_description='Triggered on war room archival, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_unarchive', + hook_description='Triggered on war room unarchival, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_member_add', + hook_description='Triggered on war room member addition, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_member_remove', + hook_description='Triggered on war room member removal, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_case_attach', + hook_description='Triggered on case attachment to a war room, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_case_detach', + hook_description='Triggered on case detachment from a war room, after commit in DB') + + # --- War Room notes + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_note_create', + hook_description='Triggered on war room note creation, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_note_update', + hook_description='Triggered on war room note update, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_note_delete', + hook_description='Triggered on war room note deletion, after commit in DB') + + # --- War Room tasks + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_task_create', + hook_description='Triggered on war room task creation, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_task_update', + hook_description='Triggered on war room task update, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_task_delete', + hook_description='Triggered on war room task deletion, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_task_close', + hook_description='Triggered on war room task close, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_task_reopen', + hook_description='Triggered on war room task reopen, after commit in DB') + + # --- War Room chat + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_message_create', + hook_description='Triggered on war room chat message creation, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_message_update', + hook_description='Triggered on war room chat message update, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_message_delete', + hook_description='Triggered on war room chat message deletion, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_reply_create', + 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') + + # --- War Room timelines + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_timeline_create', + hook_description='Triggered on war room timeline creation, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_timeline_update', + hook_description='Triggered on war room timeline update, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_timeline_delete', + hook_description='Triggered on war room timeline deletion, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_timeline_event_create', + hook_description='Triggered on war room timeline event creation, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_timeline_event_update', + hook_description='Triggered on war room timeline event update, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_timeline_event_delete', + hook_description='Triggered on war room timeline event deletion, after commit in DB') + + # --- War Room sitreps + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_sitrep_create', + hook_description='Triggered on war room sitrep draft creation, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_sitrep_update', + hook_description='Triggered on war room sitrep update, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_sitrep_publish', + hook_description='Triggered on war room sitrep publish, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_sitrep_delete', + hook_description='Triggered on war room sitrep deletion, after commit in DB') + + # --- War Room datastore + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_datastore_file_create', + hook_description='Triggered on war room datastore file upload, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_war_room_datastore_file_delete', + hook_description='Triggered on war room datastore file deletion, after commit in DB') + + # --- Incidents + create_safe(db.session, IrisHook, hook_name='on_postload_incident_create', + hook_description='Triggered on incident creation, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_incident_update', + hook_description='Triggered on incident update, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_incident_delete', + hook_description='Triggered on incident deletion, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_incident_alert_add', + hook_description='Triggered on alert(s) linked to an incident, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_incident_alert_remove', + hook_description='Triggered on alert unlinked from an incident, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_incident_escalate', + hook_description='Triggered on incident escalation to a case, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_incident_merge', + hook_description='Triggered on incident merge into an existing case, after commit in DB') + def create_safe_languages(): """Creates new Language objects if they do not already exist. @@ -716,6 +815,18 @@ def create_safe_alert_status(): create_safe(db.session, AlertStatus, status_name='Escalated', status_description="Alert converted to a new case") +def create_safe_incident_status(): + """Seed the IncidentStatus lookup. Idempotent — safe to re-run at boot.""" + create_safe(db.session, IncidentStatus, status_name='Open', + status_description='Incident is open and receiving alerts') + create_safe(db.session, IncidentStatus, status_name='Investigating', + status_description='Incident is being investigated by an analyst') + create_safe(db.session, IncidentStatus, status_name='Dismissed', + status_description='Incident closed with no further action') + create_safe(db.session, IncidentStatus, status_name='Escalated', + status_description='Incident escalated to a case') + + def create_safe_evidence_types(): """Creates new Evidence Types objects if they do not already exist. @@ -997,62 +1108,57 @@ def create_safe_default_organisation(): def create_safe_group(name, description, auto_follow, auto_follow_access_level, permissions): + # Returns (group, created). `created=False` means the group was + # already in the database — callers use that signal to avoid + # overwriting admin-edited attributes on subsequent worker boots. try: - return groups_get_by_name(name) + return groups_get_by_name(name), False except ObjectNotFoundError: group = Group(group_name=name, group_description=description, group_auto_follow=auto_follow, group_auto_follow_access_level=auto_follow_access_level, group_permissions=permissions) - return groups_create(group) + return groups_create(group), True -# TODO is it really necessary to do all that? -# shouldn't the migration upgrade step already have put the database in the expected state? -# and shouldn't we protect modifications on initial entries that are not supposed to be modified -# (maybe comparing the identifier of the objects that are to be updated with the last identifier after database -# initialization) def create_safe_auth_model(): - """Creates new Organisation, Group, and User objects if they do not already exist. - - This function creates a new Organisation object with the specified name and description, - and creates new Group objects with the specified name, description, auto-follow status, - auto-follow access level, and permissions if they do not already exist in the database. - It also updates the attributes of the existing Group objects if they have changed. - + """Ensure the default org + built-in Administrators/Analysts groups exist. + + Historically this function also *re-synced* the built-in groups' + permissions and auto-follow attributes on every boot. That silently + clobbered any changes an admin had made through the UI: PATCH the + Analysts permissions → 200 → next worker restart → post_init runs + → the row snaps back to `ac_get_mask_analyst()`. From now on we + only seed the defaults when the group is *first* created; + subsequent boots leave admin-edited attributes alone. """ def_org = create_safe_default_organisation() - # Create new Administrator Group object - gadm = create_safe_group('Administrators', 'Administrators', True, - CaseAccessLevel.full_access.value, ac_get_mask_full_permissions()) - - # Update Administrator Group object attributes - if gadm.group_permissions != ac_get_mask_full_permissions(): + gadm, gadm_created = create_safe_group( + 'Administrators', 'Administrators', True, + CaseAccessLevel.full_access.value, ac_get_mask_full_permissions()) + + ganalysts, ganalysts_created = create_safe_group( + 'Analysts', 'Standard Analysts', False, + CaseAccessLevel.full_access.value, ac_get_mask_analyst()) + + # Only enforce defaults on the very first boot after the row was + # created. The `_created` branches are effectively no-ops in that + # case (the constructor already set every attribute) but we leave + # them explicit so a future change to the default mask still + # propagates onto a freshly-created group without a second + # migration. + if gadm_created: gadm.group_permissions = ac_get_mask_full_permissions() - - if gadm.group_auto_follow_access_level != CaseAccessLevel.full_access.value: gadm.group_auto_follow_access_level = CaseAccessLevel.full_access.value - - if gadm.group_auto_follow is not True: gadm.group_auto_follow = True - db.session.commit() - - # Create new Analysts Group object - ganalysts = create_safe_group('Analysts', 'Standard Analysts', False, - CaseAccessLevel.full_access.value, ac_get_mask_analyst()) - - # Update Analysts Group object attributes - if ganalysts.group_permissions != ac_get_mask_analyst(): + if ganalysts_created: ganalysts.group_permissions = ac_get_mask_analyst() - - if ganalysts.group_auto_follow: ganalysts.group_auto_follow = False - - if ganalysts.group_auto_follow_access_level != CaseAccessLevel.full_access.value: ganalysts.group_auto_follow_access_level = CaseAccessLevel.full_access.value - db.session.commit() + if gadm_created or ganalysts_created: + db.session.commit() return def_org, gadm, ganalysts @@ -1329,6 +1435,9 @@ def run(self): self._logger.info("Creating base alert status") create_safe_alert_status() + self._logger.info("Creating base incident status") + create_safe_incident_status() + self._logger.info("Creating base evidence types") create_safe_evidence_types() @@ -1345,6 +1454,32 @@ def run(self): self._logger.info("Creating base hooks") create_safe_hooks() + # Seed the admin-default notification settings. Idempotent + # so operator toggles from a prior boot survive. + self._logger.info("Seeding notification admin defaults") + from app.iris_engine.notifications.service import seed_admin_defaults + seed_admin_defaults() + + # Wire the built-in notification hook listeners. + # `register_notification_listeners` is idempotent — a + # second call is a no-op. + self._logger.info("Registering notification hook listeners") + from app.iris_engine.notifications.hook_listeners import register_notification_listeners + register_notification_listeners() + + # Import the mail engine so its Celery tasks (outbound + # send + inbound poll) and the beat-schedule connect + # handler register. The connect handler is idempotent + # — if IMAP is disabled the task itself short-circuits + # each tick. Import for side-effects. + self._logger.info("Registering mail Celery tasks + beat schedule") + import app.iris_engine.mail # noqa: F401 side-effects only + + # Register the incident-rules evaluator so the Celery worker + # discovers it at boot. Import for side-effects only. + self._logger.info("Registering incident-rules Celery tasks") + import app.iris_engine.incident_rules # noqa: F401 side-effects only + # Create initial authorization model, administrative user, and customer self._logger.info("Creating initial authorisation model") def_org, gadm, ganalysts = create_safe_auth_model() diff --git a/source/app/schema/marshables.py b/source/app/schema/marshables.py index 454d857e8..23077cd52 100644 --- a/source/app/schema/marshables.py +++ b/source/app/schema/marshables.py @@ -28,6 +28,7 @@ from flask import current_app from marshmallow import EXCLUDE from marshmallow import fields +from marshmallow import post_dump from marshmallow import post_load from marshmallow import pre_load from marshmallow.exceptions import ValidationError @@ -51,6 +52,7 @@ from app.datamgmt.manage.manage_attribute_db import merge_custom_attributes from app.datamgmt.manage.manage_tags_db import add_db_tag from app.datamgmt.case.case_iocs_db import get_ioc_links +from app.datamgmt.case.case_tasks_db import get_task_assignees from app.iris_engine.access_control.utils import ac_mask_from_val_list from app.models.models import SavedFilter from app.models.models import DataStorePath @@ -82,6 +84,14 @@ from app.models.alerts import Severity from app.models.alerts import AlertStatus from app.models.alerts import AlertResolutionStatus +from app.models.incidents import Incident +from app.models.incidents import IncidentStatus +from app.models.incident_rules import IncidentRule +from app.models.incident_rules import RULE_ACTION_CREATE_INCIDENT +from app.models.investigation_flows import InvestigationFlow +from app.models.investigation_flows import InvestigationFlowStep +from app.models.investigation_flows import AlertInvestigationProgress +from app.models.investigation_flows import IncidentInvestigationProgress from app.models.authorization import Group from app.models.authorization import Organisation from app.models.authorization import User @@ -263,8 +273,18 @@ class Meta: model = User load_instance = True include_fk = True + # `avatar_blob` is bytes — never serialise it through JSON; + # the actual image is fetched lazily from + # `/api/v2/users/<id>/avatar`. `avatar_mime` is an + # implementation detail, also dropped. `avatar_updated_at` + # passes through so the SPA can cache-bust the avatar URL. exclude = ['api_key', 'password', 'ctx_case', 'ctx_human_case', 'user', 'name', 'email', - 'is_service_account', 'mfa_secrets', 'webauthn_credentials'] + 'is_service_account', 'mfa_secrets', 'webauthn_credentials', + 'avatar_blob', 'avatar_mime', + # `preferences` has its own dedicated endpoints — no + # reason to ship a potentially large JSONB blob on + # every user serialisation. + 'preferences'] unknown = EXCLUDE @pre_load() @@ -1129,7 +1149,10 @@ class Meta: model = User load_instance = True include_fk = True - exclude = ['password', 'ctx_case', 'ctx_human_case', 'mfa_secrets', 'webauthn_credentials'] + exclude = ['password', 'ctx_case', 'ctx_human_case', 'mfa_secrets', 'webauthn_credentials', + 'avatar_blob', 'avatar_mime', + # `preferences` has its own dedicated endpoints. + 'preferences'] unknown = EXCLUDE @@ -1318,6 +1341,7 @@ class DSFileSchema(ma.SQLAlchemyAutoSchema): file_original_name: str = auto_field('file_original_name', required=True, validate=Length(min=1), allow_none=False) file_description: str = auto_field('file_description', allow_none=False) file_content: Optional[bytes] = fields.Raw(required=False) + file_local_name: Optional[str] = auto_field('file_local_name', required=False, load_only=True) class Meta: model = DataStoreFile @@ -1469,19 +1493,67 @@ def ds_store_file(self, file_storage: FileStorage, location: Path, is_ioc: bool, class ServerSettingsSchema(ma.SQLAlchemyAutoSchema): - """Schema for serializing and deserializing ServerSettings objects. + """Schema for the singleton `ServerSettings` row. - This schema defines the fields to include when serializing and deserializing ServerSettings objects. - It includes fields for the HTTP proxy, HTTPS proxy, and whether to prevent post-modification repush. + Every editable column on the model is declared explicitly so the + SvelteKit `/settings/server` page can introspect the field list + via `Meta.fields` if it ever needs to. Two columns are intentionally + excluded from load and load-only respectively: + * `id` — the row is a singleton anchored at id=1; clients never + get to touch it. + * `has_updates_available` — written by the periodic update + checker, surfaced read-only on the page. """ - http_proxy: Optional[str] = fields.String(required=False, allow_none=False) - https_proxy: Optional[str] = fields.String(required=False, allow_none=False) + http_proxy: Optional[str] = fields.String(required=False, allow_none=True) + https_proxy: Optional[str] = fields.String(required=False, allow_none=True) prevent_post_mod_repush: Optional[bool] = fields.Boolean(required=False) + prevent_post_objects_repush: Optional[bool] = fields.Boolean(required=False) + has_updates_available: Optional[bool] = fields.Boolean(dump_only=True) + enable_updates_check: Optional[bool] = fields.Boolean(required=False) + password_policy_min_length: Optional[int] = fields.Integer(required=False) + password_policy_upper_case: Optional[bool] = fields.Boolean(required=False) + password_policy_lower_case: Optional[bool] = fields.Boolean(required=False) + password_policy_digit: Optional[bool] = fields.Boolean(required=False) + password_policy_special_chars: Optional[str] = fields.String(required=False, allow_none=True) + enforce_mfa: Optional[bool] = fields.Boolean(required=False) + force_confirmation_before_delete: Optional[bool] = fields.Boolean(required=False) + + # ---- Mail — outbound (SMTP) -------------------------------------- + # Passwords are load-only: the GET path must never return the + # ciphertext (leaking it doesn't leak the plaintext, but it does + # reveal that the field is set, and future rotation of SECRET_KEY + # would make the exposure worse). Instead the read path returns a + # sentinel `_password_set` boolean the SPA uses to render "•••••" + # placeholders in the form — see `ServerOperations.read_settings`. + mail_smtp_enabled: Optional[bool] = fields.Boolean(required=False, allow_none=True) + mail_smtp_host: Optional[str] = fields.String(required=False, allow_none=True) + mail_smtp_port: Optional[int] = fields.Integer(required=False, allow_none=True) + mail_smtp_user: Optional[str] = fields.String(required=False, allow_none=True) + mail_smtp_password: Optional[str] = fields.String(required=False, allow_none=True, load_only=True) + mail_smtp_use_tls: Optional[bool] = fields.Boolean(required=False, allow_none=True) + mail_smtp_use_ssl: Optional[bool] = fields.Boolean(required=False, allow_none=True) + mail_from_address: Optional[str] = fields.String(required=False, allow_none=True) + mail_from_name: Optional[str] = fields.String(required=False, allow_none=True) + + # ---- Mail — inbound (IMAP) --------------------------------------- + mail_imap_enabled: Optional[bool] = fields.Boolean(required=False, allow_none=True) + mail_imap_host: Optional[str] = fields.String(required=False, allow_none=True) + mail_imap_port: Optional[int] = fields.Integer(required=False, allow_none=True) + mail_imap_user: Optional[str] = fields.String(required=False, allow_none=True) + mail_imap_password: Optional[str] = fields.String(required=False, allow_none=True, load_only=True) + mail_imap_use_ssl: Optional[bool] = fields.Boolean(required=False, allow_none=True) + mail_imap_mailbox: Optional[str] = fields.String(required=False, allow_none=True) + mail_imap_poll_interval_sec: Optional[int] = fields.Integer(required=False, allow_none=True) + mail_imap_max_attachment_mb: Optional[int] = fields.Integer(required=False, allow_none=True) class Meta: model = ServerSettings load_instance = True + # `id` is excluded because the row is a singleton — there's + # only ever id=1 and the API shouldn't expose a way to change + # it. + exclude = ('id',) unknown = EXCLUDE @@ -1987,6 +2059,29 @@ def custom_attributes_merge(self, data: Dict[str, Any], **kwargs: Any) -> Dict[s return data + @post_dump(pass_original=True) + def populate_assignees(self, data: Dict[str, Any], original: Any, **kwargs: Any) -> Dict[str, Any]: + """Inject the assignee list for the task. + + `task_assignees` and `task_assignees_id` are declared as schema + fields, but the `CaseTasks` model itself has no relationship to + `TaskAssignee` — so without this hook both fields always serialise + to `None`, which silently hides the assignees stored in the DB + and breaks the frontend assignee picker on re-edit. + + We query `TaskAssignee` once per dumped task and back-fill both + fields. When the schema is dumped without a model instance (e.g. + from a dict), we leave the data untouched. + """ + task_id = getattr(original, 'id', None) if original is not None else None + if task_id is None: + return data + + assignees = get_task_assignees(task_id) + data['task_assignees'] = assignees + data['task_assignees_id'] = [assignee['id'] for assignee in assignees] + return data + class CaseEvidenceSchema(ma.SQLAlchemyAutoSchema): """Schema for serializing and deserializing CaseEvidence objects. @@ -2095,29 +2190,38 @@ def verify_unique(self, data: Dict[str, Any], **kwargs: Any) -> Dict[str, Any]: @pre_load def parse_permissions(self, data: Dict[str, Any], **kwargs: Any) -> Dict[str, Any]: - """Parses the group permissions. + """Normalise `group_permissions` into an access-control bitmask. - This method parses the group permissions specified in the data and converts them to an access control mask. - If no permissions are specified, it sets the mask to 0. + Accepts either an int (already a mask) or a list of ints + (OR-folded into a mask). Historically this method injected + `group_permissions = 0` when the caller omitted the key, which + silently wiped a group's permissions on any partial PATCH that + happened to only touch `group_name` / `group_description`. + We now leave the key alone when it isn't supplied so `load(..., + partial=True)` can preserve the persisted value. Args: - data: The data to load. - kwargs: Additional keyword arguments. + data: The raw payload to load. + kwargs: Marshmallow-supplied context (unused). Returns: - The loaded data with the access control mask. - + The payload with `group_permissions` normalised, or + untouched if the caller didn't send it. """ - permissions = data.get('group_permissions') - if type(permissions) != list and not isinstance(permissions, type(None)): - permissions = [permissions] - - if permissions is not None: - data['group_permissions'] = ac_mask_from_val_list(permissions) + if 'group_permissions' not in data: + return data - else: + permissions = data['group_permissions'] + if permissions is None: + # Explicit null → treat as "clear all permissions" (0). + # Distinct from "key absent", which we skip above. data['group_permissions'] = 0 + return data + + if not isinstance(permissions, list): + permissions = [permissions] + data['group_permissions'] = ac_mask_from_val_list(permissions) return data @@ -2187,7 +2291,8 @@ class Meta: model = User load_instance = True exclude = ['password', 'api_key', 'ctx_case', 'ctx_human_case', 'active', 'external_id', 'in_dark_mode', - 'id', 'name', 'email', 'user', 'uuid'] + 'id', 'name', 'email', 'user', 'uuid', 'mfa_secrets', 'webauthn_credentials', + 'avatar_blob', 'avatar_mime'] unknown = EXCLUDE @@ -2271,6 +2376,8 @@ class AlertSchema(ma.SQLAlchemyAutoSchema): assets = ma.Nested(CaseAssetsSchema, many=True, exclude=['alerts']) resolution_status = ma.Nested(AlertResolutionSchema) cases = fields.Pluck(AlertCaseSchema, 'case_id', many=True, required=False) + incidents = fields.Method('_incident_ids', dump_only=True) + investigation_flow = fields.Method('_flow_summary', dump_only=True) class Meta: model = Alert @@ -2279,6 +2386,15 @@ class Meta: load_instance = True unknown = EXCLUDE + def _incident_ids(self, alert: Alert): + return [i.incident_id for i in (alert.incidents or [])] + + def _flow_summary(self, alert: Alert): + flow = alert.investigation_flow + if not flow: + return None + return {'flow_id': flow.flow_id, 'flow_name': flow.flow_name} + @pre_load def verify_data(self, data: Dict[str, Any], **kwargs: Any) -> Dict[str, Any]: """ @@ -2556,7 +2672,13 @@ class Meta: load_instance = True include_fk = True exclude = ['api_key', 'password', 'ctx_human_case', 'user', 'name', 'email', 'is_service_account', 'mfa_secrets', - 'webauthn_credentials', 'mfa_setup_complete', 'external_id', 'active', 'id'] + 'webauthn_credentials', 'mfa_setup_complete', 'external_id', 'active', 'id', + # See UserSchema above — bytes blob never goes + # through JSON; image bytes are served lazily. + 'avatar_blob', 'avatar_mime', + # `preferences` has its own dedicated endpoints — + # kept out of the general user serialiser. + 'preferences'] unknown = EXCLUDE def get_user_primary_organisation(self, obj): @@ -2698,3 +2820,199 @@ def verify_password(self, data: Dict[str, Any], **kwargs: Any) -> Dict[str, Any] raise ValidationError(password_error, field_name='user_password') return data + + +class IncidentStatusSchema(ma.SQLAlchemyAutoSchema): + class Meta: + model = IncidentStatus + load_instance = True + unknown = EXCLUDE + + +class IncidentSchema(ma.SQLAlchemyAutoSchema): + status = ma.Nested(IncidentStatusSchema, dump_only=True) + severity = ma.Nested(SeveritySchema, dump_only=True) + customer = ma.Nested(CustomerSchema, dump_only=True) + owner = ma.Nested(UserSchema, only=['id', 'user_name', 'user_login', 'user_email'], dump_only=True) + alert_ids = fields.Method('_alert_ids', dump_only=True) + investigation_flow = fields.Method('_flow_summary', dump_only=True) + source_rule = fields.Method('_source_rule_summary', dump_only=True) + + class Meta: + model = Incident + include_relationships = True + include_fk = True + load_instance = True + unknown = EXCLUDE + + def _alert_ids(self, incident: Incident): + return [a.alert_id for a in (incident.alerts or [])] + + def _flow_summary(self, incident: Incident): + flow = incident.investigation_flow + if not flow: + return None + return {'flow_id': flow.flow_id, 'flow_name': flow.flow_name} + + def _source_rule_summary(self, incident: Incident): + # Surface the rule that created the incident so the detail page can + # link back to /settings/incident-rules for auditability. + rule = incident.source_rule + if not rule: + return None + return {'rule_id': rule.rule_id, 'rule_name': rule.rule_name} + + +def _validate_condition_node(node, path): + """Recursively check that a condition tree is well-formed. + + A node is either: + * a leaf `{field, operator[, value]}` + * a group `{logic, conditions: [...]}` where each entry is itself a node + + Same shape `apply_custom_conditions` accepts. We stay lenient — the + SQL layer will reject unknown fields / operators when the rule + fires; the goal here is only to reject obviously malformed rows. + """ + if not isinstance(node, dict): + raise ValidationError(f'{path} must be an object') + if 'conditions' in node and 'field' not in node: + # Group node + logic = node.get('logic', 'and') + if logic not in ('and', 'or', 'not'): + raise ValidationError(f"{path}.logic must be one of 'and'/'or'/'not'") + inner = node.get('conditions') + if not isinstance(inner, list): + raise ValidationError(f'{path}.conditions must be a list') + for idx, sub in enumerate(inner): + _validate_condition_node(sub, f'{path}.conditions[{idx}]') + return + # Leaf node + if 'field' not in node or 'operator' not in node: + raise ValidationError(f'{path} must include field and operator') + + +def _validate_rule_conditions(payload): + if not isinstance(payload, dict): + raise ValidationError('rule_conditions must be an object') + logic = payload.get('logic', 'and') + if logic not in ('and', 'or', 'not'): + raise ValidationError("rule_conditions.logic must be one of 'and'/'or'/'not'") + conditions = payload.get('conditions') + if not isinstance(conditions, list) or not conditions: + raise ValidationError('rule_conditions.conditions must be a non-empty list') + for idx, cond in enumerate(conditions): + _validate_condition_node(cond, f'rule_conditions.conditions[{idx}]') + time_window = payload.get('time_window_seconds') + if time_window is not None and (not isinstance(time_window, int) or time_window < 0): + raise ValidationError('rule_conditions.time_window_seconds must be a non-negative integer') + group_by = payload.get('group_by') + if group_by is not None and (not isinstance(group_by, list) + or not all(isinstance(g, str) for g in group_by)): + raise ValidationError('rule_conditions.group_by must be a list of field names') + + +class IncidentRuleSchema(ma.SQLAlchemyAutoSchema): + class Meta: + model = IncidentRule + include_fk = True + load_instance = True + unknown = EXCLUDE + + @pre_load + def _validate(self, data: Dict[str, Any], **kwargs: Any) -> Dict[str, Any]: + if 'rule_conditions' in data: + _validate_rule_conditions(data['rule_conditions']) + action = data.get('rule_action_type') + # The historical `attach_flow` action was removed — flows now own + # their own conditions (see InvestigationFlow.flow_conditions), so + # the only remaining rule action is stacking alerts into incidents. + if action is not None and action != RULE_ACTION_CREATE_INCIDENT: + raise ValidationError( + f'rule_action_type must be {RULE_ACTION_CREATE_INCIDENT}' + ) + scope = data.get('rule_customer_scope') + if scope is not None and (not isinstance(scope, list) + or not all(isinstance(s, int) for s in scope)): + raise ValidationError('rule_customer_scope must be null or a list of customer ids') + return data + + +class InvestigationFlowStepSchema(ma.SQLAlchemyAutoSchema): + # `flow_id` is set by the route from the URL path (see + # `flow_step_create` in `app/business/investigation_flows.py`), so the + # client must not have to repeat it in the body. Without this override + # marshmallow-sqlalchemy makes it required (the column is + # `nullable=False`) and the POST 400s with "Missing data for required + # field.". `load_default=None` lets `.load()` succeed without it; the + # route stamps the correct id before commit. + flow_id = auto_field(required=False, load_default=None) + + class Meta: + model = InvestigationFlowStep + include_fk = True + load_instance = True + unknown = EXCLUDE + + +class InvestigationFlowSchema(ma.SQLAlchemyAutoSchema): + steps = ma.Nested(InvestigationFlowStepSchema, many=True) + + class Meta: + model = InvestigationFlow + include_relationships = True + include_fk = True + load_instance = True + unknown = EXCLUDE + + @pre_load + def _validate(self, data: Dict[str, Any], **kwargs: Any) -> Dict[str, Any]: + # Reuse the same conditions validator as incident rules so the DSL + # semantics stay identical across features. `flow_conditions` may + # be omitted (an empty condition list is the default), but a + # payload that includes it must be well-formed. + from app.models.investigation_flows import FLOW_TARGETS + conditions = data.get('flow_conditions') + if conditions is not None: + if not isinstance(conditions, dict): + raise ValidationError('flow_conditions must be an object') + logic = conditions.get('logic', 'and') + if logic not in ('and', 'or', 'not'): + raise ValidationError("flow_conditions.logic must be 'and'/'or'/'not'") + cond_list = conditions.get('conditions') + if not isinstance(cond_list, list): + raise ValidationError('flow_conditions.conditions must be a list') + # Empty list is allowed here (unlike rules) — a flow with no + # conditions simply never auto-attaches; deploy skips it too. + for idx, cond in enumerate(cond_list): + _validate_condition_node(cond, f'flow_conditions.conditions[{idx}]') + target = data.get('flow_target') + if target is not None and target not in FLOW_TARGETS: + raise ValidationError( + f'flow_target must be one of {"/".join(FLOW_TARGETS)}' + ) + scope = data.get('flow_customer_scope') + if scope is not None and (not isinstance(scope, list) + or not all(isinstance(s, int) for s in scope)): + raise ValidationError('flow_customer_scope must be null or a list of customer ids') + return data + + +class AlertInvestigationProgressSchema(ma.SQLAlchemyAutoSchema): + completed_by = ma.Nested(UserSchema, only=['id', 'user_name', 'user_login'], dump_only=True) + + class Meta: + model = AlertInvestigationProgress + include_fk = True + load_instance = True + unknown = EXCLUDE + + +class IncidentInvestigationProgressSchema(ma.SQLAlchemyAutoSchema): + completed_by = ma.Nested(UserSchema, only=['id', 'user_name', 'user_login'], dump_only=True) + + class Meta: + model = IncidentInvestigationProgress + include_fk = True + load_instance = True + unknown = EXCLUDE diff --git a/source/app/templates/includes/sidenav.html b/source/app/templates/includes/sidenav.html index 7b201e196..bfa1b9407 100644 --- a/source/app/templates/includes/sidenav.html +++ b/source/app/templates/includes/sidenav.html @@ -29,7 +29,9 @@ </a> </li> <li> - <a href={{ url_for('dashboard_rest.logout') }}> + <!-- Logout must be POST-only to prevent CSRF (e.g. <img src="/logout"> in a crafted page). --> + <form id="logout-form" method="POST" action="{{ url_for('dashboard_rest.logout') }}" style="display: none;"></form> + <a href="#" onclick="document.getElementById('logout-form').submit(); return false;"> <span class="link-collapse">Logout</span> </a> </li> diff --git a/source/app/views.py b/source/app/views.py index 090bb3e49..e3e180cee 100644 --- a/source/app/views.py +++ b/source/app/views.py @@ -94,14 +94,12 @@ from app.blueprints.rest.profile_routes import profile_rest_blueprint from app.blueprints.rest.reports_route import reports_rest_blueprint from app.blueprints.rest.search_routes import search_rest_blueprint -from app.blueprints.graphql.graphql_route import graphql_blueprint from app.blueprints.rest.api_v2_routes import rest_v2_blueprint from app.models.authorization import User def register_blueprints(app): - app.register_blueprint(graphql_blueprint) app.register_blueprint(dashboard_blueprint) app.register_blueprint(dashboard_rest_blueprint) app.register_blueprint(overview_blueprint) diff --git a/source/dependencies/evtx2splunk-2.0.1-py3-none-any.whl b/source/dependencies/evtx2splunk-2.0.1-py3-none-any.whl deleted file mode 100644 index 8d64ea96d..000000000 Binary files a/source/dependencies/evtx2splunk-2.0.1-py3-none-any.whl and /dev/null differ diff --git a/source/dependencies/evtxdump_binaries/event_bind.json b/source/dependencies/evtxdump_binaries/event_bind.json deleted file mode 100644 index a0d8ba48b..000000000 --- a/source/dependencies/evtxdump_binaries/event_bind.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "configuration": { - "evtx_dump": { - "linux": "/iriswebapp/dependencies/evtxdump_binaries/linux/x64/evtx_dump" - }, - "fdfind": { - "linux": "/iriswebapp/dependencies/evtxdump_binaries/linux/x64/fd" - } - } -} diff --git a/source/dependencies/evtxdump_binaries/linux/x64/evtx_dump b/source/dependencies/evtxdump_binaries/linux/x64/evtx_dump deleted file mode 100644 index 75d8b5b1c..000000000 Binary files a/source/dependencies/evtxdump_binaries/linux/x64/evtx_dump and /dev/null differ diff --git a/source/dependencies/evtxdump_binaries/linux/x64/fd b/source/dependencies/evtxdump_binaries/linux/x64/fd deleted file mode 100644 index 5a250f416..000000000 Binary files a/source/dependencies/evtxdump_binaries/linux/x64/fd and /dev/null differ diff --git a/source/dependencies/iris_evtx-1.2.0-py3-none-any.whl b/source/dependencies/iris_evtx-1.2.0-py3-none-any.whl deleted file mode 100644 index 37ae9e281..000000000 Binary files a/source/dependencies/iris_evtx-1.2.0-py3-none-any.whl and /dev/null differ diff --git a/source/requirements.txt b/source/requirements.txt index 2d1a2f4ee..145d2249e 100644 --- a/source/requirements.txt +++ b/source/requirements.txt @@ -24,6 +24,19 @@ Werkzeug==3.1.8 WTForms==3.2.2 Flask-SocketIO==5.6.1 pycrdt-websocket==0.16.3 +# Server-side Yjs/CRDT bindings (Rust `yrs` core via Python) — powers +# the collaborative markdown editor. We hold the authoritative Y.Doc +# server-side, apply every client update through it, and render the +# doc back to markdown on flush. Kept pinned to a minor range so a +# wire-format change in a future major release doesn't silently break +# the on-disk `collab_doc.y_state` blobs. +pycrdt>=0.14,<0.15 +# `markdown-it-py` is the Python port of the same markdown-it parser +# the frontend runs (via `tiptap-markdown`). Same token semantics on +# both sides means the round-trip (client markdown → server Y.Doc → +# server markdown → source column) doesn't lose information at the +# parser boundary. +markdown-it-py>=3.0,<4 uvicorn[standard]==0.49.0 alembic==1.18.4 setuptools==81.0.0 @@ -35,18 +48,14 @@ cryptography==48.0.0 ldap3==2.9.1 pyintelowl==5.1.0 pyotp==2.9.0 -graphene==3.4.3 qrcode[pil]==8.2 dictdiffer==0.9.0 oic==1.7.0 -graphql-server[flask]==3.0.0 -graphene-sqlalchemy==3.0.0rc2 bleach==6.3.0 +imap-tools==1.5.0 https://github.com/dfir-iris/docx-generator/releases/download/v0.9.1/docx_generator-0.9.1-py3-none-any.whl dependencies/iris_interface-1.2.0-py3-none-any.whl -dependencies/evtx2splunk-2.0.1-py3-none-any.whl -dependencies/iris_evtx-1.2.0-py3-none-any.whl dependencies/iris_check_module-1.0.1-py3-none-any.whl dependencies/iris_vt_module-1.2.1-py3-none-any.whl dependencies/iris_misp_module-1.3.0-py3-none-any.whl diff --git a/source/spectaql/config.yml b/source/spectaql/config.yml deleted file mode 100644 index d4668ec26..000000000 --- a/source/spectaql/config.yml +++ /dev/null @@ -1,21 +0,0 @@ -spectaql: - logoFile: ui/public/assets/img/logo-full-blue.png - -introspection: - url: http://127.0.0.1:8000/graphql - headers: - Authorization: Bearer B8BA5D730210B50F41C06941582D7965D57319D5685440587F98DFDC45A01594 - -extensions: - graphqlScalarExamples: true - -info: - title: GraphQL DFIR-IRIS DOCUMENTATION - description: Welcome to DFIR-IRIS GraphQL Documentation. - x-introItems: - - title: IRIS API - description: To use these API endpoint, an API key is needed and can be found in every user profile under My settings > API Key. - -servers: - - url: https://v200.beta.dfir-iris.org/login - production: true diff --git a/source/tests/app/iris_engine/__init__.py b/source/tests/app/iris_engine/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/source/tests/app/iris_engine/mail/__init__.py b/source/tests/app/iris_engine/mail/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/source/tests/app/iris_engine/mail/test_rules.py b/source/tests/app/iris_engine/mail/test_rules.py new file mode 100644 index 000000000..066142eff --- /dev/null +++ b/source/tests/app/iris_engine/mail/test_rules.py @@ -0,0 +1,46 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Unit tests for the mail rules regex predicate. + +Kept pure-python — the `_regex_matches` helper is the entire +predicate logic and doesn't touch the DB. The full `evaluate_rules` +function needs a Flask app context (SQLAlchemy query), which is +covered by the docker-compose integration tests. +""" + +from unittest import TestCase + +from app.iris_engine.mail.rules import _regex_matches + + +class TestRegexMatches(TestCase): + + def test_none_pattern_matches_anything(self): + self.assertTrue(_regex_matches(None, 'anything')) + self.assertTrue(_regex_matches(None, None)) + self.assertTrue(_regex_matches('', 'anything')) + + def test_none_value_is_no_match_when_pattern_set(self): + self.assertFalse(_regex_matches('foo', None)) + + def test_simple_prefix_matches(self): + self.assertTrue(_regex_matches(r'^alerts', 'alerts@example.com')) + + def test_case_insensitive_by_default(self): + # The router only cares about content match, not case — a rule + # like `abuse@` should match `Abuse@company.com` too. + self.assertTrue(_regex_matches(r'abuse@', 'Abuse@Company.com')) + + def test_mid_string_pattern(self): + self.assertTrue(_regex_matches(r'\bCRITICAL\b', + 'Subject: [critical] queue full')) + + def test_pattern_that_should_not_match(self): + self.assertFalse(_regex_matches(r'^alerts', 'no-alerts@example.com')) + + def test_bad_regex_is_treated_as_no_match(self): + # A stray `(` from an admin typo should not raise into the + # poller — the router logs and skips the rule. + self.assertFalse(_regex_matches(r'(unclosed', 'anything')) diff --git a/source/tests/app/iris_engine/notifications/__init__.py b/source/tests/app/iris_engine/notifications/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/source/tests/app/iris_engine/notifications/test_mentions.py b/source/tests/app/iris_engine/notifications/test_mentions.py new file mode 100644 index 000000000..6e976d079 --- /dev/null +++ b/source/tests/app/iris_engine/notifications/test_mentions.py @@ -0,0 +1,110 @@ +# IRIS Source Code +# Copyright (C) 2026 - DFIR-IRIS +# contact@dfir-iris.org + +"""Unit tests for the mention extractor. + +These are pure-python — no Flask app context required — because the +extractor only touches SQLAlchemy for the legacy `@handle` fallback and +we can steer the code down the fast path (span-only content) or mock +the resolver for the fallback path. +""" + +from unittest import TestCase +from unittest.mock import patch + +from app.iris_engine.notifications.mentions import extract_mentioned_user_ids + + +class TestExtractMentionedUserIds(TestCase): + + def test_returns_empty_set_for_none(self): + self.assertEqual(set(), extract_mentioned_user_ids(None)) + + def test_returns_empty_set_for_empty(self): + self.assertEqual(set(), extract_mentioned_user_ids('')) + + def test_extracts_single_user_span(self): + content = ( + '<p>Hey <span data-mention="" data-kind="user" data-id="42" ' + 'data-label="John Doe">@John Doe</span> please look</p>' + ) + self.assertEqual({42}, extract_mentioned_user_ids(content)) + + def test_extracts_multiple_user_spans(self): + content = ( + '<span data-mention data-kind="user" data-id="1" data-label="a">@a</span> ' + 'and ' + '<span data-mention data-kind="user" data-id="2" data-label="b">@b</span>' + ) + self.assertEqual({1, 2}, extract_mentioned_user_ids(content)) + + def test_dedupes_repeated_mentions(self): + content = ( + '<span data-mention data-kind="user" data-id="5">@x</span> ' + '<span data-mention data-kind="user" data-id="5">@x</span>' + ) + self.assertEqual({5}, extract_mentioned_user_ids(content)) + + def test_ignores_non_user_kinds(self): + # Asset / IOC / note / task chips share the mention span shape + # but only `data-kind="user"` should count as a user mention. + content = ( + '<span data-mention data-kind="asset" data-id="99">#server</span>' + '<span data-mention data-kind="ioc" data-id="7">!ioc</span>' + '<span data-mention data-kind="user" data-id="3">@alice</span>' + ) + self.assertEqual({3}, extract_mentioned_user_ids(content)) + + def test_ignores_malformed_id(self): + content = ( + '<span data-mention data-kind="user" data-id="not-a-number">' + '@x</span>' + ) + # Regex requires \d+ so a non-numeric id doesn't match at all. + self.assertEqual(set(), extract_mentioned_user_ids(content)) + + def test_span_ordering_of_attributes_does_not_matter(self): + # The TipTap renderer fixes an order, but the extractor should + # not depend on it. + content = ( + '<span data-id="12" data-kind="user" data-mention data-label="Z">' + '@Z</span>' + ) + self.assertEqual({12}, extract_mentioned_user_ids(content)) + + @patch('app.iris_engine.notifications.mentions.resolve_user_handles') + def test_legacy_handle_fallback_when_no_spans(self, mock_resolve): + mock_resolve.return_value = {77} + content = 'Hey @bob please review' + self.assertEqual({77}, extract_mentioned_user_ids(content)) + mock_resolve.assert_called_once() + # Called with the plain handle + args, _ = mock_resolve.call_args + self.assertIn('bob', args[0]) + + @patch('app.iris_engine.notifications.mentions.resolve_user_handles') + def test_legacy_fallback_is_skipped_when_spans_present(self, mock_resolve): + # A note authored with the new editor should NOT double-count + # via the handle regex — the `@Bob` text inside a live chip is + # already covered by the span extraction. + content = ( + 'Hey <span data-mention data-kind="user" data-id="1" ' + 'data-label="Bob">@Bob</span> please review' + ) + self.assertEqual({1}, extract_mentioned_user_ids(content)) + mock_resolve.assert_not_called() + + @patch('app.iris_engine.notifications.mentions.resolve_user_handles') + def test_legacy_handle_ignores_email_mid_word(self, mock_resolve): + # `foo@bar.com` should not be parsed as `@bar.com` — the + # non-word char requirement before `@` filters this out. + mock_resolve.return_value = set() + content = 'contact me at foo@bar.com' + extract_mentioned_user_ids(content) + # No handles resolved → the resolver would have been called + # with an empty set OR not called at all. Either way, no + # user id ends up returned. + if mock_resolve.called: + args, _ = mock_resolve.call_args + self.assertNotIn('bar.com', args[0]) diff --git a/tests/graphql_api.py b/tests/graphql_api.py deleted file mode 100644 index 98dc5864b..000000000 --- a/tests/graphql_api.py +++ /dev/null @@ -1,29 +0,0 @@ -# IRIS Source Code -# Copyright (C) 2023 - 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. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -import requests - - -class GraphQLApi: - - def __init__(self, url, api_key): - self._url = url - self._headers = {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'} - - def execute(self, payload): - return requests.post(self._url, headers=self._headers, json=payload) diff --git a/tests/iris.py b/tests/iris.py index 6eb848eb2..1462c58f0 100644 --- a/tests/iris.py +++ b/tests/iris.py @@ -39,6 +39,14 @@ IRIS_PERMISSION_ALERTS_DELETE = 0x10 IRIS_PERMISSION_CUSTOMERS_WRITE = 0x80 +IRIS_PERMISSION_INCIDENTS_READ = 0x40000 +IRIS_PERMISSION_INCIDENTS_WRITE = 0x80000 +IRIS_PERMISSION_INCIDENTS_DELETE = 0x100000 +IRIS_PERMISSION_INCIDENT_RULES_READ = 0x200000 +IRIS_PERMISSION_INCIDENT_RULES_WRITE = 0x400000 +IRIS_PERMISSION_INVESTIGATION_FLOWS_READ = 0x800000 +IRIS_PERMISSION_INVESTIGATION_FLOWS_WRITE = 0x1000000 + IRIS_CASE_ACCESS_LEVEL_READ_ONLY = 0x2 IRIS_CASE_ACCESS_LEVEL_FULL_ACCESS = 0x4 @@ -66,6 +74,9 @@ def get(self, path, query_parameters=None): def update(self, path, body): return self._api.put(path, body) + def patch(self, path, body): + return self._api.patch(path, body) + def delete(self, path): return self._api.delete(path) @@ -121,10 +132,14 @@ def create_dummy_case(self, customer_identifier=IRIS_INITIAL_CUSTOMER_IDENTIFIER response = self._api.post('/api/v2/cases', body).json() return response['case_id'] - def execute_graphql_query(self, payload): - return self._administrator.execute_graphql_query(payload) - def clear_database(self): + # War rooms reference cases via FK; drop them first so the case + # delete loop below doesn't trip ON DELETE CASCADE on rows we + # then re-list. + war_rooms = self.get('/api/v2/war-rooms').json() + if isinstance(war_rooms, list): + for room in war_rooms: + self.delete(f"/api/v2/war-rooms/{room['war_room_id']}") cases = self.get('/api/v2/cases', query_parameters={'per_page': 1000000000}).json() for case in cases['data']: identifier = case['case_id'] diff --git a/tests/requirements.txt b/tests/requirements.txt index b90971163..19b8f20be 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,3 +1,4 @@ requests >= 2.31.0, < 3.0.0 python-socketio[client] python-docx ~= 1.1 +pyotp ~= 2.9 diff --git a/tests/rest_api.py b/tests/rest_api.py index 18206f5e4..44396a623 100644 --- a/tests/rest_api.py +++ b/tests/rest_api.py @@ -60,6 +60,13 @@ def put(self, path, payload): print(f'PUT {url} {payload} => {response_as_string}') return response + def patch(self, path, payload): + url = self._build_url(path) + response = requests.patch(url, headers=self._headers, json=payload) + response_as_string = self._convert_response_to_string(response) + print(f'PATCH {url} {payload} => {response_as_string}') + return response + def delete(self, path, query_parameters=None): url = self._build_url(path) response = requests.delete(url, headers=self._headers, params=query_parameters) diff --git a/tests/tests_auth_mfa.py b/tests/tests_auth_mfa.py new file mode 100644 index 000000000..1349f07f4 --- /dev/null +++ b/tests/tests_auth_mfa.py @@ -0,0 +1,253 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. + +""" +Integration tests for the MFA hardening on the SPA auth endpoints. + +These run against the docker-compose stack the rest of the suite uses. +They drive `/api/v2/auth/*` end-to-end so we exercise the JWT round-trip, +the central token gate in `_token_authentication_process`, and the +in-process throttle on `_mfa_throttle_*`. + +Response shape note: the v2 auth endpoints use `response_api_success` +which serialises the data dict DIRECTLY as the HTTP body — no envelope +wrapping. So `tokens` and `mfa_required` sit at the top level of the +JSON, NOT under `body['data']`. Error envelope is HTTP 400 with body +`{message: "..."}` (no `status` field). + +The tests flip the `enforce_mfa` server setting via the admin endpoint +where required and always restore it in `tearDown` — leaving MFA on +between tests would break every other suite in the project. +""" + +from unittest import TestCase +from uuid import uuid4 + +import pyotp +import requests +from urllib import parse + +from iris import Iris +from iris import API_URL + + +_PASSWORD = 'aA.1234567890' + + +def _login(username, password): + """POST /api/v2/auth/login and return the parsed top-level JSON body. + + We bypass the `User.login()` helper so we can read fields that helper + throws away (mfa_required, mfa_setup_complete, tokens). + """ + url = parse.urljoin(API_URL, '/api/v2/auth/login') + return requests.post(url, json={'username': username, 'password': password}).json() + + +def _refresh(refresh_token): + url = parse.urljoin(API_URL, '/api/v2/auth/refresh-token') + return requests.post(url, json={'refresh_token': refresh_token}) + + +def _mfa_verify(refresh_token, token): + url = parse.urljoin(API_URL, '/api/v2/auth/mfa-verify') + return requests.post(url, json={'refresh_token': refresh_token, 'token': token}) + + +def _mfa_setup(refresh_token, token, secret, password): + url = parse.urljoin(API_URL, '/api/v2/auth/mfa-setup') + return requests.post( + url, + json={ + 'refresh_token': refresh_token, + 'token': token, + 'mfa_secret': secret, + 'user_password': password, + }, + ) + + +def _get_with_bearer(path, access_token): + url = parse.urljoin(API_URL, path) + return requests.get(url, headers={'Authorization': f'Bearer {access_token}'}) + + +class TestsAuthMfa(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + # Snapshot enforce_mfa so each test starts from a known baseline + # regardless of state left by other suites. + self._set_enforce_mfa(False) + + def tearDown(self): + # Always turn enforce_mfa OFF before clearing the DB — otherwise + # the admin user we use to clean up gets locked into the MFA + # flow on the next login. + self._set_enforce_mfa(False) + self._subject.clear_database() + + # ---------- helpers ---------- + + def _set_enforce_mfa(self, value: bool): + """Flip the global enforce_mfa flag via the admin settings PUT. + + The endpoint accepts a partial body, so we only send the one + field we care about. The admin User in the scaffolding uses an + API key, which bypasses the token MFA gate by design. + """ + self._subject.update('/api/v2/manage/server/settings', + {'enforce_mfa': value}) + + def _provision_mfa_user(self): + """Create a user, log in once with MFA off, complete MFA setup, + then turn enforce_mfa back on. Returns (user_name, totp_secret). + + We can't enable enforce_mfa first because the very first login + (which yields the refresh token mfa-setup needs) would be a + step-1 login and we'd have to drive the whole SPA flow manually. + The end-state — user with mfa_setup_complete=True + enforce_mfa + on — is what every MFA-required test below wants. + """ + user_name = f'user{uuid4()}' + self._subject.create_user(user_name, _PASSWORD) + + body = _login(user_name, _PASSWORD) + refresh_token = body['tokens']['refresh_token'] + + secret = pyotp.random_base32() + totp = pyotp.TOTP(secret) + setup_response = _mfa_setup(refresh_token, totp.now(), secret, _PASSWORD) + self.assertEqual(200, setup_response.status_code) + + self._set_enforce_mfa(True) + return user_name, secret + + # ---------- login response shape ---------- + + def test_login_should_return_mfa_required_false_when_policy_off(self): + user_name = f'user{uuid4()}' + self._subject.create_user(user_name, _PASSWORD) + body = _login(user_name, _PASSWORD) + # SPA branches on this exact field — the rest of the auth flow + # collapses if either key disappears from the response. + self.assertIn('mfa_required', body) + self.assertIn('mfa_setup_complete', body) + self.assertFalse(body['mfa_required']) + self.assertFalse(body['mfa_setup_complete']) + + def test_login_should_return_mfa_required_true_when_policy_on(self): + user_name = f'user{uuid4()}' + self._subject.create_user(user_name, _PASSWORD) + self._set_enforce_mfa(True) + body = _login(user_name, _PASSWORD) + self.assertTrue(body['mfa_required']) + self.assertFalse(body['mfa_setup_complete']) + + # ---------- central token gate ---------- + + def test_step1_access_token_should_be_rejected_by_protected_endpoint(self): + """The critical regression: an unverified step-1 token MUST NOT + admit the caller to any `ac_api_requires` endpoint. If this ever + passes 2xx again, the whole MFA flow is theatre.""" + user_name, _ = self._provision_mfa_user() + body = _login(user_name, _PASSWORD) + access_token = body['tokens']['access_token'] + response = _get_with_bearer('/api/v2/cases', access_token) + self.assertEqual(401, response.status_code) + + def test_step1_access_token_should_be_rejected_by_whoami(self): + """whoami uses @api_auth() rather than ac_api_requires — the gate + sits in a different decorator, so we cover both paths.""" + user_name, _ = self._provision_mfa_user() + body = _login(user_name, _PASSWORD) + access_token = body['tokens']['access_token'] + response = _get_with_bearer('/api/v2/auth/whoami', access_token) + self.assertEqual(401, response.status_code) + + def test_verified_access_token_should_admit_to_protected_endpoint(self): + user_name, secret = self._provision_mfa_user() + body = _login(user_name, _PASSWORD) + refresh_token = body['tokens']['refresh_token'] + totp = pyotp.TOTP(secret) + verify = _mfa_verify(refresh_token, totp.now()).json() + access_token = verify['tokens']['access_token'] + response = _get_with_bearer('/api/v2/cases', access_token) + self.assertEqual(200, response.status_code) + + # ---------- refresh propagation ---------- + + def test_refresh_of_step1_token_should_stay_step1(self): + """A step-1 refresh token MUST NOT be launderable into a verified + access token by hitting /refresh-token. If the new access token + admitted the caller, an attacker holding a stolen step-1 refresh + could ride past the MFA challenge by refreshing once.""" + user_name, _ = self._provision_mfa_user() + body = _login(user_name, _PASSWORD) + refresh_token = body['tokens']['refresh_token'] + refreshed = _refresh(refresh_token).json() + access_token = refreshed['tokens']['access_token'] + response = _get_with_bearer('/api/v2/cases', access_token) + self.assertEqual(401, response.status_code) + + def test_refresh_of_verified_token_should_stay_verified(self): + """The mirror case: a verified user's refresh keeps them verified, + otherwise sessions would be lost every access-token expiry.""" + user_name, secret = self._provision_mfa_user() + body = _login(user_name, _PASSWORD) + refresh_token = body['tokens']['refresh_token'] + totp = pyotp.TOTP(secret) + verify = _mfa_verify(refresh_token, totp.now()).json() + verified_refresh = verify['tokens']['refresh_token'] + refreshed = _refresh(verified_refresh).json() + access_token = refreshed['tokens']['access_token'] + response = _get_with_bearer('/api/v2/cases', access_token) + self.assertEqual(200, response.status_code) + + # ---------- mfa-setup re-enrollment guard ---------- + + def test_mfa_setup_should_refuse_to_overwrite_existing_enrollment(self): + """An attacker holding a stolen step-1 refresh + the user's + password must NOT be able to silently rebind MFA to their own + device by re-running /mfa-setup. The error envelope is HTTP 400 + with `{message: "MFA already configured for this account"}`.""" + user_name, _ = self._provision_mfa_user() + body = _login(user_name, _PASSWORD) + refresh_token = body['tokens']['refresh_token'] + new_secret = pyotp.random_base32() + new_totp = pyotp.TOTP(new_secret) + response = _mfa_setup(refresh_token, new_totp.now(), new_secret, _PASSWORD) + self.assertEqual(400, response.status_code) + self.assertEqual('MFA already configured for this account', + response.json().get('message')) + + # ---------- brute-force throttle ---------- + + def test_mfa_verify_should_lock_out_after_repeated_failures(self): + """Five bad codes in a row should land the user in lockout. The + sixth attempt — even with a valid code — must be refused so the + 6-digit space (10^6) stays infeasible to enumerate.""" + user_name, secret = self._provision_mfa_user() + body = _login(user_name, _PASSWORD) + refresh_token = body['tokens']['refresh_token'] + for _ in range(5): + bad = _mfa_verify(refresh_token, '000000') + self.assertEqual(400, bad.status_code) + totp = pyotp.TOTP(secret) + locked = _mfa_verify(refresh_token, totp.now()) + self.assertEqual(400, locked.status_code) + # Message contains the remaining-time hint, which the SPA + # surfaces verbatim to the user. + self.assertIn('Too many MFA attempts', + locked.json().get('message', '')) diff --git a/tests/tests_graphql.py b/tests/tests_graphql.py deleted file mode 100644 index e461f5b14..000000000 --- a/tests/tests_graphql.py +++ /dev/null @@ -1,1111 +0,0 @@ -# IRIS Source Code -# Copyright (C) 2023 - 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. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -from unittest import TestCase -from iris import Iris -from iris import API_URL -from graphql_api import GraphQLApi -from base64 import b64encode - - -class TestsGraphQL(TestCase): - - def setUp(self) -> None: - self._subject = Iris() - - def tearDown(self): - self._subject.clear_database() - - @staticmethod - def _get_first_case(body): - for case in body['data']['cases']['edges']: - if case['node']['name'] == '#1 - Initial Demo': - return case - - def _create_case(self): - payload = { - 'query': 'mutation { caseCreate(name: "case name", description: "Some description", clientId: 1) {case { caseId } } }' - } - body = self._subject.execute_graphql_query(payload) - return int(body['data']['caseCreate']['case']['caseId']) - - def test_graphql_endpoint_should_reject_requests_with_wrong_authentication_token(self): - graphql_api = GraphQLApi(API_URL + '/graphql', 64 * '0') - payload = { - 'query': 'query { cases { edges { node { name } } } }' - } - response = graphql_api.execute(payload) - self.assertEqual(401, response.status_code) - - def test_graphql_cases_should_contain_the_initial_case(self): - payload = { - 'query': 'query { cases { edges { node { name } } } }' - } - body = self._subject.execute_graphql_query(payload) - case_names = [] - for case in body['data']['cases']['edges']: - case_names.append(case['node']['name']) - self.assertIn('#1 - Initial Demo', case_names) - - def test_graphql_cases_should_have_a_global_identifier(self): - payload = { - 'query': 'query { cases { edges { node { id name } } } }' - } - body = self._subject.execute_graphql_query(payload) - first_case = self._get_first_case(body) - self.assertEqual(b64encode(b'CaseObject:1').decode(), first_case['node']['id']) - - def test_graphql_create_ioc_should_not_fail(self): - case_identifier = self._create_case() - ioc_value = 'IOC value' - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "{ioc_value}") {{ - ioc {{ iocValue }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - test_ioc_value = response['data']['iocCreate']['ioc']['iocValue'] - self.assertEqual(test_ioc_value, ioc_value) - - def test_graphql_delete_ioc_should_not_fail(self): - case_identifier = self._create_case() - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "IOC value") {{ - ioc {{ iocId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - ioc_identifier = response['data']['iocCreate']['ioc']['iocId'] - payload = { - 'query': f'''mutation {{ - iocDelete(iocId: {ioc_identifier}) {{ - message - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertEqual(f'IOC {int(ioc_identifier)} deleted', response['data']['iocDelete']['message']) - - def test_graphql_create_ioc_should_allow_optional_description_to_be_set(self): - case_identifier = self._create_case() - description = 'Some description' - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "IOC value", - description: "{description}") {{ - ioc {{ iocDescription }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertEqual(description, response['data']['iocCreate']['ioc']['iocDescription']) - - def test_graphql_create_ioc_should_allow_optional_tags_to_be_set(self): - case_identifier = self._create_case() - tags = 'tag1,tag2' - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "IOC value", - tags: "{tags}") {{ - ioc {{ iocTags }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertEqual(tags, response['data']['iocCreate']['ioc']['iocTags']) - - def test_graphql_update_ioc_should_update_tlp(self): - case_identifier = self._create_case() - ioc_value = 'IOC value' - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "{ioc_value}") {{ - ioc {{ iocId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - ioc_identifier = response['data']['iocCreate']['ioc']['iocId'] - payload = { - 'query': f'''mutation {{ - iocUpdate(iocId: {ioc_identifier}, typeId: 1, tlpId: 2, value: "{ioc_value}") {{ - ioc {{ iocTlpId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertEqual(2, response['data']['iocUpdate']['ioc']['iocTlpId']) - - def test_graphql_update_ioc_should_not_update_typeId(self): - case_identifier = self._create_case() - ioc_value = 'IOC value' - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "{ioc_value}") {{ - ioc {{ iocId iocTypeId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - ioc_identifier = response['data']['iocCreate']['ioc']['iocId'] - ioc_type = response['data']['iocCreate']['ioc']['iocTypeId'] - payload = { - 'query': f'''mutation {{ - iocUpdate(iocId: {ioc_identifier}, tlpId:1, value: "{ioc_value}") {{ - ioc {{ iocTypeId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertEqual(ioc_type, response['data']['iocUpdate']['ioc']['iocTypeId']) - - def test_graphql_update_ioc_should_fail_when_missing_iocId(self): - case_identifier = self._create_case() - ioc_value = 'IOC value' - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "{ioc_value}") {{ - ioc {{ iocId }} - }} - }}''' - } - self._subject.execute_graphql_query(payload) - payload = { - 'query': f'''mutation {{ - iocUpdate(typeId:1, tlpId:1, value: "{ioc_value}") {{ - ioc {{ iocTlpId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertIn('errors', response) - - def test_graphql_update_ioc_should_not_update_tlpId(self): - case_identifier = self._create_case() - ioc_value = 'IOC value' - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "{ioc_value}") {{ - ioc {{ iocId iocTlpId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - ioc_identifier = response['data']['iocCreate']['ioc']['iocId'] - ioc_tlp = response['data']['iocCreate']['ioc']['iocTlpId'] - payload = { - 'query': f'''mutation {{ - iocUpdate(iocId: {ioc_identifier}, typeId:1, value: "{ioc_value}") {{ - ioc {{ iocId iocTlpId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertEqual(ioc_tlp, response['data']['iocUpdate']['ioc']['iocTlpId']) - - def test_graphql_update_ioc_should_not_update_value(self): - case_identifier = self._create_case() - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "IOC value") {{ - ioc {{ iocId iocValue }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - ioc_identifier = response['data']['iocCreate']['ioc']['iocId'] - ioc_value = response['data']['iocCreate']['ioc']['iocValue'] - payload = { - 'query': f'''mutation {{ - iocUpdate(iocId: {ioc_identifier}, typeId:1, tlpId:1) {{ - ioc {{ iocValue }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertEqual(ioc_value, response['data']['iocUpdate']['ioc']['iocValue']) - - def test_graphql_update_ioc_should_update_optional_parameter_description(self): - case_identifier = self._create_case() - ioc_value = 'IOC value' - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "{ioc_value}") {{ - ioc {{ iocId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - ioc_identifier = response['data']['iocCreate']['ioc']['iocId'] - description = 'Some description' - payload = { - 'query': f'''mutation {{ - iocUpdate(iocId: {ioc_identifier}, typeId: 1, tlpId: 2, value: "{ioc_value}", - description: "{description}") {{ - ioc {{ iocDescription }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertEqual(description, response['data']['iocUpdate']['ioc']['iocDescription']) - - def test_graphql_update_ioc_should_update_optional_parameter_tags(self): - case_identifier = self._create_case() - ioc_value = 'IOC value' - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "{ioc_value}") {{ - ioc {{ iocId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - ioc_identifier = response['data']['iocCreate']['ioc']['iocId'] - tags = 'tag1,tag2' - payload = { - 'query': f'''mutation {{ - iocUpdate(iocId: {ioc_identifier}, typeId: 1, tlpId: 2, value: "{ioc_value}", - tags: "{tags}") {{ - ioc {{ iocTags }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertEqual(tags, response['data']['iocUpdate']['ioc']['iocTags']) - - def test_graphql_case_should_return_a_case_by_its_identifier(self): - case_identifier = self._create_case() - payload = { - 'query': f'''{{ - case(caseId: {case_identifier}) {{ - caseId - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertEqual(case_identifier, response['data']['case']['caseId']) - - def test_graphql_iocs_should_return_all_iocs_of_a_case(self): - case_identifier = self._create_case() - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "IOC value") {{ - ioc {{ iocId }} - }} - }}''' - } - self._subject.execute_graphql_query(payload) - payload = { - 'query': f'''{{ - case(caseId: {case_identifier}) {{ - iocs {{ edges {{ node {{ iocId }} }} }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertNotIn('errors', response) - - def test_graphql_case_should_return_error_log_uuid_when_permission_denied(self): - user = self._subject.create_dummy_user() - case_identifier = self._create_case() - payload = { - 'query': f'''{{ - case(caseId: {case_identifier}) {{ - caseId - }} - }}''' - } - response = user.execute_graphql_query(payload) - self.assertRegex(response['errors'][0]['message'], r'Permission denied \(EID [0-9a-f-]{36}\)') - - def test_graphql_case_should_return_error_ioc_when_permission_denied(self): - user = self._subject.create_dummy_user() - case_identifier = self._create_case() - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "IOC value") {{ - ioc {{ iocId iocValue }} - }} - }}''' - } - self._subject.execute_graphql_query(payload) - payload = { - 'query': f'''{{ - case(caseId: {case_identifier}) {{ - iocs {{ totalCount edges {{ node {{ iocId }} }} }} - }} - }}''' - } - response = user.execute_graphql_query(payload) - self.assertIn('errors', response) - - def test_graphql_create_case_should_not_fail(self): - test_description = 'Some description' - payload = {'query': f''' mutation {{ caseCreate(name: "case2", description: "{test_description}", clientId: 1) {{ case {{ description }} }} }} '''} - body = self._subject.execute_graphql_query(payload) - description = body['data']['caseCreate']['case']['description'] - self.assertEqual(description, test_description) - - def test_graphql_update_case_fail_due_to_delete_case(self): - payload = { - 'query': '''mutation { - caseCreate(name: "case2", description: "Some description", clientId: 1) { - case { caseId } - } - }''' - } - response = self._subject.execute_graphql_query(payload) - case_identifier = response['data']['caseCreate']['case']['caseId'] - payload2 = { - 'query': f'''mutation {{ - caseDelete(caseId: {case_identifier}) {{ - case {{ caseId }} - }} - }}''' - } - self._subject.execute_graphql_query(payload2) - payload = { - 'query': f'''mutation {{ - caseUpdate(caseId: {case_identifier}, name: "test_delete_case") {{ - case {{ name }} - }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - self.assertIn('errors', body) - - def test_graphql_delete_case_should_fail(self): - payload = {'query': 'mutation {caseCreate(name: "case2", description: "Some description", clientId: 1) { case { caseId } } }'} - response = self._subject.execute_graphql_query(payload) - case_identifier = response['data']['caseCreate']['case']['caseId'] - payload2 = { - 'query': f'''mutation {{ - caseDelete(caseId: {case_identifier}, cur_id: 1) {{ - case {{ caseId }} - }} - }}''' - } - body = self._subject.execute_graphql_query(payload2) - self.assertIn('errors', body) - - def test_graphql_update_case_should_not_fail(self): - case_identifier = self._create_case() - test_name = 'new name' - expected_name = f'#{case_identifier} - new name' - payload = { - 'query': f'''mutation {{ - caseUpdate(caseId: {case_identifier}, name: "{test_name}") {{ - case {{ name }} - }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - name = body['data']['caseUpdate']['case']['name'] - self.assertEqual(name, expected_name) - - def test_graphql_create_case_should_use_optionals_parameters(self): - id_client = 1 - payload = { - 'query': f''' mutation {{ - caseCreate(name: "case2", description: "Some description", clientId: {id_client}, - socId: "1", classificationId : 1) {{ - case {{ clientId }} - }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - client_id = body['data']['caseCreate']['case']['clientId'] - self.assertEqual(client_id, id_client) - - def test_graphql_update_case_should_use_optionals_parameters(self): - id_case = 1 - payload = { - 'query': f'''mutation {{ - caseUpdate(caseId: {id_case}, description: "Some description", clientId: 1, socId: "1", - classificationId : 1) {{ - case {{ caseId }} - }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - case_id = body['data']['caseUpdate']['case']['caseId'] - self.assertEqual(case_id, id_case) - - def test_graphql_cases_should_return_newly_created_case(self): - payload = { - 'query': ''' mutation { caseCreate(name: "case2", description: "Some description", clientId: 1) { - case { caseId } - } - }''' - } - response = self._subject.execute_graphql_query(payload) - case_identifier = response['data']['caseCreate']['case']['caseId'] - payload = { - 'query': 'query { cases { edges { node { caseId } } } }' - } - response = self._subject.execute_graphql_query(payload) - case_identifiers = [] - for case in response['data']['cases']['edges']: - case_identifiers.append(case['node']['caseId']) - self.assertIn(case_identifier, case_identifiers) - - def test_graphql_update_case_should_update_optional_parameter_description(self): - payload = { - 'query': ''' mutation { caseCreate(name: "case2", description: "Some description", clientId: 1, socId: "1", - classificationId : 1) { - case { caseId } - } - }''' - } - body = self._subject.execute_graphql_query(payload) - case_identifier = body['data']['caseCreate']['case']['caseId'] - description = 'Some description' - payload = { - 'query': f'''mutation {{ - caseUpdate(caseId: {case_identifier}, description: "{description}") {{ - case {{ description }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertEqual(description, response['data']['caseUpdate']['case']['description']) - - def test_graphql_update_case_should_update_optional_parameter_socId(self): - payload = { - 'query': ''' mutation { - caseCreate(name: "case2", description: "Some description", clientId: 1, socId: "1", - classificationId : 1) { - case { caseId } - } - }''' - } - body = self._subject.execute_graphql_query(payload) - case_identifier = body['data']['caseCreate']['case']['caseId'] - soc_id = '17' - payload = { - 'query': f'''mutation {{ - caseUpdate(caseId: {case_identifier}, socId: "{soc_id}") {{ - case {{ socId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertEqual(soc_id, response['data']['caseUpdate']['case']['socId']) - - def test_graphql_update_case_should_update_optional_parameter_classificationId(self): - payload = { - 'query': ''' mutation { - caseCreate(name: "case2", description: "Some description", clientId: 1, socId: "1", - classificationId : 1) { - case { caseId } - } - }''' - } - body = self._subject.execute_graphql_query(payload) - case_identifier = body['data']['caseCreate']['case']['caseId'] - classification_id = 2 - payload = { - 'query': f'''mutation {{ - caseUpdate(caseId: {case_identifier}, classificationId: {classification_id}) {{ - case {{ classificationId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertEqual(2, response['data']['caseUpdate']['case']['classificationId']) - - def test_graphql_update_case_with_optional_parameter_severityId(self): - case_identifier = self._create_case() - payload = {'query': f'mutation {{ caseUpdate(caseId: {case_identifier}, severityId: 1) {{ case {{ severityId }} }} }}'} - body = self._subject.execute_graphql_query(payload) - self.assertEqual(1, body['data']['caseUpdate']['case']['severityId']) - - def test_graphql_update_case_with_optional_parameter_ownerId(self): - case_identifier = self._create_case() - payload = { - 'query': f'''mutation {{ - caseUpdate(caseId: {case_identifier}, ownerId: 1) {{ - case {{ ownerId }} - }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - self.assertEqual(1, body['data']['caseUpdate']['case']['ownerId']) - - def test_graphql_update_case_with_optional_parameter_stateId_reviewerId(self): - case_identifier = self._create_case() - payload = { - 'query': f'''mutation {{ - caseUpdate(caseId: {case_identifier}, reviewerId: 1) {{ - case {{ reviewerId }} - }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - self.assertEqual(1, body['data']['caseUpdate']['case']['reviewerId']) - - def test_graphql_update_case_with_optional_parameter_stateId(self): - case_identifier = self._create_case() - payload = { - 'query': f'''mutation {{ - caseUpdate(caseId: {case_identifier}, stateId: 1) {{ - case {{ stateId }} - }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - self.assertEqual(1, body['data']['caseUpdate']['case']['stateId']) - - def test_graphql_update_case_with_optional_parameter_reviewStatusId(self): - case_identifier = self._create_case() - payload = { - 'query': f'''mutation {{ - caseUpdate(caseId: {case_identifier}, reviewStatusId: 1) {{ - case {{ reviewStatusId }} - }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - self.assertEqual(1, body['data']['caseUpdate']['case']['reviewStatusId']) - - def test_graphql_query_ioc_should_not_fail(self): - case_identifier = self._create_case() - ioc_value = 'IOC value' - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "{ioc_value}") {{ - ioc {{ iocId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - ioc_identifier = response['data']['iocCreate']['ioc']['iocId'] - payload = { - 'query': f'''query {{ - ioc(iocId: {ioc_identifier}) {{ iocValue }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - self.assertEqual(ioc_value, body['data']['ioc']['iocValue']) - - def test_graphql_cases_should_not_fail(self): - test_name = '#1 - Initial Demo' - payload = { - 'query': '{ cases(first: 1) { edges { node { id name } } } }' - } - body = self._subject.execute_graphql_query(payload) - for case in body['data']['cases']['edges']: - name = case['node']['name'] - self.assertEqual(test_name, name) - - def test_graphql_update_ioc_should_update_misp(self): - case_identifier = self._create_case() - ioc_value = 'IOC value' - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "{ioc_value}") {{ - ioc {{ iocId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - ioc_identifier = response['data']['iocCreate']['ioc']['iocId'] - payload = { - 'query': f'''mutation {{ - iocUpdate(iocId: {ioc_identifier}, typeId: 1, tlpId: 2, iocMisp: "test", - value: "{ioc_value}") {{ - ioc {{ iocMisp }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertEqual("test", response['data']['iocUpdate']['ioc']['iocMisp']) - - def test_graphql_update_ioc_should_update_userId(self): - case_identifier = self._create_case() - ioc_value = 'IOC value' - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "{ioc_value}") {{ - ioc {{ iocId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - ioc_identifier = response['data']['iocCreate']['ioc']['iocId'] - payload = { - 'query': f'''mutation {{ - iocUpdate(iocId: {ioc_identifier}, typeId: 1, tlpId: 2, userId: 1, value: "{ioc_value}") {{ - ioc {{ userId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertEqual(1, response['data']['iocUpdate']['ioc']['userId']) - - def test_graphql_update_ioc_should_update_iocEnrichment(self): - case_identifier = self._create_case() - ioc_value = 'IOC value' - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "{ioc_value}") {{ - ioc {{ iocId }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - ioc_identifier = response['data']['iocCreate']['ioc']['iocId'] - payload = { - 'query': f'''mutation {{ - iocUpdate(iocId: {ioc_identifier}, typeId: 1, tlpId: 2, iocEnrichment: "test", - value: "{ioc_value}") {{ - ioc {{ iocEnrichment }} - }} - }}''' - } - response = self._subject.execute_graphql_query(payload) - self.assertEqual('"test"', response['data']['iocUpdate']['ioc']['iocEnrichment']) - - def test_cursor_first_after(self): - payload = {'query': 'mutation {caseCreate(name: "case2", description: "Some description", clientId: 1, socId: "1", classificationId : 1) { case { ' - 'caseId }}'} - self._subject.execute_graphql_query(payload) - case_id = 2 - self._subject.execute_graphql_query(payload) - payload = {'query': 'query { cases { edges { node { caseId name } cursor } } }'} - self._subject.execute_graphql_query(payload) - payload = {'query': 'query { cases(first:1, after:"YXJyYXljb25uZWN0aW9uOjA="){ edges { node { caseId } cursor } } }'} - body = self._subject.execute_graphql_query(payload) - for case in body['data']['cases']['edges']: - test_case_id = case['node']['caseId'] - self.assertEqual(case_id, test_case_id) - - def test_graphql_cases_classificationId_should_not_fail(self): - classification_id = 1 - payload = { - 'query': f'''mutation {{ caseCreate(name: "case1", description: "Some description", clientId: 1, socId: "1", - classificationId : {classification_id}) {{ case {{ caseId }} }}}}'''} - self._subject.execute_graphql_query(payload) - payload = { - 'query': 'mutation { caseCreate(name: "case2", description: "Some description", clientId: 1, socId: "1", classificationId : 3) {case { caseId ' - 'classificationId}}}'} - self._subject.execute_graphql_query(payload) - payload = {'query': f'''mutation {{ caseCreate(name: "case3", description: "Some description", clientId: 1, socId: "1", classificationId : - {classification_id}) {{ case {{ classificationId }} }} }}'''} - self._subject.execute_graphql_query(payload) - payload = { - 'query': f'''query {{ cases(classificationId: {classification_id} ) - {{ edges {{ node {{ name caseId classificationId }} cursor }} }} }}''' - } - body = self._subject.execute_graphql_query(payload) - for case in body['data']['cases']['edges']: - test_classification = case['node']['classificationId'] - self.assertEqual(classification_id, test_classification) - - def test_graphql_cases_filter_clientId_should_not_fail(self): - payload = {'query': 'mutation { caseCreate(name: "case2", description: "Some description", clientId: 1, socId: "1", classificationId : 1) {case { ' - 'clientId }}}'} - response = self._subject.execute_graphql_query(payload) - client_id = response['data']['caseCreate']['case']['clientId'] - payload = { - 'query': f'''query {{ cases(clientId: {client_id}) {{ edges {{ node {{ clientId }} }} }} }}''' - } - body = self._subject.execute_graphql_query(payload) - for case in body['data']['cases']['edges']: - test_client = case['node']['clientId'] - self.assertEqual(client_id, test_client) - - def test_graphql_cases_filter_stateId_should_not_fail(self): - payload = {'query': 'mutation {caseCreate(name: "case2", description: "Some description", clientId: 1, socId: "1", classificationId : 1) {case { ' - 'stateId }}}'} - response = self._subject.execute_graphql_query(payload) - state_id = response['data']['caseCreate']['case']['stateId'] - payload = { - 'query': f'''query {{ cases(stateId: {state_id}) - {{ edges {{ node {{ stateId }} }} }} }}''' - } - body = self._subject.execute_graphql_query(payload) - for case in body['data']['cases']['edges']: - test_state = case['node']['stateId'] - self.assertEqual(state_id, test_state) - - def test_graphql_cases_filter_ownerId_should_not_fail(self): - payload = {'query': 'mutation { caseCreate(name: "case2", description: "Some description", clientId: 1, socId: "1", classificationId : 1) {case { ' - 'ownerId }}}'} - response = self._subject.execute_graphql_query(payload) - owner_id = response['data']['caseCreate']['case']['ownerId'] - payload = { - 'query': f'''query {{ cases(ownerId: {owner_id}) - {{ edges {{ node {{ ownerId }} }} }} }}''' - } - body = self._subject.execute_graphql_query(payload) - for case in body['data']['cases']['edges']: - test_owner = case['node']['ownerId'] - self.assertEqual(owner_id, test_owner) - - def test_graphql_cases_filter_openDate_should_not_fail(self): - payload = {'query': 'mutation { caseCreate(name: "case2", description: "Some description", clientId: 1, socId: "1", classificationId : 1) { case { ' - 'openDate clientId } } }'} - response = self._subject.execute_graphql_query(payload) - open_date = response['data']['caseCreate']['case']['openDate'] - clientId = response['data']['caseCreate']['case']['clientId'] - payload = { - 'query': f'''query {{ cases(openDate: "{open_date}") - {{ edges {{ node {{ openDate clientId }} }} }} }}''' - } - body = self._subject.execute_graphql_query(payload) - for case in body['data']['cases']['edges']: - test_id = case['node']['clientId'] - self.assertEqual(clientId, test_id) - - def test_graphql_cases_filter_name_should_not_fail(self): - payload = {'query': 'mutation { caseCreate(name: "case2", description: "Some description", clientId: 1, socId: "1", classificationId : 1) {case { ' - 'name }}}'} - response = self._subject.execute_graphql_query(payload) - name = response['data']['caseCreate']['case']['name'] - payload = { - 'query': f'''query {{ cases(name: "{name}") - {{ edges {{ node {{ name }} }} }} }}''' - } - body = self._subject.execute_graphql_query(payload) - for case in body['data']['cases']['edges']: - test_name = case['node']['name'] - self.assertEqual(name, test_name) - - def test_graphql_cases_filter_socId_should_not_fail(self): - payload = {'query': 'mutation { caseCreate(name: "case2", description: "Some description", clientId: 1, socId: "1", classificationId : 1) { case { ' - 'socId } } }'} - response = self._subject.execute_graphql_query(payload) - soc_id = response['data']['caseCreate']['case']['socId'] - payload = { - 'query': f'''query {{ cases(socId: "{soc_id}") - {{ edges {{ node {{ socId }} }} }} }}''' - } - body = self._subject.execute_graphql_query(payload) - for case in body['data']['cases']['edges']: - test_soc_id = case['node']['socId'] - self.assertEqual(soc_id, test_soc_id) - - def test_graphql_cases_filter_severityId_should_not_fail(self): - payload = {'query': 'mutation { caseCreate(name: "case2", description: "Some description", clientId: 1, socId: "1", classificationId : 1) { case { ' - 'severityId }} }'} - response = self._subject.execute_graphql_query(payload) - severity_id = response['data']['caseCreate']['case']['severityId'] - payload = { - 'query': f'''query {{ cases(severityId: {severity_id}) - {{ edges {{ node {{ severityId }} }} }} }}''' - } - body = self._subject.execute_graphql_query(payload) - for case in body['data']['cases']['edges']: - test_severity = case['node']['severityId'] - self.assertEqual(severity_id, test_severity) - - def test_graphql_cases_parameter_totalCount_should_not_fail(self): - payload = { - 'query': 'query { cases { totalCount } }' - } - body = self._subject.execute_graphql_query(payload) - test_total = body['data']['cases']['totalCount'] - payload = { - 'query': 'mutation { caseCreate(name: "case2", description: "Some description", clientId: 1, socId: "1", classificationId : 1) { case { name } }}'} - self._subject.execute_graphql_query(payload) - test_total += 1 - payload = { - 'query': 'query { cases { totalCount } }' - } - body = self._subject.execute_graphql_query(payload) - total = body['data']['cases']['totalCount'] - self.assertEqual(total, test_total) - - def test_graphql_iocs_filter_iocId_should_not_fail(self): - case_identifier = self._create_case() - query = f'mutation {{ iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "33") {{ ioc {{ iocId }} }} }}' - payload = {'query': query} - response = self._subject.execute_graphql_query(payload) - ioc_identifier = response['data']['iocCreate']['ioc']['iocId'] - payload = {'query': f'{{ case(caseId: {case_identifier}) {{ iocs(iocId: 11) {{ edges {{ node {{ iocId }} }} }} }} }}'} - body = self._subject.execute_graphql_query(payload) - for ioc in body['data']['case']['iocs']['edges']: - test_ioc_identifier = ioc['node']['iocId'] - self.assertEqual(ioc_identifier, test_ioc_identifier) - - def test_graphql_iocs_filter_iocUuid_should_not_fail(self): - case_identifier = self._create_case() - payload = {'query': f'mutation {{ iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "33") {{ ioc {{ iocUuid iocId }} }} }}'} - response = self._subject.execute_graphql_query(payload) - ioc_uuid = response['data']['iocCreate']['ioc']['iocUuid'] - payload = { - 'query': f'''{{ - case(caseId: {case_identifier}) {{ - iocs(iocUuid: "{ioc_uuid}") {{ edges {{ node {{ iocId iocUuid }} }} }} - }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - for ioc in body['data']['case']['iocs']['edges']: - test_ioc_uuid = ioc['node']['iocUuid'] - self.assertEqual(ioc_uuid, test_ioc_uuid) - - def test_graphql_iocs_filter_iocValue_should_not_fail(self): - case_identifier = self._create_case() - ioc_value = 'test' - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "{ioc_value}") {{ - ioc {{ iocValue }} - }} - }}''' - } - self._subject.execute_graphql_query(payload) - payload = { - 'query': f'''mutation {{ - iocCreate(caseId: {case_identifier}, typeId: 2, tlpId: 1, value: "{ioc_value}") {{ - ioc {{ iocValue }} - }} - }}''' - } - self._subject.execute_graphql_query(payload) - payload = {'query': f'mutation {{ iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "testtest") {{ ioc {{ iocValue }} }} }}'} - self._subject.execute_graphql_query(payload) - payload = { - 'query': f'''{{ - case(caseId: {case_identifier}) {{ - iocs(iocValue: "{ioc_value}") {{ edges {{ node {{ iocValue iocId }} }} }} }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - for ioc in body['data']['case']['iocs']['edges']: - test_ioc_value = ioc['node']['iocValue'] - self.assertEqual(ioc_value, test_ioc_value) - - def test_graphql_iocs_filter_first_should_not_fail(self): - case_identifier = self._create_case() - payload = {'query': f'mutation {{ iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "test2") {{ ioc {{ iocValue iocId }} }} }}'} - response = self._subject.execute_graphql_query(payload) - ioc_identifier = response['data']['iocCreate']['ioc']['iocId'] - payload = { - 'query': f'mutation {{ iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "testtest") {{ ioc {{ iocValue iocId }} }} }}'} - self._subject.execute_graphql_query(payload) - payload = { - 'query': f'''{{ - case(caseId: {case_identifier}) {{ - iocs(first: 1) {{ edges {{ node {{ iocValue iocId }} }} }} }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - for ioc in body['data']['case']['iocs']['edges']: - iocid = ioc['node']['iocId'] - self.assertEqual(ioc_identifier, iocid) - - def test_graphql_iocs_filter_iocTypeId_should_not_fail(self): - case_identifier = self._create_case() - payload = { - 'query': f'mutation {{ iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "test") {{ ioc {{ iocTypeId }} }} }}'} - response = self._subject.execute_graphql_query(payload) - ioc_type_id = response['data']['iocCreate']['ioc']['iocTypeId'] - payload = { - 'query': f'''{{ - case(caseId: {case_identifier}) {{ - iocs(iocTypeId: {ioc_type_id}) {{ edges {{ node {{ iocTypeId }} }} }} }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - for ioc in body['data']['case']['iocs']['edges']: - test_type_id = ioc['node']['iocTypeId'] - self.assertEqual(test_type_id, ioc_type_id) - - def test_graphql_iocs_filter_iocDescription_should_not_fail(self): - case_identifier = self._create_case() - payload = { - 'query': f'mutation {{ iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "test") {{ ioc {{ iocDescription }} }} }}'} - self._subject.execute_graphql_query(payload) - description = 'Some description' - payload = { - 'query': f'''mutation {{ - iocUpdate(iocId: 1, description: "{description}", typeId:1, tlpId:1, value: "test") {{ - ioc {{ iocDescription }} - }} - }}''' - } - self._subject.execute_graphql_query(payload) - payload = { - 'query': f'''{{ - case(caseId: {case_identifier}) {{ - iocs(iocDescription: "{description}") {{ edges {{ node {{ iocDescription }} }} }} }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - for ioc in body['data']['case']['iocs']['edges']: - test_description = ioc['node']['iocDescription'] - self.assertEqual(test_description, description) - - def test_graphql_iocs_filter_iocTlpId_should_not_fail(self): - case_identifier = self._create_case() - payload = {'query': f'mutation {{ iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "test") {{ ioc {{ iocTlpId }} }} }}'} - response = self._subject.execute_graphql_query(payload) - ioc_tlp_id = response['data']['iocCreate']['ioc']['iocTlpId'] - payload = { - 'query': f'''{{ - case(caseId: {case_identifier}) {{ - iocs(iocTlpId: {ioc_tlp_id}) {{ edges {{ node {{ iocTlpId }} }} }} }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - for ioc in body['data']['case']['iocs']['edges']: - test_tlp_id = ioc['node']['iocTlpId'] - self.assertEqual(test_tlp_id, ioc_tlp_id) - - def test_graphql_iocs_filter_iocTags_should_not_fail(self): - case_identifier = self._create_case() - payload = {'query': f'mutation {{ iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "test") {{ ioc {{ iocTags }} }} }}'} - self._subject.execute_graphql_query(payload) - tags = "test" - payload = { - 'query': f'''mutation {{ - iocUpdate(iocId: 1, description: "Some description", typeId:1, tlpId:1, value: "test", - tags :"{tags}") {{ - ioc {{ iocTags }} - }} - }}''' - } - self._subject.execute_graphql_query(payload) - payload = { - 'query': f'''{{ - case(caseId: {case_identifier}) {{ - iocs(iocTags: "{tags}") {{ edges {{ node {{ iocTags }} }} }} }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - for ioc in body['data']['case']['iocs']['edges']: - test_tags = ioc['node']['iocTags'] - self.assertEqual(test_tags, tags) - - def test_graphql_iocs_filter_iocMisp_should_not_fail(self): - case_identifier = self._create_case() - payload = {'query': f'mutation {{ iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "test") {{ ioc {{ iocMisp }} }} }}'} - self._subject.execute_graphql_query(payload) - misp = "test" - payload = { - 'query': f'''{{ - case(caseId: {case_identifier}) {{ - iocs(iocMisp: "{misp}") {{ edges {{ node {{ iocMisp }} }} }} }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - for ioc in body['data']['case']['iocs']['edges']: - test_misp = ioc['node']['iocMisp'] - self.assertNotEqual(test_misp, misp) - - def test_graphql_iocs_filter_userId_should_not_fail(self): - case_identifier = self._create_case() - payload = {'query': f'mutation {{ iocCreate(caseId: {case_identifier}, typeId: 1, tlpId: 1, value: "test") {{ ioc {{ userId }} }} }}'} - response = self._subject.execute_graphql_query(payload) - user_id = response['data']['iocCreate']['ioc']['userId'] - payload = { - 'query': f'''{{ - case(caseId: {case_identifier}) {{ - iocs(userId: {user_id}) {{ edges {{ node {{ userId }} }} }} }} - }}''' - } - body = self._subject.execute_graphql_query(payload) - for ioc in body['data']['case']['iocs']['edges']: - test_user = ioc['node']['userId'] - self.assertEqual(test_user, user_id) - - def test_graphql_case_should_return_error_cases_query_when_permission_denied(self): - user = self._subject.create_dummy_user() - name = "cases_query_permission_denied" - case_id = None - payload = { - 'query': f'''mutation {{ - caseCreate(name: "{name}", description: "Some description", clientId: 1) {{ - case {{ caseId }} - }} - }}''' - } - self._subject.execute_graphql_query(payload) - payload = { - 'query': f'''query {{ cases (name :"{name}") - {{ edges {{ node {{ caseId }} }} }} }}''' - } - body = user.execute_graphql_query(payload) - for case in body['data']['cases']['edges']: - test_case_id = case['node']['caseId'] - self.assertEqual(case_id, test_case_id) - - def test_graphql_case_should_return_success_cases_query(self): - user = self._subject.create_dummy_user() - name = 'cases_query_permission_denied' - payload = { - 'query': f'''mutation {{ - caseCreate(name: "{name}", description: "Some description", clientId: 1) {{ - case {{ caseId }} - }} - }}''' - } - body = user.execute_graphql_query(payload) - case_id = body['data']['caseCreate']['case']['caseId'] - payload = { - 'query': f'''query {{ cases (name :"{name}") - {{ edges {{ node {{ caseId }} }} }} }}''' - } - body = user.execute_graphql_query(payload) - for case in body['data']['cases']['edges']: - test_case_id = case['node']['caseId'] - self.assertEqual(case_id, test_case_id) - - def test_graphql_case_should_work_with_tags(self): - payload = { - 'query': 'mutation { caseCreate(name: "test_case_tag", description: "Some description", clientId: 1) { case { caseId } } }' - } - body = self._subject.execute_graphql_query(payload) - case_identifier = body['data']['caseCreate']['case']['caseId'] - - payload = { - 'query': f'''mutation {{ - caseUpdate(caseId: {case_identifier}, tags: "test_case_number1") {{ - case {{ name }} - }} - }}''' - } - self._subject.execute_graphql_query(payload) - payload = { - 'query': 'query { cases (tags :"test_case_number1"){ edges { node { caseId } } } }' - } - body = self._subject.execute_graphql_query(payload) - for case in body['data']['cases']['edges']: - test_case_id = case['node']['caseId'] - self.assertEqual(case_identifier, test_case_id) - - def test_graphql_case_should_work_with_open_since(self): - payload = { - 'query': 'mutation {caseCreate(name: "test_case_open_since", description: "Some description", clientId: 1) { case { caseId } } } ' - } - body = self._subject.execute_graphql_query(payload) - case_id = body['data']['caseCreate']['case']['caseId'] - payload = { - 'query': 'query { cases (openSince: 0, name: "test_case_open_since") { edges { node { caseId initialDate openDate } } } }' - } - body = self._subject.execute_graphql_query(payload) - for case in body['data']['cases']['edges']: - test = case['node']['caseId'] - self.assertEqual(test, case_id) diff --git a/tests/tests_rest_case_activities.py b/tests/tests_rest_case_activities.py new file mode 100644 index 000000000..a82d33c35 --- /dev/null +++ b/tests/tests_rest_case_activities.py @@ -0,0 +1,71 @@ +# IRIS Source Code +# Copyright (C) 2025 - 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from unittest import TestCase +from iris import Iris + +_IDENTIFIER_FOR_NONEXISTENT_OBJECT = 123456789 + + +class TestsRestCaseActivities(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + self._subject.clear_database() + + def test_list_case_activities_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/activities') + self.assertEqual(200, response.status_code) + + def test_list_case_activities_should_return_404_when_case_does_not_exist(self): + response = self._subject.get(f'/api/v2/cases/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}/activities') + self.assertEqual(404, response.status_code) + + def test_list_case_activities_should_return_403_when_user_has_no_access_to_case(self): + case_identifier = self._subject.create_dummy_case() + user = self._subject.create_dummy_user() + response = user.get(f'/api/v2/cases/{case_identifier}/activities') + self.assertEqual(403, response.status_code) + + def test_list_case_activities_should_return_a_list(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/activities').json() + self.assertIsInstance(response, list) + + def test_list_case_activities_should_include_case_creation_activity(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/activities').json() + self.assertGreaterEqual(len(response), 1) + + def test_list_case_activities_entries_should_include_user_name(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/activities').json() + self.assertIn('user_name', response[0]) + + def test_list_case_activities_entries_should_include_activity_date(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/activities').json() + self.assertIn('activity_date', response[0]) + + def test_list_case_activities_entries_should_include_activity_description(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/activities').json() + self.assertIn('activity_desc', response[0]) diff --git a/tests/tests_rest_case_timelines.py b/tests/tests_rest_case_timelines.py new file mode 100644 index 000000000..fbef2cf19 --- /dev/null +++ b/tests/tests_rest_case_timelines.py @@ -0,0 +1,202 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. + +""" +Integration tests for `/api/v2/cases/<id>/timelines`. + +Covers the create-case → auto-Main flow, full CRUD on user-created +timelines, and the m2m wiring between events and timelines via the +events endpoint's `timeline_ids` field. Hits the docker-compose stack +end-to-end through the existing `Iris` helper. +""" + +from unittest import TestCase + +from iris import Iris + + +class TestsRestCaseTimelines(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + self._subject.clear_database() + + def test_new_case_should_have_a_default_main_timeline(self): + case_id = self._subject.create_dummy_case() + timelines = self._subject.get(f'/api/v2/cases/{case_id}/timelines').json() + + defaults = [t for t in timelines if t['is_default']] + self.assertEqual(1, len(defaults)) + self.assertEqual('Main', defaults[0]['name']) + + def test_create_timeline_should_return_201_and_appear_in_list(self): + case_id = self._subject.create_dummy_case() + response = self._subject.create( + f'/api/v2/cases/{case_id}/timelines', + {'name': 'Attacker activity', 'color': '#ff0000'} + ) + self.assertEqual(201, response.status_code) + + timelines = self._subject.get(f'/api/v2/cases/{case_id}/timelines').json() + names = [t['name'] for t in timelines] + self.assertIn('Attacker activity', names) + + def test_create_timeline_with_duplicate_name_should_return_400(self): + case_id = self._subject.create_dummy_case() + first = self._subject.create( + f'/api/v2/cases/{case_id}/timelines', {'name': 'Network'} + ) + self.assertEqual(201, first.status_code) + second = self._subject.create( + f'/api/v2/cases/{case_id}/timelines', {'name': 'Network'} + ) + self.assertEqual(400, second.status_code) + + def test_create_timeline_with_invalid_color_should_return_400(self): + case_id = self._subject.create_dummy_case() + response = self._subject.create( + f'/api/v2/cases/{case_id}/timelines', + {'name': 'Bad', 'color': 'red'} + ) + self.assertEqual(400, response.status_code) + + def test_create_timeline_with_empty_name_should_return_400(self): + case_id = self._subject.create_dummy_case() + response = self._subject.create( + f'/api/v2/cases/{case_id}/timelines', {'name': ' '} + ) + self.assertEqual(400, response.status_code) + + def test_update_timeline_should_change_fields(self): + case_id = self._subject.create_dummy_case() + created = self._subject.create( + f'/api/v2/cases/{case_id}/timelines', {'name': 'Network'} + ).json() + + updated = self._subject.update( + f'/api/v2/cases/{case_id}/timelines/{created["timeline_id"]}', + {'name': 'Network forensic', 'color': '#00aa55'} + ).json() + self.assertEqual('Network forensic', updated['name']) + self.assertEqual('#00aa55', updated['color']) + + def test_delete_default_timeline_should_return_400(self): + case_id = self._subject.create_dummy_case() + timelines = self._subject.get(f'/api/v2/cases/{case_id}/timelines').json() + default = next(t for t in timelines if t['is_default']) + + response = self._subject.delete( + f'/api/v2/cases/{case_id}/timelines/{default["timeline_id"]}' + ) + self.assertEqual(400, response.status_code) + + def test_delete_custom_timeline_should_return_204(self): + case_id = self._subject.create_dummy_case() + created = self._subject.create( + f'/api/v2/cases/{case_id}/timelines', {'name': 'Throwaway'} + ).json() + response = self._subject.delete( + f'/api/v2/cases/{case_id}/timelines/{created["timeline_id"]}' + ) + self.assertEqual(204, response.status_code) + + def test_event_create_without_timeline_ids_should_attach_to_default(self): + case_id = self._subject.create_dummy_case() + event = self._subject.create( + f'/api/v2/cases/{case_id}/events', + { + 'event_title': 'Initial access', + 'event_content': '', + 'event_raw': '', + 'event_source': '', + 'event_date': '2026-01-01T00:00:00.000', + 'event_tz': '+00:00', + 'event_category_id': 1, + 'event_assets': [], + 'event_iocs': [], + 'event_in_summary': False, + 'event_in_graph': False + } + ).json() + + # Returned payload should expose the timeline_ids list and it + # should include the Main default's id. + self.assertIn('timeline_ids', event) + self.assertEqual(1, len(event['timeline_ids'])) + + def test_event_create_with_unknown_timeline_id_should_return_400(self): + case_id = self._subject.create_dummy_case() + response = self._subject.create( + f'/api/v2/cases/{case_id}/events', + { + 'event_title': 'Bad', + 'event_content': '', + 'event_raw': '', + 'event_source': '', + 'event_date': '2026-01-01T00:00:00.000', + 'event_tz': '+00:00', + 'event_category_id': 1, + 'event_assets': [], + 'event_iocs': [], + 'event_in_summary': False, + 'event_in_graph': False, + 'timeline_ids': [9999999] + } + ) + self.assertEqual(400, response.status_code) + + def test_event_update_can_move_event_between_timelines(self): + case_id = self._subject.create_dummy_case() + extra = self._subject.create( + f'/api/v2/cases/{case_id}/timelines', {'name': 'Forensic'} + ).json() + + event = self._subject.create( + f'/api/v2/cases/{case_id}/events', + { + 'event_title': 'Move me', + 'event_content': '', + 'event_raw': '', + 'event_source': '', + 'event_date': '2026-01-01T00:00:00.000', + 'event_tz': '+00:00', + 'event_category_id': 1, + 'event_assets': [], + 'event_iocs': [], + 'event_in_summary': False, + 'event_in_graph': False + } + ).json() + + updated = self._subject.update( + f'/api/v2/cases/{case_id}/events/{event["event_id"]}', + { + 'event_title': 'Move me', + 'event_content': '', + 'event_raw': '', + 'event_source': '', + 'event_date': '2026-01-01T00:00:00.000', + 'event_tz': '+00:00', + 'event_category_id': 1, + 'event_assets': [], + 'event_iocs': [], + 'event_in_summary': False, + 'event_in_graph': False, + 'timeline_ids': [extra['timeline_id']] + } + ).json() + + self.assertEqual([extra['timeline_id']], updated['timeline_ids']) diff --git a/tests/tests_rest_custom_dashboards.py b/tests/tests_rest_custom_dashboards.py new file mode 100644 index 000000000..54ff98e20 --- /dev/null +++ b/tests/tests_rest_custom_dashboards.py @@ -0,0 +1,209 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from unittest import TestCase +from iris import Iris + +STATISTICS_DASHBOARD_UUID = '00000000-0000-4000-8000-000000000001' + +_CUSTOM_DASHBOARDS_READ = 0x1000 +_CUSTOM_DASHBOARDS_WRITE = 0x2000 +_CUSTOM_DASHBOARDS_SHARE = 0x4000 + + +def _minimal_widget(name='widget'): + return { + 'name': name, + 'chart_type': 'number', + 'fields': [{'table': 'alerts', 'column': 'alert_id', 'aggregation': 'count', 'alias': 'total'}], + } + + +def _minimal_dashboard(name='dashboard'): + return { + 'name': name, + 'description': 'test dashboard', + 'widgets': [_minimal_widget('total alerts')], + } + + +class TestsRestCustomDashboards(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + self._subject.clear_database() + + def test_list_dashboards_returns_seeded_statistics(self): + response = self._subject.get('/api/v2/custom-dashboards').json() + self.assertIn('data', response) + uuids = [d['dashboard_uuid'] for d in response['data']] + self.assertIn(STATISTICS_DASHBOARD_UUID, uuids) + + def test_seeded_statistics_dashboard_is_system(self): + response = self._subject.get(f'/api/v2/custom-dashboards/{STATISTICS_DASHBOARD_UUID}').json() + self.assertTrue(response['data'].get('is_system')) + + def test_create_dashboard_returns_201(self): + response = self._subject.create('/api/v2/custom-dashboards', _minimal_dashboard('mine')) + self.assertEqual(201, response.status_code) + body = response.json() + self.assertEqual('mine', body['data']['name']) + self.assertFalse(body['data']['is_system']) + + def test_update_dashboard_returns_200(self): + created = self._subject.create('/api/v2/custom-dashboards', _minimal_dashboard('first')).json() + uuid = created['data']['dashboard_uuid'] + renamed = _minimal_dashboard('renamed') + response = self._subject.update(f'/api/v2/custom-dashboards/{uuid}', renamed) + self.assertEqual(200, response.status_code) + self.assertEqual('renamed', response.json()['data']['name']) + + def test_delete_dashboard_succeeds(self): + created = self._subject.create('/api/v2/custom-dashboards', _minimal_dashboard('todelete')).json() + uuid = created['data']['dashboard_uuid'] + response = self._subject.delete(f'/api/v2/custom-dashboards/{uuid}') + self.assertIn(response.status_code, (200, 204)) + follow_up = self._subject.get(f'/api/v2/custom-dashboards/{uuid}') + self.assertEqual(404, follow_up.status_code) + + def test_update_system_dashboard_is_refused(self): + renamed = _minimal_dashboard('hijacked') + response = self._subject.update(f'/api/v2/custom-dashboards/{STATISTICS_DASHBOARD_UUID}', renamed) + self.assertNotEqual(200, response.status_code) + + def test_delete_system_dashboard_is_refused(self): + response = self._subject.delete(f'/api/v2/custom-dashboards/{STATISTICS_DASHBOARD_UUID}') + self.assertNotEqual(200, response.status_code) + self.assertNotEqual(204, response.status_code) + + def test_render_rejects_unknown_table(self): + body = { + 'definition': { + 'name': 'evil', + 'widgets': [{ + 'name': 'bad', + 'chart_type': 'number', + 'fields': [{'table': 'pg_user', 'column': 'usename', 'aggregation': 'count', 'alias': 'x'}], + }], + } + } + response = self._subject.create( + f'/api/v2/custom-dashboards/{STATISTICS_DASHBOARD_UUID}/render', body, + ) + self.assertEqual(200, response.status_code) + widgets = response.json()['data']['widgets'] + self.assertTrue(any('error' in w for w in widgets)) + + def test_render_rejects_unknown_named_aggregation(self): + body = { + 'definition': { + 'name': 'computed', + 'widgets': [{ + 'name': 'bad', + 'chart_type': 'number', + 'fields': [{'table': 'computed', 'column': 'no_such_metric', 'alias': 'x'}], + }], + } + } + response = self._subject.create( + f'/api/v2/custom-dashboards/{STATISTICS_DASHBOARD_UUID}/render', body, + ) + self.assertEqual(200, response.status_code) + widgets = response.json()['data']['widgets'] + self.assertTrue(any('error' in w for w in widgets)) + + def test_render_pie_chart_executes(self): + body = { + 'definition': { + 'name': 'render-test', + 'widgets': [{ + 'name': 'alerts by severity', + 'chart_type': 'pie', + 'fields': [ + {'table': 'severities', 'column': 'severity_name', 'alias': 'severity'}, + {'table': 'alerts', 'column': 'alert_id', 'aggregation': 'count', 'alias': 'total'}, + ], + 'group_by': ['severities.severity_name'], + }], + } + } + response = self._subject.create( + f'/api/v2/custom-dashboards/{STATISTICS_DASHBOARD_UUID}/render', body, + ) + self.assertEqual(200, response.status_code) + widgets = response.json()['data']['widgets'] + self.assertEqual(1, len(widgets)) + self.assertNotIn('error', widgets[0]) + + def test_render_named_aggregation_returns_value(self): + body = { + 'definition': { + 'name': 'mttd-test', + 'widgets': [{ + 'name': 'mttd', + 'chart_type': 'number', + 'fields': [{'table': 'computed', 'column': 'mttd_seconds', 'alias': 'mttd_seconds'}], + }], + } + } + response = self._subject.create( + f'/api/v2/custom-dashboards/{STATISTICS_DASHBOARD_UUID}/render', body, + ) + self.assertEqual(200, response.status_code) + widgets = response.json()['data']['widgets'] + self.assertEqual('mttd_seconds', widgets[0].get('computed')) + + def test_schema_endpoint_lists_named_aggregations(self): + response = self._subject.get('/api/v2/custom-dashboards/schema').json() + agg_names = [a['name'] for a in response['data']['named_aggregations']] + self.assertIn('mttd_seconds', agg_names) + self.assertIn('mttr_seconds', agg_names) + self.assertIn('false_positive_rate', agg_names) + self.assertIn('escalation_rate', agg_names) + self.assertIn('alerts_window_count', agg_names) + + def test_presets_endpoint_returns_entries(self): + response = self._subject.get('/api/v2/custom-dashboards/presets').json() + self.assertGreater(len(response['data']), 0) + + def test_unauthenticated_user_without_read_cannot_list(self): + user = self._subject.create_dummy_user(permissions=0) + response = user.get('/api/v2/custom-dashboards') + self.assertEqual(403, response.status_code) + + def test_ownership_scoping_hides_other_users_unshared_dashboards(self): + alice = self._subject.create_dummy_user(permissions=_CUSTOM_DASHBOARDS_READ | _CUSTOM_DASHBOARDS_WRITE) + bob = self._subject.create_dummy_user(permissions=_CUSTOM_DASHBOARDS_READ | _CUSTOM_DASHBOARDS_WRITE) + created = alice.create('/api/v2/custom-dashboards', _minimal_dashboard('alice-private')).json() + uuid = created['data']['dashboard_uuid'] + response = bob.get(f'/api/v2/custom-dashboards/{uuid}') + self.assertIn(response.status_code, (403, 404)) + + def test_shared_dashboards_visible_to_other_users(self): + alice = self._subject.create_dummy_user( + permissions=_CUSTOM_DASHBOARDS_READ | _CUSTOM_DASHBOARDS_WRITE | _CUSTOM_DASHBOARDS_SHARE + ) + bob = self._subject.create_dummy_user(permissions=_CUSTOM_DASHBOARDS_READ) + payload = _minimal_dashboard('alice-shared') + payload['is_shared'] = True + created = alice.create('/api/v2/custom-dashboards', payload).json() + uuid = created['data']['dashboard_uuid'] + response = bob.get(f'/api/v2/custom-dashboards/{uuid}') + self.assertEqual(200, response.status_code) diff --git a/tests/tests_rest_dashboard_activities.py b/tests/tests_rest_dashboard_activities.py new file mode 100644 index 000000000..c8d35900c --- /dev/null +++ b/tests/tests_rest_dashboard_activities.py @@ -0,0 +1,125 @@ +# IRIS Source Code +# Copyright (C) 2025 - 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from unittest import TestCase +from iris import Iris + + +class TestsRestDashboardActivities(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + self._subject.clear_database() + + def test_list_recent_activities_should_return_200(self): + response = self._subject.get('/api/v2/dashboard/activities/recent') + self.assertEqual(200, response.status_code) + + def test_list_recent_activities_should_return_a_list(self): + response = self._subject.get('/api/v2/dashboard/activities/recent').json() + self.assertIsInstance(response, list) + + def test_list_recent_activities_should_include_recently_created_case(self): + self._subject.create_dummy_case() + response = self._subject.get('/api/v2/dashboard/activities/recent').json() + self.assertGreaterEqual(len(response), 1) + + def test_list_recent_activities_entries_should_include_user_name(self): + self._subject.create_dummy_case() + response = self._subject.get('/api/v2/dashboard/activities/recent').json() + self.assertIn('user_name', response[0]) + + def test_list_recent_activities_entries_should_include_activity_date(self): + self._subject.create_dummy_case() + response = self._subject.get('/api/v2/dashboard/activities/recent').json() + self.assertIn('activity_date', response[0]) + + def test_list_recent_activities_entries_should_include_activity_description(self): + self._subject.create_dummy_case() + response = self._subject.get('/api/v2/dashboard/activities/recent').json() + self.assertIn('activity_desc', response[0]) + + def test_list_recent_activities_should_honour_limit_query_param(self): + # Create a few cases so we have multiple activity entries. + for _ in range(3): + self._subject.create_dummy_case() + response = self._subject.get('/api/v2/dashboard/activities/recent?limit=1').json() + self.assertEqual(1, len(response)) + + def test_list_recent_activities_should_clamp_invalid_limit(self): + # Negative / zero limits should fall back to the default rather than + # erroring out. + response = self._subject.get('/api/v2/dashboard/activities/recent?limit=-5') + self.assertEqual(200, response.status_code) + + def test_list_recent_activities_should_honour_offset_query_param(self): + # Two pages of size 1 — the first row of page 2 must differ from + # the first row of page 1 (assuming we have at least 2 activity + # entries, which any populated case will provide). + for _ in range(3): + self._subject.create_dummy_case() + first_page = self._subject.get('/api/v2/dashboard/activities/recent?limit=1&offset=0').json() + second_page = self._subject.get('/api/v2/dashboard/activities/recent?limit=1&offset=1').json() + self.assertEqual(1, len(first_page)) + self.assertEqual(1, len(second_page)) + if first_page and second_page: + # IDs are stable per-row, so two distinct offsets must yield + # two distinct rows. + self.assertNotEqual(first_page[0].get('id'), second_page[0].get('id')) + + def test_list_recent_activities_should_scope_to_user_accessible_cases(self): + # An activity authored on a case the other user has no access to + # must not leak into their feed. + case_identifier = self._subject.create_dummy_case() + # Make sure the case exists (the call itself creates an activity row). + self.assertIsNotNone(case_identifier) + + other_user = self._subject.create_dummy_user() + response = other_user.get('/api/v2/dashboard/activities/recent').json() + # The other user has no access to the case we created — none of the + # returned rows should reference it. + self.assertTrue(all(row.get('case_id') != case_identifier for row in response)) + + def test_list_recent_major_case_activities_should_return_200(self): + response = self._subject.get('/api/v2/dashboard/activities/cases/major') + self.assertEqual(200, response.status_code) + + def test_list_recent_major_case_activities_should_include_required_fields(self): + self._subject.create_dummy_case() + response = self._subject.get('/api/v2/dashboard/activities/cases/major').json() + + self.assertGreaterEqual(len(response), 1) + self.assertIn('case_id', response[0]) + self.assertIn('case_name', response[0]) + self.assertIn('owner_name', response[0]) + self.assertIn('opened_by_name', response[0]) + self.assertIn('customer_name', response[0]) + + def test_list_recent_major_case_activities_should_honour_limit_offset(self): + for _ in range(3): + self._subject.create_dummy_case() + + first_page = self._subject.get('/api/v2/dashboard/activities/cases/major?limit=1&offset=0').json() + second_page = self._subject.get('/api/v2/dashboard/activities/cases/major?limit=1&offset=1').json() + + self.assertEqual(1, len(first_page)) + self.assertEqual(1, len(second_page)) + if first_page and second_page: + self.assertNotEqual(first_page[0].get('id'), second_page[0].get('id')) diff --git a/tests/tests_rest_datastore.py b/tests/tests_rest_datastore.py new file mode 100644 index 000000000..f0e0b6e3e --- /dev/null +++ b/tests/tests_rest_datastore.py @@ -0,0 +1,341 @@ +# IRIS Source Code +# Copyright (C) 2024 - 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +import io +from unittest import TestCase +from iris import Iris + +_IDENTIFIER_FOR_NONEXISTENT_OBJECT = 123456789 + + +class TestsRestDatastore(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + self._subject.clear_database() + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + def _get_root_folder_id(self, case_identifier): + tree = self._subject.get(f'/api/v2/cases/{case_identifier}/datastore/tree').json() + # Tree shape: {'d-<root_id>': {...}} + root_key = next(iter(tree['data'])) + return int(root_key.split('-')[1]) + + def _create_folder(self, case_identifier, name='Folder', parent=None): + if parent is None: + parent = self._get_root_folder_id(case_identifier) + body = {'parent_node': parent, 'folder_name': name} + return self._subject.create(f'/api/v2/cases/{case_identifier}/datastore/folders', body) + + def _upload_file(self, case_identifier, folder_id, filename='hello.txt', content=b'hello world', + description='desc'): + data = { + 'file_original_name': filename, + 'file_description': description + } + files = {'file_content': (filename, io.BytesIO(content), 'application/octet-stream')} + return self._subject.post_multipart_encoded_files( + f'/api/v2/cases/{case_identifier}/datastore/folders/{folder_id}/files', + data, + files + ) + + # ------------------------------------------------------------------ + # tree + # ------------------------------------------------------------------ + def test_get_tree_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/datastore/tree') + self.assertEqual(200, response.status_code) + + def test_get_tree_should_return_404_when_case_does_not_exist(self): + response = self._subject.get(f'/api/v2/cases/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}/datastore/tree') + self.assertEqual(404, response.status_code) + + def test_get_tree_should_return_403_when_user_has_no_access(self): + case_identifier = self._subject.create_dummy_case() + user = self._subject.create_dummy_user() + response = user.get(f'/api/v2/cases/{case_identifier}/datastore/tree') + self.assertEqual(403, response.status_code) + + def test_get_tree_should_include_root_directory(self): + case_identifier = self._subject.create_dummy_case() + tree = self._subject.get(f'/api/v2/cases/{case_identifier}/datastore/tree').json()['data'] + root_key = next(iter(tree)) + self.assertTrue(tree[root_key].get('is_root')) + + # ------------------------------------------------------------------ + # folder CRUD + # ------------------------------------------------------------------ + def test_add_folder_should_return_201(self): + case_identifier = self._subject.create_dummy_case() + response = self._create_folder(case_identifier, 'Folder1') + self.assertEqual(201, response.status_code) + + def test_add_folder_should_return_400_when_missing_fields(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.create(f'/api/v2/cases/{case_identifier}/datastore/folders', {}) + self.assertEqual(400, response.status_code) + + def test_add_folder_should_return_404_when_case_does_not_exist(self): + response = self._subject.create( + f'/api/v2/cases/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}/datastore/folders', + {'parent_node': 1, 'folder_name': 'X'} + ) + self.assertEqual(404, response.status_code) + + def test_add_folder_should_return_403_when_user_has_no_access(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + user = self._subject.create_dummy_user() + response = user.create( + f'/api/v2/cases/{case_identifier}/datastore/folders', + {'parent_node': root, 'folder_name': 'X'} + ) + self.assertEqual(403, response.status_code) + + def test_rename_folder_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + created = self._create_folder(case_identifier, 'Folder1').json()['data'] + folder_id = created['path_id'] + response = self._subject.create( + f'/api/v2/cases/{case_identifier}/datastore/folders/{folder_id}/rename', + {'folder_name': 'Renamed'} + ) + self.assertEqual(200, response.status_code) + self.assertEqual('Renamed', response.json()['data']['path_name']) + + def test_rename_folder_should_return_404_when_folder_missing(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.create( + f'/api/v2/cases/{case_identifier}/datastore/folders/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}/rename', + {'folder_name': 'Renamed'} + ) + self.assertEqual(404, response.status_code) + + def test_move_folder_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + folder_a = self._create_folder(case_identifier, 'A', parent=root).json()['data'] + folder_b = self._create_folder(case_identifier, 'B', parent=root).json()['data'] + response = self._subject.create( + f'/api/v2/cases/{case_identifier}/datastore/folders/{folder_a["path_id"]}/move', + {'destination_node': folder_b['path_id']} + ) + self.assertEqual(200, response.status_code) + + def test_move_folder_should_return_400_when_same_destination(self): + case_identifier = self._subject.create_dummy_case() + folder = self._create_folder(case_identifier, 'A').json()['data'] + response = self._subject.create( + f'/api/v2/cases/{case_identifier}/datastore/folders/{folder["path_id"]}/move', + {'destination_node': folder['path_id']} + ) + self.assertEqual(400, response.status_code) + + def test_delete_folder_should_return_204(self): + case_identifier = self._subject.create_dummy_case() + folder = self._create_folder(case_identifier, 'Folder1').json()['data'] + response = self._subject.delete( + f'/api/v2/cases/{case_identifier}/datastore/folders/{folder["path_id"]}' + ) + self.assertEqual(204, response.status_code) + + def test_delete_folder_should_return_404_when_folder_missing(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.delete( + f'/api/v2/cases/{case_identifier}/datastore/folders/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}' + ) + self.assertEqual(404, response.status_code) + + def test_delete_root_folder_should_return_400(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + response = self._subject.delete(f'/api/v2/cases/{case_identifier}/datastore/folders/{root}') + self.assertEqual(400, response.status_code) + + def test_list_folders_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/datastore/folders') + self.assertEqual(200, response.status_code) + + def test_get_folder_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + folder = self._create_folder(case_identifier, 'Folder1').json()['data'] + response = self._subject.get( + f'/api/v2/cases/{case_identifier}/datastore/folders/{folder["path_id"]}' + ) + self.assertEqual(200, response.status_code) + + def test_get_folder_should_return_404_when_missing(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get( + f'/api/v2/cases/{case_identifier}/datastore/folders/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}' + ) + self.assertEqual(404, response.status_code) + + # ------------------------------------------------------------------ + # file CRUD + # ------------------------------------------------------------------ + def test_add_file_should_return_201(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + response = self._upload_file(case_identifier, root) + self.assertEqual(201, response.status_code) + + def test_add_file_should_return_404_when_folder_missing(self): + case_identifier = self._subject.create_dummy_case() + response = self._upload_file(case_identifier, _IDENTIFIER_FOR_NONEXISTENT_OBJECT) + self.assertEqual(404, response.status_code) + + def test_add_file_should_return_404_when_case_missing(self): + response = self._upload_file(_IDENTIFIER_FOR_NONEXISTENT_OBJECT, 1) + self.assertEqual(404, response.status_code) + + def test_add_file_should_return_403_when_user_has_no_access(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + user = self._subject.create_dummy_user() + files = {'file_content': ('hello.txt', io.BytesIO(b'hi'), 'application/octet-stream')} + response = user.post_multipart_encoded_files( + f'/api/v2/cases/{case_identifier}/datastore/folders/{root}/files', + {'file_original_name': 'hello.txt'}, + files + ) + self.assertEqual(403, response.status_code) + + def test_get_file_info_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + created = self._upload_file(case_identifier, root).json()['data'] + response = self._subject.get( + f'/api/v2/cases/{case_identifier}/datastore/files/{created["file_id"]}/info' + ) + self.assertEqual(200, response.status_code) + + def test_get_file_info_should_return_404_when_missing(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get( + f'/api/v2/cases/{case_identifier}/datastore/files/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}/info' + ) + self.assertEqual(404, response.status_code) + + def test_view_file_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + created = self._upload_file(case_identifier, root).json()['data'] + response = self._subject.get( + f'/api/v2/cases/{case_identifier}/datastore/files/{created["file_id"]}' + ) + self.assertEqual(200, response.status_code) + + def test_list_files_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/datastore/files') + self.assertEqual(200, response.status_code) + + def test_list_files_should_return_total_zero_for_new_case(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get(f'/api/v2/cases/{case_identifier}/datastore/files').json() + self.assertEqual(0, response['data']['total']) + + def test_list_files_should_return_400_when_order_by_invalid(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.get( + f'/api/v2/cases/{case_identifier}/datastore/files', + {'order_by': 'an_invalid_field'} + ) + self.assertEqual(400, response.status_code) + + def test_list_files_should_return_uploaded_file(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + self._upload_file(case_identifier, root, filename='abc.txt') + response = self._subject.get(f'/api/v2/cases/{case_identifier}/datastore/files').json()['data'] + self.assertEqual(1, response['total']) + + def test_update_file_should_change_description(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + created = self._upload_file(case_identifier, root).json()['data'] + data = {'file_description': 'updated'} + response = self._subject.post_multipart_encoded_files( + f'/api/v2/cases/{case_identifier}/datastore/files/{created["file_id"]}', + data, + {} + ) + self.assertEqual(200, response.status_code) + self.assertEqual('updated', response.json()['data']['file_description']) + + def test_update_file_should_return_404_when_missing(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.post_multipart_encoded_files( + f'/api/v2/cases/{case_identifier}/datastore/files/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}', + {'file_description': 'x'}, + {} + ) + self.assertEqual(404, response.status_code) + + def test_move_file_should_return_200(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + folder = self._create_folder(case_identifier, 'Dest', parent=root).json()['data'] + created = self._upload_file(case_identifier, root).json()['data'] + response = self._subject.create( + f'/api/v2/cases/{case_identifier}/datastore/files/{created["file_id"]}/move', + {'destination_node': folder['path_id']} + ) + self.assertEqual(200, response.status_code) + + def test_move_file_should_return_404_when_missing(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.create( + f'/api/v2/cases/{case_identifier}/datastore/files/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}/move', + {'destination_node': 1} + ) + self.assertEqual(404, response.status_code) + + def test_delete_file_should_return_204(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + created = self._upload_file(case_identifier, root).json()['data'] + response = self._subject.delete( + f'/api/v2/cases/{case_identifier}/datastore/files/{created["file_id"]}' + ) + self.assertEqual(204, response.status_code) + + def test_delete_file_should_return_404_when_missing(self): + case_identifier = self._subject.create_dummy_case() + response = self._subject.delete( + f'/api/v2/cases/{case_identifier}/datastore/files/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}' + ) + self.assertEqual(404, response.status_code) + + def test_delete_file_should_return_403_when_user_has_no_access(self): + case_identifier = self._subject.create_dummy_case() + root = self._get_root_folder_id(case_identifier) + created = self._upload_file(case_identifier, root).json()['data'] + user = self._subject.create_dummy_user() + response = user.delete( + f'/api/v2/cases/{case_identifier}/datastore/files/{created["file_id"]}' + ) + self.assertEqual(403, response.status_code) diff --git a/tests/tests_rest_followed_cases.py b/tests/tests_rest_followed_cases.py new file mode 100644 index 000000000..3576cb3fb --- /dev/null +++ b/tests/tests_rest_followed_cases.py @@ -0,0 +1,105 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. + +""" +Integration tests for `/api/v2/me/followed-cases` and the related +`/api/v2/cases/<id>/followers` endpoint. + +Hits the docker-compose stack with the existing `Iris` test helper so +the JWT round-trip, access-control gate, and DB cascades are all +exercised end-to-end. Response shape mirrors the rest of the v2 API: +`response_api_success` ships the data dict directly as the JSON body +(no envelope), `response_api_error` returns HTTP 400 with +`{message: "..."}`. +""" + +from unittest import TestCase + +from iris import Iris + + +class TestsRestFollowedCases(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + self._subject.clear_database() + + def test_follow_case_should_return_201_and_list_should_include_it(self): + case_id = self._subject.create_dummy_case() + response = self._subject.create('/api/v2/me/followed-cases', {'case_id': case_id}) + self.assertEqual(201, response.status_code) + + listed = self._subject.get('/api/v2/me/followed-cases').json() + ids = [c['case_id'] for c in listed] + self.assertIn(case_id, ids) + + def test_follow_case_twice_should_be_idempotent(self): + case_id = self._subject.create_dummy_case() + first = self._subject.create('/api/v2/me/followed-cases', {'case_id': case_id}) + second = self._subject.create('/api/v2/me/followed-cases', {'case_id': case_id}) + self.assertEqual(201, first.status_code) + self.assertEqual(201, second.status_code) + + listed = self._subject.get('/api/v2/me/followed-cases').json() + # An idempotent re-follow must not create a duplicate row. + followed_for_case = [c for c in listed if c['case_id'] == case_id] + self.assertEqual(1, len(followed_for_case)) + + def test_follow_case_with_missing_case_id_should_return_400(self): + response = self._subject.create('/api/v2/me/followed-cases', {}) + self.assertEqual(400, response.status_code) + + def test_follow_case_with_non_integer_case_id_should_return_400(self): + response = self._subject.create('/api/v2/me/followed-cases', {'case_id': 'abc'}) + self.assertEqual(400, response.status_code) + + def test_follow_unknown_case_should_return_404(self): + response = self._subject.create('/api/v2/me/followed-cases', {'case_id': 999_999_999}) + self.assertEqual(404, response.status_code) + + def test_unfollow_case_should_return_204_and_remove_from_list(self): + case_id = self._subject.create_dummy_case() + self._subject.create('/api/v2/me/followed-cases', {'case_id': case_id}) + + response = self._subject.delete(f'/api/v2/me/followed-cases/{case_id}') + self.assertEqual(204, response.status_code) + + listed = self._subject.get('/api/v2/me/followed-cases').json() + self.assertNotIn(case_id, [c['case_id'] for c in listed]) + + def test_unfollow_unfollowed_case_should_return_204(self): + # Idempotent unfollow: the SPA's toggle should never error out + # because a stale UI clicked the wrong direction. + case_id = self._subject.create_dummy_case() + response = self._subject.delete(f'/api/v2/me/followed-cases/{case_id}') + self.assertEqual(204, response.status_code) + + def test_followers_endpoint_should_list_following_users(self): + case_id = self._subject.create_dummy_case() + self._subject.create('/api/v2/me/followed-cases', {'case_id': case_id}) + + response = self._subject.get(f'/api/v2/cases/{case_id}/followers').json() + # We expect at least the administrator (who follows via the + # POST above) to show up. + self.assertGreaterEqual(len(response), 1) + for row in response: + self.assertIn('user_id', row) + self.assertIn('user_login', row) + self.assertIn('user_name', row) + + def test_followers_for_unknown_case_should_return_404(self): + response = self._subject.get('/api/v2/cases/999999999/followers') + self.assertEqual(404, response.status_code) diff --git a/tests/tests_rest_incident_rules.py b/tests/tests_rest_incident_rules.py new file mode 100644 index 000000000..a7aedf8f0 --- /dev/null +++ b/tests/tests_rest_incident_rules.py @@ -0,0 +1,208 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from unittest import TestCase + +from iris import Iris +from iris import IRIS_PERMISSION_INCIDENT_RULES_READ +from iris import IRIS_PERMISSION_INCIDENT_RULES_WRITE + + +def _rule_body(**overrides): + body = { + 'rule_name': 'Stack brute-force alerts', + 'rule_description': 'Group repeated brute-force alerts from the same source', + 'rule_is_active': True, + 'rule_priority': 100, + 'rule_customer_scope': None, + 'rule_conditions': { + 'logic': 'and', + 'conditions': [ + {'field': 'alert_title', 'operator': 'like', 'value': 'brute'}, + ], + 'time_window_seconds': 3600, + 'group_by': ['alert_source'], + }, + 'rule_action_type': 'create_incident', + 'rule_action_config': { + 'title_template': 'Brute-force cluster: {alert_title}', + }, + } + body.update(overrides) + return body + + +class TestsRestIncidentRules(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + # Rules aren't wiped by clear_database — do it inline. + rules = self._subject.get('/api/v2/incident-rules').json() + for rule in rules.get('data', []) if isinstance(rules, dict) else rules: + rid = rule.get('rule_id') if isinstance(rule, dict) else None + if rid: + self._subject.delete(f'/api/v2/incident-rules/{rid}') + self._subject.clear_database() + + def test_create_rule_returns_201(self): + response = self._subject.create('/api/v2/incident-rules', _rule_body()) + self.assertEqual(201, response.status_code) + + def test_create_rule_rejects_empty_conditions(self): + response = self._subject.create( + '/api/v2/incident-rules', + _rule_body(rule_conditions={'logic': 'and', 'conditions': []}) + ) + self.assertEqual(400, response.status_code) + + def test_create_rule_rejects_unknown_action(self): + response = self._subject.create( + '/api/v2/incident-rules', _rule_body(rule_action_type='not_a_real_action') + ) + self.assertEqual(400, response.status_code) + + def test_attach_flow_action_is_no_longer_accepted(self): + # Flow attachment moved into the flow itself (flow_conditions). + # The rules engine only stacks alerts into incidents now. + response = self._subject.create( + '/api/v2/incident-rules', + _rule_body(rule_action_type='attach_flow', rule_action_config={'flow_id': 1}) + ) + self.assertEqual(400, response.status_code) + + def test_update_rule_persists_changes(self): + created = self._subject.create('/api/v2/incident-rules', _rule_body()).json() + response = self._subject.update( + f'/api/v2/incident-rules/{created["data"]["rule_id"] if "data" in created else created["rule_id"]}', + {'rule_name': 'Renamed'} + ) + self.assertEqual(200, response.status_code) + + def test_test_endpoint_returns_matches(self): + # Create a matching alert first so the dry-run sample includes it. + self._subject.create('/api/v2/alerts', { + 'alert_title': 'brute force login', + 'alert_severity_id': 4, + 'alert_status_id': 3, + 'alert_customer_id': 1, + }) + created = self._subject.create('/api/v2/incident-rules', _rule_body()).json() + rule_id = created.get('rule_id') or created['data']['rule_id'] + response = self._subject.create( + f'/api/v2/incident-rules/{rule_id}/test', {'sample_days': 30} + ) + self.assertEqual(200, response.status_code) + payload = response.json() + # Wrapped by response_api_success — data lives under 'data' + matches = payload.get('matching_alert_ids') or payload.get('data', {}).get('matching_alert_ids') + self.assertIsNotNone(matches) + + def test_user_without_write_permission_cannot_create(self): + user = self._subject.create_dummy_user(permissions=IRIS_PERMISSION_INCIDENT_RULES_READ) + response = user.create('/api/v2/incident-rules', _rule_body()) + self.assertEqual(403, response.status_code) + + # ------------------------------------------------------------------- + # Security regression coverage for the tenancy hardening pass. + # These tests all use a non-admin user with only the incident-rules + # permissions — they must NOT be able to create global rules, reach + # rules scoped to unreachable customers, or spoof attribution. + # ------------------------------------------------------------------- + + def test_non_admin_cannot_create_global_rule(self): + # Null customer_scope means "all tenants" — reserved for + # server_administrator, otherwise incident_rules_write would + # double as tenant elevation. + rw = IRIS_PERMISSION_INCIDENT_RULES_READ | IRIS_PERMISSION_INCIDENT_RULES_WRITE + user = self._subject.create_dummy_user(permissions=rw) + response = user.create( + '/api/v2/incident-rules', _rule_body(rule_customer_scope=None) + ) + # The scope check raises BusinessProcessingError → 400. + self.assertEqual(400, response.status_code) + + def test_non_admin_cannot_create_rule_scoped_to_unreachable_customer(self): + rw = IRIS_PERMISSION_INCIDENT_RULES_READ | IRIS_PERMISSION_INCIDENT_RULES_WRITE + user = self._subject.create_dummy_user(permissions=rw) + other_customer = self._subject.create_dummy_customer() + response = user.create( + '/api/v2/incident-rules', + _rule_body(rule_customer_scope=[other_customer]) + ) + self.assertEqual(400, response.status_code) + + def test_non_admin_cannot_read_rule_scoped_to_unreachable_customer(self): + # Admin creates a rule scoped to a customer the dummy user + # doesn't belong to. That rule must be invisible via GET / list / + # PUT / DELETE / test / backfill. + other_customer = self._subject.create_dummy_customer() + created = self._subject.create( + '/api/v2/incident-rules', + _rule_body(rule_customer_scope=[other_customer]) + ).json() + rule_id = created.get('rule_id') or created['data']['rule_id'] + + rw = IRIS_PERMISSION_INCIDENT_RULES_READ | IRIS_PERMISSION_INCIDENT_RULES_WRITE + user = self._subject.create_dummy_user(permissions=rw) + response = user.get(f'/api/v2/incident-rules/{rule_id}') + self.assertEqual(404, response.status_code) + + def test_non_admin_cannot_backfill_rule_scoped_to_unreachable_customer(self): + other_customer = self._subject.create_dummy_customer() + created = self._subject.create( + '/api/v2/incident-rules', + _rule_body(rule_customer_scope=[other_customer]) + ).json() + rule_id = created.get('rule_id') or created['data']['rule_id'] + + rw = IRIS_PERMISSION_INCIDENT_RULES_READ | IRIS_PERMISSION_INCIDENT_RULES_WRITE + user = self._subject.create_dummy_user(permissions=rw) + response = user.create( + f'/api/v2/incident-rules/{rule_id}/backfill', {'sample_days': 7} + ) + self.assertEqual(404, response.status_code) + + def test_list_hides_rules_from_unreachable_tenants(self): + other_customer = self._subject.create_dummy_customer() + created = self._subject.create( + '/api/v2/incident-rules', + _rule_body(rule_customer_scope=[other_customer]) + ).json() + rule_id = created.get('rule_id') or created['data']['rule_id'] + + rw = IRIS_PERMISSION_INCIDENT_RULES_READ | IRIS_PERMISSION_INCIDENT_RULES_WRITE + user = self._subject.create_dummy_user(permissions=rw) + response = user.get('/api/v2/incident-rules').json() + rows = response.get('data', response) if isinstance(response, dict) else response + rule_ids = [r['rule_id'] for r in rows] if isinstance(rows, list) else [] + self.assertNotIn(rule_id, rule_ids) + + def test_created_by_is_server_owned_not_client_spoofable(self): + # A caller who supplies a bogus `rule_created_by` in the body must + # not have it honoured — the server stamps its own user id. + body = _rule_body() + body['rule_created_by'] = 999999 + response = self._subject.create('/api/v2/incident-rules', body).json() + # The response schema exposes rule_created_by (include_fk=True). + stamped = response.get('rule_created_by') + if stamped is None and isinstance(response.get('data'), dict): + stamped = response['data'].get('rule_created_by') + # 1 is the seeded administrator user id used by the harness. + self.assertEqual(1, stamped) diff --git a/tests/tests_rest_incidents.py b/tests/tests_rest_incidents.py new file mode 100644 index 000000000..6c9a2de06 --- /dev/null +++ b/tests/tests_rest_incidents.py @@ -0,0 +1,152 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from unittest import TestCase + +from iris import Iris +from iris import IRIS_PERMISSION_ALERTS_WRITE +from iris import IRIS_PERMISSION_INCIDENTS_READ +from iris import IRIS_PERMISSION_INCIDENTS_WRITE + +_IDENTIFIER_FOR_NONEXISTENT_OBJECT = 123456789 +_OPEN_STATUS_ID = 1 # seeded first by create_safe_incident_status + + +def _alert_body(): + return { + 'alert_title': 'title', + 'alert_severity_id': 4, + 'alert_status_id': 3, + 'alert_customer_id': 1, + } + + +def _incident_body(**overrides): + body = { + 'incident_title': 'Test incident', + 'incident_description': 'Grouping brute-force alerts', + 'incident_status_id': _OPEN_STATUS_ID, + 'incident_customer_id': 1, + } + body.update(overrides) + return body + + +class TestsRestIncidents(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + self._subject.clear_database() + + def test_create_incident_should_return_201(self): + response = self._subject.create('/api/v2/incidents', _incident_body()) + self.assertEqual(201, response.status_code) + + def test_create_incident_persists_title(self): + response = self._subject.create('/api/v2/incidents', _incident_body()).json() + self.assertEqual('Test incident', response['incident_title']) + + def test_create_incident_without_permission_returns_403(self): + user = self._subject.create_dummy_user() + response = user.create('/api/v2/incidents', _incident_body()) + self.assertEqual(403, response.status_code) + + def test_read_incident_returns_200(self): + created = self._subject.create('/api/v2/incidents', _incident_body()).json() + response = self._subject.get(f'/api/v2/incidents/{created["incident_id"]}') + self.assertEqual(200, response.status_code) + + def test_read_nonexistent_incident_returns_404(self): + response = self._subject.get(f'/api/v2/incidents/{_IDENTIFIER_FOR_NONEXISTENT_OBJECT}') + self.assertEqual(404, response.status_code) + + def test_update_cannot_change_customer_id(self): + created = self._subject.create('/api/v2/incidents', _incident_body()).json() + # Attempt to move to a different customer — must be silently stripped. + response = self._subject.update( + f'/api/v2/incidents/{created["incident_id"]}', + {'incident_customer_id': 9999, 'incident_title': 'Renamed'} + ).json() + self.assertEqual(1, response['incident_customer_id']) + self.assertEqual('Renamed', response['incident_title']) + + def test_delete_incident_returns_204(self): + created = self._subject.create('/api/v2/incidents', _incident_body()).json() + response = self._subject.delete(f'/api/v2/incidents/{created["incident_id"]}') + self.assertEqual(204, response.status_code) + + def test_add_alerts_attaches_alerts(self): + alert = self._subject.create('/api/v2/alerts', _alert_body()).json() + incident = self._subject.create('/api/v2/incidents', _incident_body()).json() + response = self._subject.create( + f'/api/v2/incidents/{incident["incident_id"]}/alerts', + {'alert_ids': [alert['alert_id']]} + ) + self.assertEqual(200, response.status_code) + self.assertIn(alert['alert_id'], response.json()['alert_ids']) + + def test_add_alerts_rejects_cross_tenant_alert(self): + # Different customer than the incident — should not attach. + other_customer = self._subject.create_dummy_customer() + alert_body = _alert_body() + alert_body['alert_customer_id'] = other_customer + alert = self._subject.create('/api/v2/alerts', alert_body).json() + incident = self._subject.create('/api/v2/incidents', _incident_body()).json() + response = self._subject.create( + f'/api/v2/incidents/{incident["incident_id"]}/alerts', + {'alert_ids': [alert['alert_id']]} + ).json() + self.assertNotIn(alert['alert_id'], response['alert_ids']) + + def test_escalate_creates_case_and_updates_incident(self): + alert = self._subject.create('/api/v2/alerts', _alert_body()).json() + incident = self._subject.create('/api/v2/incidents', _incident_body()).json() + self._subject.create( + f'/api/v2/incidents/{incident["incident_id"]}/alerts', + {'alert_ids': [alert['alert_id']]} + ) + response = self._subject.create( + f'/api/v2/incidents/{incident["incident_id"]}/escalate', + {'case_title': 'Escalated incident'} + ) + self.assertEqual(200, response.status_code) + payload = response.json() + self.assertIn('case_id', payload) + self.assertIsNotNone(payload['case_id']) + + def test_escalate_empty_incident_returns_error(self): + incident = self._subject.create('/api/v2/incidents', _incident_body()).json() + response = self._subject.create( + f'/api/v2/incidents/{incident["incident_id"]}/escalate', {} + ) + self.assertEqual(400, response.status_code) + + def test_list_incidents_filters_by_customer(self): + self._subject.create('/api/v2/incidents', _incident_body(incident_title='A')) + self._subject.create('/api/v2/incidents', _incident_body(incident_title='B')) + response = self._subject.get( + '/api/v2/incidents', query_parameters={'customer_id': 1, 'per_page': 100} + ).json() + self.assertGreaterEqual(response['total'], 2) + + def test_user_without_read_permission_cannot_list(self): + user = self._subject.create_dummy_user(permissions=IRIS_PERMISSION_INCIDENTS_WRITE) + response = user.get('/api/v2/incidents') + self.assertEqual(403, response.status_code) diff --git a/tests/tests_rest_investigation_flows.py b/tests/tests_rest_investigation_flows.py new file mode 100644 index 000000000..328e7d6e8 --- /dev/null +++ b/tests/tests_rest_investigation_flows.py @@ -0,0 +1,221 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from unittest import TestCase + +from iris import Iris +from iris import IRIS_PERMISSION_INVESTIGATION_FLOWS_READ +from iris import IRIS_PERMISSION_INVESTIGATION_FLOWS_WRITE + + +def _flow_body(**overrides): + body = { + 'flow_name': 'Brute-force triage', + 'flow_description': 'Investigate suspected brute-force alerts', + 'flow_is_active': True, + 'flow_customer_scope': None, + } + body.update(overrides) + return body + + +class TestsRestInvestigationFlows(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + flows = self._subject.get('/api/v2/investigation-flows').json() + raw = flows.get('data', flows) if isinstance(flows, dict) else flows + if isinstance(raw, list): + for flow in raw: + self._subject.delete(f'/api/v2/investigation-flows/{flow["flow_id"]}') + self._subject.clear_database() + + def _create_flow(self): + response = self._subject.create('/api/v2/investigation-flows', _flow_body()).json() + return response.get('flow_id') or response.get('data', {}).get('flow_id') + + def test_create_flow_returns_201(self): + response = self._subject.create('/api/v2/investigation-flows', _flow_body()) + self.assertEqual(201, response.status_code) + + def test_create_step_returns_201(self): + flow_id = self._create_flow() + response = self._subject.create( + f'/api/v2/investigation-flows/{flow_id}/steps', + {'step_order': 1, 'step_title': 'Check auth logs', 'step_description': 'Look at Splunk'} + ) + self.assertEqual(201, response.status_code) + + def test_update_step_persists_changes(self): + flow_id = self._create_flow() + step = self._subject.create( + f'/api/v2/investigation-flows/{flow_id}/steps', + {'step_order': 1, 'step_title': 'First'} + ).json() + step_id = step.get('step_id') or step['data']['step_id'] + response = self._subject.update( + f'/api/v2/investigation-flows/{flow_id}/steps/{step_id}', + {'step_title': 'Renamed step'} + ) + self.assertEqual(200, response.status_code) + + def test_delete_flow_cascades_steps(self): + flow_id = self._create_flow() + self._subject.create( + f'/api/v2/investigation-flows/{flow_id}/steps', + {'step_order': 1, 'step_title': 'Doomed step'} + ) + response = self._subject.delete(f'/api/v2/investigation-flows/{flow_id}') + self.assertEqual(204, response.status_code) + + def test_user_without_write_permission_cannot_create(self): + user = self._subject.create_dummy_user(permissions=IRIS_PERMISSION_INVESTIGATION_FLOWS_READ) + response = user.create('/api/v2/investigation-flows', _flow_body()) + self.assertEqual(403, response.status_code) + + def test_record_progress_requires_alert_with_flow(self): + # Alert without a flow attached — check-off must fail cleanly. + alert = self._subject.create('/api/v2/alerts', { + 'alert_title': 'title', + 'alert_severity_id': 4, + 'alert_status_id': 3, + 'alert_customer_id': 1, + }).json() + flow_id = self._create_flow() + step = self._subject.create( + f'/api/v2/investigation-flows/{flow_id}/steps', + {'step_order': 1, 'step_title': 'Detached step'} + ).json() + step_id = step.get('step_id') or step['data']['step_id'] + response = self._subject.create( + f'/api/v2/alerts/{alert["alert_id"]}/investigation-progress/{step_id}', {} + ) + self.assertEqual(400, response.status_code) + + def test_deploy_backfills_matching_alerts(self): + # Create an alert first, THEN a flow that matches it, then deploy + # — the alert should get the flow_id back-filled. + alert = self._subject.create('/api/v2/alerts', { + 'alert_title': 'brute force login attempt', + 'alert_severity_id': 4, + 'alert_status_id': 3, + 'alert_customer_id': 1, + }).json() + create_res = self._subject.create('/api/v2/investigation-flows', _flow_body( + flow_target='alert', + flow_conditions={ + 'logic': 'and', + 'conditions': [ + {'field': 'alert_title', 'operator': 'like', 'value': 'brute'} + ], + }, + )).json() + flow_id = create_res.get('flow_id') or create_res['data']['flow_id'] + deploy = self._subject.create( + f'/api/v2/investigation-flows/{flow_id}/deploy', {} + ) + self.assertEqual(200, deploy.status_code) + payload = deploy.json() + counts = payload.get('data', payload) + self.assertGreaterEqual(counts['alerts_attached'], 1) + + # ------------------------------------------------------------------- + # Security regression coverage (mirror tests_rest_incident_rules.py). + # ------------------------------------------------------------------- + + def test_non_admin_cannot_create_global_flow(self): + rw = IRIS_PERMISSION_INVESTIGATION_FLOWS_READ | IRIS_PERMISSION_INVESTIGATION_FLOWS_WRITE + user = self._subject.create_dummy_user(permissions=rw) + response = user.create( + '/api/v2/investigation-flows', + _flow_body(flow_customer_scope=None) + ) + self.assertEqual(400, response.status_code) + + def test_non_admin_cannot_create_flow_scoped_to_unreachable_customer(self): + rw = IRIS_PERMISSION_INVESTIGATION_FLOWS_READ | IRIS_PERMISSION_INVESTIGATION_FLOWS_WRITE + user = self._subject.create_dummy_user(permissions=rw) + other_customer = self._subject.create_dummy_customer() + response = user.create( + '/api/v2/investigation-flows', + _flow_body(flow_customer_scope=[other_customer]) + ) + self.assertEqual(400, response.status_code) + + def test_non_admin_cannot_deploy_flow_scoped_to_unreachable_customer(self): + other_customer = self._subject.create_dummy_customer() + created = self._subject.create( + '/api/v2/investigation-flows', + _flow_body(flow_customer_scope=[other_customer]) + ).json() + flow_id = created.get('flow_id') or created['data']['flow_id'] + + rw = IRIS_PERMISSION_INVESTIGATION_FLOWS_READ | IRIS_PERMISSION_INVESTIGATION_FLOWS_WRITE + user = self._subject.create_dummy_user(permissions=rw) + response = user.create(f'/api/v2/investigation-flows/{flow_id}/deploy', {}) + self.assertEqual(404, response.status_code) + + def test_list_hides_flows_from_unreachable_tenants(self): + other_customer = self._subject.create_dummy_customer() + created = self._subject.create( + '/api/v2/investigation-flows', + _flow_body(flow_customer_scope=[other_customer]) + ).json() + flow_id = created.get('flow_id') or created['data']['flow_id'] + + rw = IRIS_PERMISSION_INVESTIGATION_FLOWS_READ | IRIS_PERMISSION_INVESTIGATION_FLOWS_WRITE + user = self._subject.create_dummy_user(permissions=rw) + response = user.get('/api/v2/investigation-flows').json() + rows = response.get('data', response) if isinstance(response, dict) else response + flow_ids = [f['flow_id'] for f in rows] if isinstance(rows, list) else [] + self.assertNotIn(flow_id, flow_ids) + + def test_created_by_is_server_owned_not_client_spoofable(self): + body = _flow_body() + body['flow_created_by'] = 999999 + response = self._subject.create('/api/v2/investigation-flows', body).json() + stamped = response.get('flow_created_by') + if stamped is None and isinstance(response.get('data'), dict): + stamped = response['data'].get('flow_created_by') + self.assertEqual(1, stamped) + + def test_deploy_skips_alerts_already_attached(self): + # Once attached, deploy shouldn't overwrite — analyst may have + # chosen the current flow deliberately. + alert = self._subject.create('/api/v2/alerts', { + 'alert_title': 'brute force login attempt', + 'alert_severity_id': 4, + 'alert_status_id': 3, + 'alert_customer_id': 1, + }).json() + create_res = self._subject.create('/api/v2/investigation-flows', _flow_body( + flow_conditions={ + 'logic': 'and', + 'conditions': [{'field': 'alert_title', 'operator': 'like', 'value': 'brute'}], + }, + )).json() + flow_id = create_res.get('flow_id') or create_res['data']['flow_id'] + self._subject.create(f'/api/v2/investigation-flows/{flow_id}/deploy', {}) + # Second deploy should attach 0 new alerts. + second = self._subject.create( + f'/api/v2/investigation-flows/{flow_id}/deploy', {} + ).json() + counts = second.get('data', second) + self.assertEqual(0, counts['alerts_attached']) diff --git a/tests/tests_rest_mail.py b/tests/tests_rest_mail.py new file mode 100644 index 000000000..a1f51d3d2 --- /dev/null +++ b/tests/tests_rest_mail.py @@ -0,0 +1,177 @@ +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. + +"""Integration tests for /api/v2/manage/mail and mail config on +/api/v2/manage/server. + +Covers rules CRUD (including admin gating), ingest-log read, mail +config round-trip on server settings (password write-only + set +flag), and the SMTP test-send endpoint's "SMTP not configured" +error path (we can't test successful SMTP delivery from the +integration harness without a real relay). +""" + +from unittest import TestCase + +from iris import Iris + + +class TestsRestMail(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + # Clean up rules we created — the harness doesn't wipe them. + rules = self._subject.get('/api/v2/manage/mail/rules').json() + if rules.get('data', {}).get('data'): + for rule in rules['data']['data']: + self._subject.delete( + f"/api/v2/manage/mail/rules/{rule['id']}") + self._subject.clear_database() + + # --- Rules CRUD ------------------------------------------------------- + + def test_list_rules_returns_empty_list_on_fresh_state(self): + response = self._subject.get('/api/v2/manage/mail/rules').json() + self.assertEqual([], response['data']['data']) + + def test_create_rule_persists_and_returns_the_row(self): + body = { + 'name': 'catch-all', + 'priority': 500, + 'action': 'create_alert', + 'match_subject_regex': r'^alerts', + } + response = self._subject.create('/api/v2/manage/mail/rules', body).json() + self.assertEqual('catch-all', response['data']['name']) + self.assertEqual(500, response['data']['priority']) + self.assertEqual('create_alert', response['data']['action']) + + def test_create_rule_rejects_missing_name(self): + response = self._subject.create('/api/v2/manage/mail/rules', + {'action': 'create_alert'}) + self.assertEqual(400, response.status_code) + + def test_create_rule_rejects_unknown_action(self): + body = {'name': 'bad', 'action': 'launch_missiles'} + response = self._subject.create('/api/v2/manage/mail/rules', body) + self.assertEqual(400, response.status_code) + + def test_update_rule_writes_the_change(self): + created = self._subject.create('/api/v2/manage/mail/rules', + {'name': 'edit-me', + 'action': 'create_alert'}).json() + rule_id = created['data']['id'] + response = self._subject.update( + f'/api/v2/manage/mail/rules/{rule_id}', + {'name': 'edited', 'enabled': False}).json() + self.assertEqual('edited', response['data']['name']) + self.assertFalse(response['data']['enabled']) + + def test_delete_rule_removes_it(self): + created = self._subject.create('/api/v2/manage/mail/rules', + {'name': 'gone-soon', + 'action': 'drop'}).json() + rule_id = created['data']['id'] + response = self._subject.delete( + f'/api/v2/manage/mail/rules/{rule_id}') + self.assertEqual(200, response.status_code) + # A follow-up GET should now 404 + response = self._subject.get(f'/api/v2/manage/mail/rules/{rule_id}') + self.assertEqual(404, response.status_code) + + # --- Admin gating ------------------------------------------------------ + + def test_list_rules_returns_403_for_non_admin(self): + user = self._subject.create_dummy_user() + response = user.get('/api/v2/manage/mail/rules') + self.assertEqual(403, response.status_code) + + def test_create_rule_returns_403_for_non_admin(self): + user = self._subject.create_dummy_user() + response = user.create('/api/v2/manage/mail/rules', + {'name': 'x', 'action': 'drop'}) + self.assertEqual(403, response.status_code) + + # --- Ingest log -------------------------------------------------------- + + def test_ingest_log_is_empty_on_fresh_state(self): + response = self._subject.get( + '/api/v2/manage/mail/ingest-log').json() + self.assertEqual([], response['data']['data']) + + def test_ingest_log_returns_403_for_non_admin(self): + user = self._subject.create_dummy_user() + response = user.get('/api/v2/manage/mail/ingest-log') + self.assertEqual(403, response.status_code) + + # --- Server settings — mail config ----------------------------------- + + def test_mail_settings_included_in_server_settings_response(self): + response = self._subject.get('/api/v2/manage/server/settings').json() + # The mail columns default to safe values (disabled). We assert + # only that the keys exist — the actual defaults are what the + # migration wrote and covered in the schema tests. + settings = response['data']['settings'] + self.assertIn('mail_smtp_enabled', settings) + self.assertIn('mail_imap_enabled', settings) + # Password fields must NOT be returned even if set + self.assertNotIn('mail_smtp_password', settings) + self.assertNotIn('mail_imap_password', settings) + # …but the *_password_set booleans should be there so the SPA + # can render placeholder inputs. + self.assertIn('mail_smtp_password_set', settings) + self.assertIn('mail_imap_password_set', settings) + + def test_smtp_password_write_never_leaks_back(self): + # Setting a password via PUT should mark `_password_set` + # True on the next GET but never return the ciphertext or + # plaintext. + self._subject.update('/api/v2/manage/server/settings', { + 'mail_smtp_enabled': True, + 'mail_smtp_host': 'smtp.example.com', + 'mail_smtp_port': 587, + 'mail_smtp_user': 'iris', + 'mail_smtp_password': 'p@ss w0rd!', + 'mail_from_address': 'iris@example.com', + }) + response = self._subject.get( + '/api/v2/manage/server/settings').json() + settings = response['data']['settings'] + self.assertTrue(settings['mail_smtp_password_set']) + self.assertNotIn('mail_smtp_password', settings) + + def test_test_send_returns_400_when_smtp_not_configured(self): + # No SMTP config → the endpoint refuses to fake a delivery. + # Reset any prior config left over from other tests first. + self._subject.update('/api/v2/manage/server/settings', { + 'mail_smtp_enabled': False, + }) + response = self._subject.create( + '/api/v2/manage/server/mail/test-send', + {'to': 'someone@example.com'}) + self.assertEqual(400, response.status_code) + + def test_test_send_rejects_bad_recipient(self): + response = self._subject.create( + '/api/v2/manage/server/mail/test-send', + {'to': 'not-an-email'}) + self.assertEqual(400, response.status_code) + + def test_test_send_returns_403_for_non_admin(self): + user = self._subject.create_dummy_user() + response = user.create('/api/v2/manage/server/mail/test-send', + {'to': 'x@y.com'}) + self.assertEqual(403, response.status_code) diff --git a/tests/tests_rest_notifications.py b/tests/tests_rest_notifications.py new file mode 100644 index 000000000..fd2fda6f0 --- /dev/null +++ b/tests/tests_rest_notifications.py @@ -0,0 +1,144 @@ +# 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. + +"""Integration tests for /api/v2/notifications and related endpoints. + +These run against the live docker-compose stack via the existing +`Iris` harness. The suite covers: + +* Feed shape + pagination + unread count +* Mark-read behaviour and cross-user isolation +* User settings upsert + effective view +* Admin settings gating (non-admin gets 403) +* End-to-end: mentioning a user in a note produces a notification +""" + +from unittest import TestCase + +from iris import Iris +from iris import IRIS_PERMISSION_SERVER_ADMINISTRATOR + + +class TestsRestNotifications(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + self._subject.clear_database() + + # --- Feed -------------------------------------------------------------- + + def test_get_notifications_should_return_success(self): + response = self._subject.get('/api/v2/notifications') + self.assertEqual(200, response.status_code) + + def test_get_notifications_should_return_envelope_with_data_and_count(self): + response = self._subject.get('/api/v2/notifications').json() + self.assertIn('data', response['data']) + self.assertIn('unread_count', response['data']) + self.assertIsInstance(response['data']['data'], list) + + def test_get_unread_count_should_return_zero_on_fresh_state(self): + response = self._subject.get('/api/v2/notifications/unread-count').json() + self.assertEqual(0, response['data']['unread_count']) + + # --- Settings ---------------------------------------------------------- + + def test_get_settings_should_include_event_types_and_channels(self): + response = self._subject.get('/api/v2/notifications/settings').json() + payload = response['data'] + self.assertIn('event_types', payload) + self.assertIn('channels', payload) + self.assertIn('settings', payload) + # Sanity: mention/in_app must be represented (it's a core event) + self.assertIn('mention', payload['event_types']) + self.assertIn('in_app', payload['channels']) + + def test_put_settings_should_persist_toggles(self): + body = {'settings': {'mention': {'in_app': False}}} + response = self._subject.update('/api/v2/notifications/settings', body).json() + self.assertFalse(response['data']['settings']['mention']['in_app']) + + # Re-fetch — the row should still be there. + follow_up = self._subject.get('/api/v2/notifications/settings').json() + self.assertFalse(follow_up['data']['settings']['mention']['in_app']) + + def test_put_settings_should_reject_non_object_body(self): + response = self._subject.update('/api/v2/notifications/settings', + {'settings': 'not-an-object'}) + self.assertEqual(400, response.status_code) + + def test_put_settings_should_silently_drop_unknown_events(self): + body = {'settings': {'not_a_real_event': {'in_app': False}}} + response = self._subject.update('/api/v2/notifications/settings', body) + # Bogus keys are dropped, not rejected — a stale SPA that + # sends an unknown event type should still save what it can. + self.assertEqual(200, response.status_code) + + # --- Admin gating ------------------------------------------------------ + + def test_get_admin_settings_should_return_success_for_admin(self): + response = self._subject.get('/api/v2/manage/notification-settings') + self.assertEqual(200, response.status_code) + + def test_get_admin_settings_should_return_403_for_non_admin(self): + # Ordinary user without server_administrator gets denied. + user = self._subject.create_dummy_user() + response = user.get('/api/v2/manage/notification-settings') + self.assertEqual(403, response.status_code) + + def test_put_admin_settings_should_return_403_for_non_admin(self): + user = self._subject.create_dummy_user() + response = user.update('/api/v2/manage/notification-settings', + {'settings': {'mention': {'in_app': False}}}) + self.assertEqual(403, response.status_code) + + def test_put_admin_settings_should_persist_for_admin(self): + body = {'settings': {'case_state_change': {'email': True}}} + response = self._subject.update( + '/api/v2/manage/notification-settings', body).json() + self.assertTrue(response['data']['settings']['case_state_change']['email']) + + # --- Cross-user isolation --------------------------------------------- + + def test_mark_read_should_not_affect_another_users_rows(self): + # Admin fires a notification against a fresh user by mentioning + # them in a case note; then that user marks-all-read and the + # admin's own feed should not shift. + user = self._subject.create_dummy_user( + [IRIS_PERMISSION_SERVER_ADMINISTRATOR]) + case_identifier = self._subject.create_dummy_case() + + # Content shape mirrors what the TipTap editor emits. + mention_span = ( + f'<span data-mention data-kind="user" ' + f'data-id="{user.get_identifier()}" data-label="dummy">' + f'@dummy</span>' + ) + note_body = f'<p>Hey {mention_span} please look</p>' + + self._subject.create(f'/api/v2/cases/{case_identifier}/notes', { + 'note_title': 'title', + 'note_content': note_body, + }) + + # user sees a notification, admin does not + user_count = user.get( + '/api/v2/notifications/unread-count').json()['data']['unread_count'] + admin_count = self._subject.get( + '/api/v2/notifications/unread-count').json()['data']['unread_count'] + + self.assertGreaterEqual(user_count, 1) + self.assertEqual(0, admin_count) + + # user marks all read → admin count still 0, user drops to 0 + user.create('/api/v2/notifications/mark-read', {'all': True}) + self.assertEqual(0, user.get( + '/api/v2/notifications/unread-count').json()['data']['unread_count']) diff --git a/tests/tests_rest_profile.py b/tests/tests_rest_profile.py index dbe55a2f8..6ccda7d81 100644 --- a/tests/tests_rest_profile.py +++ b/tests/tests_rest_profile.py @@ -50,10 +50,35 @@ def test_update_me_should_modify_user_email(self): def test_update_me_should_modify_user_password(self): user = self._subject.create_user('name', 'aA.1234567890') - user.update('/api/v2/me', {'user_password': 'bB.1234567890'}) + user.update('/api/v2/me', { + 'user_current_password': 'aA.1234567890', + 'user_password': 'bB.1234567890' + }) response = user.login('bB.1234567890') self.assertEqual(200, response.status_code) + def test_update_me_should_return_400_when_changing_password_without_current_password(self): + user = self._subject.create_user('pwdrequired', 'aA.1234567890') + response = user.update('/api/v2/me', {'user_password': 'bB.1234567890'}) + self.assertEqual(400, response.status_code) + + def test_update_me_should_return_400_when_changing_password_with_wrong_current_password(self): + user = self._subject.create_user('pwdwrong', 'aA.1234567890') + response = user.update('/api/v2/me', { + 'user_current_password': 'wrong-password', + 'user_password': 'bB.1234567890' + }) + self.assertEqual(400, response.status_code) + + def test_update_me_should_not_change_password_when_current_password_is_wrong(self): + user = self._subject.create_user('pwdkeep', 'aA.1234567890') + user.update('/api/v2/me', { + 'user_current_password': 'wrong-password', + 'user_password': 'bB.1234567890' + }) + response = user.login('aA.1234567890') + self.assertEqual(200, response.status_code) + def test_update_me_should_modify_ctx_case(self): user = self._subject.create_dummy_user() case_identifier = self._subject.create_dummy_case() @@ -122,3 +147,44 @@ def test_update_me_should_return_400_when_field_user_name_is_not_a_string(self): response = user.update('/api/v2/me', {'user_name': 123}) self.assertEqual(400, response.status_code) + + def test_renew_api_key_should_return_200(self): + user = self._subject.create_dummy_user() + response = user.create('/api/v2/me/api-key/renew', {}) + self.assertEqual(200, response.status_code) + + def test_renew_api_key_should_change_api_key(self): + user = self._subject.create_dummy_user() + before = user.get('/api/v2/me').json()['user_api_key'] + response = user.create('/api/v2/me/api-key/renew', {}).json() + self.assertNotEqual(before, response['user_api_key']) + + def test_refresh_permissions_should_return_200(self): + user = self._subject.create_dummy_user() + response = user.create('/api/v2/me/permissions/refresh', {}) + self.assertEqual(200, response.status_code) + + def test_get_context_should_return_200(self): + response = self._subject.get('/api/v2/me/context') + self.assertEqual(200, response.status_code) + + def test_get_context_should_expose_iris_version(self): + response = self._subject.get('/api/v2/me/context').json() + self.assertIn('iris_version', response) + self.assertIsInstance(response['iris_version'], str) + self.assertTrue(response['iris_version']) + + def test_get_context_should_expose_demo_mode_flag(self): + response = self._subject.get('/api/v2/me/context').json() + self.assertIn('demo_mode', response) + self.assertIsInstance(response['demo_mode'], bool) + + def test_get_context_should_expose_permission_mask_and_names(self): + response = self._subject.get('/api/v2/me/context').json() + permissions = response.get('permissions') or {} + self.assertIn('mask', permissions) + self.assertIn('names', permissions) + self.assertIsInstance(permissions['mask'], int) + self.assertIsInstance(permissions['names'], list) + # Every authenticated user gets standard_user implicitly. + self.assertIn('standard_user', permissions['names']) diff --git a/tests/tests_rest_search.py b/tests/tests_rest_search.py new file mode 100644 index 000000000..20b9d2bbe --- /dev/null +++ b/tests/tests_rest_search.py @@ -0,0 +1,92 @@ +# IRIS Source Code +# Copyright (C) 2024 - 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from unittest import TestCase +from iris import Iris + + +class TestsRestSearch(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + self._subject.clear_database() + + def test_search_should_return_200(self): + response = self._subject.get('/api/v2/search', query_parameters={'types': 'ioc', 'value': '%'}) + self.assertEqual(200, response.status_code) + + def test_search_should_return_400_when_types_is_missing(self): + response = self._subject.get('/api/v2/search', query_parameters={'value': '%'}) + self.assertEqual(400, response.status_code) + + def test_search_should_return_400_when_value_is_missing(self): + response = self._subject.get('/api/v2/search', query_parameters={'types': 'ioc'}) + self.assertEqual(400, response.status_code) + + def test_search_should_return_400_when_type_is_unsupported(self): + response = self._subject.get('/api/v2/search', query_parameters={'types': 'magic', 'value': '%'}) + self.assertEqual(400, response.status_code) + + def test_search_should_return_envelope_with_data_and_pagination(self): + response = self._subject.get('/api/v2/search', query_parameters={'types': 'notes', 'value': '%'}) + body = response.json() + self.assertIn('data', body) + self.assertIn('pagination', body) + self.assertIn('total', body['pagination']) + self.assertIn('page', body['pagination']) + self.assertIn('per_page', body['pagination']) + + def test_search_should_accept_multiple_types_comma_separated(self): + response = self._subject.get('/api/v2/search', query_parameters={'types': 'ioc,notes,comments', 'value': '%'}) + self.assertEqual(200, response.status_code) + + def test_search_should_accept_assets_events_tasks_evidences_types(self): + response = self._subject.get('/api/v2/search', query_parameters={'types': 'assets,events,tasks,evidences', 'value': '%'}) + self.assertEqual(200, response.status_code) + + def test_search_per_page_should_be_clamped(self): + response = self._subject.get('/api/v2/search', query_parameters={'types': 'notes', 'value': '%', 'per_page': 9999}) + body = response.json() + # Capped at 100 per `search_across`. + self.assertLessEqual(body['pagination']['per_page'], 100) + + def test_search_should_accept_case_id_scope(self): + case_id = self._subject.create_dummy_case() + response = self._subject.get( + f'/api/v2/search?types=ioc,notes,assets,events,tasks,comments,evidences&value=%25&case_id={case_id}' + ) + self.assertEqual(200, response.status_code) + + def test_search_should_accept_case_ids_scope_csv(self): + c1 = self._subject.create_dummy_case() + c2 = self._subject.create_dummy_case() + response = self._subject.get( + f'/api/v2/search?types=ioc,notes&value=%25&case_ids={c1},{c2}' + ) + self.assertEqual(200, response.status_code) + + def test_search_results_should_be_scoped_to_user_accessible_cases(self): + # A fresh user with no group membership only inherits whatever + # default access groups the seed provides. We just assert the + # envelope shape returns and the call is access-checked — actual + # row visibility is enforced by the data-layer scope filter. + user = self._subject.create_dummy_user() + response = user.get('/api/v2/search?types=notes,ioc,assets,events,tasks,comments,evidences&value=%25') + self.assertEqual(200, response.status_code) diff --git a/tests/tests_rest_tasks.py b/tests/tests_rest_tasks.py index 61e301b16..db0b69bde 100644 --- a/tests/tests_rest_tasks.py +++ b/tests/tests_rest_tasks.py @@ -125,6 +125,56 @@ def test_update_task_should_update_assignees(self): response = self._subject.get('/case/tasks/list', query_parameters={'cid': case_identifier}).json() self.assertEqual(user.get_identifier(), response['data']['tasks'][0]['task_assignees'][0]['id']) + def test_create_task_with_assignees_response_should_include_task_assignees_id(self): + case_identifier = self._subject.create_dummy_case() + user = self._subject.create_dummy_user() + body = {'task_assignees_id': [user.get_identifier()], 'task_status_id': 1, 'task_title': 'dummy title'} + response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json() + self.assertEqual([user.get_identifier()], response['task_assignees_id']) + + def test_create_task_with_assignees_response_should_include_task_assignees(self): + case_identifier = self._subject.create_dummy_case() + user = self._subject.create_dummy_user() + body = {'task_assignees_id': [user.get_identifier()], 'task_status_id': 1, 'task_title': 'dummy title'} + response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json() + self.assertEqual(user.get_identifier(), response['task_assignees'][0]['id']) + + def test_update_task_response_should_include_assignees(self): + case_identifier = self._subject.create_dummy_case() + user = self._subject.create_dummy_user() + body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'dummy title'} + response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json() + identifier = response['id'] + response = self._subject.update( + f'/api/v2/cases/{case_identifier}/tasks/{identifier}', + {'task_title': 'dummy title', 'task_status_id': 1, 'task_assignees_id': [user.get_identifier()]} + ).json() + self.assertEqual([user.get_identifier()], response['task_assignees_id']) + + def test_get_task_should_return_assignees(self): + case_identifier = self._subject.create_dummy_case() + user = self._subject.create_dummy_user() + body = {'task_assignees_id': [user.get_identifier()], 'task_status_id': 1, 'task_title': 'dummy title'} + identifier = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()['id'] + response = self._subject.get(f'/api/v2/cases/{case_identifier}/tasks/{identifier}').json() + self.assertEqual([user.get_identifier()], response['task_assignees_id']) + + def test_get_tasks_list_should_return_assignees(self): + case_identifier = self._subject.create_dummy_case() + user = self._subject.create_dummy_user() + body = {'task_assignees_id': [user.get_identifier()], 'task_status_id': 1, 'task_title': 'dummy title'} + self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body) + response = self._subject.get(f'/api/v2/cases/{case_identifier}/tasks').json() + self.assertEqual([user.get_identifier()], response['data'][0]['task_assignees_id']) + + def test_get_task_without_assignees_should_return_empty_assignees_list(self): + case_identifier = self._subject.create_dummy_case() + body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'dummy title'} + identifier = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', body).json()['id'] + response = self._subject.get(f'/api/v2/cases/{case_identifier}/tasks/{identifier}').json() + self.assertEqual([], response['task_assignees_id']) + self.assertEqual([], response['task_assignees']) + def test_update_task_without_task_status_id_should_return_400(self): case_identifier = self._subject.create_dummy_case() body = {'task_assignees_id': [], 'task_status_id': 1, 'task_title': 'dummy title'} diff --git a/tests/tests_rest_war_rooms.py b/tests/tests_rest_war_rooms.py new file mode 100644 index 000000000..74407787e --- /dev/null +++ b/tests/tests_rest_war_rooms.py @@ -0,0 +1,192 @@ +# 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. + +""" +Integration tests for `/api/v2/war-rooms`. + +End-to-end hits against the docker-compose stack via the existing +`Iris` helper. Each test creates the dependencies it needs and the +`tearDown` clears every war room before the cases (the helper's +`clear_database` was extended to drop war rooms first so the FKs +unwind in the right order). +""" + +from unittest import TestCase + +from iris import Iris + + +class TestsRestWarRoomsCrud(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + self._subject.clear_database() + + def _create(self, name='Crisis Alpha', **kwargs): + body = {'name': name} + body.update(kwargs) + return self._subject.create('/api/v2/war-rooms', body) + + def test_create_should_return_201_and_the_war_room(self): + response = self._create(description='Multi-case ransomware sweep') + self.assertEqual(201, response.status_code) + body = response.json() + self.assertEqual('Crisis Alpha', body['name']) + self.assertEqual('open', body['state']) + self.assertIsNotNone(body['war_room_id']) + self.assertIsNotNone(body['war_room_uuid']) + + def test_create_should_reject_empty_name(self): + response = self._subject.create('/api/v2/war-rooms', {'name': ' '}) + self.assertEqual(400, response.status_code) + + def test_create_should_reject_invalid_color(self): + response = self._create(color='blue') + self.assertEqual(400, response.status_code) + + def test_create_should_accept_hex_color(self): + response = self._create(color='#ff0044') + self.assertEqual(201, response.status_code) + self.assertEqual('#ff0044', response.json()['color']) + + def test_list_should_include_created_room(self): + response = self._create(name='Listed Crisis') + room_id = response.json()['war_room_id'] + listed = self._subject.get('/api/v2/war-rooms').json() + ids = [r['war_room_id'] for r in listed] + self.assertIn(room_id, ids) + + def test_list_should_filter_by_state(self): + first = self._create(name='Open Room') + second = self._create(name='Standby Room') + room_id = second.json()['war_room_id'] + self._subject.patch(f'/api/v2/war-rooms/{room_id}', {'state': 'standby'}) + listed = self._subject.get('/api/v2/war-rooms', + query_parameters={'state': 'standby'}).json() + ids = [r['war_room_id'] for r in listed] + self.assertIn(room_id, ids) + self.assertNotIn(first.json()['war_room_id'], ids) + + def test_get_should_return_404_when_absent(self): + response = self._subject.get('/api/v2/war-rooms/999999999') + self.assertEqual(404, response.status_code) + + def test_patch_should_update_name(self): + room_id = self._create().json()['war_room_id'] + response = self._subject.patch(f'/api/v2/war-rooms/{room_id}', + {'name': 'Updated Name'}) + self.assertEqual(200, response.status_code) + self.assertEqual('Updated Name', response.json()['name']) + + def test_patch_state_closed_should_stamp_closed_at(self): + room_id = self._create().json()['war_room_id'] + response = self._subject.patch(f'/api/v2/war-rooms/{room_id}', + {'state': 'closed'}) + self.assertEqual(200, response.status_code) + body = response.json() + self.assertEqual('closed', body['state']) + self.assertIsNotNone(body['closed_at']) + + def test_patch_state_back_to_open_should_clear_closed_at(self): + room_id = self._create().json()['war_room_id'] + self._subject.patch(f'/api/v2/war-rooms/{room_id}', {'state': 'closed'}) + response = self._subject.patch(f'/api/v2/war-rooms/{room_id}', + {'state': 'open'}) + body = response.json() + self.assertEqual('open', body['state']) + self.assertIsNone(body['closed_at']) + + def test_delete_should_return_204(self): + room_id = self._create().json()['war_room_id'] + response = self._subject.delete(f'/api/v2/war-rooms/{room_id}') + self.assertEqual(204, response.status_code) + + +class TestsRestWarRoomsMembers(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + self._subject.clear_database() + + def test_creator_should_be_lead_member(self): + response = self._subject.create('/api/v2/war-rooms', {'name': 'A'}) + room_id = response.json()['war_room_id'] + members = self._subject.get(f'/api/v2/war-rooms/{room_id}/members').json() + self.assertEqual(1, len(members)) + self.assertEqual('lead', members[0]['role']) + + +class TestsRestWarRoomsCaseAttachment(TestCase): + + def setUp(self) -> None: + self._subject = Iris() + + def tearDown(self): + self._subject.clear_database() + + def test_attach_case_should_return_201(self): + room_id = self._subject.create('/api/v2/war-rooms', + {'name': 'Attached'}).json()['war_room_id'] + case_id = self._subject.create_dummy_case() + response = self._subject.create(f'/api/v2/war-rooms/{room_id}/cases', + {'case_id': case_id, 'note': 'Primary'}) + self.assertEqual(201, response.status_code) + body = response.json() + self.assertEqual(case_id, body['case_id']) + self.assertEqual('Primary', body['note']) + + def test_list_cases_should_include_attached_case(self): + room_id = self._subject.create('/api/v2/war-rooms', + {'name': 'Attached'}).json()['war_room_id'] + case_id = self._subject.create_dummy_case() + self._subject.create(f'/api/v2/war-rooms/{room_id}/cases', + {'case_id': case_id}) + listed = self._subject.get(f'/api/v2/war-rooms/{room_id}/cases').json() + case_ids = [c['case_id'] for c in listed] + self.assertIn(case_id, case_ids) + + def test_attach_case_twice_should_be_idempotent(self): + room_id = self._subject.create('/api/v2/war-rooms', + {'name': 'Attached'}).json()['war_room_id'] + case_id = self._subject.create_dummy_case() + first = self._subject.create(f'/api/v2/war-rooms/{room_id}/cases', + {'case_id': case_id, 'note': 'first'}) + second = self._subject.create(f'/api/v2/war-rooms/{room_id}/cases', + {'case_id': case_id, 'note': 'second'}) + self.assertEqual(201, first.status_code) + # The second call must not raise on the unique constraint. + self.assertEqual(201, second.status_code) + listed = self._subject.get(f'/api/v2/war-rooms/{room_id}/cases').json() + case_entries = [c for c in listed if c['case_id'] == case_id] + self.assertEqual(1, len(case_entries)) + + def test_detach_case_should_return_204(self): + room_id = self._subject.create('/api/v2/war-rooms', + {'name': 'Attached'}).json()['war_room_id'] + case_id = self._subject.create_dummy_case() + self._subject.create(f'/api/v2/war-rooms/{room_id}/cases', + {'case_id': case_id}) + response = self._subject.delete( + f'/api/v2/war-rooms/{room_id}/cases/{case_id}' + ) + self.assertEqual(204, response.status_code) + + def test_case_war_rooms_endpoint_should_list_attachment(self): + room_id = self._subject.create('/api/v2/war-rooms', + {'name': 'Reverse Lookup'}).json()['war_room_id'] + case_id = self._subject.create_dummy_case() + self._subject.create(f'/api/v2/war-rooms/{room_id}/cases', + {'case_id': case_id}) + listed = self._subject.get(f'/api/v2/cases/{case_id}/war-rooms').json() + room_ids = [r['war_room_id'] for r in listed] + self.assertIn(room_id, room_ids) diff --git a/tests/user.py b/tests/user.py index 438c7bd65..4e3851838 100644 --- a/tests/user.py +++ b/tests/user.py @@ -15,7 +15,6 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -from graphql_api import GraphQLApi from rest_api import RestApi import requests from urllib import parse @@ -25,7 +24,6 @@ class User: def __init__(self, iris_url, login, api_key, identifier): self._iris_url = iris_url - self._graphql_api = GraphQLApi(iris_url + '/graphql', api_key) self._api = RestApi(iris_url, api_key) self._identifier = identifier self._login = login @@ -33,12 +31,6 @@ def __init__(self, iris_url, login, api_key, identifier): def get_identifier(self): return self._identifier - def execute_graphql_query(self, payload): - response = self._graphql_api.execute(payload) - body = response.json() - print(f'{payload} => {body}') - return body - def create(self, path, payload): return self._api.post(path, payload) @@ -51,6 +43,9 @@ def update(self, path, body): def delete(self, path): return self._api.delete(path) + def post_multipart_encoded_files(self, path, data, files): + return self._api.post_multipart_encoded_files(path, data, files) + def login(self, password): url = parse.urljoin(self._iris_url, '/api/v2/auth/login') return requests.post(url, json={'username': self._login, 'password': password}) diff --git a/upgrades/upgrade_db_pg12_to_pg18.sh b/upgrades/upgrade_db_pg12_to_pg18.sh new file mode 100755 index 000000000..97075767a --- /dev/null +++ b/upgrades/upgrade_db_pg12_to_pg18.sh @@ -0,0 +1,356 @@ +#!/usr/bin/env bash +# +# IRIS v3.0.0-beta database migration: PostgreSQL 12 -> 18 +# +# This script performs a logical dump-and-restore migration of the IRIS +# database volume. It is designed for the docker-compose deployment and +# never destroys the original PG12 volume — you can roll back at any time +# by reverting docker-compose.yml and pointing back at the preserved +# volume. +# +# It is SAFE to re-run: each step checks state before acting. +# +# What it does (in order): +# 1. Sanity-checks: docker available, compose project present, db service stopped. +# 2. Snapshots the existing db_data volume to a tarball on the host +# (cold backup — strongest rollback guarantee, no data loss possible). +# 3. Boots a temporary postgres:12-alpine container against the OLD volume +# and runs pg_dumpall to a host-side .sql file. +# 4. Renames the old volume to <project>_db_data_pg12_backup (preserved). +# 5. Creates a fresh empty volume that the new PG18 image will initialise. +# 6. Boots a temporary postgres:18-alpine container against the new volume, +# waits for it to be ready, then restores the dump. +# 7. Stops the temporary container. The next `docker compose up` will +# bring the new iriswebapp_db (PG18) online with all data restored. +# +# Rollback: see upgrade_to_3.0.0.md. + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Compose project name — derived the same way docker compose does: basename +# of the project directory, lowercased, keeping only [a-z0-9_-]. Hyphens are +# preserved (unlike a naive alnum-only strip). Override with +# COMPOSE_PROJECT_NAME in your .env if you use a custom one. +DEFAULT_PROJECT="$(basename "${PROJECT_ROOT}" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_-')" +COMPOSE_PROJECT="${COMPOSE_PROJECT_NAME:-${DEFAULT_PROJECT}}" + +OLD_VOLUME="${COMPOSE_PROJECT}_db_data" +BACKUP_VOLUME="${COMPOSE_PROJECT}_db_data_pg12_backup" +NEW_VOLUME="${OLD_VOLUME}" # final name the compose file expects +DUMP_DIR="${PROJECT_ROOT}/upgrades/backups" +TS="${MIGRATION_TIMESTAMP:-$(date +%Y%m%d-%H%M%S)}" +DUMP_FILE="${DUMP_DIR}/iris_pg12_dump_${TS}.sql" +TAR_FILE="${DUMP_DIR}/iris_pg12_volume_${TS}.tar.gz" + +OLD_IMAGE="postgres:12-alpine" +NEW_IMAGE="postgres:18-alpine" + +TMP_OLD_CTR="iris_pg12_migrate_dump_${TS}" +TMP_NEW_CTR="iris_pg18_migrate_restore_${TS}" + +# Load .env so we pick up POSTGRES_USER / POSTGRES_PASSWORD that the +# original cluster was initialised with. +if [[ -f "${PROJECT_ROOT}/.env" ]]; then + set -a + # shellcheck disable=SC1091 + source "${PROJECT_ROOT}/.env" + set +a +fi + +: "${POSTGRES_USER:?POSTGRES_USER must be set (check .env)}" +: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set (check .env)}" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +log() { printf '\033[1;34m[migrate]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[migrate]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31m[migrate]\033[0m %s\n' "$*" >&2; exit 1; } + +cleanup() { + docker rm -f "${TMP_OLD_CTR}" >/dev/null 2>&1 || true + docker rm -f "${TMP_NEW_CTR}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +volume_exists() { + docker volume inspect "$1" >/dev/null 2>&1 +} + +ensure_no_iris_db_running() { + if docker ps --format '{{.Names}}' | grep -qx 'iriswebapp_db'; then + die "iriswebapp_db is still running. Stop the stack first: \`docker compose down\`" + fi + # A stopped-but-not-removed container still holds a reference on the + # volume and blocks the rename step later. `docker compose down` removes + # it; a bare `docker stop` does not. + if docker ps -a --format '{{.Names}}' | grep -qx 'iriswebapp_db'; then + die "iriswebapp_db container exists (stopped) and still holds the volume. Run \`docker compose down\` (not just stop) before retrying." + fi +} + +# --------------------------------------------------------------------------- +# Steps +# --------------------------------------------------------------------------- + +# Set to 1 by pre-flight when we detect a resume-from-restore state (backup +# volume already holds PG12 data and the logical dump file exists). In that +# state the backup/dump/swap steps are no-ops. +RESUME_FROM_RESTORE=0 + +# Read the PG_VERSION of a volume's pgdata/ sub-path, or empty string if no +# such file exists. The IRIS compose file runs postgres with PGDATA pointing +# at the pgdata/ sub-path, so PG_VERSION lives one level below the mountpoint. +read_pg_version() { + local vol="$1" + docker run --rm -v "${vol}:/var/lib/postgresql/data" "${OLD_IMAGE}" \ + sh -c 'cat /var/lib/postgresql/data/pgdata/PG_VERSION 2>/dev/null || true' +} + +# Is the volume mount empty (aside from the mount metadata)? Used to +# distinguish "fresh empty volume" from "populated cluster". +volume_is_empty() { + local vol="$1" + local count + count="$(docker run --rm -v "${vol}:/data" alpine:3.20 \ + sh -c 'ls -A /data | wc -l' 2>/dev/null | tr -d ' ')" + [[ "${count}" == "0" ]] +} + +step_preflight() { + log "Pre-flight checks" + command -v docker >/dev/null || die "docker not found in PATH" + ensure_no_iris_db_running + mkdir -p "${DUMP_DIR}" + + # Resume path: the backup volume exists and holds PG12 data, and there is + # at least one dump file in the dump dir. This is the state after the + # swap step succeeded but the restore step failed or hasn't run yet. We + # short-circuit to just the restore step below. + if volume_exists "${BACKUP_VOLUME}"; then + local backup_ver + backup_ver="$(read_pg_version "${BACKUP_VOLUME}")" + local latest_dump + latest_dump="$(ls -1t "${DUMP_DIR}"/iris_pg12_dump_*.sql 2>/dev/null | head -1)" + if [[ "${backup_ver}" == "12" && -n "${latest_dump}" ]]; then + # The remaining restore step reads from ${DUMP_FILE}; re-point it + # at whatever dump we have on disk so we don't try to create a + # fresh one against the (now gone) old volume. + DUMP_FILE="${latest_dump}" + RESUME_FROM_RESTORE=1 + log "Resuming from post-swap state — backup ${BACKUP_VOLUME} holds PG12 data, using dump ${DUMP_FILE}" + return + fi + fi + + if ! volume_exists "${OLD_VOLUME}"; then + if volume_exists "${BACKUP_VOLUME}"; then + die "Old volume ${OLD_VOLUME} missing but ${BACKUP_VOLUME} exists — looks like a previous run was interrupted after step 4 and no dump file remains. Inspect manually before continuing." + fi + die "Expected volume ${OLD_VOLUME} not found. Set COMPOSE_PROJECT_NAME if your project is named differently." + fi + + # Detect that the volume actually contains a PG12 cluster — if PG_VERSION + # already says 18 the migration was done; bail out so we don't double-run. + local pgver + pgver="$(read_pg_version "${OLD_VOLUME}")" + if [[ -z "${pgver}" ]]; then + die "Volume ${OLD_VOLUME} does not look like a Postgres data directory (no PG_VERSION file at pgdata/)." + fi + if [[ "${pgver}" != "12" ]]; then + die "Volume ${OLD_VOLUME} reports PG_VERSION=${pgver}, expected 12. Aborting — this script is only for the 12 → 18 jump." + fi + + log "OK — found PG12 cluster in volume ${OLD_VOLUME}" +} + +step_cold_tar_backup() { + if [[ "${RESUME_FROM_RESTORE}" == "1" ]]; then + log "Resume: PG12 data already preserved in ${BACKUP_VOLUME}, skipping cold tarball" + return + fi + if [[ -f "${TAR_FILE}" ]]; then + log "Cold tar backup already exists at ${TAR_FILE} — skipping" + return + fi + log "Creating cold tarball backup of ${OLD_VOLUME} → ${TAR_FILE}" + docker run --rm \ + -v "${OLD_VOLUME}:/data:ro" \ + -v "${DUMP_DIR}:/backup" \ + alpine:3.20 \ + sh -c "cd /data && tar czf /backup/$(basename "${TAR_FILE}") ." + log "Cold backup written ($(du -h "${TAR_FILE}" | cut -f1))" +} + +step_dump_logical() { + if [[ "${RESUME_FROM_RESTORE}" == "1" ]]; then + log "Resume: reusing existing dump ${DUMP_FILE}" + return + fi + if [[ -s "${DUMP_FILE}" ]]; then + log "Logical dump already exists at ${DUMP_FILE} — skipping" + return + fi + log "Starting temporary PG12 container to take a logical dump" + # PGDATA must match the compose service (pgdata/ sub-path); otherwise + # postgres would try to init a fresh cluster at the mountpoint root + # instead of picking up the existing one. + docker run -d --rm \ + --name "${TMP_OLD_CTR}" \ + -v "${OLD_VOLUME}:/var/lib/postgresql/data" \ + -e POSTGRES_USER="${POSTGRES_USER}" \ + -e POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" \ + -e PGDATA=/var/lib/postgresql/data/pgdata \ + "${OLD_IMAGE}" >/dev/null + + log "Waiting for PG12 to accept connections" + for i in $(seq 1 60); do + if docker exec "${TMP_OLD_CTR}" pg_isready -U "${POSTGRES_USER}" >/dev/null 2>&1; then + break + fi + sleep 1 + if [[ "$i" -eq 60 ]]; then + die "PG12 did not become ready within 60s" + fi + done + + log "Running pg_dumpall → ${DUMP_FILE}" + # --clean so the restore is idempotent. --if-exists avoids errors when + # DROP targets do not exist in the fresh cluster. + docker exec "${TMP_OLD_CTR}" \ + pg_dumpall -U "${POSTGRES_USER}" --clean --if-exists > "${DUMP_FILE}" + + log "Stopping temporary PG12 container" + docker stop "${TMP_OLD_CTR}" >/dev/null + log "Logical dump written ($(du -h "${DUMP_FILE}" | cut -f1))" +} + +step_swap_volumes() { + if volume_exists "${BACKUP_VOLUME}"; then + log "Backup volume ${BACKUP_VOLUME} already exists — swap was done in a prior run, skipping" + return + fi + + log "Renaming ${OLD_VOLUME} → ${BACKUP_VOLUME} (copy + remove original)" + # Docker has no native rename for volumes; we create a new named volume + # and copy contents. The original is then removed. + docker volume create "${BACKUP_VOLUME}" >/dev/null + docker run --rm \ + -v "${OLD_VOLUME}:/from:ro" \ + -v "${BACKUP_VOLUME}:/to" \ + alpine:3.20 \ + sh -c 'cd /from && cp -a . /to/' + docker volume rm "${OLD_VOLUME}" >/dev/null + log "Original PG12 data preserved as volume ${BACKUP_VOLUME}" +} + +step_restore_to_pg18() { + # Compose will create the volume on `up`, but we want to restore now + # so the first compose-managed boot is clean and triggers no init scripts. + if ! volume_exists "${NEW_VOLUME}"; then + log "Creating empty volume ${NEW_VOLUME} for PG18" + docker volume create "${NEW_VOLUME}" >/dev/null + fi + + # If the volume was already initialised as PG18 from a previous failed + # run we want to wipe it before re-restoring. We look inside the + # `pgdata` sub-path because that's where PG18 actually stores its + # cluster (see the PGDATA env var below for why). + local existing_ver + existing_ver="$(docker run --rm -v "${NEW_VOLUME}:/var/lib/postgresql/data" "${NEW_IMAGE}" \ + sh -c 'cat /var/lib/postgresql/data/pgdata/PG_VERSION 2>/dev/null || true')" + if [[ -n "${existing_ver}" && "${existing_ver}" != "18" ]]; then + die "${NEW_VOLUME} contains PG_VERSION=${existing_ver} — refusing to overwrite. Inspect manually." + fi + + log "Starting temporary PG18 container to receive the restore" + # PGDATA points at a sub-path inside the mounted volume to satisfy + # PG18's "PGDATA must not equal the mountpoint" check. This matches + # the compose service so the volume the restore writes is the same + # layout the running iriswebapp_db will pick up. + # + # The bootstrap superuser is deliberately NOT ${POSTGRES_USER}. The + # dump was taken with `pg_dumpall --clean`, which emits `DROP ROLE + # ${POSTGRES_USER}` early on. Postgres refuses to drop the role the + # restore session is connected as, so we bootstrap with a throwaway + # superuser ("migrator") and let the dump recreate ${POSTGRES_USER} + # itself. When compose brings up the real service, it connects as + # ${POSTGRES_USER} exactly as before. + local BOOTSTRAP_USER="migrator" + docker run -d --rm \ + --name "${TMP_NEW_CTR}" \ + -v "${NEW_VOLUME}:/var/lib/postgresql/data" \ + -v "${DUMP_DIR}:/dumps:ro" \ + -e POSTGRES_USER="${BOOTSTRAP_USER}" \ + -e POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" \ + -e PGDATA=/var/lib/postgresql/data/pgdata \ + "${NEW_IMAGE}" >/dev/null + + log "Waiting for PG18 to accept connections" + for i in $(seq 1 60); do + if docker exec "${TMP_NEW_CTR}" pg_isready -U "${BOOTSTRAP_USER}" >/dev/null 2>&1; then + break + fi + sleep 1 + if [[ "$i" -eq 60 ]]; then + die "PG18 did not become ready within 60s" + fi + done + + log "Restoring dump into PG18" + # ON_ERROR_STOP so a partial restore aborts loudly instead of silently + # leaving the new cluster in a half-state. + docker exec -i "${TMP_NEW_CTR}" \ + psql -v ON_ERROR_STOP=1 -U "${BOOTSTRAP_USER}" -d postgres \ + -f "/dumps/$(basename "${DUMP_FILE}")" + + # PG18 defaults to scram-sha-256 for network auth; PG12 defaulted to md5. + # pg_dumpall exports the pre-hashed rolpassword verbatim, so the restored + # roles keep their md5 hashes and pg_hba.conf's `scram-sha-256` rule + # rejects them at login. Re-hash the two IRIS roles with the plaintext + # from .env so the running app can connect on first boot. + log "Re-hashing role passwords as scram-sha-256 (PG18 default)" + docker exec -i "${TMP_NEW_CTR}" \ + psql -v ON_ERROR_STOP=1 -U "${BOOTSTRAP_USER}" -d postgres <<SQL +SET password_encryption = 'scram-sha-256'; +ALTER USER "${POSTGRES_USER}" WITH PASSWORD '${POSTGRES_PASSWORD}'; +$( [[ -n "${POSTGRES_ADMIN_USER:-}" && -n "${POSTGRES_ADMIN_PASSWORD:-}" ]] && \ + echo "ALTER USER \"${POSTGRES_ADMIN_USER}\" WITH PASSWORD '${POSTGRES_ADMIN_PASSWORD}';" ) +SQL + + log "Stopping temporary PG18 container" + docker stop "${TMP_NEW_CTR}" >/dev/null + log "Restore complete" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +log "IRIS DB migration: PostgreSQL 12 → 18" +log "Project: ${COMPOSE_PROJECT}" +log "Old volume: ${OLD_VOLUME}" +log "Backup volume (will be created): ${BACKUP_VOLUME}" +log "Dump/tarball directory: ${DUMP_DIR}" + +step_preflight +step_cold_tar_backup +step_dump_logical +step_swap_volumes +step_restore_to_pg18 + +log "Done. You can now start IRIS v3.0.0-beta with: docker compose up -d" +log "Original PG12 data is preserved in volume '${BACKUP_VOLUME}'" +log "Cold tarball backup: ${TAR_FILE}" +log "Logical dump: ${DUMP_FILE}" +log "Once you have verified the new stack, you can reclaim space with:" +log " docker volume rm ${BACKUP_VOLUME}" +log " rm '${TAR_FILE}' '${DUMP_FILE}'" diff --git a/upgrades/upgrade_to_3.0.0.md b/upgrades/upgrade_to_3.0.0.md new file mode 100644 index 000000000..b006c2324 --- /dev/null +++ b/upgrades/upgrade_to_3.0.0.md @@ -0,0 +1,236 @@ +# Upgrading IRIS to v3.0.0-beta + +> **Audience:** operators currently running IRIS **v2.4.29** (or any v2.4.x) +> against the bundled `iriswebapp_db` image, which ships PostgreSQL **12**. +> **Target:** v3.0.0-beta, which ships PostgreSQL **18** in the same image. + +This release upgrades the bundled database from PostgreSQL 12 to PostgreSQL +18. A PG18 server **cannot read a PG12 data directory** — simply pulling the +new image and restarting will fail with a `database files are incompatible +with server` error. The data must be migrated. + +This document and the accompanying script perform a **logical +dump-and-restore** migration. No data is destroyed by the procedure: the +original PG12 volume is preserved under a new name, and a cold tarball is +written to disk before any mutation. You can roll back to v2.4.29 at any +point until you choose to reclaim the backup. + +--- + +## 1. Before you start + +- **Schedule a maintenance window.** IRIS must be stopped for the duration + of the migration. Dump/restore time is roughly proportional to database + size; on a typical case-management instance budget 1–5 minutes per GB of + `db_data`. +- **Free disk space.** You will temporarily need free disk equal to roughly + **3×** the size of the current `db_data` volume (raw tarball + SQL dump + + the freshly restored PG18 cluster). Check first: + ```bash + docker system df -v | grep db_data + df -h . + ``` +- **Confirm your `.env`** still has the original `POSTGRES_USER` and + `POSTGRES_PASSWORD`. The migration script reads them to authenticate + against both the PG12 dump and the PG18 restore. +- **Update the IRIS source tree** to the v3.0.0-beta tag before running + the migration — the script lives at + `upgrades/upgrade_db_pg12_to_pg18.sh`. + +## 2. What changes + +| Component | v2.4.x | v3.0.0-beta | +| --- | --- | --- | +| `docker/db/Dockerfile` base image | `postgres:12-alpine` | `postgres:18-alpine` | +| Default `DB_IMAGE_TAG` | `v2.4.20` | `v3.0.0-beta` | +| Default `APP_IMAGE_TAG` | `v2.4.20` | `v3.0.0-beta` | +| Default `NGINX_IMAGE_TAG` | `v2.4.20` | `v3.0.0-beta` | +| `deploy/eks_manifest/psql/deployment.yml` image tag | `v2.2.2` | `v3.0.0-beta` | + +The IRIS application schema is unchanged by this jump itself — Alembic +migrations are applied as normal on first boot of the new app container. +Only the underlying PostgreSQL major version changes. + +## 3. Migration procedure (docker-compose deployments) + +The steps below correspond to the automation in +`upgrades/upgrade_db_pg12_to_pg18.sh`. Run the script for the happy path; +the prose here is to help you understand and recover if anything goes +sideways. + +### 3.1 Stop the stack + +```bash +cd /path/to/iris-web +docker compose down +``` + +Do **not** pass `-v` — that would delete the very volume we're about to +migrate. + +### 3.2 Pull the v3.0.0-beta source + +```bash +git fetch --tags +git checkout v3.0.0-beta +``` + +At this point `docker-compose.yml` references the new PG18-based DB image, +but your `db_data` volume still holds a PG12 cluster. + +### 3.3 Run the migration script + +```bash +./upgrades/upgrade_db_pg12_to_pg18.sh +``` + +The script: + +1. **Pre-flight checks** — confirms Docker is present, the `iriswebapp_db` + container is stopped, and the existing volume actually contains a PG12 + cluster (reads `PG_VERSION`). Refuses to run otherwise. +2. **Cold tarball backup** — gzipped tar of the entire volume goes to + `upgrades/backups/iris_pg12_volume_<timestamp>.tar.gz`. This is your + nuclear-option rollback artifact. +3. **Logical dump** — spins up a throwaway `postgres:12-alpine` container + bound to the existing volume read-write, runs `pg_dumpall --clean + --if-exists`, writes + `upgrades/backups/iris_pg12_dump_<timestamp>.sql`, then stops. +4. **Volume swap** — copies the PG12 volume to a new named volume + `<project>_db_data_pg12_backup` and removes the original. The backup + volume is **kept**, not deleted, until you choose to reclaim it. +5. **Restore** — creates a fresh `<project>_db_data` volume, boots a + throwaway `postgres:18-alpine` against it, replays the dump with + `psql -v ON_ERROR_STOP=1`, then stops. + +The script is idempotent: each step detects prior completion and skips. +If it dies halfway through you can simply rerun it. + +### 3.4 Bring up v3.0.0-beta + +```bash +docker compose up -d +``` + +The `iriswebapp_db` container will now run PostgreSQL 18 against the +restored data. The `app` container applies any Alembic migrations on +first boot — watch the logs: + +```bash +docker compose logs -f app +``` + +### 3.5 Verify + +- Log in to the UI and confirm cases, IOCs, users, and customizations are + present. +- Check the new server version: + ```bash + docker compose exec db psql -U "$POSTGRES_USER" -d iris_db -c 'SELECT version();' + ``` + Should report PostgreSQL **18.x**. +- Confirm the `pgcrypto` extension is still present: + ```bash + docker compose exec db psql -U "$POSTGRES_USER" -d iris_db \ + -c "SELECT extname, extversion FROM pg_extension WHERE extname='pgcrypto';" + ``` + +### 3.6 Reclaim space (only after verification) + +Once you are confident the new stack is healthy, drop the preserved +PG12 volume and the on-disk backup artefacts: + +```bash +docker volume rm <project>_db_data_pg12_backup +rm upgrades/backups/iris_pg12_*.tar.gz upgrades/backups/iris_pg12_*.sql +``` + +Do not do this on the same day as the migration. Wait until the next +business cycle so any latent issue surfaces while you still have a +trivial rollback path. + +## 4. Rolling back + +You can roll back at three different points: + +### 4.1 Before reclaiming the backup volume — fastest + +```bash +docker compose down +docker volume rm <project>_db_data # drop the PG18 cluster +docker volume create <project>_db_data # empty target +docker run --rm \ + -v <project>_db_data_pg12_backup:/from:ro \ + -v <project>_db_data:/to \ + alpine:3.20 sh -c 'cd /from && cp -a . /to/' +git checkout v2.4.29 +docker compose up -d +``` + +### 4.2 Before reclaiming the cold tarball — slower but identical outcome + +```bash +docker compose down +docker volume rm <project>_db_data +docker volume create <project>_db_data +docker run --rm \ + -v <project>_db_data:/to \ + -v "$(pwd)/upgrades/backups:/backup:ro" \ + alpine:3.20 sh -c 'cd /to && tar xzf /backup/iris_pg12_volume_<timestamp>.tar.gz' +git checkout v2.4.29 +docker compose up -d +``` + +### 4.3 Logical-only rollback + +If, much later, you only have the `.sql` dump available, re-deploy +v2.4.29, let it initialise an empty cluster, drop the empty `iris_db`, +then `psql -f iris_pg12_dump_<timestamp>.sql`. + +## 5. Kubernetes deployments + +The bundled Helm chart and EKS manifest both reference the +`iriswebapp_db` image. The same major-version jump applies — a PG18 pod +will refuse to start against a PG12 `PersistentVolumeClaim`. + +We **do not** ship an automated migration for Kubernetes in this +release. Recommended approach: + +1. Scale the IRIS app/worker deployments to 0. +2. Exec into the running PG12 pod and `pg_dumpall` to a file on the PVC + (or stream out via `kubectl exec ... > dump.sql`). +3. Take a volume snapshot of the PVC (cloud-provider feature) as a + belt-and-braces backup. +4. Delete the PG12 StatefulSet/Deployment and its PVC. +5. Apply the v3.0.0-beta manifests so PG18 initialises a fresh PVC. +6. `kubectl cp` the dump into the new PG18 pod and `psql -v + ON_ERROR_STOP=1 -f dump.sql`. +7. Scale the app/worker back up. + +If you need a scripted version, open an issue — we will prioritise it +based on demand. + +## 6. FAQ + +**Why not `pg_upgrade`?** `pg_upgrade` is faster but requires both +PG12 *and* PG18 binaries side-by-side in a single container, plus +matching `--bindir` layouts. Bundling both inflates the image and adds +a brittle code path. Logical dump-and-restore is a few minutes slower +on typical IRIS datasets and is far easier to reason about. + +**Will the dump preserve roles and passwords?** Yes — +`pg_dumpall` (not `pg_dump`) is used precisely so that the +`POSTGRES_USER` and `POSTGRES_ADMIN_USER` roles, their passwords, and +ownership are restored exactly. + +**My `db_data` volume is huge — can I stream instead of writing the +`.sql` file to disk?** Possible but not implemented in the bundled +script, because writing the dump to disk is what makes the procedure +restartable. If disk is a hard constraint, contact us before +migrating. + +**Can I jump straight from a much older IRIS version?** This document +only covers the v2.4.x → v3.0.0-beta jump. If you are on something +older, upgrade to v2.4.29 first using +[`upgrades/upgrade_to_2.0.0.py`](upgrade_to_2.0.0.py) and the regular +release notes, then come back here.