Skip to content

Commit 3c7fea1

Browse files
committed
fix(storage): prevent migration FK-cascade data loss; surface decrypt failures
- Disable foreign-key enforcement for the whole migration run (outside the per-migration transaction, per SQLite's documented table-rebuild procedure) and run `foreign_key_check` before re-enabling. The `PRAGMA foreign_keys=OFF` inside migrations 005/006/012 was a no-op within the runner's transaction, so their `DROP TABLE` rebuilds were cascade-deleting oauth_tokens, auth codes, and the workspace-binding↔FeatureSet junction. Regression-tested. - `decrypt_input_values` now distinguishes a legacy plaintext row from a real decrypt failure (wrong key / tampered ciphertext) and propagates an error instead of silently launching a server with all input values missing. - Wrap workspace-binding create/update (parent row + junction rewrite) in a transaction so a partial write can't orphan the binding. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 6eea108 commit 3c7fea1

3 files changed

Lines changed: 251 additions & 24 deletions

File tree

crates/mcpmux-storage/src/database.rs

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,23 @@ impl Database {
178178
MIGRATIONS.last().map(|m| m.version).unwrap_or(0)
179179
);
180180

181+
// Disable foreign-key enforcement for the duration of the migration
182+
// run, following SQLite's documented "other kinds of table schema
183+
// changes" procedure (https://sqlite.org/lang_altertable.html): the
184+
// table-rebuild migrations (005/006 rebuild inbound_clients, 012
185+
// rebuilds workspace_bindings) `DROP TABLE` the parent, and SQLite's
186+
// implicit pre-DROP DELETE FIRES `ON DELETE CASCADE` into child rows
187+
// (oauth_tokens, oauth_authorization_codes, the FS junction) — wiping
188+
// them. The `PRAGMA foreign_keys=OFF` inside those migration files is
189+
// a NO-OP because it runs inside the per-migration transaction below;
190+
// the pragma only takes effect OUTSIDE a transaction, so it must be
191+
// set here, before any `BEGIN`. The connection-level setting persists
192+
// across the per-migration transactions until we restore it.
193+
let fk_was_on = self.foreign_keys_enabled();
194+
if fk_was_on {
195+
self.conn.pragma_update(None, "foreign_keys", "OFF")?;
196+
}
197+
181198
// Run all migrations that haven't been applied yet
182199
for migration in MIGRATIONS {
183200
if migration.version > current_version {
@@ -196,6 +213,10 @@ impl Database {
196213
migration.name,
197214
e
198215
);
216+
// Best-effort restore of FK enforcement before bailing.
217+
if fk_was_on {
218+
let _ = self.conn.pragma_update(None, "foreign_keys", "ON");
219+
}
199220
return Err(anyhow::anyhow!(
200221
"Failed to run migration {} ({}): {}",
201222
migration.version,
@@ -219,6 +240,45 @@ impl Database {
219240
}
220241
}
221242

243+
// Re-enable FK enforcement and verify the migrated schema is still
244+
// referentially consistent (catches any genuine orphan a migration
245+
// might have introduced while enforcement was off).
246+
if fk_was_on {
247+
self.foreign_key_check()?;
248+
self.conn.pragma_update(None, "foreign_keys", "ON")?;
249+
}
250+
251+
Ok(())
252+
}
253+
254+
/// Whether foreign-key enforcement is currently enabled on the connection.
255+
fn foreign_keys_enabled(&self) -> bool {
256+
self.conn
257+
.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))
258+
.map(|v| v != 0)
259+
.unwrap_or(false)
260+
}
261+
262+
/// Run `PRAGMA foreign_key_check` and fail if any violation is reported.
263+
/// Called after a migration run completes with FK enforcement temporarily
264+
/// disabled, so a buggy migration can't silently leave dangling rows.
265+
fn foreign_key_check(&self) -> Result<()> {
266+
let mut stmt = self.conn.prepare("PRAGMA foreign_key_check")?;
267+
let mut rows = stmt.query([])?;
268+
let mut violations: Vec<String> = Vec::new();
269+
while let Some(row) = rows.next()? {
270+
// Columns: table, rowid, referred_table, fk_index
271+
let table: String = row.get(0).unwrap_or_default();
272+
let referred: String = row.get(2).unwrap_or_default();
273+
violations.push(format!("{table} -> {referred}"));
274+
}
275+
if !violations.is_empty() {
276+
return Err(anyhow::anyhow!(
277+
"Post-migration foreign_key_check found {} violation(s): {}",
278+
violations.len(),
279+
violations.join(", ")
280+
));
281+
}
222282
Ok(())
223283
}
224284

@@ -316,6 +376,140 @@ mod tests {
316376
assert!(count > 0, "Tables should be created");
317377
}
318378

379+
/// After a full migration run on a fresh DB, FK enforcement must be back
380+
/// ON and the schema must be referentially consistent — the contract of
381+
/// the runner's disable-during-migration / re-enable-after logic.
382+
#[test]
383+
fn fresh_db_has_fk_enabled_and_no_orphans() {
384+
let db = Database::open_in_memory().unwrap();
385+
let fk: i64 = db
386+
.connection()
387+
.query_row("PRAGMA foreign_keys", [], |r| r.get(0))
388+
.unwrap();
389+
assert_eq!(
390+
fk, 1,
391+
"runner must re-enable FK enforcement after migrations"
392+
);
393+
let violations: i64 = db
394+
.connection()
395+
.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| {
396+
r.get(0)
397+
})
398+
.unwrap();
399+
assert_eq!(violations, 0, "migrated schema must have no FK violations");
400+
}
401+
402+
/// The exact mechanic the table-rebuild migrations (005/006/012) depend
403+
/// on: DROPping a parent referenced by a child via `ON DELETE CASCADE`
404+
/// must NOT wipe the child — which only holds when FK enforcement is OFF
405+
/// during the rebuild (the runner disables it outside the transaction;
406+
/// the `PRAGMA foreign_keys=OFF` *inside* a migration file is a no-op).
407+
#[test]
408+
fn dropping_parent_with_fk_off_preserves_cascade_child() {
409+
use rusqlite::Connection;
410+
let conn = Connection::open_in_memory().unwrap();
411+
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
412+
conn.execute_batch(
413+
"CREATE TABLE parent(id TEXT PRIMARY KEY);
414+
CREATE TABLE child(id TEXT PRIMARY KEY,
415+
pid TEXT REFERENCES parent(id) ON DELETE CASCADE);
416+
INSERT INTO parent VALUES('p1');
417+
INSERT INTO child VALUES('c1','p1');",
418+
)
419+
.unwrap();
420+
421+
// Disable FK enforcement the way the runner does, then rebuild parent.
422+
conn.pragma_update(None, "foreign_keys", "OFF").unwrap();
423+
conn.execute_batch(
424+
"CREATE TABLE parent_new(id TEXT PRIMARY KEY);
425+
INSERT INTO parent_new SELECT * FROM parent;
426+
DROP TABLE parent;
427+
ALTER TABLE parent_new RENAME TO parent;",
428+
)
429+
.unwrap();
430+
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
431+
432+
let n: i64 = conn
433+
.query_row("SELECT count(*) FROM child", [], |r| r.get(0))
434+
.unwrap();
435+
assert_eq!(
436+
n, 1,
437+
"child must survive a parent table-rebuild when FK enforcement is off"
438+
);
439+
}
440+
441+
/// End-to-end regression for the 005/006 data-loss bug: an
442+
/// `oauth_tokens` row seeded BEFORE the inbound_clients rebuild
443+
/// migrations must survive the upgrade. With FK enforcement left on
444+
/// (pre-fix), `DROP TABLE inbound_clients` cascade-deleted every token.
445+
#[test]
446+
fn migration_upgrade_preserves_oauth_tokens() {
447+
use rusqlite::{params, Connection};
448+
449+
let conn = Connection::open_in_memory().unwrap();
450+
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
451+
let db = Database { conn };
452+
db.ensure_migrations_table().unwrap();
453+
454+
// Apply migrations up to v4 (last version before the first
455+
// inbound_clients rebuild at 005), recording each as applied.
456+
const PRE_REBUILD: i64 = 4;
457+
for m in MIGRATIONS.iter().filter(|m| m.version <= PRE_REBUILD) {
458+
db.conn.execute_batch(m.sql).unwrap();
459+
db.conn
460+
.execute(
461+
"INSERT OR REPLACE INTO schema_migrations (version, name, applied_at) \
462+
VALUES (?1, ?2, datetime('now'))",
463+
params![m.version, m.name],
464+
)
465+
.unwrap();
466+
}
467+
468+
// Seed an inbound client + an issued OAuth token (child via CASCADE).
469+
db.conn
470+
.execute(
471+
"INSERT INTO inbound_clients
472+
(client_id, registration_type, client_name, redirect_uris,
473+
grant_types, response_types, token_endpoint_auth_method,
474+
created_at, updated_at)
475+
VALUES ('client-1','dcr','Test','[\"http://127.0.0.1/cb\"]',
476+
'[\"authorization_code\"]','[\"code\"]','none',
477+
datetime('now'), datetime('now'))",
478+
[],
479+
)
480+
.unwrap();
481+
db.conn
482+
.execute(
483+
"INSERT INTO oauth_tokens
484+
(id, client_id, token_type, token_hash, created_at)
485+
VALUES ('tok-1','client-1','access','deadbeef', datetime('now'))",
486+
[],
487+
)
488+
.unwrap();
489+
490+
// Run the remaining migrations (005..=latest) through the real runner.
491+
db.run_migrations().unwrap();
492+
493+
let tokens: i64 = db
494+
.conn
495+
.query_row("SELECT count(*) FROM oauth_tokens", [], |r| r.get(0))
496+
.unwrap();
497+
assert_eq!(
498+
tokens, 1,
499+
"oauth token must survive the inbound_clients rebuild migrations"
500+
);
501+
// And the client row itself was preserved through the rebuilds.
502+
let clients: i64 = db
503+
.conn
504+
.query_row(
505+
"SELECT count(*) FROM inbound_clients WHERE client_id='client-1'",
506+
[],
507+
|r| r.get(0),
508+
)
509+
.unwrap();
510+
assert_eq!(clients, 1, "inbound client must survive the rebuilds");
511+
}
512+
319513
#[test]
320514
fn test_persistent_database() {
321515
let temp_dir = TempDir::new().unwrap();

crates/mcpmux-storage/src/repositories/installed_server_repository.rs

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,37 @@ impl SqliteInstalledServerRepository {
5353
}
5454

5555
/// Decrypt input values from storage.
56-
/// Falls back to plaintext JSON for backward compatibility with unencrypted data.
57-
fn decrypt_input_values(&self, stored: Option<String>) -> HashMap<String, String> {
56+
///
57+
/// Three cases, kept distinct so a real failure can't masquerade as an
58+
/// empty config (which would silently launch a server with all its
59+
/// secrets missing):
60+
/// * `None` / empty column → no input values (`Ok(empty)`).
61+
/// * Decrypts cleanly → parse the plaintext JSON (a parse failure here
62+
/// is corruption → error).
63+
/// * Decrypt fails → it may be a legacy *unencrypted* row, so try a
64+
/// plaintext-JSON parse; if THAT also fails the data is neither
65+
/// decryptable nor valid plaintext (wrong master key or tampered
66+
/// ciphertext) → propagate a hard error rather than returning empty.
67+
fn decrypt_input_values(&self, stored: Option<String>) -> Result<HashMap<String, String>> {
5868
let Some(data) = stored else {
59-
return HashMap::new();
69+
return Ok(HashMap::new());
6070
};
61-
// Try decrypting first (new encrypted format)
71+
if data.trim().is_empty() {
72+
return Ok(HashMap::new());
73+
}
74+
// Try decrypting first (new encrypted format).
6275
if let Ok(json) = self.encryptor.decrypt(&data) {
63-
return serde_json::from_str(&json).unwrap_or_default();
76+
return serde_json::from_str(&json)
77+
.map_err(|e| anyhow::anyhow!("Corrupt decrypted input values (not JSON): {}", e));
6478
}
65-
// Fallback: try parsing as plaintext JSON (backward compat)
66-
serde_json::from_str(&data).unwrap_or_default()
79+
// Fallback: legacy unencrypted row stored as plaintext JSON.
80+
serde_json::from_str(&data).map_err(|e| {
81+
anyhow::anyhow!(
82+
"Failed to decrypt input values and data is not valid plaintext JSON \
83+
(wrong master key or tampered ciphertext): {}",
84+
e
85+
)
86+
})
6787
}
6888

6989
/// Parse a datetime string to DateTime<Utc>.
@@ -154,14 +174,17 @@ impl SqliteInstalledServerRepository {
154174
}
155175

156176
/// Build InstalledServer from extracted row data (needs &self for decryption).
157-
fn build_server(&self, row: RawServerRow) -> InstalledServer {
158-
InstalledServer {
177+
fn build_server(&self, row: RawServerRow) -> Result<InstalledServer> {
178+
let input_values = self
179+
.decrypt_input_values(row.input_values)
180+
.map_err(|e| anyhow::anyhow!("server {}: {}", row.server_id, e))?;
181+
Ok(InstalledServer {
159182
id: Uuid::parse_str(&row.id).unwrap_or_else(|_| Uuid::new_v4()),
160183
space_id: row.space_id,
161184
server_id: row.server_id,
162185
server_name: row.server_name,
163186
cached_definition: row.cached_definition,
164-
input_values: self.decrypt_input_values(row.input_values),
187+
input_values,
165188
enabled: row.enabled,
166189
env_overrides: Self::parse_json_map(row.env_overrides),
167190
args_append: Self::parse_json_vec(row.args_append),
@@ -170,7 +193,7 @@ impl SqliteInstalledServerRepository {
170193
source: Self::parse_source(row.source),
171194
created_at: Self::parse_datetime(&row.created_at),
172195
updated_at: Self::parse_datetime(&row.updated_at),
173-
}
196+
})
174197
}
175198
}
176199

@@ -189,7 +212,7 @@ impl InstalledServerRepository for SqliteInstalledServerRepository {
189212
.query_map([], Self::extract_row)?
190213
.collect::<Result<Vec<_>, _>>()?;
191214

192-
Ok(rows.into_iter().map(|r| self.build_server(r)).collect())
215+
rows.into_iter().map(|r| self.build_server(r)).collect()
193216
}
194217

195218
async fn list_for_space(&self, space_id: &str) -> Result<Vec<InstalledServer>> {
@@ -205,7 +228,7 @@ impl InstalledServerRepository for SqliteInstalledServerRepository {
205228
.query_map([space_id], Self::extract_row)?
206229
.collect::<Result<Vec<_>, _>>()?;
207230

208-
Ok(rows.into_iter().map(|r| self.build_server(r)).collect())
231+
rows.into_iter().map(|r| self.build_server(r)).collect()
209232
}
210233

211234
async fn list_by_source_file(
@@ -227,7 +250,7 @@ impl InstalledServerRepository for SqliteInstalledServerRepository {
227250
.query_map([&source_prefix], Self::extract_row)?
228251
.collect::<Result<Vec<_>, _>>()?;
229252

230-
Ok(rows.into_iter().map(|r| self.build_server(r)).collect())
253+
rows.into_iter().map(|r| self.build_server(r)).collect()
231254
}
232255

233256
async fn get(&self, id: &Uuid) -> Result<Option<InstalledServer>> {
@@ -243,7 +266,7 @@ impl InstalledServerRepository for SqliteInstalledServerRepository {
243266
.query_row([id.to_string()], Self::extract_row)
244267
.optional()?;
245268

246-
Ok(row.map(|r| self.build_server(r)))
269+
row.map(|r| self.build_server(r)).transpose()
247270
}
248271

249272
async fn get_by_server_id(
@@ -263,7 +286,7 @@ impl InstalledServerRepository for SqliteInstalledServerRepository {
263286
.query_row([space_id, server_id], Self::extract_row)
264287
.optional()?;
265288

266-
Ok(row.map(|r| self.build_server(r)))
289+
row.map(|r| self.build_server(r)).transpose()
267290
}
268291

269292
async fn install(&self, server: &InstalledServer) -> Result<()> {
@@ -350,7 +373,7 @@ impl InstalledServerRepository for SqliteInstalledServerRepository {
350373
.query_map([space_id], Self::extract_row)?
351374
.collect::<Result<Vec<_>, _>>()?;
352375

353-
Ok(rows.into_iter().map(|r| self.build_server(r)).collect())
376+
rows.into_iter().map(|r| self.build_server(r)).collect()
354377
}
355378

356379
async fn list_enabled_all(&self) -> Result<Vec<InstalledServer>> {
@@ -366,7 +389,7 @@ impl InstalledServerRepository for SqliteInstalledServerRepository {
366389
.query_map([], Self::extract_row)?
367390
.collect::<Result<Vec<_>, _>>()?;
368391

369-
Ok(rows.into_iter().map(|r| self.build_server(r)).collect())
392+
rows.into_iter().map(|r| self.build_server(r)).collect()
370393
}
371394

372395
async fn set_enabled(&self, id: &Uuid, enabled: bool) -> Result<()> {

0 commit comments

Comments
 (0)