fix: give the remaining asset foreign keys a delete rule

Migration 040 fixed the two constraints pointing at vulnerabilities, which was
only half the chain — I checked what referenced findings and not what
referenced assets. So the delete got one step further and failed on the next
constraint instead:

    ForeignKeyViolation: update or delete on table "assets" violates foreign
    key constraint "notification_logs_asset_id_fkey"

This time every foreign key into the delete path was enumerated. Three had no
rule, each gets the one that fits: the notification log keeps its row with the
reference cleared (it records that a mail WAS sent, which stays true), while
scans and findings belong to their asset, are NOT NULL, and go with it. The
asset_groups association is included even though SQLAlchemy clears it itself —
the database should not depend on the application getting the order right.

Verified no foreign key into assets, vulnerabilities or scans is left without
a rule.
This commit is contained in:
2026-08-10 15:37:03 +02:00
parent a7b601001c
commit 5436745d0e
5 changed files with 71 additions and 4 deletions
+54
View File
@@ -0,0 +1,54 @@
"""Give the remaining asset FKs a delete rule.
Migration 040 fixed the two constraints pointing at vulnerabilities, which was
only half the chain. Deleting an asset then got one step further and failed on
the next one:
ForeignKeyViolation: update or delete on table "assets" violates foreign
key constraint "notification_logs_asset_id_fkey"
Three tables reference assets with no ON DELETE rule. Each gets the rule that
fits it: a notification log records that a mail WAS sent and stays (reference
cleared, column is already nullable); a scan and a finding belong to their
asset, cannot outlive it, and are NOT NULL, so they go with it.
Revision ID: 041
Revises: 040
"""
from alembic import op
revision = "041"
down_revision = "040"
branch_labels = None
depends_on = None
# (constraint, table, ondelete)
_FKS = [
("notification_logs_asset_id_fkey", "notification_logs", "SET NULL"),
("scans_asset_id_fkey", "scans", "CASCADE"),
("vulnerabilities_asset_id_fkey", "vulnerabilities", "CASCADE"),
# SQLAlchemy clears the association rows itself, so this one never failed —
# included so the database does not rely on it.
("asset_groups_asset_id_fkey", "asset_groups", "CASCADE"),
]
def upgrade():
for name, table, rule in _FKS:
# The constraint name is the PostgreSQL default; tolerate a database
# where it was created differently rather than aborting the upgrade.
try:
op.drop_constraint(name, table, type_="foreignkey")
except Exception:
continue
op.create_foreign_key(name, table, "assets", ["asset_id"], ["id"],
ondelete=rule)
def downgrade():
for name, table, _rule in _FKS:
try:
op.drop_constraint(name, table, type_="foreignkey")
except Exception:
continue
op.create_foreign_key(name, table, "assets", ["asset_id"], ["id"])
+4 -1
View File
@@ -17,7 +17,10 @@ user_groups = Table(
asset_groups = Table(
'asset_groups',
Base.metadata,
Column('asset_id', Integer, ForeignKey('assets.id'), primary_key=True),
# CASCADE: SQLAlchemy clears these rows itself on delete, but the database
# should not depend on the application doing it in the right order.
Column('asset_id', Integer, ForeignKey('assets.id', ondelete='CASCADE'),
primary_key=True),
Column('group_id', Integer, ForeignKey('groups.id'), primary_key=True)
)
+6 -1
View File
@@ -34,7 +34,12 @@ class NotificationLog(Base):
# (ForeignKeyViolation on notification_logs_vulnerability_id_fkey).
vulnerability_id = Column(Integer, ForeignKey("vulnerabilities.id", ondelete="SET NULL"),
nullable=True, index=True)
asset_id = Column(Integer, ForeignKey("assets.id"), nullable=True, index=True)
# SET NULL for the same reason as vulnerability_id above: the log records
# that a notification WAS sent, which stays true after the asset is gone.
# Without a rule this blocked asset deletion outright — the vulnerability_id
# fix in migration 040 only moved the failure one constraint along.
asset_id = Column(Integer, ForeignKey("assets.id", ondelete="SET NULL"),
nullable=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
notification_type = Column(SQLEnum(NotificationType), nullable=False, index=True)
sent_at = Column(DateTime, nullable=False, index=True)
+3 -1
View File
@@ -32,7 +32,9 @@ class Scan(Base, TimestampMixin):
__tablename__ = "scans"
id = Column(Integer, primary_key=True, index=True)
asset_id = Column(Integer, ForeignKey("assets.id"), nullable=False, index=True)
# CASCADE: a scan belongs to its asset and cannot outlive it (NOT NULL).
asset_id = Column(Integer, ForeignKey("assets.id", ondelete="CASCADE"),
nullable=False, index=True)
# Scan-Details
scan_type = Column(
+4 -1
View File
@@ -47,7 +47,10 @@ class Vulnerability(Base, TimestampMixin):
# non-CVE findings (EOL, compliance, weak ciphers) are imported with a
# pseudo-CVE id of the form 'NESSUS-PLUGIN-{plugin_id}'.
cve_id = Column(String(50), nullable=False, index=True) # CVE-2024-1234 or NESSUS-PLUGIN-12345
asset_id = Column(Integer, ForeignKey("assets.id"), nullable=False, index=True)
# CASCADE: the ORM already cascades, but the database should not depend on
# the application getting the order right.
asset_id = Column(Integer, ForeignKey("assets.id", ondelete="CASCADE"),
nullable=False, index=True)
# CVSS-Bewertung
cvss_score = Column(Float, nullable=True, index=True)