diff --git a/backend/alembic/versions/027_router_config_snapshots.py b/backend/alembic/versions/027_router_config_snapshots.py new file mode 100644 index 0000000..6ed5fcb --- /dev/null +++ b/backend/alembic/versions/027_router_config_snapshots.py @@ -0,0 +1,129 @@ +"""Add router config snapshot, diff, and change tables. + +Creates three tables for config snapshot storage: +- router_config_snapshots: point-in-time config captures (Transit-encrypted) +- router_config_diffs: unified diffs between consecutive snapshots +- router_config_changes: parsed semantic changes from diffs + +All tables have RLS tenant isolation and performance indexes. + +Revision ID: 027 +Revises: 026 +Create Date: 2026-03-12 +""" + +revision = "027" +down_revision = "026" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade() -> None: + conn = op.get_bind() + + # ── router_config_snapshots ────────────────────────────────────────── + conn.execute(sa.text(""" + CREATE TABLE router_config_snapshots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + config_text TEXT NOT NULL, + sha256_hash VARCHAR(64) NOT NULL, + collected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """)) + + # RLS + conn.execute(sa.text("ALTER TABLE router_config_snapshots ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE router_config_snapshots FORCE ROW LEVEL SECURITY")) + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON router_config_snapshots + USING (tenant_id::text = current_setting('app.current_tenant', true)) + WITH CHECK (tenant_id::text = current_setting('app.current_tenant', true)) + """)) + + # Grants + conn.execute(sa.text("GRANT SELECT, INSERT, DELETE ON router_config_snapshots TO app_user")) + + # Indexes + conn.execute(sa.text( + "CREATE INDEX idx_rcs_device_collected ON router_config_snapshots (device_id, collected_at DESC)" + )) + conn.execute(sa.text( + "CREATE INDEX idx_rcs_device_hash ON router_config_snapshots (device_id, sha256_hash)" + )) + + # ── router_config_diffs ────────────────────────────────────────────── + conn.execute(sa.text(""" + CREATE TABLE router_config_diffs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + old_snapshot_id UUID NOT NULL REFERENCES router_config_snapshots(id) ON DELETE CASCADE, + new_snapshot_id UUID NOT NULL REFERENCES router_config_snapshots(id) ON DELETE CASCADE, + diff_text TEXT NOT NULL, + lines_added INT NOT NULL DEFAULT 0, + lines_removed INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """)) + + # RLS + conn.execute(sa.text("ALTER TABLE router_config_diffs ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE router_config_diffs FORCE ROW LEVEL SECURITY")) + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON router_config_diffs + USING (tenant_id::text = current_setting('app.current_tenant', true)) + WITH CHECK (tenant_id::text = current_setting('app.current_tenant', true)) + """)) + + # Grants + conn.execute(sa.text("GRANT SELECT, INSERT, DELETE ON router_config_diffs TO app_user")) + + # Indexes + conn.execute(sa.text( + "CREATE UNIQUE INDEX idx_rcd_snapshot_pair ON router_config_diffs (old_snapshot_id, new_snapshot_id)" + )) + + # ── router_config_changes ──────────────────────────────────────────── + conn.execute(sa.text(""" + CREATE TABLE router_config_changes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + diff_id UUID NOT NULL REFERENCES router_config_diffs(id) ON DELETE CASCADE, + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + component TEXT NOT NULL, + summary TEXT NOT NULL, + raw_line TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """)) + + # RLS + conn.execute(sa.text("ALTER TABLE router_config_changes ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE router_config_changes FORCE ROW LEVEL SECURITY")) + conn.execute(sa.text(""" + CREATE POLICY tenant_isolation ON router_config_changes + USING (tenant_id::text = current_setting('app.current_tenant', true)) + WITH CHECK (tenant_id::text = current_setting('app.current_tenant', true)) + """)) + + # Grants + conn.execute(sa.text("GRANT SELECT, INSERT, DELETE ON router_config_changes TO app_user")) + + # Indexes + conn.execute(sa.text( + "CREATE INDEX idx_rcc_diff_id ON router_config_changes (diff_id)" + )) + + +def downgrade() -> None: + conn = op.get_bind() + + conn.execute(sa.text("DROP TABLE IF EXISTS router_config_changes")) + conn.execute(sa.text("DROP TABLE IF EXISTS router_config_diffs")) + conn.execute(sa.text("DROP TABLE IF EXISTS router_config_snapshots"))