Skip to main content

romance_core/addon/
multitenancy.rs

1use crate::addon::Addon;
2use anyhow::Result;
3use std::path::Path;
4
5pub struct MultitenancyAddon;
6
7impl Addon for MultitenancyAddon {
8    fn name(&self) -> &str {
9        "multitenancy"
10    }
11
12    fn check_prerequisites(&self, project_root: &Path) -> Result<()> {
13        super::check_romance_project(project_root)?;
14        super::check_auth_exists(project_root)
15    }
16
17    fn is_already_installed(&self, project_root: &Path) -> bool {
18        project_root.join("backend/src/tenant.rs").exists()
19    }
20
21    fn install(&self, project_root: &Path) -> Result<()> {
22        install_multitenancy(project_root)
23    }
24
25    fn uninstall(&self, project_root: &Path) -> Result<()> {
26        uninstall_multitenancy(project_root)
27    }
28
29    fn dependencies(&self) -> Vec<&str> {
30        vec!["auth"]
31    }
32}
33
34fn install_multitenancy(project_root: &Path) -> Result<()> {
35    use crate::template::TemplateEngine;
36    use crate::utils;
37    use colored::Colorize;
38    use tera::Context;
39
40    println!("{}", "Installing multitenancy...".bold());
41
42    let engine = TemplateEngine::new()?;
43    let ctx = Context::new();
44
45    // 1. Generate tenant extractor module
46    let content = engine.render("addon/multitenancy/tenant.rs.tera", &ctx)?;
47    utils::write_file(&project_root.join("backend/src/tenant.rs"), &content)?;
48    println!("  {} backend/src/tenant.rs", "create".green());
49
50    // 2. Generate tenant entity model
51    let content = engine.render("addon/multitenancy/tenant_model.rs.tera", &ctx)?;
52    utils::write_file(
53        &project_root.join("backend/src/entities/tenant.rs"),
54        &content,
55    )?;
56    println!("  {} backend/src/entities/tenant.rs", "create".green());
57
58    // 3. Generate tenant handlers
59    let content = engine.render("addon/multitenancy/tenant_handlers.rs.tera", &ctx)?;
60    utils::write_file(
61        &project_root.join("backend/src/handlers/tenant.rs"),
62        &content,
63    )?;
64    println!("  {} backend/src/handlers/tenant.rs", "create".green());
65
66    // 4. Generate tenant routes
67    let content = engine.render("addon/multitenancy/tenant_routes.rs.tera", &ctx)?;
68    utils::write_file(
69        &project_root.join("backend/src/routes/tenant.rs"),
70        &content,
71    )?;
72    println!("  {} backend/src/routes/tenant.rs", "create".green());
73
74    // 5. Generate tenants table migration
75    let ts1 = crate::generator::migration::next_timestamp();
76    let content = engine.render("addon/multitenancy/tenant_migration.rs.tera", &ctx)?;
77    let migration1_module = format!("m{}_create_tenants_table", ts1);
78    utils::write_file(
79        &project_root.join(format!("backend/migration/src/{}.rs", migration1_module)),
80        &content,
81    )?;
82    println!(
83        "  {} backend/migration/src/{}.rs",
84        "create".green(),
85        migration1_module
86    );
87
88    // 6. Generate add_tenant_to_users migration (1 second later to avoid collision)
89    let ts2 = crate::generator::migration::next_timestamp();
90    let content = engine.render(
91        "addon/multitenancy/add_tenant_to_users_migration.rs.tera",
92        &ctx,
93    )?;
94    let migration2_module = format!("m{}_add_tenant_id_to_users", ts2);
95    utils::write_file(
96        &project_root.join(format!("backend/migration/src/{}.rs", migration2_module)),
97        &content,
98    )?;
99    println!(
100        "  {} backend/migration/src/{}.rs",
101        "create".green(),
102        migration2_module
103    );
104
105    // Register modules via markers
106    let mods_marker = "// === ROMANCE:MODS ===";
107
108    utils::insert_at_marker(
109        &project_root.join("backend/src/entities/mod.rs"),
110        mods_marker,
111        "pub mod tenant;",
112    )?;
113    utils::insert_at_marker(
114        &project_root.join("backend/src/handlers/mod.rs"),
115        mods_marker,
116        "pub mod tenant;",
117    )?;
118    utils::insert_at_marker(
119        &project_root.join("backend/src/routes/mod.rs"),
120        mods_marker,
121        "pub mod tenant;",
122    )?;
123    utils::insert_at_marker(
124        &project_root.join("backend/src/routes/mod.rs"),
125        "// === ROMANCE:ROUTES ===",
126        "        .merge(tenant::router())",
127    )?;
128
129    // Register migrations
130    let lib_path = project_root.join("backend/migration/src/lib.rs");
131    utils::insert_at_marker(
132        &lib_path,
133        "// === ROMANCE:MIGRATION_MODS ===",
134        &format!("mod {};", migration1_module),
135    )?;
136    utils::insert_at_marker(
137        &lib_path,
138        "// === ROMANCE:MIGRATIONS ===",
139        &format!("            Box::new({}::Migration),", migration1_module),
140    )?;
141    utils::insert_at_marker(
142        &lib_path,
143        "// === ROMANCE:MIGRATION_MODS ===",
144        &format!("mod {};", migration2_module),
145    )?;
146    utils::insert_at_marker(
147        &lib_path,
148        "// === ROMANCE:MIGRATIONS ===",
149        &format!("            Box::new({}::Migration),", migration2_module),
150    )?;
151
152    // Add mod tenant to main.rs
153    super::add_mod_to_main(project_root, "tenant")?;
154
155    // Patch existing auth files in-place
156    patch_auth_for_multitenancy(project_root)?;
157
158    // Update romance.toml
159    super::update_feature_flag(project_root, "multitenancy", true)?;
160
161    println!();
162    println!(
163        "{}",
164        "Multitenancy installed successfully!".green().bold()
165    );
166    println!("  All future entities will include tenant_id column.");
167    println!("  Existing entities need manual migration to add tenant_id.");
168    println!("  Tenant admin API: POST/GET /api/tenants (admin-only)");
169    println!();
170    println!("Next steps:");
171    println!("  romance db migrate");
172
173    Ok(())
174}
175
176/// Insert `new_line` after the first line that contains `needle`.
177fn insert_after_first(content: &str, needle: &str, new_line: &str) -> String {
178    let mut result = String::with_capacity(content.len() + new_line.len() + 2);
179    let mut found = false;
180    for line in content.lines() {
181        result.push_str(line);
182        result.push('\n');
183        if !found && line.contains(needle) {
184            result.push_str(new_line);
185            result.push('\n');
186            found = true;
187        }
188    }
189    result
190}
191
192/// Insert `block` before the first line that contains `needle`.
193fn insert_before_first(content: &str, needle: &str, block: &str) -> String {
194    let mut result = String::with_capacity(content.len() + block.len());
195    let mut found = false;
196    for line in content.lines() {
197        if !found && line.contains(needle) {
198            result.push_str(block);
199            if !block.ends_with('\n') {
200                result.push('\n');
201            }
202            found = true;
203        }
204        result.push_str(line);
205        result.push('\n');
206    }
207    result
208}
209
210/// Patch existing auth files to add tenant_id support.
211/// This is needed when multitenancy is installed AFTER `romance generate auth`.
212/// Uses line-based operations instead of multi-line string matching for robustness.
213fn patch_auth_for_multitenancy(project_root: &Path) -> Result<()> {
214    use colored::Colorize;
215
216    // === 1. Patch backend/src/auth.rs ===
217    let auth_path = project_root.join("backend/src/auth.rs");
218    if auth_path.exists() {
219        let content = std::fs::read_to_string(&auth_path)?;
220        if !content.contains("tenant_id") {
221            let mut patched = content;
222
223            // Add tenant_id field to Claims (after "pub role: String," — unique in auth.rs)
224            patched = insert_after_first(
225                &patched,
226                "pub role: String,",
227                "    pub tenant_id: Option<String>,",
228            );
229
230            // Update create_token signature (single-line match — reliable)
231            patched = patched.replace(
232                "pub fn create_token(user_id: Uuid, email: &str, role: &str)",
233                "pub fn create_token(user_id: Uuid, email: &str, role: &str, tenant_id: Option<Uuid>)",
234            );
235
236            // Add tenant_id to Claims construction (after "role: role.to_string()," — unique in auth.rs)
237            patched = insert_after_first(
238                &patched,
239                "role: role.to_string(),",
240                "        tenant_id: tenant_id.map(|t| t.to_string()),",
241            );
242
243            std::fs::write(&auth_path, patched)?;
244            println!(
245                "  {} backend/src/auth.rs (added tenant_id)",
246                "patch".yellow()
247            );
248        }
249    }
250
251    // === 2. Patch backend/src/entities/user.rs ===
252    let user_model_path = project_root.join("backend/src/entities/user.rs");
253    if user_model_path.exists() {
254        let content = std::fs::read_to_string(&user_model_path)?;
255        if !content.contains("tenant_id") {
256            let mut lines: Vec<String> = content.lines().map(String::from).collect();
257            let mut insertions: Vec<(usize, String)> = Vec::new();
258            let mut in_update_user_role = false;
259
260            for (i, line) in lines.iter().enumerate() {
261                if line.contains("pub struct UpdateUserRole") {
262                    in_update_user_role = true;
263                }
264                if in_update_user_role && line.trim() == "}" {
265                    in_update_user_role = false;
266                }
267                // Add tenant_id: Uuid to Model and UserPublic (skip UpdateUserRole)
268                if line.contains("pub role: String,") && !in_update_user_role {
269                    insertions.push((i + 1, "    pub tenant_id: Uuid,".to_string()));
270                }
271                // Add tenant_id: Option<Uuid> to CreateUser
272                if line.contains("pub password: String,") {
273                    insertions.push((i + 1, "    pub tenant_id: Option<Uuid>,".to_string()));
274                }
275            }
276
277            for (idx, new_line) in insertions.into_iter().rev() {
278                lines.insert(idx, new_line);
279            }
280
281            let patched = lines.join("\n") + "\n";
282            std::fs::write(&user_model_path, patched)?;
283            println!(
284                "  {} backend/src/entities/user.rs (added tenant_id)",
285                "patch".yellow()
286            );
287        }
288    }
289
290    // === 3. Patch backend/src/handlers/auth.rs ===
291    let auth_handlers_path = project_root.join("backend/src/handlers/auth.rs");
292    if auth_handlers_path.exists() {
293        let content = std::fs::read_to_string(&auth_handlers_path)?;
294        if !content.contains("tenant_id") {
295            let mut patched = content;
296
297            // Update create_token calls (unique single-line matches — reliable)
298            patched = patched.replace(
299                "auth::create_token(created.id, &created.email, &created.role)",
300                "auth::create_token(created.id, &created.email, &created.role, Some(created.tenant_id))",
301            );
302            patched = patched.replace(
303                "auth::create_token(user.id, &user.email, &user.role)",
304                "auth::create_token(user.id, &user.email, &user.role, Some(user.tenant_id))",
305            );
306
307            // Add tenant_id: Set(tenant_id) to register's ActiveModel
308            patched = insert_after_first(
309                &patched,
310                "role: Set(\"user\".to_string()),",
311                "        tenant_id: Set(tenant_id),",
312            );
313
314            // Add tenant resolution block before "let model = user::ActiveModel {"
315            let tenant_block = "\
316    // Resolve tenant: use provided tenant_id or create a default tenant
317    let tenant_id = if let Some(tid) = input.tenant_id {
318        crate::entities::tenant::Entity::find_by_id(tid)
319            .one(&state.db)
320            .await?
321            .ok_or_else(|| AppError::NotFound(\"Tenant not found\".into()))?;
322        tid
323    } else {
324        let tid = Uuid::new_v4();
325        let tenant_model = crate::entities::tenant::ActiveModel {
326            id: Set(tid),
327            name: Set(format!(\"{}\'s Organization\", input.email)),
328            slug: Set(format!(\"org-{}\", user_id)),
329            created_at: Set(now),
330            updated_at: Set(now),
331        };
332        tenant_model.insert(&state.db).await?;
333        tid
334    };
335
336";
337            patched = insert_before_first(
338                &patched,
339                "let model = user::ActiveModel {",
340                tenant_block,
341            );
342
343            // Add tenant_id to all UserPublic constructions via line-based insertion
344            let mut lines: Vec<String> = patched.lines().map(String::from).collect();
345            let mut insertions: Vec<(usize, String)> = Vec::new();
346            for (i, line) in lines.iter().enumerate() {
347                let trimmed = line.trim();
348                // Match "role: VAR.role," patterns in UserPublic constructions
349                if trimmed.starts_with("role:") && trimmed.ends_with(".role,") {
350                    let indent: String =
351                        line.chars().take_while(|c| c.is_whitespace()).collect();
352                    if let Some(var) = trimmed
353                        .strip_prefix("role: ")
354                        .and_then(|s| s.strip_suffix(".role,"))
355                    {
356                        insertions.push((
357                            i + 1,
358                            format!("{}tenant_id: {}.tenant_id,", indent, var),
359                        ));
360                    }
361                }
362            }
363
364            for (idx, new_line) in insertions.into_iter().rev() {
365                lines.insert(idx, new_line);
366            }
367
368            let patched = lines.join("\n") + "\n";
369            std::fs::write(&auth_handlers_path, patched)?;
370            println!(
371                "  {} backend/src/handlers/auth.rs (added tenant_id)",
372                "patch".yellow()
373            );
374        }
375    }
376
377    Ok(())
378}
379
380fn uninstall_multitenancy(project_root: &Path) -> Result<()> {
381    use colored::Colorize;
382
383    println!("{}", "Uninstalling multitenancy...".bold());
384
385    // Delete generated files
386    let files_to_remove = [
387        "backend/src/tenant.rs",
388        "backend/src/entities/tenant.rs",
389        "backend/src/handlers/tenant.rs",
390        "backend/src/routes/tenant.rs",
391    ];
392
393    for file in &files_to_remove {
394        if super::remove_file_if_exists(&project_root.join(file))? {
395            println!("  {} {}", "delete".red(), file);
396        }
397    }
398
399    // Remove mod declarations
400    super::remove_mod_from_main(project_root, "tenant")?;
401
402    super::remove_line_from_file(
403        &project_root.join("backend/src/entities/mod.rs"),
404        "pub mod tenant;",
405    )?;
406    super::remove_line_from_file(
407        &project_root.join("backend/src/handlers/mod.rs"),
408        "pub mod tenant;",
409    )?;
410    super::remove_line_from_file(
411        &project_root.join("backend/src/routes/mod.rs"),
412        "pub mod tenant;",
413    )?;
414    super::remove_line_from_file(
415        &project_root.join("backend/src/routes/mod.rs"),
416        ".merge(tenant::router())",
417    )?;
418
419    // Unpatch auth.rs — remove tenant_id from Claims
420    let auth_path = project_root.join("backend/src/auth.rs");
421    if auth_path.exists() {
422        let content = std::fs::read_to_string(&auth_path)?;
423        if content.contains("tenant_id") {
424            let mut patched = content;
425            // Remove tenant_id field from Claims
426            patched = patched.replace("    pub tenant_id: Option<String>,\n", "");
427            // Revert create_token signature
428            patched = patched.replace(
429                "pub fn create_token(user_id: Uuid, email: &str, role: &str, tenant_id: Option<Uuid>) -> Result<String>",
430                "pub fn create_token(user_id: Uuid, email: &str, role: &str) -> Result<String>",
431            );
432            // Remove tenant_id from Claims construction
433            patched = patched.replace(
434                "        tenant_id: tenant_id.map(|t| t.to_string()),\n",
435                "",
436            );
437            std::fs::write(&auth_path, patched)?;
438            println!("  {} backend/src/auth.rs (removed tenant_id)", "patch".yellow());
439        }
440    }
441
442    // Unpatch user model
443    let user_model_path = project_root.join("backend/src/entities/user.rs");
444    if user_model_path.exists() {
445        let content = std::fs::read_to_string(&user_model_path)?;
446        if content.contains("    pub tenant_id: Uuid,") {
447            let patched = content
448                .replace("    pub tenant_id: Uuid,\n", "")
449                .replace("    pub tenant_id: Option<Uuid>,\n", "");
450            std::fs::write(&user_model_path, patched)?;
451            println!(
452                "  {} backend/src/entities/user.rs (removed tenant_id)",
453                "patch".yellow()
454            );
455        }
456    }
457
458    // Unpatch auth handlers — revert create_token calls and remove tenant_id
459    let auth_handlers_path = project_root.join("backend/src/handlers/auth.rs");
460    if auth_handlers_path.exists() {
461        let content = std::fs::read_to_string(&auth_handlers_path)?;
462        if content.contains("tenant_id") {
463            let mut patched = content;
464            // Revert create_token calls (remove tenant_id argument)
465            patched = patched.replace(
466                ", Some(created.tenant_id))",
467                ")",
468            );
469            patched = patched.replace(
470                ", Some(user.tenant_id))",
471                ")",
472            );
473            // Remove line by line: tenant resolution block, tenant_id fields, Set(tenant_id)
474            let lines: Vec<&str> = patched.lines().collect();
475            let mut result = Vec::new();
476            let mut skip_block = false;
477            for line in &lines {
478                if line.contains("// Resolve tenant:") {
479                    skip_block = true;
480                    continue;
481                }
482                if skip_block {
483                    // Skip until we reach the ActiveModel that we want to keep
484                    if line.contains("let model = user::ActiveModel") || line.contains("let model: user::ActiveModel") {
485                        skip_block = false;
486                        // Don't skip — keep this line
487                    } else {
488                        continue;
489                    }
490                }
491                // Remove tenant_id: Set(tenant_id) from ActiveModel
492                if line.contains("tenant_id: Set(tenant_id)") {
493                    continue;
494                }
495                // Remove tenant_id from UserPublic constructions (any indentation)
496                let trimmed = line.trim();
497                if trimmed.starts_with("tenant_id:") && trimmed.ends_with(".tenant_id,") {
498                    continue;
499                }
500                result.push(*line);
501            }
502            patched = result.join("\n");
503            if !patched.ends_with('\n') {
504                patched.push('\n');
505            }
506
507            std::fs::write(&auth_handlers_path, patched)?;
508            println!(
509                "  {} backend/src/handlers/auth.rs (removed tenant_id)",
510                "patch".yellow()
511            );
512        }
513    }
514
515    // Clean up tenant references in all generated entity files
516    cleanup_entity_tenant_references(project_root)?;
517
518    // Remove feature flag
519    super::remove_feature_flag(project_root, "multitenancy")?;
520
521    // Note: migration files are left in place (can't safely remove without risking DB state)
522
523    // Regenerate AI context
524    crate::ai_context::regenerate(project_root).ok();
525
526    println!();
527    println!(
528        "{}",
529        "Multitenancy uninstalled successfully.".green().bold()
530    );
531    println!(
532        "  {}",
533        "Note: Migration files were left in place. Create a new migration to remove tenant_id columns."
534            .dimmed()
535    );
536
537    Ok(())
538}
539
540/// Clean up tenant_id references from all generated entity files.
541/// This handles entity model and handler files that were generated while multitenancy was active.
542fn cleanup_entity_tenant_references(project_root: &Path) -> Result<()> {
543    use colored::Colorize;
544
545    // Clean up entity handler files
546    let handlers_dir = project_root.join("backend/src/handlers");
547    if handlers_dir.exists() {
548        for entry in std::fs::read_dir(&handlers_dir)? {
549            let entry = entry?;
550            let path = entry.path();
551            if path.extension().map(|e| e == "rs").unwrap_or(false) {
552                let name = path.file_stem().unwrap().to_string_lossy().to_string();
553                // Skip files already handled by other uninstall steps
554                if matches!(
555                    name.as_str(),
556                    "auth" | "tenant" | "mod" | "upload" | "search" | "audit_log" | "dev_dashboard"
557                ) {
558                    continue;
559                }
560                let content = std::fs::read_to_string(&path)?;
561                if !content.contains("TenantGuard") {
562                    continue;
563                }
564                let mut patched = content;
565                // Replace TenantGuard import with AuthUser
566                patched = patched.replace(
567                    "use crate::tenant::TenantGuard;\n",
568                    "use crate::auth::AuthUser;\n",
569                );
570                // Replace parameter name
571                patched = patched.replace("tenant: TenantGuard,", "_auth: AuthUser,");
572                // Replace audit log / claims references
573                patched = patched.replace("tenant.claims.", "_auth.0.");
574
575                // Remove tenant-specific lines
576                let lines: Vec<&str> = patched.lines().collect();
577                let mut result = Vec::new();
578                for line in &lines {
579                    let trimmed = line.trim();
580                    if trimmed.contains("Column::TenantId.eq(tenant.tenant_id)") {
581                        continue;
582                    }
583                    if trimmed.contains("tenant_id: Set(tenant.tenant_id)") {
584                        continue;
585                    }
586                    result.push(*line);
587                }
588                patched = result.join("\n");
589                if !patched.ends_with('\n') {
590                    patched.push('\n');
591                }
592                std::fs::write(&path, patched)?;
593                println!(
594                    "  {} backend/src/handlers/{}.rs (removed tenant_id)",
595                    "patch".yellow(),
596                    name
597                );
598            }
599        }
600    }
601
602    // Clean up entity model files
603    let entities_dir = project_root.join("backend/src/entities");
604    if entities_dir.exists() {
605        for entry in std::fs::read_dir(&entities_dir)? {
606            let entry = entry?;
607            let path = entry.path();
608            if path.extension().map(|e| e == "rs").unwrap_or(false) {
609                let name = path.file_stem().unwrap().to_string_lossy().to_string();
610                if matches!(name.as_str(), "user" | "tenant" | "mod" | "audit_entry") {
611                    continue;
612                }
613                let content = std::fs::read_to_string(&path)?;
614                if !content.contains("tenant_id") && !content.contains("super::tenant") {
615                    continue;
616                }
617
618                let lines: Vec<&str> = content.lines().collect();
619                let mut result: Vec<&str> = Vec::new();
620                let mut skip_relation_attr = false;
621                let mut skip_related_impl = false;
622                let mut impl_depth: i32 = 0;
623
624                for line in &lines {
625                    let trimmed = line.trim();
626
627                    // Remove tenant_id field
628                    if trimmed == "pub tenant_id: Uuid," {
629                        continue;
630                    }
631
632                    // Skip Tenant relation attributes block
633                    if trimmed.contains("belongs_to = \"super::tenant::Entity\"") {
634                        skip_relation_attr = true;
635                        // Also remove the #[sea_orm( line we already pushed
636                        if let Some(last) = result.last() {
637                            if last.trim() == "#[sea_orm(" {
638                                result.pop();
639                            }
640                        }
641                        continue;
642                    }
643                    if skip_relation_attr {
644                        if trimmed == "Tenant," {
645                            skip_relation_attr = false;
646                        }
647                        continue;
648                    }
649
650                    // Skip Related<super::tenant::Entity> impl block (track brace depth)
651                    if trimmed.starts_with("impl Related<super::tenant::Entity>") {
652                        skip_related_impl = true;
653                        impl_depth = 0;
654                        for ch in trimmed.chars() {
655                            if ch == '{' { impl_depth += 1; }
656                            if ch == '}' { impl_depth -= 1; }
657                        }
658                        continue;
659                    }
660                    if skip_related_impl {
661                        for ch in trimmed.chars() {
662                            if ch == '{' { impl_depth += 1; }
663                            if ch == '}' { impl_depth -= 1; }
664                        }
665                        if impl_depth <= 0 {
666                            skip_related_impl = false;
667                        }
668                        continue;
669                    }
670
671                    result.push(*line);
672                }
673
674                let mut patched = result.join("\n");
675                if !patched.ends_with('\n') {
676                    patched.push('\n');
677                }
678                std::fs::write(&path, patched)?;
679                println!(
680                    "  {} backend/src/entities/{}.rs (removed tenant_id)",
681                    "patch".yellow(),
682                    name
683                );
684            }
685        }
686    }
687
688    Ok(())
689}