"""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