Files
Juan DenisandClaude Fable 5 7fd90a6c87 refactor(models): one door for delete cascades on NOT NULL child FKs
Replaces the 29 hand-written cascade='all, delete-orphan' declarations
from c1d4c0af/1704d60b/79a78c3c with a single mapper hook
(_delete_cascade_policy): at configure time, every one-to-many whose
child FK is NOT NULL gets the delete cascade automatically — with a
NOT NULL FK the children cannot outlive the parent, so the only choice
is cascade or crash. New models (extensions included — registering one
re-fires the hook) are covered with zero ceremony; nothing to remember.

Relationships where the parent delete should be REFUSED instead opt
out in the module's DELIBERATELY_UNCASCADED registry with their
justification (User.applications guarded by delete_user's 409,
CloudProvider.servers with no delete path). The sweep test now proves
the door works rather than policing model authors, and pins the
registry so it can only shrink.

Model files revert to their pre-fix text: delete semantics live in one
place instead of twenty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:04:12 -04:00

88 lines
3.6 KiB
Python

from datetime import datetime
from app import db
from app.models.json_column_mixin import JsonColumnMixin
import json
class ImageVulnerabilityScan(JsonColumnMixin, db.Model):
"""CVE scan result for a Docker image used by an application."""
__tablename__ = 'image_vulnerability_scans'
id = db.Column(db.Integer, primary_key=True)
application_id = db.Column(db.Integer, db.ForeignKey('applications.id'), nullable=False, index=True)
image_ref = db.Column(db.String(500), nullable=False)
scanner = db.Column(db.String(50), default='grype')
scanner_version = db.Column(db.String(50))
status = db.Column(db.String(20), default='pending') # pending, running, completed, failed
severity_counts = db.Column(db.Text) # JSON: {'critical': 0, 'high': 1, ...}
findings = db.Column(db.Text) # JSON list of grype matches
error_message = db.Column(db.Text)
started_at = db.Column(db.DateTime, default=datetime.utcnow)
completed_at = db.Column(db.DateTime, nullable=True)
application = db.relationship('Application', backref=db.backref('image_scans', lazy='dynamic', order_by='ImageVulnerabilityScan.started_at.desc()'))
def set_counts(self, counts):
self.severity_counts = json.dumps(counts)
def get_counts(self):
return self._json_read('severity_counts')
def set_findings(self, findings):
self._json_write('findings', findings)
def get_findings(self):
return self._json_read('findings', [])
@property
def highest_severity(self):
counts = self.get_counts()
for sev in ('critical', 'high', 'medium', 'low', 'negligible', 'unknown'):
if counts.get(sev, 0) > 0:
return sev
return 'none'
def to_dict(self, include_findings=False):
return {
'id': self.id,
'application_id': self.application_id,
'image_ref': self.image_ref,
'scanner': self.scanner,
'scanner_version': self.scanner_version,
'status': self.status,
'severity_counts': self.get_counts(),
'highest_severity': self.highest_severity,
'error_message': self.error_message,
'started_at': self.started_at.isoformat() if self.started_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
'findings': self.get_findings() if include_findings else None,
}
class SbomArtifact(JsonColumnMixin, db.Model):
"""Generated SPDX SBOM for a Docker image."""
__tablename__ = 'sbom_artifacts'
id = db.Column(db.Integer, primary_key=True)
application_id = db.Column(db.Integer, db.ForeignKey('applications.id'), nullable=False, index=True)
image_ref = db.Column(db.String(500), nullable=False)
generator = db.Column(db.String(50), default='syft')
generator_version = db.Column(db.String(50))
format = db.Column(db.String(20), default='spdx-json')
sbom_json = db.Column(db.Text) # SPDX 2.3 JSON
created_at = db.Column(db.DateTime, default=datetime.utcnow)
application = db.relationship('Application', backref=db.backref('sboms', lazy='dynamic', order_by='SbomArtifact.created_at.desc()'))
def to_dict(self, include_sbom=False):
return {
'id': self.id,
'application_id': self.application_id,
'image_ref': self.image_ref,
'generator': self.generator,
'generator_version': self.generator_version,
'format': self.format,
'created_at': self.created_at.isoformat() if self.created_at else None,
'sbom': self._json_read('sbom_json', None) if include_sbom else None,
}