diff --git a/bV-local-dev-environment-setup.md b/bV-local-dev-environment-setup.md index 6387221df..bdd594a71 100644 --- a/bV-local-dev-environment-setup.md +++ b/bV-local-dev-environment-setup.md @@ -68,6 +68,31 @@ podman compose -f docker-compose.bv.yml -p iris-bv logs -f bv-iriswebapp-app --- +## Running the test suite + +`tests/*.py` talk to the app container directly on `127.0.0.1:8000` (see `API_URL` in +`tests/iris.py`), so run the stack under its own project name (`iris-test`) and only start the +services the tests need — no nginx, no frontend. Never point this project name at a real dev +stack; treat its volumes as throwaway. + +Don't run this alongside a real dev stack (`iris-bv`, `iris-web-bv`, ...) — they'd collide on the +same host ports (8000/5432). + +```bash +# Build and start a disposable test stack (no nginx/worker needed to exercise the REST API) +podman compose -f docker-compose.dev.yml -p iris-test up -d --build db rabbitmq app worker + +# Wait for the app container to become healthy, then run the suite from the host +# (test files are named tests_*.py, not pytest's default test_*.py pattern) +cd tests +python -m unittest discover -p 'tests_*.py' -v + +# Tear down completely when done — safe to delete, this project only ever holds test fixtures +podman compose -f docker-compose.dev.yml -p iris-test down -v +``` + +--- + ## Working on UI (JavaScript/Svelte) changes The `ui/dist` folder is bind-mounted into the container at `/iriswebapp/static`, so you can diff --git a/source/app/alembic/versions/a1c9f7b2e3d4_add_incidents_rules_and_investigation_flows.py b/source/app/alembic/versions/a1c9f7b2e3d4_add_alert_clusters_rules_and_investigation_flows.py similarity index 74% rename from source/app/alembic/versions/a1c9f7b2e3d4_add_incidents_rules_and_investigation_flows.py rename to source/app/alembic/versions/a1c9f7b2e3d4_add_alert_clusters_rules_and_investigation_flows.py index 715059b80..ec7ee51b5 100644 --- a/source/app/alembic/versions/a1c9f7b2e3d4_add_incidents_rules_and_investigation_flows.py +++ b/source/app/alembic/versions/a1c9f7b2e3d4_add_alert_clusters_rules_and_investigation_flows.py @@ -1,11 +1,11 @@ -"""Add incidents, incident rules, and investigation flows. +"""Add alert clusters, cluster 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 + * `alert_cluster_status` — lookup (Open / Investigating / Dismissed / Escalated) + * `alert_clusters` — the alert-container entity + * `alert_cluster_association` — N:N alerts↔clusters + * `cluster_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 @@ -14,7 +14,7 @@ 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()` +The four AlertClusterStatus rows are seeded by `post_init.create_safe_alert_cluster_status()` at boot, not by the migration, to match how AlertStatus / AlertResolutionStatus are handled. @@ -35,23 +35,23 @@ depends_on = None -def _create_incident_status(): - if _has_table('incident_status'): +def _create_alert_cluster_status(): + if _has_table('alert_cluster_status'): return op.create_table( - 'incident_status', + 'alert_cluster_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'), + sa.UniqueConstraint('status_name', name='uq_alert_cluster_status_name'), ) -def _create_incident_rules(): - if _has_table('incident_rules'): +def _create_cluster_rules(): + if _has_table('cluster_rules'): return op.create_table( - 'incident_rules', + 'cluster_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()')), @@ -72,7 +72,7 @@ def _create_incident_rules(): 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'), + sa.UniqueConstraint('rule_uuid', name='uq_cluster_rules_uuid'), ) @@ -126,45 +126,45 @@ def _add_alert_investigation_flow_column(): ) -def _create_incidents(): - if _has_table('incidents'): +def _create_alert_clusters(): + if _has_table('alert_clusters'): 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), + 'alert_clusters', + sa.Column('cluster_id', sa.BigInteger(), primary_key=True), + sa.Column('cluster_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.Column('cluster_title', sa.Text(), nullable=False), + sa.Column('cluster_description', sa.Text(), nullable=True), + sa.Column('cluster_status_id', sa.Integer(), + sa.ForeignKey('alert_cluster_status.status_id'), nullable=False), + sa.Column('cluster_severity_id', sa.Integer(), sa.ForeignKey('severities.severity_id'), nullable=True), - sa.Column('incident_customer_id', sa.BigInteger(), + sa.Column('cluster_customer_id', sa.BigInteger(), sa.ForeignKey('client.client_id'), nullable=False), - sa.Column('incident_owner_id', sa.BigInteger(), + sa.Column('cluster_owner_id', sa.BigInteger(), sa.ForeignKey('user.id'), nullable=True), - sa.Column('incident_creation_time', sa.DateTime(), nullable=False, + sa.Column('cluster_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.Column('cluster_source_rule_id', sa.BigInteger(), + sa.ForeignKey('cluster_rules.rule_id'), nullable=True), + sa.Column('cluster_case_id', sa.BigInteger(), sa.ForeignKey('cases.case_id'), nullable=True), - sa.Column('incident_dedupe_key', sa.Text(), nullable=True, index=True), + sa.Column('cluster_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'), + sa.UniqueConstraint('cluster_uuid', name='uq_alert_clusters_uuid'), ) -def _create_alert_incident_association(): - if _has_table('alert_incident_association'): +def _create_alert_cluster_association(): + if _has_table('alert_cluster_association'): return op.create_table( - 'alert_incident_association', + 'alert_cluster_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, + sa.Column('cluster_id', sa.BigInteger(), + sa.ForeignKey('alert_clusters.cluster_id'), primary_key=True, nullable=False, index=True), ) @@ -192,23 +192,23 @@ def _create_alert_investigation_progress(): def upgrade(): - _create_incident_status() - _create_incident_rules() + _create_alert_cluster_status() + _create_cluster_rules() _create_investigation_flows() _create_investigation_flow_steps() _add_alert_investigation_flow_column() - _create_incidents() - _create_alert_incident_association() + _create_alert_clusters() + _create_alert_cluster_association() _create_alert_investigation_progress() def downgrade(): op.drop_table('alert_investigation_progress') - op.drop_table('alert_incident_association') - op.drop_table('incidents') + op.drop_table('alert_cluster_association') + op.drop_table('alert_clusters') 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') + op.drop_table('cluster_rules') + op.drop_table('alert_cluster_status') diff --git a/source/app/alembic/versions/a2b3c4d5e6f7_dashboard_owner_fk_set_null.py b/source/app/alembic/versions/a2b3c4d5e6f7_dashboard_owner_fk_set_null.py new file mode 100644 index 000000000..7df4d3514 --- /dev/null +++ b/source/app/alembic/versions/a2b3c4d5e6f7_dashboard_owner_fk_set_null.py @@ -0,0 +1,47 @@ +"""custom_dashboard.owner_id FK: SET NULL on user delete instead of blocking. + +Deleting a user who owns a custom dashboard raised a raw +ForeignKeyViolation/500 instead of either succeeding or returning a clean +4xx, because `custom_dashboard_owner_id_fkey` had no ON DELETE rule. Shared +dashboards should survive their owner's account being removed, so orphan +them (owner_id -> NULL) rather than blocking the delete or cascading it. + +Idempotent via `_has_table`/`_table_has_column`. + +Revision ID: a2b3c4d5e6f7 +Revises: db034de157b9 +Create Date: 2026-07-08 00:00:00.000000 +""" +from alembic import op + +from app.alembic.alembic_utils import _has_table +from app.alembic.alembic_utils import _table_has_column + + +revision = 'a2b3c4d5e6f7' +down_revision = 'db034de157b9' +branch_labels = None +depends_on = None + + +def upgrade(): + if not _has_table('custom_dashboard') or not _table_has_column('custom_dashboard', 'owner_id'): + return + op.drop_constraint('custom_dashboard_owner_id_fkey', 'custom_dashboard', type_='foreignkey') + op.create_foreign_key( + 'custom_dashboard_owner_id_fkey', + 'custom_dashboard', 'user', + ['owner_id'], ['id'], + ondelete='SET NULL', + ) + + +def downgrade(): + if not _has_table('custom_dashboard') or not _table_has_column('custom_dashboard', 'owner_id'): + return + op.drop_constraint('custom_dashboard_owner_id_fkey', 'custom_dashboard', type_='foreignkey') + op.create_foreign_key( + 'custom_dashboard_owner_id_fkey', + 'custom_dashboard', 'user', + ['owner_id'], ['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 index 016e01a98..6f73bff23 100644 --- 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 @@ -1,14 +1,14 @@ -"""Decouple investigation flows from incident rules. +"""Decouple investigation flows from cluster 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: +and/or alert clusters on their own — the `attach_flow` rule action is +retired (rules only stack alerts into clusters). This migration adds: - * `investigation_flows.flow_target` — 'alert' / 'incident' / 'both' + * `investigation_flows.flow_target` — 'alert' / 'alert_cluster' / '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 + * `alert_clusters.cluster_investigation_flow_id` — cached FK + * `alert_cluster_investigation_progress` — per-cluster check-off table Every step is guarded by `_has_table`/`_table_has_column` so the migration is idempotent — safe to re-run. @@ -54,24 +54,24 @@ def _add_flow_columns(): ) -def _add_incident_flow_column(): - if _table_has_column('incidents', 'incident_investigation_flow_id'): +def _add_cluster_flow_column(): + if _table_has_column('alert_clusters', 'cluster_investigation_flow_id'): return op.add_column( - 'incidents', - sa.Column('incident_investigation_flow_id', sa.BigInteger(), + 'alert_clusters', + sa.Column('cluster_investigation_flow_id', sa.BigInteger(), sa.ForeignKey('investigation_flows.flow_id'), nullable=True), ) -def _create_incident_investigation_progress(): - if _has_table('incident_investigation_progress'): +def _create_alert_cluster_investigation_progress(): + if _has_table('alert_cluster_investigation_progress'): return op.create_table( - 'incident_investigation_progress', + 'alert_cluster_investigation_progress', sa.Column('id', sa.BigInteger(), primary_key=True), - sa.Column('incident_id', sa.BigInteger(), - sa.ForeignKey('incidents.incident_id', ondelete='CASCADE'), + sa.Column('cluster_id', sa.BigInteger(), + sa.ForeignKey('alert_clusters.cluster_id', ondelete='CASCADE'), nullable=False, index=True), sa.Column('step_id', sa.BigInteger(), sa.ForeignKey('investigation_flow_steps.step_id', ondelete='CASCADE'), @@ -81,21 +81,21 @@ def _create_incident_investigation_progress(): 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'), + sa.UniqueConstraint('cluster_id', 'step_id', + name='uq_alert_cluster_investigation_progress_cluster_step'), ) def upgrade(): _add_flow_columns() - _add_incident_flow_column() - _create_incident_investigation_progress() + _add_cluster_flow_column() + _create_alert_cluster_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') + op.drop_table('alert_cluster_investigation_progress') + if _table_has_column('alert_clusters', 'cluster_investigation_flow_id'): + op.drop_column('alert_clusters', 'cluster_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'): diff --git a/source/app/alembic/versions/b5c6d7e8f9a0_merge_dashboard_owner_fk_and_collab_doc_seeder_heads.py b/source/app/alembic/versions/b5c6d7e8f9a0_merge_dashboard_owner_fk_and_collab_doc_seeder_heads.py new file mode 100644 index 000000000..69e4d007a --- /dev/null +++ b/source/app/alembic/versions/b5c6d7e8f9a0_merge_dashboard_owner_fk_and_collab_doc_seeder_heads.py @@ -0,0 +1,28 @@ +"""Merge dashboard_owner_fk and collab_doc_seeder_version heads + +Our custom_dashboard.owner_id FK fix (a2b3c4d5e6f7) was based on +db034de157b9, but merging in 3 newer upstream commits added +f3a1b2c3d4e5 (add_collab_doc_seeder_version) on a sibling branch off +e2f3g4h5i6j7, re-forking the alembic head into two. + +Revision ID: b5c6d7e8f9a0 +Revises: a2b3c4d5e6f7, f3a1b2c3d4e5 +Create Date: 2026-07-08 00:00:00.000000 + +""" + +# revision identifiers, used by Alembic. +revision = 'b5c6d7e8f9a0' +down_revision = ('a2b3c4d5e6f7', 'f3a1b2c3d4e5') +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/c8f3a2d47b19_add_cluster_id_in_comments.py b/source/app/alembic/versions/c8f3a2d47b19_add_cluster_id_in_comments.py new file mode 100644 index 000000000..a08a22648 --- /dev/null +++ b/source/app/alembic/versions/c8f3a2d47b19_add_cluster_id_in_comments.py @@ -0,0 +1,38 @@ +"""Add cluster scope to comments. + +Mirrors the alert path (`comment_alert_id`) so alert clusters can carry a +first-class analyst comment stream, feeding the cluster 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_cluster_id'): + op.add_column( + 'comments', + sa.Column('comment_cluster_id', sa.BigInteger(), nullable=True), + ) + op.create_foreign_key( + 'fk_comments_cluster_id', + 'comments', 'alert_clusters', + ['comment_cluster_id'], ['cluster_id'], + ) + + +def downgrade(): + op.drop_constraint('fk_comments_cluster_id', 'comments', type_='foreignkey') + op.drop_column('comments', 'comment_cluster_id') 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 deleted file mode 100644 index 7a0d35f8d..000000000 --- a/source/app/alembic/versions/c8f3a2d47b19_add_incident_id_in_comments.py +++ /dev/null @@ -1,38 +0,0 @@ -"""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/f3a1b2c3d4e5_add_collab_doc_seeder_version.py b/source/app/alembic/versions/f3a1b2c3d4e5_add_collab_doc_seeder_version.py new file mode 100644 index 000000000..bad2f6602 --- /dev/null +++ b/source/app/alembic/versions/f3a1b2c3d4e5_add_collab_doc_seeder_version.py @@ -0,0 +1,48 @@ +"""Add seeder_version to collab_doc. + +Rows persisted by an older markdown→Y.Doc seeder need to be +re-seeded when the parser learns to handle a new block type (tables +were the first case — the CommonMark parser silently dropped GFM +tables, so any legacy note with a table lost its tabular structure +after the first open). The version column lets `ensure_snapshot` +detect stale rows and re-seed them from the source column instead +of returning the lossy y_state. + +Existing rows default to `0`; the current seeder version lives in +`_CURRENT_SEEDER_VERSION` in `business/collab.py`. + +Idempotent via `_table_has_column`. + +Revision ID: f3a1b2c3d4e5 +Revises: e2f3g4h5i6j7 +Create Date: 2026-07-07 13: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 + + +revision = 'f3a1b2c3d4e5' +down_revision = 'e2f3g4h5i6j7' +branch_labels = None +depends_on = None + + +def upgrade(): + if not _has_table('collab_doc'): + return + if _table_has_column('collab_doc', 'seeder_version'): + return + op.add_column( + 'collab_doc', + sa.Column('seeder_version', sa.Integer(), nullable=True), + ) + + +def downgrade(): + if _has_table('collab_doc') and _table_has_column( + 'collab_doc', 'seeder_version' + ): + op.drop_column('collab_doc', 'seeder_version') diff --git a/source/app/blueprints/access_controls.py b/source/app/blueprints/access_controls.py index ff121f3d6..6bed54d62 100644 --- a/source/app/blueprints/access_controls.py +++ b/source/app/blueprints/access_controls.py @@ -658,7 +658,7 @@ 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 + helper — e.g. the cluster-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).""" diff --git a/source/app/blueprints/rest/api_v2_routes.py b/source/app/blueprints/rest/api_v2_routes.py index 33cc0640a..c6494c0dc 100644 --- a/source/app/blueprints/rest/api_v2_routes.py +++ b/source/app/blueprints/rest/api_v2_routes.py @@ -45,8 +45,8 @@ 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.alert_clusters import alert_clusters_blueprint +from app.blueprints.rest.v2.cluster_rules import cluster_rules_blueprint from app.blueprints.rest.v2.investigation_flows import investigation_flows_blueprint @@ -81,6 +81,6 @@ 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(alert_clusters_blueprint) +rest_v2_blueprint.register_blueprint(cluster_rules_blueprint) rest_v2_blueprint.register_blueprint(investigation_flows_blueprint) diff --git a/source/app/blueprints/rest/v2/incidents.py b/source/app/blueprints/rest/v2/alert_clusters.py similarity index 64% rename from source/app/blueprints/rest/v2/incidents.py rename to source/app/blueprints/rest/v2/alert_clusters.py index 52e817594..06323ce68 100644 --- a/source/app/blueprints/rest/v2/incidents.py +++ b/source/app/blueprints/rest/v2/alert_clusters.py @@ -31,62 +31,62 @@ 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.alert_clusters_routes.comments import ( + alert_clusters_comments_blueprint, ) -from app.blueprints.rest.v2.incidents_routes.investigation_progress import ( - incidents_investigation_progress_blueprint, +from app.blueprints.rest.v2.alert_clusters_routes.investigation_progress import ( + alert_clusters_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 case_unlink_alert_cluster 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.business.alert_clusters import alert_cluster_add_alerts +from app.business.alert_clusters import alert_cluster_correlation_graph +from app.business.alert_clusters import alert_cluster_escalate_to_case +from app.business.alert_clusters import alert_cluster_merge_to_case +from app.business.alert_clusters import alert_cluster_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.business.alert_clusters import alert_clusters_create +from app.business.alert_clusters import alert_clusters_delete +from app.business.alert_clusters import alert_clusters_get +from app.business.alert_clusters import alert_clusters_search +from app.business.alert_clusters import alert_clusters_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 +from app.schema.marshables import AlertClusterSchema # 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', +# on the alerts blueprint — re-attributing cluster_customer_id would let a +# user with write access to one tenant move a cluster into another). +_CLUSTER_READONLY_UPDATE_FIELDS = frozenset({ + 'cluster_id', + 'cluster_customer_id', + 'cluster_creation_time', + 'cluster_case_id', + 'cluster_dedupe_key', + 'cluster_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} + return {k: v for k, v in payload.items() if k not in _CLUSTER_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) +alert_clusters_blueprint = Blueprint('alert_clusters_rest_v2', __name__, url_prefix='/alert-clusters') +alert_clusters_blueprint.register_blueprint(alert_clusters_investigation_progress_blueprint) +alert_clusters_blueprint.register_blueprint(alert_clusters_comments_blueprint) -_schema = IncidentSchema() +_schema = AlertClusterSchema() -@incidents_blueprint.get('') -@ac_api_requires(Permissions.incidents_read) -def list_incidents(): +@alert_clusters_blueprint.get('') +@ac_api_requires(Permissions.alert_clusters_read) +def list_clusters(): page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 10, type=int) @@ -94,7 +94,7 @@ def list_incidents(): if ac_current_user_has_permission(Permissions.server_administrator): user_filter = None - paginated = incidents_search( + paginated = alert_clusters_search( customer_id=request.args.get('customer_id', type=int), status_id=request.args.get('status_id', type=int), title=request.args.get('title'), @@ -106,29 +106,29 @@ def list_incidents(): return response_api_paginated(_schema, paginated) -@incidents_blueprint.post('') -@ac_api_requires(Permissions.incidents_write) -def create_incident(): +@alert_clusters_blueprint.post('') +@ac_api_requires(Permissions.alert_clusters_write) +def create_cluster(): payload = request.get_json() or {} try: - incident = _schema.load(payload) + cluster = _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') + if not ac_current_user_has_customer_access(cluster.cluster_customer_id): + return response_api_error('User not entitled to create clusters for the client') alert_ids = payload.get('alert_ids') or [] - result = incidents_create(incident) + result = alert_clusters_create(cluster) if alert_ids: - incident_add_alerts(result, alert_ids) + alert_cluster_add_alerts(result, alert_ids) return response_api_created(_schema.dump(result)) -@incidents_blueprint.get('/') -@ac_api_requires(Permissions.incidents_read) -def read_incident(identifier): +@alert_clusters_blueprint.get('/') +@ac_api_requires(Permissions.alert_clusters_read) +def read_cluster(identifier): try: - incident = incidents_get( + cluster = alert_clusters_get( iris_current_user, session.get('permissions') or 0, identifier, @@ -136,14 +136,14 @@ def read_incident(identifier): ) except ObjectNotFoundError: return response_api_not_found() - return response_api_success(_schema.dump(incident)) + return response_api_success(_schema.dump(cluster)) -@incidents_blueprint.put('/') -@ac_api_requires(Permissions.incidents_write) -def update_incident(identifier): +@alert_clusters_blueprint.put('/') +@ac_api_requires(Permissions.alert_clusters_write) +def update_cluster(identifier): try: - incident = incidents_get( + cluster = alert_clusters_get( iris_current_user, session.get('permissions') or 0, identifier, @@ -154,18 +154,18 @@ def update_incident(identifier): 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) + _schema.load(payload, instance=cluster, partial=True) except ValidationError as exc: return response_api_error('Data error', data=exc.messages) - result = incidents_update(incident, payload) + result = alert_clusters_update(cluster, payload) return response_api_success(_schema.dump(result)) -@incidents_blueprint.delete('/') -@ac_api_requires(Permissions.incidents_delete) -def delete_incident(identifier): +@alert_clusters_blueprint.delete('/') +@ac_api_requires(Permissions.alert_clusters_delete) +def delete_cluster(identifier): try: - incident = incidents_get( + cluster = alert_clusters_get( iris_current_user, session.get('permissions') or 0, identifier, @@ -173,15 +173,15 @@ def delete_incident(identifier): ) except ObjectNotFoundError: return response_api_not_found() - incidents_delete(incident) + alert_clusters_delete(cluster) return response_api_deleted() -@incidents_blueprint.post('//alerts') -@ac_api_requires(Permissions.incidents_write) +@alert_clusters_blueprint.post('//alerts') +@ac_api_requires(Permissions.alert_clusters_write) def add_alerts(identifier): try: - incident = incidents_get( + cluster = alert_clusters_get( iris_current_user, session.get('permissions') or 0, identifier, @@ -193,15 +193,15 @@ def add_alerts(identifier): 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) + result = alert_cluster_add_alerts(cluster, alert_ids) return response_api_success(_schema.dump(result)) -@incidents_blueprint.delete('//alerts/') -@ac_api_requires(Permissions.incidents_write) +@alert_clusters_blueprint.delete('//alerts/') +@ac_api_requires(Permissions.alert_clusters_write) def remove_alert(identifier, alert_id): try: - incident = incidents_get( + cluster = alert_clusters_get( iris_current_user, session.get('permissions') or 0, identifier, @@ -209,15 +209,15 @@ def remove_alert(identifier, alert_id): ) except ObjectNotFoundError: return response_api_not_found() - result = incident_remove_alert(incident, alert_id) + result = alert_cluster_remove_alert(cluster, alert_id) return response_api_success(_schema.dump(result)) -@incidents_blueprint.post('//escalate') -@ac_api_requires(Permissions.incidents_write) +@alert_clusters_blueprint.post('//escalate') +@ac_api_requires(Permissions.alert_clusters_write) def escalate(identifier): try: - incident = incidents_get( + cluster = alert_clusters_get( iris_current_user, session.get('permissions') or 0, identifier, @@ -227,8 +227,8 @@ def escalate(identifier): return response_api_not_found() payload = request.get_json() or {} try: - case = incident_escalate_to_case( - incident, + case = alert_cluster_escalate_to_case( + cluster, template_id=payload.get('template_id'), case_title=payload.get('case_title'), note=payload.get('note'), @@ -238,24 +238,24 @@ def escalate(identifier): except BusinessProcessingError as exc: return response_api_error(exc.get_message(), data=exc.get_data()) return response_api_success({ - 'incident_id': incident.incident_id, + 'cluster_id': cluster.cluster_id, 'case_id': case.case_id, }) -@incidents_blueprint.post('//merge') -@ac_api_requires(Permissions.incidents_write) +@alert_clusters_blueprint.post('//merge') +@ac_api_requires(Permissions.alert_clusters_write) def merge(identifier): - """Merge an incident's alerts into an already-existing case. + """Merge a cluster's alerts into an already-existing case. Mirrors the alert-merge endpoint (`POST /alerts/merge/`) but - fanned across every alert on the incident. `target_case_id` in the body + fanned across every alert on the cluster. `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( + cluster = alert_clusters_get( iris_current_user, session.get('permissions') or 0, identifier, @@ -278,8 +278,8 @@ def merge(identifier): ) try: - case = incident_merge_to_case( - incident, + case = alert_cluster_merge_to_case( + cluster, target_case_id=target_case_id, note=payload.get('note'), import_as_event=bool(payload.get('import_as_event', False)), @@ -289,25 +289,25 @@ def merge(identifier): return response_api_error(exc.get_message(), data=exc.get_data()) return response_api_success({ - 'incident_id': incident.incident_id, + 'cluster_id': cluster.cluster_id, 'case_id': case.case_id, }) -@incidents_blueprint.delete('//case') -@ac_api_requires(Permissions.incidents_write) +@alert_clusters_blueprint.delete('//case') +@ac_api_requires(Permissions.alert_clusters_write) def unlink_case(identifier): - """Reverse an incident->case escalation/merge from the incident side. + """Reverse a cluster->case escalation/merge from the cluster side. - Same behaviour as `DELETE /api/v2/cases/{case_id}/source-incident` - but rooted at the incident URL so the incident-detail page's + Same behaviour as `DELETE /api/v2/cases/{case_id}/source-cluster` + but rooted at the cluster URL so the cluster-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. + source-cluster link either. """ try: - incident = incidents_get( + cluster = alert_clusters_get( iris_current_user, session.get('permissions') or 0, identifier, @@ -316,10 +316,10 @@ def unlink_case(identifier): except ObjectNotFoundError: return response_api_not_found() - if incident.incident_case_id is None: + if cluster.cluster_case_id is None: return response_api_success(data={'unlinked': False}) - linked_case_id = incident.incident_case_id + linked_case_id = cluster.cluster_case_id if not ac_fast_check_current_user_has_case_access( linked_case_id, [CaseAccessLevel.full_access] ): @@ -327,30 +327,30 @@ def unlink_case(identifier): case = cases_get_by_identifier(linked_case_id) try: - case_unlink_incident(case) + case_unlink_alert_cluster(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, + 'cluster_id': cluster.cluster_id, }) -@incidents_blueprint.get('//graph') +@alert_clusters_blueprint.get('//graph') @ac_api_requires() def graph(identifier): - """Correlation graph for an incident. + """Correlation graph for an alert cluster. 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) + looking at this view. Read-only, gated by cluster (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( + cluster = alert_clusters_get( iris_current_user, session.get('permissions') or 0, identifier, @@ -359,4 +359,4 @@ def graph(identifier): except ObjectNotFoundError: return response_api_not_found() - return response_api_success(data=incident_correlation_graph(incident)) + return response_api_success(data=alert_cluster_correlation_graph(cluster)) diff --git a/source/app/blueprints/rest/v2/incidents_routes/__init__.py b/source/app/blueprints/rest/v2/alert_clusters_routes/__init__.py similarity index 100% rename from source/app/blueprints/rest/v2/incidents_routes/__init__.py rename to source/app/blueprints/rest/v2/alert_clusters_routes/__init__.py diff --git a/source/app/blueprints/rest/v2/incidents_routes/comments.py b/source/app/blueprints/rest/v2/alert_clusters_routes/comments.py similarity index 68% rename from source/app/blueprints/rest/v2/incidents_routes/comments.py rename to source/app/blueprints/rest/v2/alert_clusters_routes/comments.py index 5fe637fc2..a2c2a8f6a 100644 --- a/source/app/blueprints/rest/v2/incidents_routes/comments.py +++ b/source/app/blueprints/rest/v2/alert_clusters_routes/comments.py @@ -32,30 +32,30 @@ 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.business.comments import comments_create_for_alert_cluster +from app.business.comments import comments_delete_for_alert_cluster +from app.business.comments import comments_get_filtered_by_alert_cluster +from app.business.comments import comments_get_for_alert_cluster 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='//comments' +alert_clusters_comments_blueprint = Blueprint( + 'alert_clusters_comments', __name__, url_prefix='//comments' ) _schema = CommentSchema() -@incidents_comments_blueprint.get('') -@ac_api_requires(Permissions.incidents_read) -def list_incident_comments(incident_identifier): +@alert_clusters_comments_blueprint.get('') +@ac_api_requires(Permissions.alert_clusters_read) +def list_cluster_comments(cluster_identifier): pagination = parse_pagination_parameters(request) try: - comments = comments_get_filtered_by_incident( + comments = comments_get_filtered_by_alert_cluster( iris_current_user, (session.get('permissions') or 0), - incident_identifier, + cluster_identifier, pagination, fallback_customer_access=ac_current_user_has_customer_access, ) @@ -64,19 +64,19 @@ def list_incident_comments(incident_identifier): return response_api_paginated(_schema, comments) -@incidents_comments_blueprint.post('') -@ac_api_requires(Permissions.incidents_write) -def create_incident_comment(incident_identifier): +@alert_clusters_comments_blueprint.post('') +@ac_api_requires(Permissions.alert_clusters_write) +def create_cluster_comment(cluster_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( + comments_create_for_alert_cluster( iris_current_user, (session.get('permissions') or 0), comment, - incident_identifier, + cluster_identifier, fallback_customer_access=ac_current_user_has_customer_access, ) except ObjectNotFoundError: @@ -84,42 +84,42 @@ def create_incident_comment(incident_identifier): return response_api_created(_schema.dump(comment)) -@incidents_comments_blueprint.get('/') -@ac_api_requires(Permissions.incidents_read) -def read_incident_comment(incident_identifier, identifier): - # Verify caller can see the parent incident before returning the +@alert_clusters_comments_blueprint.get('/') +@ac_api_requires(Permissions.alert_clusters_read) +def read_cluster_comment(cluster_identifier, identifier): + # Verify caller can see the parent cluster before returning the # comment — otherwise an authorised-by-comment-id lookup would leak # comment text across tenants. try: - comments_get_filtered_by_incident( + comments_get_filtered_by_alert_cluster( iris_current_user, (session.get('permissions') or 0), - incident_identifier, + cluster_identifier, parse_pagination_parameters(request), fallback_customer_access=ac_current_user_has_customer_access, ) - comment = comments_get_for_incident(incident_identifier, identifier) + comment = comments_get_for_alert_cluster(cluster_identifier, identifier) except ObjectNotFoundError: return response_api_not_found() return response_api_success(_schema.dump(comment)) -@incidents_comments_blueprint.delete('/') -@ac_api_requires(Permissions.incidents_write) -def delete_incident_comment(incident_identifier, identifier): +@alert_clusters_comments_blueprint.delete('/') +@ac_api_requires(Permissions.alert_clusters_write) +def delete_cluster_comment(cluster_identifier, identifier): try: - # Access check on the parent incident. - comments_get_filtered_by_incident( + # Access check on the parent cluster. + comments_get_filtered_by_alert_cluster( iris_current_user, (session.get('permissions') or 0), - incident_identifier, + cluster_identifier, parse_pagination_parameters(request), fallback_customer_access=ac_current_user_has_customer_access, ) - comment = comments_get_for_incident(incident_identifier, identifier) + comment = comments_get_for_alert_cluster(cluster_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) + comments_delete_for_alert_cluster(comment) return response_api_deleted() diff --git a/source/app/blueprints/rest/v2/incidents_routes/investigation_progress.py b/source/app/blueprints/rest/v2/alert_clusters_routes/investigation_progress.py similarity index 66% rename from source/app/blueprints/rest/v2/incidents_routes/investigation_progress.py rename to source/app/blueprints/rest/v2/alert_clusters_routes/investigation_progress.py index 057fd3839..7cf69b1bf 100644 --- a/source/app/blueprints/rest/v2/incidents_routes/investigation_progress.py +++ b/source/app/blueprints/rest/v2/alert_clusters_routes/investigation_progress.py @@ -16,7 +16,7 @@ # 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-cluster-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.""" @@ -31,25 +31,25 @@ 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.business.alert_clusters import alert_clusters_get +from app.business.investigation_flows import alert_cluster_progress_list +from app.business.investigation_flows import alert_cluster_progress_record +from app.business.investigation_flows import alert_cluster_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 +from app.schema.marshables import AlertClusterInvestigationProgressSchema -incidents_investigation_progress_blueprint = Blueprint( - 'incidents_investigation_progress_rest_v2', __name__ +alert_clusters_investigation_progress_blueprint = Blueprint( + 'alert_clusters_investigation_progress_rest_v2', __name__ ) -_schema = IncidentInvestigationProgressSchema() +_schema = AlertClusterInvestigationProgressSchema() -def _load_incident(identifier): - return incidents_get( +def _load_cluster(identifier): + return alert_clusters_get( iris_current_user, session.get('permissions') or 0, identifier, @@ -57,15 +57,15 @@ def _load_incident(identifier): ) -@incidents_investigation_progress_blueprint.get('//investigation-progress') -@ac_api_requires(Permissions.incidents_read) +@alert_clusters_investigation_progress_blueprint.get('//investigation-progress') +@ac_api_requires(Permissions.alert_clusters_read) def list_progress(identifier): try: - incident = _load_incident(identifier) + cluster = _load_cluster(identifier) except ObjectNotFoundError: return response_api_not_found() - rows = incident_progress_list(incident) - flow = incident.investigation_flow + rows = alert_cluster_progress_list(cluster) + flow = cluster.investigation_flow return response_api_success({ 'flow_id': flow.flow_id if flow else None, 'flow_name': flow.flow_name if flow else None, @@ -83,17 +83,17 @@ def list_progress(identifier): }) -@incidents_investigation_progress_blueprint.post('//investigation-progress/') -@ac_api_requires(Permissions.incidents_write) +@alert_clusters_investigation_progress_blueprint.post('//investigation-progress/') +@ac_api_requires(Permissions.alert_clusters_write) def record_progress(identifier, step_id): try: - incident = _load_incident(identifier) + cluster = _load_cluster(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') + row = alert_cluster_progress_record( + cluster, 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()) @@ -102,12 +102,12 @@ def record_progress(identifier, step_id): return response_api_success(_schema.dump(row)) -@incidents_investigation_progress_blueprint.delete('//investigation-progress/') -@ac_api_requires(Permissions.incidents_write) +@alert_clusters_investigation_progress_blueprint.delete('//investigation-progress/') +@ac_api_requires(Permissions.alert_clusters_write) def uncheck_progress(identifier, step_id): try: - incident = _load_incident(identifier) + cluster = _load_cluster(identifier) except ObjectNotFoundError: return response_api_not_found() - incident_progress_uncheck(incident, step_id) + alert_cluster_progress_uncheck(cluster, step_id) return response_api_deleted() diff --git a/source/app/blueprints/rest/v2/alerts.py b/source/app/blueprints/rest/v2/alerts.py index 7a16dcfce..a53a68788 100644 --- a/source/app/blueprints/rest/v2/alerts.py +++ b/source/app/blueprints/rest/v2/alerts.py @@ -142,7 +142,7 @@ def search(self): page, per_page, request.args.get('sort'), - request.args.get('incident_id', type=int), + request.args.get('cluster_id', type=int), ) if filtered_alerts is None: diff --git a/source/app/blueprints/rest/v2/cases.py b/source/app/blueprints/rest/v2/cases.py index a78035bfa..d3a7de3b0 100644 --- a/source/app/blueprints/rest/v2/cases.py +++ b/source/app/blueprints/rest/v2/cases.py @@ -46,7 +46,7 @@ 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 case_unlink_alert_cluster from app.business.cases import cases_create from app.business.cases import cases_close from app.business.cases import cases_delete @@ -525,21 +525,21 @@ def list_case_followers(identifier): ]) -@cases_blueprint.get('//source-incident') +@cases_blueprint.get('//source-alert-cluster') @ac_api_requires() -def get_case_source_incident(identifier): - """Return the incident this case was created from, if any. +def get_case_source_alert_cluster(identifier): + """Return the alert cluster 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 + can read the case can see which cluster it was escalated / merged + from. Read-only, gated by case read access — the cluster 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 + Returns 200 with `null` when the case has no source cluster so the frontend can conditionally render the chip without a 404. """ - from app.business.incidents import incidents_get_by_case + from app.business.alert_clusters import alert_clusters_get_by_case if not cases_exists(identifier): return response_api_not_found() @@ -548,14 +548,14 @@ def get_case_source_incident(identifier): ): return ac_api_return_access_denied(caseid=identifier) - incident = incidents_get_by_case(identifier) - if incident is None: + cluster = alert_clusters_get_by_case(identifier) + if cluster 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, + 'cluster_id': cluster.cluster_id, + 'cluster_title': cluster.cluster_title, + 'cluster_status': cluster.status.status_name if cluster.status else None, }) @@ -586,16 +586,16 @@ def rest_v2_case_unlink_alert(identifier, alert_id): return response_api_deleted() -@cases_blueprint.delete('//source-incident') +@cases_blueprint.delete('//source-alert-cluster') @ac_api_requires(Permissions.standard_user) -def rest_v2_case_unlink_incident(identifier): - """Unlink the incident that produced this case. +def rest_v2_case_unlink_alert_cluster(identifier): + """Unlink the alert cluster that produced this case. - Clears the incident's back-reference, moves it back to Investigating, + Clears the cluster'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. + sourced from a cluster. """ if not cases_exists(identifier): return response_api_not_found() @@ -606,15 +606,15 @@ def rest_v2_case_unlink_incident(identifier): case = cases_get_by_identifier(identifier) try: - incident = case_unlink_incident(case) + cluster = case_unlink_alert_cluster(case) except BusinessProcessingError as exc: return response_api_error(exc.get_message(), data=exc.get_data()) - if incident is None: + if cluster is None: return response_api_success(data={'unlinked': False}) return response_api_success(data={ 'unlinked': True, - 'incident_id': incident.incident_id, + 'cluster_id': cluster.cluster_id, }) diff --git a/source/app/blueprints/rest/v2/incident_rules.py b/source/app/blueprints/rest/v2/cluster_rules.py similarity index 83% rename from source/app/blueprints/rest/v2/incident_rules.py rename to source/app/blueprints/rest/v2/cluster_rules.py index 466231cea..b2b36d425 100644 --- a/source/app/blueprints/rest/v2/incident_rules.py +++ b/source/app/blueprints/rest/v2/cluster_rules.py @@ -29,17 +29,17 @@ 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.business.cluster_rules import backfill_rule +from app.business.cluster_rules import rule_dry_run +from app.business.cluster_rules import rules_create +from app.business.cluster_rules import rules_delete +from app.business.cluster_rules import rules_get +from app.business.cluster_rules import rules_list +from app.business.cluster_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 +from app.schema.marshables import ClusterRuleSchema # Fields the caller must never be able to set / mutate via the request @@ -72,21 +72,21 @@ def _caller(): return iris_current_user, ac_current_user_permissions_mask() -incident_rules_blueprint = Blueprint('incident_rules_rest_v2', __name__, url_prefix='/incident-rules') +cluster_rules_blueprint = Blueprint('cluster_rules_rest_v2', __name__, url_prefix='/cluster-rules') -_schema = IncidentRuleSchema() +_schema = ClusterRuleSchema() -@incident_rules_blueprint.get('') -@ac_api_requires(Permissions.incident_rules_read) +@cluster_rules_blueprint.get('') +@ac_api_requires(Permissions.cluster_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) +@cluster_rules_blueprint.post('') +@ac_api_requires(Permissions.cluster_rules_write) def create_rule(): user, perms = _caller() payload = _strip_readonly(request.get_json() or {}) @@ -107,8 +107,8 @@ def create_rule(): return response_api_created(_schema.dump(result)) -@incident_rules_blueprint.get('/') -@ac_api_requires(Permissions.incident_rules_read) +@cluster_rules_blueprint.get('/') +@ac_api_requires(Permissions.cluster_rules_read) def read_rule(identifier): user, perms = _caller() try: @@ -121,8 +121,8 @@ def read_rule(identifier): return response_api_success(_schema.dump(rule)) -@incident_rules_blueprint.put('/') -@ac_api_requires(Permissions.incident_rules_write) +@cluster_rules_blueprint.put('/') +@ac_api_requires(Permissions.cluster_rules_write) def update_rule(identifier): user, perms = _caller() try: @@ -149,8 +149,8 @@ def update_rule(identifier): return response_api_success(_schema.dump(result)) -@incident_rules_blueprint.delete('/') -@ac_api_requires(Permissions.incident_rules_write) +@cluster_rules_blueprint.delete('/') +@ac_api_requires(Permissions.cluster_rules_write) def delete_rule(identifier): user, perms = _caller() try: @@ -164,8 +164,8 @@ def delete_rule(identifier): return response_api_deleted() -@incident_rules_blueprint.post('//test') -@ac_api_requires(Permissions.incident_rules_read) +@cluster_rules_blueprint.post('//test') +@ac_api_requires(Permissions.cluster_rules_read) def test_rule(identifier): user, perms = _caller() try: @@ -186,11 +186,11 @@ def test_rule(identifier): }) -@incident_rules_blueprint.post('//backfill') -@ac_api_requires(Permissions.incident_rules_write) +@cluster_rules_blueprint.post('//backfill') +@ac_api_requires(Permissions.cluster_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 + grouped into a cluster 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. diff --git a/source/app/blueprints/rest/v2/investigation_flows.py b/source/app/blueprints/rest/v2/investigation_flows.py index b8f5574ba..717c76e2c 100644 --- a/source/app/blueprints/rest/v2/investigation_flows.py +++ b/source/app/blueprints/rest/v2/investigation_flows.py @@ -226,7 +226,7 @@ def delete_step(flow_id, step_id): @investigation_flows_blueprint.post('//deploy') @ac_api_requires(Permissions.investigation_flows_write) def deploy(identifier): - """Back-fill this flow onto historical alerts / incidents whose + """Back-fill this flow onto historical alerts / alert clusters whose investigation-flow FK is empty and whose contents match the flow's own conditions. Returns per-target attach counts. @@ -242,9 +242,9 @@ def deploy(identifier): ) except ObjectNotFoundError: return response_api_not_found() - alerts_attached, incidents_attached = deploy_flow(flow) + alerts_attached, clusters_attached = deploy_flow(flow) return response_api_success({ 'flow_id': flow.flow_id, 'alerts_attached': alerts_attached, - 'incidents_attached': incidents_attached, + 'clusters_attached': clusters_attached, }) diff --git a/source/app/blueprints/rest/v2/manage_routes/taxonomies.py b/source/app/blueprints/rest/v2/manage_routes/taxonomies.py index 5c8389b54..261950bf1 100644 --- a/source/app/blueprints/rest/v2/manage_routes/taxonomies.py +++ b/source/app/blueprints/rest/v2/manage_routes/taxonomies.py @@ -50,7 +50,7 @@ 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.alert_clusters import AlertClusterStatus from app.models.iocs import Tlp from app.models.models import EventCategory from app.models.models import TaskStatus @@ -58,7 +58,7 @@ 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 AlertClusterStatusSchema from app.schema.marshables import SeveritySchema from app.schema.marshables import TaskStatusSchema from app.schema.marshables import TlpSchema @@ -161,14 +161,14 @@ def list_route() -> Response: order_column='resolution_status_id', ) -# Incident statuses (Open / Investigating / Dismissed / Escalated) — -# seeded at boot. Exposed here so the incident detail page can populate +# Alert cluster statuses (Open / Investigating / Dismissed / Escalated) — +# seeded at boot. Exposed here so the cluster 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, +alert_cluster_statuses_blueprint = _build_readonly_blueprint( + url_prefix='alert-cluster-statuses', + blueprint_name='alert_cluster_statuses_rest_v2', + model=AlertClusterStatus, + schema_factory=AlertClusterStatusSchema, search_columns=('status_name', 'status_description'), order_column='status_id', ) @@ -209,7 +209,7 @@ def list_route() -> Response: 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(alert_cluster_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/business/access_controls.py b/source/app/business/access_controls.py index 111dc02b5..c17c865a4 100644 --- a/source/app/business/access_controls.py +++ b/source/app/business/access_controls.py @@ -146,7 +146,7 @@ def access_controls_user_has_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. + `customer_scope` shape used by cluster rules and investigation flows. Semantics — mirror the tenant-safety pattern used elsewhere: * `customer_scope == None` (a null-scope "global" resource) requires diff --git a/source/app/business/incidents.py b/source/app/business/alert_clusters.py similarity index 59% rename from source/app/business/incidents.py rename to source/app/business/alert_clusters.py index 67fc777db..740d4cca0 100644 --- a/source/app/business/incidents.py +++ b/source/app/business/alert_clusters.py @@ -34,8 +34,8 @@ 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.models.alert_clusters import AlertCluster +from app.models.alert_clusters import AlertClusterStatus from app.util import add_obj_history_entry @@ -45,10 +45,10 @@ # 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', + 'cluster_status_id': 'status', + 'cluster_owner_id': 'owner', + 'cluster_severity_id': 'severity', + 'cluster_title': 'title', } @@ -57,28 +57,28 @@ # 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' +CLUSTER_STATUS_OPEN = 'Open' +CLUSTER_STATUS_INVESTIGATING = 'Investigating' +CLUSTER_STATUS_DISMISSED = 'Dismissed' +CLUSTER_STATUS_ESCALATED = 'Escalated' -_STATUS_OPEN = INCIDENT_STATUS_OPEN -_STATUS_INVESTIGATING = INCIDENT_STATUS_INVESTIGATING -_STATUS_DISMISSED = INCIDENT_STATUS_DISMISSED -_STATUS_ESCALATED = INCIDENT_STATUS_ESCALATED +_STATUS_OPEN = CLUSTER_STATUS_OPEN +_STATUS_INVESTIGATING = CLUSTER_STATUS_INVESTIGATING +_STATUS_DISMISSED = CLUSTER_STATUS_DISMISSED +_STATUS_ESCALATED = CLUSTER_STATUS_ESCALATED def resolve_status_id(status_name: str) -> int: - """Look up an `IncidentStatus.status_id` by its human-readable name. + """Look up an `AlertClusterStatus.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 + Public so other business modules (e.g. `cluster_rules._apply_create_cluster`) + can resolve the "Open" status when opening a new cluster, 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() + status = AlertClusterStatus.query.filter_by(status_name=status_name).first() if not status: - raise BusinessProcessingError(f'IncidentStatus "{status_name}" is not seeded') + raise BusinessProcessingError(f'AlertClusterStatus "{status_name}" is not seeded') return status.status_id @@ -87,20 +87,20 @@ def resolve_status_id(status_name: str) -> int: _status_id = resolve_status_id -# Incident -> alert status mapping applied whenever the incident's status +# Cluster -> alert status mapping applied whenever the cluster's status # is updated. Chosen so that the alert timeline mirrors what the analyst -# is doing at the incident level: +# is doing at the cluster 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 +# The `Escalated` cluster 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', +_CLUSTER_TO_ALERT_STATUS = { + CLUSTER_STATUS_OPEN: 'Assigned', + CLUSTER_STATUS_INVESTIGATING: 'In progress', + CLUSTER_STATUS_DISMISSED: 'Closed', } @@ -116,219 +116,219 @@ def _resolve_alert_status_id(status_name: str) -> Optional[int]: 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 +def _propagate_status_to_alerts(cluster: AlertCluster, new_status_name: str) -> None: + """Push the cluster's new status down to every member alert according to + `_CLUSTER_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) + target_alert_status = _CLUSTER_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: + for alert in cluster.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, + cluster, 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) +def alert_clusters_create(cluster: AlertCluster) -> AlertCluster: + if cluster.cluster_status_id is None: + cluster.cluster_status_id = _status_id(_STATUS_OPEN) + cluster.cluster_creation_time = datetime.utcnow() + db.session.add(cluster) 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 + add_obj_history_entry(cluster, 'created', commit=True) + track_activity(f'created alert cluster #{cluster.cluster_id} - {cluster.cluster_title}', ctx_less=True) + _enqueue_flow_evaluation(cluster.cluster_id) + cluster = call_modules_hook('on_postload_alert_cluster_create', cluster) + return cluster -def _enqueue_flow_evaluation(incident_id: int) -> None: +def _enqueue_flow_evaluation(cluster_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.""" + cluster. Import is deferred and errors are swallowed — a broken + worker must not block cluster writes on the request path.""" try: - from app.iris_engine.incident_rules.tasks import evaluate_incident_flows - evaluate_incident_flows.delay(incident_id) + from app.iris_engine.cluster_rules.tasks import evaluate_alert_cluster_flows + evaluate_alert_cluster_flows.delay(cluster_id) except Exception: # noqa: BLE001 from app.logger import logger - logger.exception('Failed to enqueue flow evaluation for incident #%s', incident_id) + logger.exception('Failed to enqueue flow evaluation for alert cluster #%s', cluster_id) -def incidents_get(user, permissions, identifier, fallback_customer_access=None) -> Incident: - incident = Incident.query.filter_by(incident_id=identifier).first() - if not incident: +def alert_clusters_get(user, permissions, identifier, fallback_customer_access=None) -> AlertCluster: + cluster = AlertCluster.query.filter_by(cluster_id=identifier).first() + if not cluster: raise ObjectNotFoundError() if not access_controls_user_has_customer_access( - user, permissions, incident.incident_customer_id, + user, permissions, cluster.cluster_customer_id, fallback_customer_access=fallback_customer_access ): raise ObjectNotFoundError() - return incident + return cluster -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. +def alert_clusters_get_by_case(case_id: int) -> Optional[AlertCluster]: + """Return the cluster this case was created from (via escalate/merge), + or None if the case wasn't sourced from a cluster. Powers the "back + to source cluster" chip in the case topbar; callers must gate access + on the case, not the cluster — anyone who can read the case can see + which cluster it came from. """ - return Incident.query.filter_by(incident_case_id=case_id).first() + return AlertCluster.query.filter_by(cluster_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 +def alert_clusters_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 cluster 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 + query = AlertCluster.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)) + query = query.filter(AlertCluster.cluster_customer_id.in_(accessible)) if customer_id is not None: - query = query.filter(Incident.incident_customer_id == customer_id) + query = query.filter(AlertCluster.cluster_customer_id == customer_id) if status_id is not None: - query = query.filter(Incident.incident_status_id == status_id) + query = query.filter(AlertCluster.cluster_status_id == status_id) if title: - query = query.filter(Incident.incident_title.ilike(f'%{title}%')) + query = query.filter(AlertCluster.cluster_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()) + query = query.order_by(AlertCluster.cluster_creation_time.asc()) else: - query = query.order_by(Incident.incident_creation_time.desc()) + query = query.order_by(AlertCluster.cluster_creation_time.desc()) return query.paginate(page=page, per_page=per_page, error_out=False) -def incidents_update(incident: Incident, changes: dict) -> Incident: +def alert_clusters_update(cluster: AlertCluster, changes: dict) -> AlertCluster: # 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) + key: getattr(cluster, 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'] + 'cluster_status_id' in changes + and audit_before.get('cluster_status_id') != changes['cluster_status_id'] ) for key, value in changes.items(): - setattr(incident, key, value) + setattr(cluster, 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) + new_value = getattr(cluster, key, None) if old_value == new_value: continue add_obj_history_entry( - incident, + cluster, 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 + # Cascade the cluster's new status onto its member alerts (see + # `_CLUSTER_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) + if status_changed and cluster.status is not None: + _propagate_status_to_alerts(cluster, cluster.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 + track_activity(f'updated alert cluster #{cluster.cluster_id}', ctx_less=True) + _enqueue_flow_evaluation(cluster.cluster_id) + cluster = call_modules_hook('on_postload_alert_cluster_update', cluster) + return cluster -def incidents_delete(incident: Incident) -> None: - identifier = incident.incident_id - db.session.delete(incident) +def alert_clusters_delete(cluster: AlertCluster) -> None: + identifier = cluster.cluster_id + db.session.delete(cluster) db.session.commit() - track_activity(f'deleted incident #{identifier}', ctx_less=True) - call_modules_hook('on_postload_incident_delete', identifier) + track_activity(f'deleted alert cluster #{identifier}', ctx_less=True) + call_modules_hook('on_postload_alert_cluster_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`.""" +def alert_cluster_add_alerts(cluster: AlertCluster, alert_ids: Iterable[int]) -> AlertCluster: + """Attach alerts to a cluster. Silently skips alerts already attached and + alerts belonging to a different customer than the cluster — mixing tenants + inside a container would leak data through `cluster.alerts`.""" ids = list(alert_ids) if not ids: - return incident + return cluster alerts = Alert.query.filter( Alert.alert_id.in_(ids), - Alert.alert_customer_id == incident.incident_customer_id + Alert.alert_customer_id == cluster.cluster_customer_id ).all() - existing = {a.alert_id for a in incident.alerts} + existing = {a.alert_id for a in cluster.alerts} added_ids = [] for alert in alerts: if alert.alert_id not in existing: - incident.alerts.append(alert) + cluster.alerts.append(alert) added_ids.append(alert.alert_id) if added_ids: - add_obj_history_entry(incident, f'linked alerts: {added_ids}') + add_obj_history_entry(cluster, f'linked alerts: {added_ids}') db.session.commit() if added_ids: - call_modules_hook('on_postload_incident_alert_add', - {'incident_id': incident.incident_id, + call_modules_hook('on_postload_alert_cluster_alert_add', + {'cluster_id': cluster.cluster_id, 'alert_ids': added_ids}) - return incident + return cluster -def incident_remove_alert(incident: Incident, alert_id: int) -> Incident: - was_linked = any(a.alert_id == alert_id for a in incident.alerts) +def alert_cluster_remove_alert(cluster: AlertCluster, alert_id: int) -> AlertCluster: + was_linked = any(a.alert_id == alert_id for a in cluster.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}') + cluster.alerts = [a for a in cluster.alerts if a.alert_id != alert_id] + add_obj_history_entry(cluster, f'unlinked alert #{alert_id}') db.session.commit() if was_linked: - call_modules_hook('on_postload_incident_alert_remove', - {'incident_id': incident.incident_id, + call_modules_hook('on_postload_alert_cluster_alert_remove', + {'cluster_id': cluster.cluster_id, 'alert_id': alert_id}) - return incident + return cluster -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 +def alert_cluster_escalate_to_case(cluster: AlertCluster, template_id: Optional[int] = None, + case_title: Optional[str] = None, note: Optional[str] = None, + import_as_event: bool = False, case_tags: str = ''): + """Escalate the cluster 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') + a linked cluster raises, callers should check `cluster_case_id` first.""" + if cluster.cluster_case_id is not None: + raise BusinessProcessingError('Alert cluster already escalated to a case') + if not cluster.alerts: + raise BusinessProcessingError('Cannot escalate a cluster 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 '', + alerts=list(cluster.alerts), + iocs_list=[str(i.ioc_uuid) for a in cluster.alerts for i in (a.iocs or [])], + assets_list=[str(a2.asset_uuid) for a in cluster.alerts for a2 in (a.assets or [])], + case_title=case_title or cluster.cluster_title, + note=note or cluster.cluster_description or '', import_as_event=import_as_event, case_tags=case_tags, template_id=template_id, @@ -343,88 +343,88 @@ def incident_escalate_to_case(incident: Incident, template_id: Optional[int] = N # 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. + # trackers, external syncs) shouldn't have to special-case clusters. 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) + cluster.cluster_case_id = case.case_id + cluster.cluster_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 + # the cluster-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: + for alert in cluster.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}') + add_obj_history_entry(cluster, f'escalated to case #{case.case_id}') db.session.commit() track_activity( - f'escalated incident #{incident.incident_id} to case #{case.case_id}', + f'escalated alert cluster #{cluster.cluster_id} to case #{case.case_id}', ctx_less=True, ) - call_modules_hook('on_postload_incident_escalate', - {'incident_id': incident.incident_id, + call_modules_hook('on_postload_alert_cluster_escalate', + {'cluster_id': cluster.cluster_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 +def alert_cluster_merge_to_case(cluster: AlertCluster, target_case_id: int, + note: Optional[str] = None, import_as_event: bool = False, + case_tags: str = ''): + """Merge a cluster'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 + fanned out across every alert on the cluster: each alert links to the + target case, its selected IOCs/assets get imported, and the cluster 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 + Same guardrails as escalate: re-merging a linked cluster raises, and + a cluster 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') + if cluster.cluster_case_id is not None: + raise BusinessProcessingError('Alert cluster already linked to a case') + if not cluster.alerts: + raise BusinessProcessingError('Cannot merge a cluster 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: + if case.client_id != cluster.cluster_customer_id: raise BusinessProcessingError( - 'Target case belongs to a different customer than the incident' + 'Target case belongs to a different customer than the cluster' ) # 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) + alerts_snapshot = list(cluster.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 '', + note=note or cluster.cluster_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) + cluster.cluster_case_id = case.case_id + cluster.cluster_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: + for alert in cluster.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}') + add_obj_history_entry(cluster, f'merged into case #{case.case_id}') db.session.commit() track_activity( - f'merged incident #{incident.incident_id} into case #{case.case_id}', + f'merged alert cluster #{cluster.cluster_id} into case #{case.case_id}', ctx_less=True, ) # Best-effort hook fire. `call_modules_hook` raises when the hook name @@ -434,18 +434,18 @@ def incident_merge_to_case(incident: Incident, target_case_id: int, # 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, + call_modules_hook('on_postload_alert_cluster_merge', + {'cluster_id': cluster.cluster_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') + logger.exception('on_postload_alert_cluster_merge hook failed; merge already committed') return case -def incident_correlation_graph(incident: Incident) -> dict: - """Build a correlation graph (nodes + edges) for the incident. +def alert_cluster_correlation_graph(cluster: AlertCluster) -> dict: + """Build a correlation graph (nodes + edges) for the cluster. Nodes are one of three kinds: * `alert` — one per member alert @@ -465,7 +465,7 @@ def incident_correlation_graph(incident: Incident) -> dict: seen_iocs: dict[tuple[str, Optional[int]], str] = {} seen_assets: dict[tuple[str, Optional[int]], str] = {} - for alert in incident.alerts: + for alert in cluster.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') @@ -541,16 +541,16 @@ def incident_correlation_graph(incident: Incident) -> dict: 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.""" +def alert_cluster_open_matching(customer_id: int, dedupe_key: str) -> Optional[AlertCluster]: + """Look up an open cluster with the given dedupe key for stacking. Used by + the rules engine when a rule action is create_cluster and its dedupe key + matches a cluster 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() + return AlertCluster.query.filter( + AlertCluster.cluster_customer_id == customer_id, + AlertCluster.cluster_dedupe_key == dedupe_key, + AlertCluster.cluster_status_id.in_([open_status_id, investigating_status_id]), + ).order_by(AlertCluster.cluster_creation_time.desc()).first() diff --git a/source/app/business/alerts.py b/source/app/business/alerts.py index 4c1360331..54768d92b 100644 --- a/source/app/business/alerts.py +++ b/source/app/business/alerts.py @@ -44,7 +44,7 @@ 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, - incident_identifier=None): + cluster_identifier=None): return get_filtered_alerts( start_date, @@ -71,7 +71,7 @@ def alerts_search(start_date, end_date, source_start_date, source_end_date, titl user_identifier_filter, source_reference, custom_conditions, - incident_id=incident_identifier, + cluster_id=cluster_identifier, ) @@ -106,10 +106,10 @@ 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 + succeeds even if the cluster-rules feature is disabled or the worker module fails to load.""" try: - from app.iris_engine.incident_rules.tasks import evaluate_alert_rules + from app.iris_engine.cluster_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 diff --git a/source/app/business/cases.py b/source/app/business/cases.py index b84e9b570..d844d45bc 100644 --- a/source/app/business/cases.py +++ b/source/app/business/cases.py @@ -60,7 +60,7 @@ from app.models.cases import Cases from app.models.cases import ReviewStatusList from app.models.customers import Client -from app.models.incidents import Incident +from app.models.alert_clusters import AlertCluster def cases_filter(current_user, pagination_parameters, name=None, case_identifiers=None, customer_identifier=None, @@ -123,7 +123,7 @@ def cases_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 +# status the cluster-status propagator moves alerts to on cluster # `Open`, so linked and unlinked alerts converge on the same state. _RESET_ALERT_STATUS_NAME = 'Assigned' @@ -173,17 +173,17 @@ def case_unlink_alert(case: Cases, alert_id: int) -> Alert: return alert -def case_unlink_incident(case: Cases) -> Incident | None: - """Reverse an incident->case escalation/merge in one shot. +def case_unlink_alert_cluster(case: Cases) -> AlertCluster | None: + """Reverse a cluster->case escalation/merge in one shot. - Clears the incident's `incident_case_id`, flips the incident status + Clears the cluster's `cluster_case_id`, flips the cluster 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). + cluster so the caller can render a "unlinked from #N" toast; None + when the case wasn't sourced from a cluster (idempotent no-op). """ - incident = Incident.query.filter_by(incident_case_id=case.case_id).first() - if incident is None: + cluster = AlertCluster.query.filter_by(cluster_case_id=case.case_id).first() + if cluster is None: return None # Detach each member alert from the case using the association @@ -191,29 +191,29 @@ def case_unlink_incident(case: Cases) -> Incident | None: # `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): + for alert in list(cluster.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 + from app.business.alert_clusters import resolve_status_id, CLUSTER_STATUS_INVESTIGATING - incident.incident_case_id = None - incident.incident_status_id = resolve_status_id(INCIDENT_STATUS_INVESTIGATING) + cluster.cluster_case_id = None + cluster.cluster_status_id = resolve_status_id(CLUSTER_STATUS_INVESTIGATING) add_obj_history_entry( - incident, f'unlinked from case #{case.case_id} (moved back to Investigating)' + cluster, f'unlinked from case #{case.case_id} (moved back to Investigating)' ) add_obj_history_entry( - case, f'incident #{incident.incident_id} unlinked' + case, f'alert cluster #{cluster.cluster_id} unlinked' ) db.session.commit() track_activity( - f'unlinked incident #{incident.incident_id} from case #{case.case_id}', + f'unlinked alert cluster #{cluster.cluster_id} from case #{case.case_id}', caseid=case.case_id, ctx_less=False, ) - return incident + return cluster def cases_create(user, case: Cases, case_template_id) -> Cases: diff --git a/source/app/business/incident_rules.py b/source/app/business/cluster_rules.py similarity index 77% rename from source/app/business/incident_rules.py rename to source/app/business/cluster_rules.py index 72edecb84..9c7021692 100644 --- a/source/app/business/incident_rules.py +++ b/source/app/business/cluster_rules.py @@ -24,7 +24,7 @@ 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.business.alert_clusters import alert_cluster_open_matching from app.datamgmt.filtering import apply_custom_conditions from app.datamgmt.filtering import combine_conditions from app.db import db @@ -33,9 +33,9 @@ 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 +from app.models.cluster_rules import ClusterRule +from app.models.cluster_rules import RULE_ACTION_CREATE_CLUSTER +from app.models.alert_clusters import AlertCluster # --------------------------------------------------------------------------- @@ -49,19 +49,19 @@ # MUST NOT be called from route handlers. # --------------------------------------------------------------------------- -def _rules_get_unchecked(identifier: int) -> IncidentRule: - rule = IncidentRule.query.filter_by(rule_id=identifier).first() +def _rules_get_unchecked(identifier: int) -> ClusterRule: + rule = ClusterRule.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: +def rules_create(user, permissions, rule: ClusterRule, + fallback_customer_access=None) -> ClusterRule: """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` + server_administrator) is rejected — otherwise `cluster_rules_write` would double as customer-elevation.""" if not access_controls_user_has_customer_scope( user, permissions, rule.rule_customer_scope, @@ -73,12 +73,12 @@ def rules_create(user, permissions, rule: IncidentRule, ) db.session.add(rule) db.session.commit() - track_activity(f'created incident rule "{rule.rule_name}"') + track_activity(f'created cluster rule "{rule.rule_name}"') return rule def rules_get(user, permissions, identifier: int, - fallback_customer_access=None) -> IncidentRule: + fallback_customer_access=None) -> ClusterRule: """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 @@ -92,7 +92,7 @@ def rules_get(user, permissions, identifier: int, return rule -def rules_list(user, permissions) -> List[IncidentRule]: +def rules_list(user, permissions) -> List[ClusterRule]: """List rules visible to the caller. * `server_administrator` sees every rule (null-scope + all tenants). @@ -104,8 +104,8 @@ def rules_list(user, permissions) -> List[IncidentRule]: 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() + query = ClusterRule.query.order_by( + ClusterRule.rule_priority.asc(), ClusterRule.rule_id.asc() ) if accessible is None: # server_administrator return query.all() @@ -118,8 +118,8 @@ def rules_list(user, permissions) -> List[IncidentRule]: ] -def rules_update(user, permissions, rule: IncidentRule, changes: dict, - fallback_customer_access=None) -> IncidentRule: +def rules_update(user, permissions, rule: ClusterRule, changes: dict, + fallback_customer_access=None) -> ClusterRule: """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 @@ -143,25 +143,25 @@ def rules_update(user, permissions, rule: IncidentRule, changes: dict, setattr(rule, key, value) rule.rule_updated_at = datetime.utcnow() db.session.commit() - track_activity(f'updated incident rule "{rule.rule_name}"') + track_activity(f'updated cluster rule "{rule.rule_name}"') return rule -def rules_delete(rule: IncidentRule) -> None: +def rules_delete(rule: ClusterRule) -> 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}"') + track_activity(f'deleted cluster rule "{name}"') # --------------------------------------------------------------------------- # Evaluation # --------------------------------------------------------------------------- -def _rule_matches_alert(rule: IncidentRule, alert_id: int) -> bool: +def _rule_matches_alert(rule: ClusterRule, 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 @@ -183,11 +183,11 @@ def _rule_matches_alert(rule: IncidentRule, alert_id: int) -> bool: return db.session.query(query.exists()).scalar() -def _dedupe_key(alert: Alert, rule: IncidentRule) -> str: +def _dedupe_key(alert: Alert, rule: ClusterRule) -> 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 + group values stack into one cluster; the bucket rolls forward with + `time_window_seconds` so the cluster stops accepting new alerts once the window closes.""" conditions_payload = rule.rule_conditions or {} group_by = conditions_payload.get('group_by') or [] @@ -204,10 +204,10 @@ def _dedupe_key(alert: Alert, rule: IncidentRule) -> str: return hashlib.sha256(raw.encode('utf-8')).hexdigest() -def _apply_create_incident(rule: IncidentRule, alert: Alert) -> Optional[Incident]: +def _apply_create_cluster(rule: ClusterRule, alert: Alert) -> Optional[AlertCluster]: config = rule.rule_action_config or {} dedupe_key = _dedupe_key(alert, rule) - existing = incident_open_matching(alert.alert_customer_id, dedupe_key) + existing = alert_cluster_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) @@ -216,25 +216,25 @@ def _apply_create_incident(rule: IncidentRule, alert: Alert) -> Optional[Inciden 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(), + cluster = AlertCluster( + cluster_title=title[:2048], + cluster_description=config.get('description'), + cluster_customer_id=alert.alert_customer_id, + cluster_severity_id=alert.alert_severity_id, + cluster_source_rule_id=rule.rule_id, + cluster_dedupe_key=dedupe_key, + cluster_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) + from app.business.alert_clusters import CLUSTER_STATUS_OPEN, resolve_status_id + cluster.cluster_status_id = resolve_status_id(CLUSTER_STATUS_OPEN) + db.session.add(cluster) db.session.flush() - incident.alerts.append(alert) + cluster.alerts.append(alert) db.session.commit() - return incident + return cluster def evaluate_rules_for_alert(alert_id: int) -> dict: @@ -246,10 +246,10 @@ def evaluate_rules_for_alert(alert_id: int) -> dict: 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) + candidates = ClusterRule.query.filter( + ClusterRule.rule_is_active.is_(True) ).order_by( - IncidentRule.rule_priority.asc(), IncidentRule.rule_id.asc() + ClusterRule.rule_priority.asc(), ClusterRule.rule_id.asc() ).all() applied = [] @@ -260,12 +260,12 @@ def evaluate_rules_for_alert(alert_id: int) -> dict: 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) + if rule.rule_action_type == RULE_ACTION_CREATE_CLUSTER: + cluster = _apply_create_cluster(rule, alert) applied.append({ 'rule_id': rule.rule_id, - 'action': RULE_ACTION_CREATE_INCIDENT, - 'incident_id': incident.incident_id if incident else None, + 'action': RULE_ACTION_CREATE_CLUSTER, + 'cluster_id': cluster.cluster_id if cluster else None, }) # Flow attachment lives in the flow evaluator — see # `app.business.investigation_flows.evaluate_flows_for_alert`. @@ -276,7 +276,7 @@ def evaluate_rules_for_alert(alert_id: int) -> dict: return {'alert_id': alert_id, 'matched_rules': applied} -def rule_dry_run(rule: IncidentRule, sample_days: int = 7, limit: int = 50) -> List[int]: +def rule_dry_run(rule: ClusterRule, 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.""" @@ -301,20 +301,20 @@ def rule_dry_run(rule: IncidentRule, sample_days: int = 7, limit: int = 50) -> L 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 — +def backfill_rule(rule: ClusterRule, sample_days: int = 30) -> dict: + """Apply this rule's `create_cluster` 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 + attached to the cluster this rule would create. The dedupe key / + time-window logic in `_apply_create_cluster` makes this naturally idempotent — re-running the same back-fill won't produce duplicate - incidents. + clusters. - Skips alerts that are already members of ANY incident to avoid + Skips alerts that are already members of ANY cluster 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} + if rule.rule_action_type != RULE_ACTION_CREATE_CLUSTER: + return {'attached': 0, 'skipped_already_in_cluster': 0, 'errors': 0} since = datetime.utcnow() - timedelta(days=max(1, sample_days)) conditions_payload = rule.rule_conditions or {} @@ -329,7 +329,7 @@ def backfill_rule(rule: IncidentRule, sample_days: int = 30) -> dict: 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} + return {'attached': 0, 'skipped_already_in_cluster': 0, 'errors': 1} combined = combine_conditions(extra, logic) if combined is not None: query = query.filter(combined) @@ -337,17 +337,17 @@ def backfill_rule(rule: IncidentRule, sample_days: int = 30) -> dict: attached = 0 skipped = 0 errors = 0 - # Materialise once — the create-incident action commits, which + # Materialise once — the create-cluster 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: + # grouped into a cluster (manually or by an earlier rule). + if alert.clusters: skipped += 1 continue try: - _apply_create_incident(rule, alert) + _apply_create_cluster(rule, alert) attached += 1 except Exception as exc: db.session.rollback() @@ -357,7 +357,7 @@ def backfill_rule(rule: IncidentRule, sample_days: int = 30) -> dict: errors += 1 return { 'attached': attached, - 'skipped_already_in_incident': skipped, + 'skipped_already_in_cluster': skipped, 'errors': errors, 'considered': len(matches), } diff --git a/source/app/business/collab.py b/source/app/business/collab.py index d5cca6cd1..e9966b088 100644 --- a/source/app/business/collab.py +++ b/source/app/business/collab.py @@ -195,6 +195,17 @@ def resolve_doc(doc_name, user_id): # Snapshot storage — server-authoritative Y.Doc bytes. # --------------------------------------------------------------------------- +# Bump this whenever `iris_engine.collab.render` grows a new block +# type (tables, task lists, footnotes…). `ensure_snapshot` re-seeds +# rows whose stored `seeder_version` is below the current value from +# the up-to-date source column, so users don't stay stuck on the +# previous parser's lossy output. Historical bumps: +# 1 — GFM pipe tables (Jul 2026). The CommonMark parser silently +# dropped tables, so any legacy note with a table lost its +# tabular structure after the first open. +_CURRENT_SEEDER_VERSION = 1 + + 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. @@ -204,19 +215,31 @@ def ensure_snapshot(doc_name, current_content): * 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. + once per document, atomically — UNLESS the row's `seeder_version` + is below `_CURRENT_SEEDER_VERSION`, in which case we re-seed + from `current_content` so the doc picks up whatever the newer + parser can now express. 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: + stored_version = getattr(row, 'seeder_version', None) if row is not None else None + is_current_version = stored_version == _CURRENT_SEEDER_VERSION + if row is not None and row.y_state and is_current_version: 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). + # Re-seed. Reaches this branch on: + # * first open of a doc (`row is None`), + # * DB tampering that emptied `y_state` (`not row.y_state`), + # * or a seeder-version bump landing on an existing row + # (`stored_version < _CURRENT_SEEDER_VERSION`). + # In the last case we DISCARD the stored y_state and re-parse + # the source column, because the old y_state was produced by a + # parser that couldn't represent the new block types — keeping + # it would leave users unable to see content they know is in + # `note.note_content` / `case.description` / etc. seed_md = current_content or '' seed_bytes = markdown_to_ydoc_update(seed_md) @@ -225,12 +248,14 @@ def ensure_snapshot(doc_name, current_content): doc_name=doc_name, y_state=seed_bytes, content_md=seed_md, + seeder_version=_CURRENT_SEEDER_VERSION, last_flushed_at=datetime.datetime.utcnow(), ) db.session.add(row) else: row.y_state = seed_bytes row.content_md = seed_md + row.seeder_version = _CURRENT_SEEDER_VERSION row.last_flushed_at = datetime.datetime.utcnow() db.session.commit() return seed_bytes diff --git a/source/app/business/comments.py b/source/app/business/comments.py index 614d4e65c..681b8670a 100644 --- a/source/app/business/comments.py +++ b/source/app/business/comments.py @@ -27,8 +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_alert_cluster_comments +from app.datamgmt.comments import get_alert_cluster_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 @@ -76,18 +76,18 @@ 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_get_filtered_by_alert_cluster(current_user, permissions, cluster_identifier: int, pagination_parameters: PaginationParameters, fallback_customer_access=None) -> Pagination: + # Deferred import — pulling alert_clusters_get at module top would import + # app.business.alert_clusters before comments' own callers finish resolving. + from app.business.alert_clusters import alert_clusters_get + alert_clusters_get(current_user, permissions, cluster_identifier, fallback_customer_access=fallback_customer_access) + return get_filtered_alert_cluster_comments(cluster_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 +def comments_create_for_alert_cluster(current_user, permissions, comment: Comments, cluster_identifier: int, fallback_customer_access=None): + from app.business.alert_clusters import alert_clusters_get + cluster = alert_clusters_get(current_user, permissions, cluster_identifier, fallback_customer_access=fallback_customer_access) + comment.comment_cluster_id = cluster_identifier comment.comment_user_id = current_user.id comment.comment_date = datetime.now() comment.comment_update_date = datetime.now() @@ -95,19 +95,19 @@ def comments_create_for_incident(current_user, permissions, comment: Comments, i db.session.add(comment) db.session.commit() - track_activity(f'incident "#{incident.incident_id}" commented', ctx_less=True) + track_activity(f'alert cluster "#{cluster.cluster_id}" commented', ctx_less=True) -def comments_get_for_incident(incident_identifier: int, identifier) -> Comments: - comment = get_incident_comment(incident_identifier, identifier) +def comments_get_for_alert_cluster(cluster_identifier: int, identifier) -> Comments: + comment = get_alert_cluster_comment(cluster_identifier, identifier) if comment is None: raise ObjectNotFoundError() return comment -def comments_delete_for_incident(comment: Comments): +def comments_delete_for_alert_cluster(comment: Comments): delete_comment(comment) - track_activity(f'comment {comment.comment_id} on incident {comment.comment_incident_id} deleted', ctx_less=True) + track_activity(f'comment {comment.comment_id} on alert cluster {comment.comment_cluster_id} deleted', ctx_less=True) def comments_get_filtered_by_asset(asset: CaseAssets, pagination_parameters: PaginationParameters) -> Pagination: diff --git a/source/app/business/investigation_flows.py b/source/app/business/investigation_flows.py index aa1dd848b..c6d000583 100644 --- a/source/app/business/investigation_flows.py +++ b/source/app/business/investigation_flows.py @@ -31,12 +31,12 @@ 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.alert_clusters import AlertCluster +from app.models.investigation_flows import AlertClusterInvestigationProgress 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 FLOW_TARGET_CLUSTER from app.models.investigation_flows import InvestigationFlow from app.models.investigation_flows import InvestigationFlowStep @@ -212,7 +212,7 @@ def alert_progress_record(alert: Alert, step_id: int, user_id: int, """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 + # Caller-scope was already enforced when the Alert/AlertCluster 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. @@ -250,34 +250,34 @@ def alert_progress_uncheck(alert: Alert, step_id: int) -> None: # --------------------------------------------------------------------------- -# Incident progress (mirror of the alert progress helpers) +# Alert-cluster progress (mirror of the alert progress helpers) # --------------------------------------------------------------------------- -def incident_progress_list(incident: Incident) -> List[IncidentInvestigationProgress]: - if not incident.incident_investigation_flow_id: +def alert_cluster_progress_list(cluster: AlertCluster) -> List[AlertClusterInvestigationProgress]: + if not cluster.cluster_investigation_flow_id: return [] - return IncidentInvestigationProgress.query.filter_by( - incident_id=incident.incident_id + return AlertClusterInvestigationProgress.query.filter_by( + cluster_id=cluster.cluster_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 +def alert_cluster_progress_record(cluster: AlertCluster, step_id: int, user_id: int, + note: Optional[str] = None) -> AlertClusterInvestigationProgress: + if not cluster.cluster_investigation_flow_id: + raise BusinessProcessingError('Alert cluster has no investigation flow attached') + # Caller-scope was already enforced when the Alert/AlertCluster 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 + if step.flow_id != cluster.cluster_investigation_flow_id: + raise BusinessProcessingError("Step does not belong to the cluster's flow") + row = AlertClusterInvestigationProgress.query.filter_by( + cluster_id=cluster.cluster_id, step_id=step_id ).first() if row is None: - row = IncidentInvestigationProgress( - incident_id=incident.incident_id, + row = AlertClusterInvestigationProgress( + cluster_id=cluster.cluster_id, step_id=step_id, completed_by_user_id=user_id, note=note, @@ -292,9 +292,9 @@ def incident_progress_record(incident: Incident, step_id: int, user_id: int, 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 +def alert_cluster_progress_uncheck(cluster: AlertCluster, step_id: int) -> None: + row = AlertClusterInvestigationProgress.query.filter_by( + cluster_id=cluster.cluster_id, step_id=step_id ).first() if row is None: return @@ -304,11 +304,11 @@ def incident_progress_uncheck(incident: Incident, step_id: int) -> None: # --------------------------------------------------------------------------- # Flow evaluator — decides which flow (if any) to attach to a given -# alert or incident, based on the flow's own `flow_conditions`. +# alert or alert cluster, 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 + """Return True when the given entity (Alert or AlertCluster) 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. @@ -321,7 +321,7 @@ def _flow_matches(flow: InvestigationFlow, model, entity_id: int) -> bool: return False logic = conditions_payload.get('logic', 'and') - pk_col = model.alert_id if model is Alert else model.incident_id + pk_col = model.alert_id if model is Alert else model.cluster_id query = model.query.filter(pk_col == entity_id) try: query, extra = apply_custom_conditions(query, model, condition_list) @@ -336,7 +336,7 @@ def _flow_matches(flow: InvestigationFlow, model, entity_id: int) -> bool: 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 + the given target ('alert' or 'alert_cluster'). Ordered by priority so the first match wins.""" accepted = (target, FLOW_TARGET_BOTH) rows = InvestigationFlow.query.filter( @@ -366,13 +366,13 @@ def evaluate_flows_for_alert(alert_id: int) -> Optional[int]: 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 +def evaluate_flows_for_alert_cluster(cluster_id: int) -> Optional[int]: + cluster = AlertCluster.query.filter_by(cluster_id=cluster_id).first() + if cluster is None or cluster.cluster_investigation_flow_id is not None: + return cluster.cluster_investigation_flow_id if cluster else None + for flow in _candidate_flows_for(FLOW_TARGET_CLUSTER, cluster.cluster_customer_id): + if _flow_matches(flow, AlertCluster, cluster_id): + cluster.cluster_investigation_flow_id = flow.flow_id db.session.commit() return flow.flow_id return None @@ -416,29 +416,29 @@ def _deploy_flow_to_model(flow: InvestigationFlow, model, fk_col, 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)`. + """Back-fill this flow onto historical alerts and/or alert clusters whose + FK is empty. Returns `(alerts_attached, clusters_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 + clusters_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, + if flow.flow_target in (FLOW_TARGET_CLUSTER, FLOW_TARGET_BOTH): + clusters_attached = _deploy_flow_to_model( + flow, AlertCluster, + AlertCluster.cluster_investigation_flow_id, + AlertCluster.cluster_customer_id, AlertCluster.cluster_id, ) track_activity( f'deployed flow #{flow.flow_id} — alerts={alerts_attached}, ' - f'incidents={incidents_attached}', + f'clusters={clusters_attached}', ctx_less=True, ) - return alerts_attached, incidents_attached + return alerts_attached, clusters_attached diff --git a/source/app/datamgmt/alerts/alerts_db.py b/source/app/datamgmt/alerts/alerts_db.py index 918e01383..31d799fcf 100644 --- a/source/app/datamgmt/alerts/alerts_db.py +++ b/source/app/datamgmt/alerts/alerts_db.py @@ -65,7 +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.alert_clusters import AlertClusterAssociation from app.models.alerts import SimilarAlertsCache from app.models.alerts import AlertResolutionStatus from app.models.alerts import AlertSimilarity @@ -120,12 +120,12 @@ def get_filtered_alerts( user_identifier: int | None, source_reference, custom_conditions: List[dict], - # `incident_id`: - # * positive int → alerts attached to that incident - # * -1 → alerts NOT attached to any incident + # `cluster_id`: + # * positive int → alerts attached to that alert cluster + # * -1 → alerts NOT attached to any cluster # (analyst filter "orphans only") # * None → no filter - incident_id: int | None = None, + cluster_id: int | None = None, ) -> Pagination: conditions = [] @@ -187,15 +187,15 @@ 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 + if cluster_id is not None: + if cluster_id == -1: + # Orphans: alerts with zero cluster memberships. Analyst # uses this to spot anything not yet triaged into an - # incident container. - conditions.append(~Alert.incidents.any()) + # alert cluster. + conditions.append(~Alert.clusters.any()) else: conditions.append( - Alert.incidents.any(AlertIncidentAssociation.incident_id == incident_id) + Alert.clusters.any(AlertClusterAssociation.cluster_id == cluster_id) ) if assets is not None: diff --git a/source/app/datamgmt/comments.py b/source/app/datamgmt/comments.py index 6c5d0dc75..b39f3491a 100644 --- a/source/app/datamgmt/comments.py +++ b/source/app/datamgmt/comments.py @@ -46,17 +46,17 @@ 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: +def get_filtered_alert_cluster_comments(cluster_identifier: int, pagination_parameters: PaginationParameters) -> Pagination: query = Comments.query.filter( - Comments.comment_incident_id == incident_identifier + Comments.comment_cluster_id == cluster_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]: +def get_alert_cluster_comment(cluster_identifier: int, comment_identifier: int) -> Optional[Comments]: return Comments.query.filter( Comments.comment_id == comment_identifier, - Comments.comment_incident_id == incident_identifier, + Comments.comment_cluster_id == cluster_identifier, ).first() diff --git a/source/app/iris_engine/cluster_rules/__init__.py b/source/app/iris_engine/cluster_rules/__init__.py new file mode 100644 index 000000000..cae1ee882 --- /dev/null +++ b/source/app/iris_engine/cluster_rules/__init__.py @@ -0,0 +1,6 @@ +"""Cluster-rules subsystem — async evaluation of alert stacking + flow attach.""" + +from app.iris_engine.cluster_rules.tasks import evaluate_alert_rules +from app.iris_engine.cluster_rules.tasks import evaluate_alert_cluster_flows + +__all__ = ['evaluate_alert_rules', 'evaluate_alert_cluster_flows'] diff --git a/source/app/iris_engine/incident_rules/tasks.py b/source/app/iris_engine/cluster_rules/tasks.py similarity index 60% rename from source/app/iris_engine/incident_rules/tasks.py rename to source/app/iris_engine/cluster_rules/tasks.py index 82b7f78bc..8726cea09 100644 --- a/source/app/iris_engine/incident_rules/tasks.py +++ b/source/app/iris_engine/cluster_rules/tasks.py @@ -16,16 +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. -"""Async evaluation of incident-stacking rules AND investigation-flow -attachment for newly created / updated alerts and incidents. +"""Async evaluation of cluster-stacking rules AND investigation-flow +attachment for newly created / updated alerts and alert clusters. Two orchestration steps per alert event: - 1. Evaluate incident rules — may stack this alert into an incident. + 1. Evaluate cluster rules — may stack this alert into a cluster. 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. + into a fresh cluster, we also fire flow evaluation for the + cluster so its own attachment gets computed right away rather + than waiting for the next cluster 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 @@ -44,7 +44,7 @@ @celery.task( bind=True, - name='iris.incident_rules.evaluate_alert', + name='iris.cluster_rules.evaluate_alert', autoretry_for=(OperationalError,), retry_backoff=True, retry_backoff_max=60, @@ -52,38 +52,38 @@ max_retries=3, ) def evaluate_alert_rules(self, alert_id: int) -> dict: - """Evaluate incident rules AND investigation flows for `alert_id`. + """Evaluate cluster 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 + Rules run first (may stack the alert into a cluster); flows run + second on the alert. If the rules created a fresh cluster, we also + kick off flow evaluation on that cluster so both attachments settle within a single task cycle.""" - from app.business.incident_rules import evaluate_rules_for_alert + from app.business.cluster_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.business.investigation_flows import evaluate_flows_for_alert_cluster 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 + # If any rule opened / attached to a cluster, evaluate flows + # on it too — a new cluster should get its own flow decided # right away, not on the next touch. - touched_incident_ids = { - entry.get('incident_id') + touched_cluster_ids = { + entry.get('cluster_id') for entry in rules_result.get('matched_rules', []) - if entry.get('action') == 'create_incident' and entry.get('incident_id') + if entry.get('action') == 'create_cluster' and entry.get('cluster_id') } - incident_flow_attachments = {} - for iid in touched_incident_ids: - attached = evaluate_flows_for_incident(iid) + cluster_flow_attachments = {} + for cid in touched_cluster_ids: + attached = evaluate_flows_for_alert_cluster(cid) if attached is not None: - incident_flow_attachments[iid] = attached + cluster_flow_attachments[cid] = attached result = { **rules_result, 'flow_attached_to_alert': flow_id, - 'flow_attached_to_incidents': incident_flow_attachments, + 'flow_attached_to_clusters': cluster_flow_attachments, } logger.debug('evaluate_alert_rules(%s) → %s', alert_id, result) return result @@ -98,27 +98,27 @@ def evaluate_alert_rules(self, alert_id: int) -> dict: @celery.task( bind=True, - name='iris.investigation_flows.evaluate_incident', + name='iris.investigation_flows.evaluate_alert_cluster', 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 +def evaluate_alert_cluster_flows(self, cluster_id: int) -> dict: + """Evaluate investigation flows for `cluster_id`. Fired when a + cluster 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.business.investigation_flows import evaluate_flows_for_alert_cluster from app.db import db try: - flow_id = evaluate_flows_for_incident(incident_id) - return {'incident_id': incident_id, 'flow_attached': flow_id} + flow_id = evaluate_flows_for_alert_cluster(cluster_id) + return {'cluster_id': cluster_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'} + logger.exception('evaluate_alert_cluster_flows(%s) failed', cluster_id) + return {'cluster_id': cluster_id, 'error': 'internal_error'} diff --git a/source/app/iris_engine/collab/render.py b/source/app/iris_engine/collab/render.py index 8674e5a72..ae8a9e22b 100644 --- a/source/app/iris_engine/collab/render.py +++ b/source/app/iris_engine/collab/render.py @@ -51,6 +51,7 @@ from __future__ import annotations import logging +import re from typing import Iterable from markdown_it import MarkdownIt @@ -83,8 +84,23 @@ def markdown_to_ydoc_update(md: str) -> bytes: frag = XmlFragment() doc[_PROSEMIRROR_FIELD] = frag - md_parser = MarkdownIt('commonmark', {'html': False, 'breaks': False}) - tokens = md_parser.parse(md or '') + # `commonmark` + explicit `enable('table')` gives us GFM pipe tables + # (thead/tbody/tr/th/td tokens) without pulling in the rest of the + # `gfm-like` preset — notably `linkify`, which requires the extra + # `linkify-it-py` dependency. Legacy notes and case summaries in + # production predate the CommonMark editor and often contain tables + # (findings, IOC lists, etc.); dropping them silently at the parser + # left users with pipes and dashes rendered as literal text. + md_parser = ( + MarkdownIt('commonmark', {'html': False, 'breaks': False}) + .enable('table') + ) + # Some legacy imports concatenated an entire GFM table onto a single + # physical line ("| A | B | |---|---| | 1 | 2 | | 3 | 4 |"). markdown-it + # sees that as one paragraph and never emits table tokens. Un-flatten + # those single-liners into proper multi-line tables before parsing — + # cheap heuristic, safe for well-formed input. + tokens = md_parser.parse(_unflatten_pipe_tables(md or '')) with doc.transaction(): _build_blocks_into(frag, tokens) @@ -92,6 +108,68 @@ def markdown_to_ydoc_update(md: str) -> bytes: return doc.get_update() +def _unflatten_pipe_tables(md: str) -> str: + """Repair single-line GFM pipe tables produced by legacy exporters. + + A well-formed table is `header | separator | row+`, each on its own + line. When an import concatenates them onto a single line — `| A | B + | |---|---| | 1 | 2 |` — markdown-it can't recognise it and drops + through to paragraph parsing. Users see pipes and dashes as literal + text. + + Detection: a line that contains + * a `| --- | --- | ... |` separator segment, AND + * multiple `| ... | ... | ... |` groups + is almost certainly a flattened table. We split on ` | | ` (the + boundary the concatenation left between adjacent rows) and re-insert + line breaks so the standard GFM table rule can pick it back up. + + Runs strictly per-line, so paragraphs and non-flat tables are + untouched. False positives are unlikely — inline pipes in normal + prose don't produce a `| --- | --- |` separator segment. + """ + if '|' not in md or '---' not in md: + return md + + separator_re = re.compile(r'\|(\s*:?-{2,}:?\s*\|)+') + # A row is `| cell | cell | ... |` with no internal newlines. + # We split on ` | | ` — the collapsed-out newline between rows — + # which reliably delimits rows in the pathological single-line + # shape without matching legitimate mid-row pipe pairs. + row_boundary_re = re.compile(r'\|\s*\|') + + out_lines: list[str] = [] + for line in md.split('\n'): + if separator_re.search(line) and row_boundary_re.search(line): + # Restore the newlines between adjacent `|...|` rows. + # `.strip()` on each piece removes the extra spaces the + # collapse left behind so the resulting rows look natural + # in the source column after a round-trip. + pieces = [p.strip() for p in row_boundary_re.split(line)] + # Each interior piece lost a leading `|` (the row-start) + # and a trailing `|` (the row-end) to the split. Re-add + # them so we emit valid GFM rows. + fixed: list[str] = [] + for idx, p in enumerate(pieces): + if not p: + continue + if idx > 0 and not p.startswith('|'): + p = '| ' + p + if idx < len(pieces) - 1 and not p.endswith('|'): + p = p + ' |' + fixed.append(p) + # Multi-line tables need a blank line separating them from + # the surrounding text — otherwise markdown-it fuses the + # first row with any prose immediately before it. + if out_lines and out_lines[-1].strip(): + out_lines.append('') + out_lines.extend(fixed) + out_lines.append('') + else: + out_lines.append(line) + return '\n'.join(out_lines) + + 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). @@ -183,11 +261,17 @@ def _build_blocks_into(container, tokens: list[Token]) -> None: i += 1 continue + if t == 'table_open': + close_i = _find_close(tokens, i, 'table_close') + el = XmlElement('table') + container.children.append(el) + _build_table_rows_into(el, tokens[i + 1: close_i]) + i = close_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. + # opener/closer noise (thead/tbody are handled inside + # `_build_table_rows_into`; anything else falls here). i += 1 @@ -208,6 +292,77 @@ def _build_list_items_into(list_el, tokens: list[Token]) -> None: i = close_i + 1 +def _build_table_rows_into(table_el, tokens: list[Token]) -> None: + """Turn the interior of a GFM table (`table_open ... table_close`) + into `tableRow` / `tableHeader` / `tableCell` XmlElements matching + the TipTap Table extension's schema. + + markdown-it wraps the row runs in `thead_open ... thead_close` and + `tbody_open ... tbody_close`; those wrappers are semantic-only in + our target schema (TipTap distinguishes header vs body cells at + the CELL level, not the section level), so we flatten them. + + Each cell in the TipTap schema requires at least one block child. + We wrap the row's inline content in a `paragraph` — same shape + y-prosemirror produces when a user types into a fresh table. + """ + i = 0 + n = len(tokens) + while i < n: + tok = tokens[i] + # Skip thead/tbody wrappers — flat row list matches TipTap. + if tok.type in ('thead_open', 'thead_close', 'tbody_open', 'tbody_close'): + i += 1 + continue + if tok.type != 'tr_open': + i += 1 + continue + row_close = _find_close(tokens, i, 'tr_close') + row_el = XmlElement('tableRow') + table_el.children.append(row_el) + _build_table_cells_into(row_el, tokens[i + 1: row_close]) + i = row_close + 1 + + +def _build_table_cells_into(row_el, tokens: list[Token]) -> None: + """Emit `tableHeader` or `tableCell` XmlElements for each th/td + inside a row. Each cell's inline content becomes a nested + `paragraph` — TipTap's Table cell requires at least one block + child, and paragraph is the neutral choice for text-only cells. + + We deliberately DO NOT copy colspan/rowspan/colwidth here. GFM + pipe tables don't express those; a legacy note being migrated + into the collab doc is always a plain rectangular grid. If the + user later merges cells in the TipTap editor, y-prosemirror + writes the merge attrs into the XmlElement and they survive + subsequent flushes — this migration path just seeds the simplest + possible table. + """ + i = 0 + n = len(tokens) + while i < n: + tok = tokens[i] + if tok.type == 'th_open': + close_i = _find_close(tokens, i, 'th_close') + cell = XmlElement('tableHeader') + row_el.children.append(cell) + para = XmlElement('paragraph') + cell.children.append(para) + _build_inline_into(para, tokens[i + 1: close_i]) + i = close_i + 1 + continue + if tok.type == 'td_open': + close_i = _find_close(tokens, i, 'td_close') + cell = XmlElement('tableCell') + row_el.children.append(cell) + para = XmlElement('paragraph') + cell.children.append(para) + _build_inline_into(para, tokens[i + 1: close_i]) + i = close_i + 1 + continue + 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. @@ -457,11 +612,91 @@ def _render_block(node, lines: list[str], *, list_context) -> None: lines.append('') return + if tag == 'table': + _render_table(node, lines) + 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_table(node, lines: list[str]) -> None: + """Render a `table` XmlElement as a GFM pipe table. + + GFM requires the first row to be the header and the second row to + be a separator (`| --- | --- |`). TipTap's schema tags header cells + as `tableHeader` and body cells as `tableCell`, and doesn't require + the header to be the first row — but pipe syntax has no way to + express a mid-table header. We handle both shapes pragmatically: + * If the first row is all `tableHeader`, treat it as the header + and emit the separator after it (round-trip friendly). + * Otherwise, synthesise an empty header row above the data so + the output re-parses as a valid GFM table. This is lossy for + the visual "no header" shape, but the alternative is emitting + raw pipes that markdown-it will re-parse as a paragraph and + we lose the tabular structure entirely. + + Cells are joined with pipes; internal pipes get escaped as `\\|` + to keep the row shape parseable. Multi-line cell content is + collapsed to a single line joined with `
` — GFM tables can't + contain block content between the pipes. + """ + rows: list[tuple[bool, list[str]]] = [] + for row in list(node.children): + if not isinstance(row, XmlElement) or row.tag != 'tableRow': + continue + cells: list[str] = [] + all_headers = True + for cell in list(row.children): + if not isinstance(cell, XmlElement): + continue + if cell.tag not in ('tableHeader', 'tableCell'): + continue + if cell.tag != 'tableHeader': + all_headers = False + cell_lines: list[str] = [] + for child in list(cell.children): + _render_block(child, cell_lines, list_context=None) + while cell_lines and cell_lines[-1] == '': + cell_lines.pop() + # GFM cells are single-line: join intra-cell breaks with + # `
`, and escape stray pipes so the row shape survives. + text = '
'.join(line.strip() for line in cell_lines if line is not None) + cells.append(text.replace('|', '\\|')) + rows.append((all_headers and bool(cells), cells)) + + if not rows: + return + + # Column count: max cells across rows. Short rows get padded so + # every emitted line has the same pipe count (a GFM parser is + # forgiving here, but consistent output is easier on humans + # reading the raw source column). + cols = max(len(r[1]) for r in rows) + if cols == 0: + return + + def _emit(cells: list[str]) -> None: + padded = cells + [''] * (cols - len(cells)) + lines.append('| ' + ' | '.join(padded) + ' |') + + if rows[0][0]: + _emit(rows[0][1]) + lines.append('| ' + ' | '.join(['---'] * cols) + ' |') + body_start = 1 + else: + # Header-less TipTap table → synthesise an empty header row so + # the output remains a valid GFM table when re-parsed. + _emit([''] * cols) + lines.append('| ' + ' | '.join(['---'] * cols) + ' |') + body_start = 0 + + for _, cells in rows[body_start:]: + _emit(cells) + lines.append('') + + def _render_list_item(node, lines: list[str], marker: str) -> None: """Render a `listItem`'s children as a marker-prefixed list item. diff --git a/source/app/iris_engine/incident_rules/__init__.py b/source/app/iris_engine/incident_rules/__init__.py deleted file mode 100644 index 03461475b..000000000 --- a/source/app/iris_engine/incident_rules/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""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/models/incidents.py b/source/app/models/alert_clusters.py similarity index 51% rename from source/app/models/incidents.py rename to source/app/models/alert_clusters.py index c34d1ac20..8a3220350 100644 --- a/source/app/models/incidents.py +++ b/source/app/models/alert_clusters.py @@ -32,57 +32,57 @@ from app.db import db -class IncidentStatus(db.Model): - __tablename__ = 'incident_status' +class AlertClusterStatus(db.Model): + __tablename__ = 'alert_cluster_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' +class AlertClusterAssociation(db.Model): + __tablename__ = 'alert_cluster_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) + cluster_id = Column(ForeignKey('alert_clusters.cluster_id'), primary_key=True, nullable=False, index=True) -class Incident(db.Model): - __tablename__ = 'incidents' +class AlertCluster(db.Model): + __tablename__ = 'alert_clusters' - 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. + cluster_id = Column(BigInteger, primary_key=True) + cluster_uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, nullable=False, + server_default=text('gen_random_uuid()'), unique=True) + cluster_title = Column(Text, nullable=False) + cluster_description = Column(Text) + cluster_status_id = Column(ForeignKey('alert_cluster_status.status_id'), nullable=False) + cluster_severity_id = Column(ForeignKey('severities.severity_id'), nullable=True) + cluster_customer_id = Column(ForeignKey('client.client_id'), nullable=False) + cluster_owner_id = Column(ForeignKey('user.id'), nullable=True) + cluster_creation_time = Column(DateTime, nullable=False, server_default=text('now()')) + cluster_source_rule_id = Column(ForeignKey('cluster_rules.rule_id'), nullable=True) + cluster_case_id = Column(ForeignKey('cases.case_id'), nullable=True) + # Stacking dedupe key computed from the rule that opened the cluster. # 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 + # the same open cluster. Left free-text so different rules can key # by whatever fields they group_by. - incident_dedupe_key = Column(Text, nullable=True, index=True) + cluster_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( + # cluster, so the detail page doesn't re-evaluate every render. + cluster_investigation_flow_id = Column( ForeignKey('investigation_flows.flow_id'), nullable=True ) modification_history = Column(JSON) - status = relationship('IncidentStatus') + status = relationship('AlertClusterStatus') 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]) + owner = relationship('User', foreign_keys=[cluster_owner_id]) + source_rule = relationship('ClusterRule', foreign_keys=[cluster_source_rule_id]) + case = relationship('Cases', foreign_keys=[cluster_case_id]) investigation_flow = relationship( - 'InvestigationFlow', foreign_keys=[incident_investigation_flow_id] + 'InvestigationFlow', foreign_keys=[cluster_investigation_flow_id] ) - alerts = relationship('Alert', secondary='alert_incident_association', back_populates='incidents') + alerts = relationship('Alert', secondary='alert_cluster_association', back_populates='clusters') diff --git a/source/app/models/alerts.py b/source/app/models/alerts.py index 67f19f017..2518ad093 100644 --- a/source/app/models/alerts.py +++ b/source/app/models/alerts.py @@ -78,7 +78,7 @@ class Alert(db.Model): 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') + clusters = relationship('AlertCluster', secondary='alert_cluster_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 8cb012c52..f32f3eed0 100644 --- a/source/app/models/authorization.py +++ b/source/app/models/authorization.py @@ -76,12 +76,12 @@ class Permissions(enum.Enum): war_rooms_write = 0x10000 war_rooms_create = 0x20000 - incidents_read = 0x40000 - incidents_write = 0x80000 - incidents_delete = 0x100000 + alert_clusters_read = 0x40000 + alert_clusters_write = 0x80000 + alert_clusters_delete = 0x100000 - incident_rules_read = 0x200000 - incident_rules_write = 0x400000 + cluster_rules_read = 0x200000 + cluster_rules_write = 0x400000 investigation_flows_read = 0x800000 investigation_flows_write = 0x1000000 diff --git a/source/app/models/incident_rules.py b/source/app/models/cluster_rules.py similarity index 95% rename from source/app/models/incident_rules.py rename to source/app/models/cluster_rules.py index 7595f955c..11861eb37 100644 --- a/source/app/models/incident_rules.py +++ b/source/app/models/cluster_rules.py @@ -33,7 +33,7 @@ from app.db import db -RULE_ACTION_CREATE_INCIDENT = 'create_incident' +RULE_ACTION_CREATE_CLUSTER = 'create_cluster' # The `attach_flow` action was removed — investigation flows now own # their own matching conditions (`InvestigationFlow.flow_conditions`), @@ -42,8 +42,8 @@ # should switch to reading `InvestigationFlow.flow_conditions` instead. -class IncidentRule(db.Model): - __tablename__ = 'incident_rules' +class ClusterRule(db.Model): + __tablename__ = 'cluster_rules' rule_id = Column(BigInteger, primary_key=True) rule_uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, nullable=False, diff --git a/source/app/models/collab.py b/source/app/models/collab.py index a47d368cc..1475d6cd3 100644 --- a/source/app/models/collab.py +++ b/source/app/models/collab.py @@ -32,6 +32,7 @@ from sqlalchemy import Column from sqlalchemy import DateTime from sqlalchemy import ForeignKey +from sqlalchemy import Integer from sqlalchemy import LargeBinary from sqlalchemy import Text @@ -46,6 +47,13 @@ class CollabDoc(db.Model): content_md = Column(Text, nullable=True) last_flushed_at = Column(DateTime, nullable=True) updated_by_id = Column(BigInteger, ForeignKey('user.id'), nullable=True) + # Seeder version. Bumped whenever `iris_engine.collab.render` grows + # a new block type (tables, task lists, etc.) — rows persisted by + # an older seeder are re-seeded from the source column on next open + # so users don't stay stuck on the previous parser's lossy output. + # See `_CURRENT_SEEDER_VERSION` in `business/collab.py` for the + # constant and the re-seed logic. + seeder_version = Column(Integer, 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 diff --git a/source/app/models/comments.py b/source/app/models/comments.py index db08b9de8..7568280ae 100644 --- a/source/app/models/comments.py +++ b/source/app/models/comments.py @@ -42,12 +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')) + comment_cluster_id = Column(ForeignKey('alert_clusters.cluster_id')) user = relationship('User') case = relationship('Cases') alert = relationship('Alert') - incident = relationship('Incident') + cluster = relationship('AlertCluster') class EventComments(db.Model): diff --git a/source/app/models/custom_dashboard.py b/source/app/models/custom_dashboard.py index f382968cd..7b798b11a 100644 --- a/source/app/models/custom_dashboard.py +++ b/source/app/models/custom_dashboard.py @@ -39,7 +39,7 @@ class CustomDashboard(db.Model): 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) + owner_id = Column(ForeignKey('user.id', ondelete='SET NULL'), 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) diff --git a/source/app/models/investigation_flows.py b/source/app/models/investigation_flows.py index a652497a4..0f1febd32 100644 --- a/source/app/models/investigation_flows.py +++ b/source/app/models/investigation_flows.py @@ -35,13 +35,13 @@ # `flow_target` values — a flow declares whether it applies to alerts, -# incidents, or both. Rules used to decide flow attachment; that was +# alert clusters, 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_CLUSTER = 'alert_cluster' FLOW_TARGET_BOTH = 'both' -FLOW_TARGETS = (FLOW_TARGET_ALERT, FLOW_TARGET_INCIDENT, FLOW_TARGET_BOTH) +FLOW_TARGETS = (FLOW_TARGET_ALERT, FLOW_TARGET_CLUSTER, FLOW_TARGET_BOTH) class InvestigationFlow(db.Model): @@ -55,18 +55,18 @@ class InvestigationFlow(db.Model): 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'. + # Which entities this flow can attach to: 'alert' / 'alert_cluster' / '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` + # Match conditions — same DSL shape as `ClusterRule.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. + # lower = higher priority, matching the cluster-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()')) @@ -112,26 +112,26 @@ class AlertInvestigationProgress(db.Model): 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 +class AlertClusterInvestigationProgress(db.Model): + """Per-cluster, per-step check-off. Mirrors AlertInvestigationProgress + but scoped to an alert cluster so the cluster-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' + cluster as a whole may cover steps that no single alert has yet.""" + __tablename__ = 'alert_cluster_investigation_progress' __table_args__ = ( - UniqueConstraint('incident_id', 'step_id', - name='uq_incident_investigation_progress_incident_step'), + UniqueConstraint('cluster_id', 'step_id', + name='uq_alert_cluster_investigation_progress_cluster_step'), ) id = Column(BigInteger, primary_key=True) - incident_id = Column(ForeignKey('incidents.incident_id', ondelete='CASCADE'), - nullable=False, index=True) + cluster_id = Column(ForeignKey('alert_clusters.cluster_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') + cluster = relationship('AlertCluster') step = relationship('InvestigationFlowStep') completed_by = relationship('User', foreign_keys=[completed_by_user_id]) diff --git a/source/app/post_init.py b/source/app/post_init.py index aec9326a1..d5b8ca9f5 100644 --- a/source/app/post_init.py +++ b/source/app/post_init.py @@ -53,7 +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.alert_clusters import AlertClusterStatus from app.models.authorization import CaseAccessLevel from app.models.authorization import Group from app.models.authorization import User @@ -655,21 +655,21 @@ def create_safe_hooks(): 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') + # --- Alert clusters + create_safe(db.session, IrisHook, hook_name='on_postload_alert_cluster_create', + hook_description='Triggered on alert cluster creation, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_alert_cluster_update', + hook_description='Triggered on alert cluster update, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_alert_cluster_delete', + hook_description='Triggered on alert cluster deletion, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_alert_cluster_alert_add', + hook_description='Triggered on alert(s) linked to an alert cluster, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_alert_cluster_alert_remove', + hook_description='Triggered on alert unlinked from an alert cluster, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_alert_cluster_escalate', + hook_description='Triggered on alert cluster escalation to a case, after commit in DB') + create_safe(db.session, IrisHook, hook_name='on_postload_alert_cluster_merge', + hook_description='Triggered on alert cluster merge into an existing case, after commit in DB') def create_safe_languages(): @@ -815,16 +815,16 @@ 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_alert_cluster_status(): + """Seed the AlertClusterStatus lookup. Idempotent — safe to re-run at boot.""" + create_safe(db.session, AlertClusterStatus, status_name='Open', + status_description='Alert cluster is open and receiving alerts') + create_safe(db.session, AlertClusterStatus, status_name='Investigating', + status_description='Alert cluster is being investigated by an analyst') + create_safe(db.session, AlertClusterStatus, status_name='Dismissed', + status_description='Alert cluster closed with no further action') + create_safe(db.session, AlertClusterStatus, status_name='Escalated', + status_description='Alert cluster escalated to a case') def create_safe_evidence_types(): @@ -1435,8 +1435,8 @@ 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 alert cluster status") + create_safe_alert_cluster_status() self._logger.info("Creating base evidence types") create_safe_evidence_types() @@ -1475,10 +1475,10 @@ def run(self): 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 + # Register the cluster-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 + self._logger.info("Registering cluster-rules Celery tasks") + import app.iris_engine.cluster_rules # noqa: F401 side-effects only # Create initial authorization model, administrative user, and customer self._logger.info("Creating initial authorisation model") diff --git a/source/app/schema/marshables.py b/source/app/schema/marshables.py index 23077cd52..4ec0ee39a 100644 --- a/source/app/schema/marshables.py +++ b/source/app/schema/marshables.py @@ -84,14 +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.alert_clusters import AlertCluster +from app.models.alert_clusters import AlertClusterStatus +from app.models.cluster_rules import ClusterRule +from app.models.cluster_rules import RULE_ACTION_CREATE_CLUSTER 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.investigation_flows import AlertClusterInvestigationProgress from app.models.authorization import Group from app.models.authorization import Organisation from app.models.authorization import User @@ -2145,7 +2145,7 @@ class AuthorizationGroupSchema(ma.SQLAlchemyAutoSchema): group_description: str = auto_field('group_description', required=True, validate=Length(min=2)) group_auto_follow_access_level: Optional[bool] = auto_field('group_auto_follow_access_level', required=False, dump_default=False) - group_permissions: int = fields.Integer(required=False) + group_permissions: int = fields.Integer(required=False, load_default=0) group_members: Optional[List[Dict[str, Any]]] = fields.List(fields.Dict, required=False, allow_none=True) group_permissions_list: Optional[List[Dict[str, Any]]] = fields.List(fields.Dict, required=False, allow_none=True) group_cases_access: Optional[List[Dict[str, Any]]] = fields.List(fields.Dict, required=False, allow_none=True) @@ -2376,7 +2376,7 @@ 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) + clusters = fields.Method('_cluster_ids', dump_only=True) investigation_flow = fields.Method('_flow_summary', dump_only=True) class Meta: @@ -2386,8 +2386,8 @@ class Meta: load_instance = True unknown = EXCLUDE - def _incident_ids(self, alert: Alert): - return [i.incident_id for i in (alert.incidents or [])] + def _cluster_ids(self, alert: Alert): + return [c.cluster_id for c in (alert.clusters or [])] def _flow_summary(self, alert: Alert): flow = alert.investigation_flow @@ -2822,15 +2822,15 @@ def verify_password(self, data: Dict[str, Any], **kwargs: Any) -> Dict[str, Any] return data -class IncidentStatusSchema(ma.SQLAlchemyAutoSchema): +class AlertClusterStatusSchema(ma.SQLAlchemyAutoSchema): class Meta: - model = IncidentStatus + model = AlertClusterStatus load_instance = True unknown = EXCLUDE -class IncidentSchema(ma.SQLAlchemyAutoSchema): - status = ma.Nested(IncidentStatusSchema, dump_only=True) +class AlertClusterSchema(ma.SQLAlchemyAutoSchema): + status = ma.Nested(AlertClusterStatusSchema, 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) @@ -2839,25 +2839,25 @@ class IncidentSchema(ma.SQLAlchemyAutoSchema): source_rule = fields.Method('_source_rule_summary', dump_only=True) class Meta: - model = Incident + model = AlertCluster 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 _alert_ids(self, cluster: AlertCluster): + return [a.alert_id for a in (cluster.alerts or [])] - def _flow_summary(self, incident: Incident): - flow = incident.investigation_flow + def _flow_summary(self, cluster: AlertCluster): + flow = cluster.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 + def _source_rule_summary(self, cluster: AlertCluster): + # Surface the rule that created the cluster so the detail page can + # link back to /settings/cluster-rules for auditability. + rule = cluster.source_rule if not rule: return None return {'rule_id': rule.rule_id, 'rule_name': rule.rule_name} @@ -2912,9 +2912,9 @@ def _validate_rule_conditions(payload): raise ValidationError('rule_conditions.group_by must be a list of field names') -class IncidentRuleSchema(ma.SQLAlchemyAutoSchema): +class ClusterRuleSchema(ma.SQLAlchemyAutoSchema): class Meta: - model = IncidentRule + model = ClusterRule include_fk = True load_instance = True unknown = EXCLUDE @@ -2926,10 +2926,10 @@ def _validate(self, data: Dict[str, Any], **kwargs: Any) -> Dict[str, Any]: 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: + # the only remaining rule action is stacking alerts into clusters. + if action is not None and action != RULE_ACTION_CREATE_CLUSTER: raise ValidationError( - f'rule_action_type must be {RULE_ACTION_CREATE_INCIDENT}' + f'rule_action_type must be {RULE_ACTION_CREATE_CLUSTER}' ) scope = data.get('rule_customer_scope') if scope is not None and (not isinstance(scope, list) @@ -2967,7 +2967,7 @@ class Meta: @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 + # Reuse the same conditions validator as cluster 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. @@ -3008,11 +3008,11 @@ class Meta: unknown = EXCLUDE -class IncidentInvestigationProgressSchema(ma.SQLAlchemyAutoSchema): +class AlertClusterInvestigationProgressSchema(ma.SQLAlchemyAutoSchema): completed_by = ma.Nested(UserSchema, only=['id', 'user_name', 'user_login'], dump_only=True) class Meta: - model = IncidentInvestigationProgress + model = AlertClusterInvestigationProgress include_fk = True load_instance = True unknown = EXCLUDE diff --git a/source/requirements.txt b/source/requirements.txt index 145d2249e..e79202c22 100644 --- a/source/requirements.txt +++ b/source/requirements.txt @@ -8,7 +8,7 @@ Flask-SQLAlchemy==3.1.1 Flask-WTF==1.3.0 flask-marshmallow==1.5.0 Flask-Caching==2.4.0 -flask-cors==6.0.2 +flask-cors==6.0.5 marshmallow==4.3.0 marshmallow-sqlalchemy==1.5.0 gunicorn==26.0.0 @@ -16,7 +16,7 @@ psycopg2-binary==2.9.12 pyunpack==0.3 packaging==26.2 requests==2.31.0 -SQLAlchemy==2.0.49 +SQLAlchemy==2.0.51 SQLAlchemy-ImageAttach==1.1.0 SQLAlchemy-Utils==0.42.1 urllib3==2.7.0 @@ -38,20 +38,20 @@ pycrdt>=0.14,<0.15 # parser boundary. markdown-it-py>=3.0,<4 uvicorn[standard]==0.49.0 -alembic==1.18.4 +alembic==1.18.5 setuptools==81.0.0 python-dateutil==2.9.0.post0 python-gnupg==0.5.6 pyzipper==0.4.0 PyJWT==2.13.0 -cryptography==48.0.0 +cryptography==49.0.0 ldap3==2.9.1 pyintelowl==5.1.0 -pyotp==2.9.0 +pyotp==2.10.0 qrcode[pil]==8.2 dictdiffer==0.9.0 oic==1.7.0 -bleach==6.3.0 +bleach==6.4.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 diff --git a/tests/iris.py b/tests/iris.py index 1462c58f0..6a84430d6 100644 --- a/tests/iris.py +++ b/tests/iris.py @@ -39,11 +39,11 @@ 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_ALERT_CLUSTERS_READ = 0x40000 +IRIS_PERMISSION_ALERT_CLUSTERS_WRITE = 0x80000 +IRIS_PERMISSION_ALERT_CLUSTERS_DELETE = 0x100000 +IRIS_PERMISSION_CLUSTER_RULES_READ = 0x200000 +IRIS_PERMISSION_CLUSTER_RULES_WRITE = 0x400000 IRIS_PERMISSION_INVESTIGATION_FLOWS_READ = 0x800000 IRIS_PERMISSION_INVESTIGATION_FLOWS_WRITE = 0x1000000 diff --git a/tests/tests_rest_incidents.py b/tests/tests_rest_alert_clusters.py similarity index 51% rename from tests/tests_rest_incidents.py rename to tests/tests_rest_alert_clusters.py index 6c9a2de06..35b2bda16 100644 --- a/tests/tests_rest_incidents.py +++ b/tests/tests_rest_alert_clusters.py @@ -20,11 +20,11 @@ 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 +from iris import IRIS_PERMISSION_ALERT_CLUSTERS_READ +from iris import IRIS_PERMISSION_ALERT_CLUSTERS_WRITE _IDENTIFIER_FOR_NONEXISTENT_OBJECT = 123456789 -_OPEN_STATUS_ID = 1 # seeded first by create_safe_incident_status +_OPEN_STATUS_ID = 1 # seeded first by create_safe_alert_cluster_status def _alert_body(): @@ -36,18 +36,18 @@ def _alert_body(): } -def _incident_body(**overrides): +def _cluster_body(**overrides): body = { - 'incident_title': 'Test incident', - 'incident_description': 'Grouping brute-force alerts', - 'incident_status_id': _OPEN_STATUS_ID, - 'incident_customer_id': 1, + 'cluster_title': 'Test cluster', + 'cluster_description': 'Grouping brute-force alerts', + 'cluster_status_id': _OPEN_STATUS_ID, + 'cluster_customer_id': 1, } body.update(overrides) return body -class TestsRestIncidents(TestCase): +class TestsRestAlertClusters(TestCase): def setUp(self) -> None: self._subject = Iris() @@ -55,98 +55,98 @@ def setUp(self) -> None: def tearDown(self): self._subject.clear_database() - def test_create_incident_should_return_201(self): - response = self._subject.create('/api/v2/incidents', _incident_body()) + def test_create_cluster_should_return_201(self): + response = self._subject.create('/api/v2/alert-clusters', _cluster_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_cluster_persists_title(self): + response = self._subject.create('/api/v2/alert-clusters', _cluster_body()).json() + self.assertEqual('Test cluster', response['cluster_title']) - def test_create_incident_without_permission_returns_403(self): + def test_create_cluster_without_permission_returns_403(self): user = self._subject.create_dummy_user() - response = user.create('/api/v2/incidents', _incident_body()) + response = user.create('/api/v2/alert-clusters', _cluster_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"]}') + def test_read_cluster_returns_200(self): + created = self._subject.create('/api/v2/alert-clusters', _cluster_body()).json() + response = self._subject.get(f'/api/v2/alert-clusters/{created["cluster_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}') + def test_read_nonexistent_cluster_returns_404(self): + response = self._subject.get(f'/api/v2/alert-clusters/{_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() + created = self._subject.create('/api/v2/alert-clusters', _cluster_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'} + f'/api/v2/alert-clusters/{created["cluster_id"]}', + {'cluster_customer_id': 9999, 'cluster_title': 'Renamed'} ).json() - self.assertEqual(1, response['incident_customer_id']) - self.assertEqual('Renamed', response['incident_title']) + self.assertEqual(1, response['cluster_customer_id']) + self.assertEqual('Renamed', response['cluster_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"]}') + def test_delete_cluster_returns_204(self): + created = self._subject.create('/api/v2/alert-clusters', _cluster_body()).json() + response = self._subject.delete(f'/api/v2/alert-clusters/{created["cluster_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() + cluster = self._subject.create('/api/v2/alert-clusters', _cluster_body()).json() response = self._subject.create( - f'/api/v2/incidents/{incident["incident_id"]}/alerts', + f'/api/v2/alert-clusters/{cluster["cluster_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. + # Different customer than the cluster — 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() + cluster = self._subject.create('/api/v2/alert-clusters', _cluster_body()).json() response = self._subject.create( - f'/api/v2/incidents/{incident["incident_id"]}/alerts', + f'/api/v2/alert-clusters/{cluster["cluster_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): + def test_escalate_creates_case_and_updates_cluster(self): alert = self._subject.create('/api/v2/alerts', _alert_body()).json() - incident = self._subject.create('/api/v2/incidents', _incident_body()).json() + cluster = self._subject.create('/api/v2/alert-clusters', _cluster_body()).json() self._subject.create( - f'/api/v2/incidents/{incident["incident_id"]}/alerts', + f'/api/v2/alert-clusters/{cluster["cluster_id"]}/alerts', {'alert_ids': [alert['alert_id']]} ) response = self._subject.create( - f'/api/v2/incidents/{incident["incident_id"]}/escalate', - {'case_title': 'Escalated incident'} + f'/api/v2/alert-clusters/{cluster["cluster_id"]}/escalate', + {'case_title': 'Escalated cluster'} ) 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() + def test_escalate_empty_cluster_returns_error(self): + cluster = self._subject.create('/api/v2/alert-clusters', _cluster_body()).json() response = self._subject.create( - f'/api/v2/incidents/{incident["incident_id"]}/escalate', {} + f'/api/v2/alert-clusters/{cluster["cluster_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')) + def test_list_clusters_filters_by_customer(self): + self._subject.create('/api/v2/alert-clusters', _cluster_body(cluster_title='A')) + self._subject.create('/api/v2/alert-clusters', _cluster_body(cluster_title='B')) response = self._subject.get( - '/api/v2/incidents', query_parameters={'customer_id': 1, 'per_page': 100} + '/api/v2/alert-clusters', 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') + user = self._subject.create_dummy_user(permissions=IRIS_PERMISSION_ALERT_CLUSTERS_WRITE) + response = user.get('/api/v2/alert-clusters') self.assertEqual(403, response.status_code) diff --git a/tests/tests_rest_incident_rules.py b/tests/tests_rest_cluster_rules.py similarity index 77% rename from tests/tests_rest_incident_rules.py rename to tests/tests_rest_cluster_rules.py index a7aedf8f0..6206c201e 100644 --- a/tests/tests_rest_incident_rules.py +++ b/tests/tests_rest_cluster_rules.py @@ -19,8 +19,8 @@ from unittest import TestCase from iris import Iris -from iris import IRIS_PERMISSION_INCIDENT_RULES_READ -from iris import IRIS_PERMISSION_INCIDENT_RULES_WRITE +from iris import IRIS_PERMISSION_CLUSTER_RULES_READ +from iris import IRIS_PERMISSION_CLUSTER_RULES_WRITE def _rule_body(**overrides): @@ -38,7 +38,7 @@ def _rule_body(**overrides): 'time_window_seconds': 3600, 'group_by': ['alert_source'], }, - 'rule_action_type': 'create_incident', + 'rule_action_type': 'create_cluster', 'rule_action_config': { 'title_template': 'Brute-force cluster: {alert_title}', }, @@ -47,50 +47,50 @@ def _rule_body(**overrides): return body -class TestsRestIncidentRules(TestCase): +class TestsRestClusterRules(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() + rules = self._subject.get('/api/v2/cluster-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.delete(f'/api/v2/cluster-rules/{rid}') self._subject.clear_database() def test_create_rule_returns_201(self): - response = self._subject.create('/api/v2/incident-rules', _rule_body()) + response = self._subject.create('/api/v2/cluster-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', + '/api/v2/cluster-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') + '/api/v2/cluster-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. + # The rules engine only stacks alerts into clusters now. response = self._subject.create( - '/api/v2/incident-rules', + '/api/v2/cluster-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() + created = self._subject.create('/api/v2/cluster-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"]}', + f'/api/v2/cluster-rules/{created["data"]["rule_id"] if "data" in created else created["rule_id"]}', {'rule_name': 'Renamed'} ) self.assertEqual(200, response.status_code) @@ -103,10 +103,10 @@ def test_test_endpoint_returns_matches(self): 'alert_status_id': 3, 'alert_customer_id': 1, }) - created = self._subject.create('/api/v2/incident-rules', _rule_body()).json() + created = self._subject.create('/api/v2/cluster-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} + f'/api/v2/cluster-rules/{rule_id}/test', {'sample_days': 30} ) self.assertEqual(200, response.status_code) payload = response.json() @@ -115,35 +115,35 @@ def test_test_endpoint_returns_matches(self): 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()) + user = self._subject.create_dummy_user(permissions=IRIS_PERMISSION_CLUSTER_RULES_READ) + response = user.create('/api/v2/cluster-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 + # These tests all use a non-admin user with only the cluster-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 + # server_administrator, otherwise cluster_rules_write would # double as tenant elevation. - rw = IRIS_PERMISSION_INCIDENT_RULES_READ | IRIS_PERMISSION_INCIDENT_RULES_WRITE + rw = IRIS_PERMISSION_CLUSTER_RULES_READ | IRIS_PERMISSION_CLUSTER_RULES_WRITE user = self._subject.create_dummy_user(permissions=rw) response = user.create( - '/api/v2/incident-rules', _rule_body(rule_customer_scope=None) + '/api/v2/cluster-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 + rw = IRIS_PERMISSION_CLUSTER_RULES_READ | IRIS_PERMISSION_CLUSTER_RULES_WRITE user = self._subject.create_dummy_user(permissions=rw) other_customer = self._subject.create_dummy_customer() response = user.create( - '/api/v2/incident-rules', + '/api/v2/cluster-rules', _rule_body(rule_customer_scope=[other_customer]) ) self.assertEqual(400, response.status_code) @@ -154,42 +154,42 @@ def test_non_admin_cannot_read_rule_scoped_to_unreachable_customer(self): # PUT / DELETE / test / backfill. other_customer = self._subject.create_dummy_customer() created = self._subject.create( - '/api/v2/incident-rules', + '/api/v2/cluster-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 + rw = IRIS_PERMISSION_CLUSTER_RULES_READ | IRIS_PERMISSION_CLUSTER_RULES_WRITE user = self._subject.create_dummy_user(permissions=rw) - response = user.get(f'/api/v2/incident-rules/{rule_id}') + response = user.get(f'/api/v2/cluster-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', + '/api/v2/cluster-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 + rw = IRIS_PERMISSION_CLUSTER_RULES_READ | IRIS_PERMISSION_CLUSTER_RULES_WRITE user = self._subject.create_dummy_user(permissions=rw) response = user.create( - f'/api/v2/incident-rules/{rule_id}/backfill', {'sample_days': 7} + f'/api/v2/cluster-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', + '/api/v2/cluster-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 + rw = IRIS_PERMISSION_CLUSTER_RULES_READ | IRIS_PERMISSION_CLUSTER_RULES_WRITE user = self._subject.create_dummy_user(permissions=rw) - response = user.get('/api/v2/incident-rules').json() + response = user.get('/api/v2/cluster-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) @@ -199,7 +199,7 @@ def test_created_by_is_server_owned_not_client_spoofable(self): # 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() + response = self._subject.create('/api/v2/cluster-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):