Skip to content

Commit 4e6728c

Browse files
committed
fix: prevent CASCADE data loss in LDAP migration and repair invitation_user
The LDAP migration (20251226) ran batch_alter_table on the invitation and user tables with PRAGMA foreign_keys=ON. In SQLite, DROP TABLE with FKs enabled triggers an implicit DELETE FROM that fires ON DELETE CASCADE, silently wiping every row in invitation_user. This caused the "Latest Accepted Invites" card to show blank after updating to v2026.3.0. - Extend PRAGMA foreign_keys=OFF to cover all batch_alter_table operations in both upgrade() and downgrade() - Add repair migration that repopulates invitation_user from invitation.used_by_id and user.code matching (same logic as the original 20250814 migration) Closes #1207
1 parent 8ede52d commit 4e6728c

2 files changed

Lines changed: 185 additions & 30 deletions

File tree

migrations/versions/20251226_add_ldap_support.py

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,12 @@ def upgrade():
6868
sa.UniqueConstraint("dn"),
6969
)
7070

71-
# ── Alter admin_account ───────────────────────────────────────────────
72-
# SQLite batch_alter_table recreates the table via DROP + CREATE.
73-
# webauthn_credential and api_key have FKs pointing at admin_account,
74-
# so the DROP fails when PRAGMA foreign_keys=ON. Temporarily disable
75-
# FK enforcement for this operation.
71+
# ── Alter existing tables ─────────────────────────────────────────────
72+
# SQLite batch_alter_table recreates tables via DROP + CREATE.
73+
# With PRAGMA foreign_keys=ON, DROP TABLE triggers an implicit
74+
# DELETE FROM which fires ON DELETE CASCADE — wiping child tables
75+
# (e.g. invitation_user, invitation_server). Disable FK enforcement
76+
# for ALL batch_alter_table operations to prevent data loss.
7677
#
7778
# Also clean up any _alembic_tmp_* tables left by prior failed runs
7879
# (SQLite has no transactional DDL, so these persist after crashes).
@@ -83,6 +84,7 @@ def upgrade():
8384
]:
8485
conn.execute(sa.text(f"DROP TABLE IF EXISTS [{tmp_table}]"))
8586

87+
# ── admin_account ─────────────────────────────────────────────
8688
if _has_column(inspector, "admin_account", "auth_source"):
8789
# Already has the new columns (prior partial run succeeded here).
8890
# Just ensure password_hash is nullable.
@@ -106,41 +108,41 @@ def upgrade():
106108
batch_op.alter_column(
107109
"password_hash", existing_type=sa.String(), nullable=True
108110
)
109-
finally:
110-
conn.execute(sa.text("PRAGMA foreign_keys=ON"))
111111

