Files
vulncheck/alembic/versions/028_reconcile_legacy_nessus_assets.py
T
vulncheck 21f85e4b89 fix(alembic): unbreak fresh installs at migration 028
A fresh `docker compose up` never came up. Migration 028 imported the
live ORM (`from app.models.asset import Asset`) and ran `db.query(Asset)`
against a database that is only at revision 028, so the SELECT asked for
`assets.intune_device_id` — a column revision 033 adds. Alembic aborted
with UndefinedColumn, the whole migration chain rolled back (one
transaction for all revisions), and the backend crash-looped against an
empty database.

028 is now raw SQL: one CTE that flips the legacy Nessus rows and writes
the audit entries, comparing `source::text` / `status::text` so it no
longer depends on today's enum labels either.

env.py switches to transaction_per_migration. 027 adds the
ASSET_DEACTIVATED enum label that 028 inserts, and PostgreSQL refuses to
use a new enum value in the transaction that added it ("unsafe use of new
value"). Per-migration transactions also make a failed upgrade resumable
instead of all-or-nothing.

Verified end to end from a clean `git archive` checkout: migrations run
027 -> 046, default admin is created, backend reports healthy, login
through the frontend proxy returns a session cookie.

tests/test_migrations_no_orm_imports.py locks the bug class down: no
migration may import app code, because the ORM mapping is always newer
than the schema of the revision it runs at.
2026-08-26 15:48:37 +02:00

93 lines
3.4 KiB
Python

"""Reconcile legacy Nessus-sourced assets without a pinned nessus_host_uuid
Revision ID: 028
Revises: 027
Create Date: 2026-06-02 14:00:00.000000
Field feedback round 2026-06-02 (#3 INACTIVE not flipping on reduced
Nessus scan): the event-driven reconciliation in
`app.services.asset_lifecycle.reconcile_missing_from_sync` only inactivates
assets whose `nessus_host_uuid` is in `seen_ids` of a recent sync. Assets
created by older Nessus syncs that matched by IP or hostname (before the
UUID-backfill path was added) have `nessus_host_uuid IS NULL` and are
silently skipped. After a reduced scan they stay ACTIVE forever, which
contradicts the "sync-driven INACTIVE" promise.
This migration is the one-shot cleanup for the existing backlog (33 rows
in the test instance). New rows created after the 0006 commit (which
adds the diagnostic log + the `reconcile_legacy_nessus_assets` runtime
helper) are handled in code.
Idempotent: a row already INACTIVE matches the filter only when the
status check is omitted, so the body re-checks status before flipping.
Audit-logged via the same `_audit_asset_status` helper as the runtime
path so the audit trail is consistent.
Downgrade is a no-op — restoring a row to ACTIVE would require operator
intent, not a migration reversal.
"""
from alembic import op
revision = "028"
down_revision = "027"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Raw SQL on purpose: importing app.models here binds the *current*
# ORM mapping to a database that is only at revision 028, so a fresh
# install crashes on columns added by later revisions (033 added
# assets.intune_device_id). A migration may only touch the schema as
# it exists at its own revision.
#
# `source::text` / `status::text` instead of enum literals keeps this
# independent of whichever labels the enum types carry at this point.
op.execute(
"""
WITH flipped AS (
UPDATE assets
SET status = 'INACTIVE'
WHERE source::text = 'NESSUS'
AND status::text = 'ACTIVE'
AND nessus_host_uuid IS NULL
RETURNING id, hostname
)
INSERT INTO audit_logs (
user_id, event_type, event_description,
resource_type, resource_id, old_value, new_value, timestamp
)
SELECT
NULL,
'ASSET_DEACTIVATED',
left(
'Asset ' || hostname || ': status active \u2192 inactive '
|| '(legacy Nessus-sourced asset without pinned '
|| 'nessus_host_uuid \u2014 flipped by alembic migration 028 '
|| '(reconcile_legacy_nessus_assets))',
500
),
'asset',
id::text,
'active',
json_build_object(
'status', 'inactive',
'hostname', hostname,
'reason', 'legacy Nessus-sourced asset without pinned '
|| 'nessus_host_uuid \u2014 flipped by alembic '
|| 'migration 028 (reconcile_legacy_nessus_assets)'
)::text,
now()
FROM flipped;
"""
)
def downgrade() -> None:
# No-op. Restoring INACTIVE -> ACTIVE is an operator decision, not a
# migration reversal. The legacy rows can be re-activated by a fresh
# Nessus sync that reports them (event-driven revive in
# reconcile_missing_from_sync).
pass