Files
ServerKit/backend/app/models/_delete_cascade_policy.py
Juan DenisandClaude Fable 5 f34969eaac fix(models): close all 26 dangling-on-delete FKs + orphan sweep migration
Every NOT NULL FK from the KNOWN_DANGLING_ON_DELETE audit now declares
its parent-side relationship, so the delete-cascade policy door covers
it: AI conversations/pending actions, API keys, dashboards, event
subscriptions, login links, sent invitations, resource grants (users);
env vars + history, previews + settings, WAF policy (applications);
surveys, doctor results, proxy stack, tunnels (servers); manifests
(projects); DDNS hosts (zones); exposed services (tunnels); drills
(backup policies); rollouts (agent versions); site plugin installs
(WordPress sites); queue messages (groups). The ratchet set is now
EMPTY and stays that way.

One new deliberate exception: Workspace.projects — projects carry
environments and app groupings, so delete_workspace now refuses (409)
while any remain instead of silently destroying them (previously it
crashed with IntegrityError).

Migration 095 sweeps rows already orphaned on existing installs by
pre-fix hard-deletes — including an application's leftover env-var
secrets, which a reused SQLite row id could have attached to a future
app.

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

63 lines
2.8 KiB
Python

"""One door for delete semantics on NOT NULL child FKs.
SQLAlchemy's default when a parent row is deleted is to NULL the child's FK —
dynamic relationships included. For a child whose FK is NOT NULL that default
is not a policy, it is a crash: the UPDATE violates the constraint and the
delete dies with IntegrityError (this is how Recycle Bin purge broke in the
field, then user/server/workspace deletes in the audit that followed).
Rather than every model remembering to write cascade='all, delete-orphan' on
every such relationship — and every reviewer remembering to check — this module
applies it automatically at mapper-configuration time: any one-to-many whose
child FK is NOT NULL gets the delete cascade, because with a NOT NULL FK the
children *cannot* outlive the parent; the only question is whether the delete
works or crashes.
A relationship where the parent delete should be REFUSED instead (the children
are too valuable to cascade) opts out here, and the code path that deletes the
parent must guard explicitly. `test_application_soft_delete.py` pins both
halves: the sweep proves the door leaves no offenders beyond this registry, and
a test per entry proves its guard exists.
Plugin/extension models are covered too: registering a new model triggers a
fresh configure_mappers() run, which fires this hook again (it is idempotent).
"""
from sqlalchemy import event
from sqlalchemy.orm import Mapper
# 'Parent.relationship' -> why cascading would be wrong. Every entry needs a
# guard on the parent's delete path; an entry without one just moves the crash.
DELIBERATELY_UNCASCADED = {
'User.applications':
'apps own containers/volumes that must go through app delete + '
'Recycle Bin purge; delete_user refuses (409) while the user owns any',
'CloudProvider.servers':
'no code path deletes a CloudProvider row today; silently dropping '
'the inventory of provisioned (billing!) VMs must stay a deliberate, '
'guarded decision if one is ever added',
'Workspace.projects':
'projects contain environments and app groupings; delete_workspace '
'refuses (409) while any remain rather than silently destroying them',
}
def _needs_delete_cascade(rel):
if rel.direction.name != 'ONETOMANY' or rel.viewonly:
return False
fk_cols = [c for c in rel.remote_side if c.foreign_keys]
if not fk_cols or all(c.nullable for c in fk_cols):
return False
return 'delete' not in rel.cascade
@event.listens_for(Mapper, 'after_configured')
def apply_delete_cascades():
from app import db
for mapper in db.Model.registry.mappers:
for rel in mapper.relationships:
if not _needs_delete_cascade(rel):
continue
if f'{mapper.class_.__name__}.{rel.key}' in DELIBERATELY_UNCASCADED:
continue
rel.cascade = 'all, delete-orphan'