112-
# ── Alter invitation ──────────────────────────────────────────────────
113-
if not _has_column(inspector, "invitation", "create_ldap_user"):
114-
with op.batch_alter_table("invitation", schema=None) as batch_op:
115-
batch_op.add_column(
116-
sa.Column(
117-
"create_ldap_user",
118-
sa.Boolean(),
119-
nullable=False,
120-
server_default="0",
112+
# ── invitation ────────────────────────────────────────────────
113+
if not _has_column(inspector, "invitation", "create_ldap_user"):
114+
with op.batch_alter_table("invitation", schema=None) as batch_op:
115+
batch_op.add_column(
116+
sa.Column(
117+
"create_ldap_user",
118+
sa.Boolean(),
119+
nullable=False,
120+
server_default="0",
121+
)
121122
)
122-
)
123123

124-
# ── Alter user ────────────────────────────────────────────────────────
125-
if not _has_column(inspector, "user", "is_ldap_user"):
126-
with op.batch_alter_table("user", schema=None) as batch_op:
127-
batch_op.add_column(
128-
sa.Column(
129-
"is_ldap_user", sa.Boolean(), nullable=False, server_default="0"
124+
# ── user ──────────────────────────────────────────────────────
125+
if not _has_column(inspector, "user", "is_ldap_user"):
126+
with op.batch_alter_table("user", schema=None) as batch_op:
127+
batch_op.add_column(
128+
sa.Column(
129+
"is_ldap_user", sa.Boolean(), nullable=False, server_default="0"
130+
)
130131
)
131-
)
132+
finally:
133+
conn.execute(sa.text("PRAGMA foreign_keys=ON"))
132134

133135

134136
def downgrade():
135-
with op.batch_alter_table("user", schema=None) as batch_op:
136-
batch_op.drop_column("is_ldap_user")
137-
138-
with op.batch_alter_table("invitation", schema=None) as batch_op:
139-
batch_op.drop_column("create_ldap_user")
140-
141137
conn = op.get_bind()
142138
conn.execute(sa.text("PRAGMA foreign_keys=OFF"))
143139
try:
140+
with op.batch_alter_table("user", schema=None) as batch_op:
141+
batch_op.drop_column("is_ldap_user")
142+
143+
with op.batch_alter_table("invitation", schema=None) as batch_op:
144+
batch_op.drop_column("create_ldap_user")
145+
144146
with op.batch_alter_table("admin_account", schema=None) as batch_op:
145147
batch_op.alter_column(
146148
"password_hash", existing_type=sa.String(), nullable=False
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Repair invitation_user data lost by LDAP migration CASCADE bug
2+
3+
The 20251226_add_ldap migration ran batch_alter_table("invitation") with
4+
PRAGMA foreign_keys=ON. In SQLite, DROP TABLE with FKs enabled triggers
5+
an implicit DELETE FROM which fires ON DELETE CASCADE — wiping every row
6+
in invitation_user. This migration repopulates the table using the same
7+
recovery logic from the original 20250814 migration.
8+
9+
Revision ID: 20260401_repair
10+
Revises: 8e5c69f96870
11+
Create Date: 2026-04-01 23:00:00.000000
12+
13+
"""
14+
15+
import sqlalchemy as sa
16+
from alembic import op
17+
18+
# revision identifiers, used by Alembic.
19+
revision = "20260401_repair"
20+
down_revision = "8e5c69f96870"
21+
branch_labels = None
22+
depends_on = None
23+
24+
25+
def upgrade():
26+
conn = op.get_bind()
27+
28+
# Check if invitation_user table exists (it should)
29+
inspector = sa.inspect(conn)
30+
if "invitation_user" not in inspector.get_table_names():
31+
print("WARNING: invitation_user table does not exist, skipping repair")
32+
return
33+
34+
# Check current state — skip if data is already present
35+
existing = conn.execute(sa.text("SELECT COUNT(*) FROM invitation_user")).scalar()
36+
if existing > 0:
37+
print(
38+
f"invitation_user already has {existing} rows — skipping repair "
39+
f"(data was not affected by the CASCADE bug)"
40+
)
41+
return
42+
43+
print("invitation_user is empty — running data recovery...")
44+
45+
# Step 1: Recover from invitation.used_by_id → user relationship
46+
result1 = conn.execute(
47+
sa.text("""
48+
SELECT i.id as invite_id, i.used_by_id as user_id,
49+
COALESCE(i.used_at, i.created) as used_at,
50+
u.server_id as server_id
51+
FROM invitation i
52+
JOIN user u ON i.used_by_id = u.id
53+
WHERE i.used_by_id IS NOT NULL
54+
""")
55+
)
56+
57+
step1_count = 0
58+
for row in result1:
59+
conn.execute(
60+
sa.text("""
61+
INSERT OR IGNORE INTO invitation_user (invite_id, user_id, used_at, server_id)
62+
VALUES (:invite_id, :user_id, :used_at, :server_id)
63+
"""),
64+
{
65+
"invite_id": row.invite_id,
66+
"user_id": row.user_id,
67+
"used_at": row.used_at,
68+
"server_id": row.server_id,
69+
},
70+
)
71+
step1_count += 1
72+
73+
if step1_count:
74+
print(f" Step 1: Recovered {step1_count} rows from invitation.used_by_id")
75+
76+
# Step 2: Recover from user.code ↔ invitation.code matching
77+
# This captures users from unlimited invitations (multiple users per code)
78+
result2 = conn.execute(
79+
sa.text("""
80+
SELECT DISTINCT i.id as invite_id, u.id as user_id,
81+
COALESCE(i.used_at, i.created) as used_at,
82+
u.server_id as server_id
83+
FROM invitation i
84+
JOIN user u ON u.code = i.code
85+
WHERE u.code IS NOT NULL
86+
AND u.code != ''
87+
AND NOT EXISTS (
88+
SELECT 1 FROM invitation_user iu
89+
WHERE iu.invite_id = i.id AND iu.user_id = u.id
90+
)
91+
""")
92+
)
93+
94+
step2_count = 0
95+
for row in result2:
96+
conn.execute(
97+
sa.text("""
98+
INSERT OR IGNORE INTO invitation_user (invite_id, user_id, used_at, server_id)
99+
VALUES (:invite_id, :user_id, :used_at, :server_id)
100+
"""),
101+
{
102+
"invite_id": row.invite_id,
103+
"user_id": row.user_id,
104+
"used_at": row.used_at,
105+
"server_id": row.server_id,
106+
},
107+
)
108+
step2_count += 1
109+
110+
if step2_count:
111+
print(
112+
f" Step 2: Recovered {step2_count} additional rows from user.code matching"
113+
)
114+
115+
# Step 3: Fix invitation_server usage flags for consistency
116+
invitations_with_users = conn.execute(
117+
sa.text("SELECT DISTINCT invite_id FROM invitation_user")
118+
).fetchall()
119+
120+
server_fixes = 0
121+
for row in invitations_with_users:
122+
invite_id = row.invite_id
123+
124+
result = conn.execute(
125+
sa.text("""
126+
UPDATE invitation_server
127+
SET used = 1, used_at = CURRENT_TIMESTAMP
128+
WHERE invite_id = :invite_id AND used = 0
129+
"""),
130+
{"invite_id": invite_id},
131+
)
132+
if result.rowcount > 0:
133+
server_fixes += result.rowcount
134+
135+
conn.execute(
136+
sa.text("""
137+
UPDATE invitation
138+
SET used = 1, used_at = COALESCE(used_at, CURRENT_TIMESTAMP)
139+
WHERE id = :invite_id AND used = 0
140+
"""),
141+
{"invite_id": invite_id},
142+
)
143+
144+
total = step1_count + step2_count
145+
print(f" Recovery complete: {total} invitation-user relationships restored")
146+
if server_fixes:
147+
print(f" Fixed {server_fixes} invitation_server usage flags")
148+
149+
150+
def downgrade():
151+
# This is a data-repair migration — downgrade is a no-op.
152+
# The repopulated data is correct and should not be removed.
153+
pass

0 commit comments

Comments
 (0)