feat(PROJ-21): Phase 2+3+5+8 Multi-Tenancy + PROJ-2 EML/MBOX Upload
Phase 2a: userstore domain_admin/superadmin Rollen, User.TenantID,
ListByTenant, UpsertLDAPUser mit tenantID
Phase 2b: storage.Save() mit tenantID *int64, email_refs Tabelle,
GetTenantForMail, GetAllIDsByTenant, StatsByTenant
Phase 2c: JWT-Claims tenant_id/tenant_slug, Session.TenantID,
Login Domain-Erkennung via E-Mail-Domain
Phase 3: tenantMiddleware, Handler-Filterung (Users, Mail, Stats)
Phase 5: SMTP Domain-Routing via DomainToTenantFunc Callback,
config smtp.tenant_routing + default_tenant_id
Phase 8: archivmail migrate-tenants Subkommando
PROJ-2: Upload-Seite /admin/upload mit DropZone + Progress-Polling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/archivmail/config"
|
||||
"github.com/archivmail/internal/userstore"
|
||||
)
|
||||
|
||||
// runMigrateTenants performs a one-time migration to set up the default tenant,
|
||||
// assign all existing users/emails/audit entries to it, and create a superadmin user.
|
||||
//
|
||||
// Usage: archivmail migrate-tenants [-config /etc/archivmail/config.yml]
|
||||
func runMigrateTenants(args []string) {
|
||||
fs := flag.NewFlagSet("migrate-tenants", flag.ExitOnError)
|
||||
configPath := fs.String("config", "/etc/archivmail/config.yml", "path to config file")
|
||||
fs.Parse(args) //nolint
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "migrate-tenants: load config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, cfg.Database.DSN())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "migrate-tenants: db connect: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// Check if migration has already run
|
||||
var tenantCount int
|
||||
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants`).Scan(&tenantCount)
|
||||
if tenantCount > 0 {
|
||||
fmt.Println("migrate-tenants: tenants table already populated — skipping migration")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// 1. Create default tenant
|
||||
var defaultTenantID int64
|
||||
err = pool.QueryRow(ctx,
|
||||
`INSERT INTO tenants (name, slug, active) VALUES ('default', 'default', true) RETURNING id`,
|
||||
).Scan(&defaultTenantID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "migrate-tenants: create default tenant: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("migrate-tenants: created default tenant with id=%d\n", defaultTenantID)
|
||||
|
||||
// 2. Assign all users without tenant to the default tenant
|
||||
tag, err := pool.Exec(ctx,
|
||||
`UPDATE users SET tenant_id = $1 WHERE tenant_id IS NULL`, defaultTenantID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "migrate-tenants: update users: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("migrate-tenants: assigned %d users to default tenant\n", tag.RowsAffected())
|
||||
|
||||
// 3. Assign all emails without tenant to the default tenant
|
||||
tag, err = pool.Exec(ctx,
|
||||
`UPDATE emails SET tenant_id = $1 WHERE tenant_id IS NULL`, defaultTenantID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "migrate-tenants: update emails: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("migrate-tenants: assigned %d emails to default tenant\n", tag.RowsAffected())
|
||||
|
||||
// 4. Seed email_refs for existing emails (all assigned to default tenant)
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO email_refs (email_id, tenant_id)
|
||||
SELECT id, $1 FROM emails WHERE tenant_id = $1
|
||||
ON CONFLICT (email_id, tenant_id) DO NOTHING
|
||||
`, defaultTenantID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "migrate-tenants: seed email_refs: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 5. Assign audit_log entries to default tenant (best-effort — column may not exist yet)
|
||||
_, _ = pool.Exec(ctx,
|
||||
`UPDATE audit_log SET tenant_id = $1 WHERE tenant_id IS NULL`, defaultTenantID)
|
||||
|
||||
// 6. Promote existing 'admin' users to 'domain_admin'
|
||||
tag, err = pool.Exec(ctx,
|
||||
`UPDATE users SET role = $1 WHERE role = 'admin'`, userstore.RoleDomainAdmin)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "migrate-tenants: promote admins: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("migrate-tenants: promoted %d admin(s) to domain_admin\n", tag.RowsAffected())
|
||||
|
||||
// 7. Create superadmin user (tenant_id = NULL = global access)
|
||||
superPw, err := randomPassword()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "migrate-tenants: generate password: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Use userstore so the password is properly bcrypt-hashed
|
||||
users, err := userstore.New(cfg.Database.DSN())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "migrate-tenants: userstore init: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer users.Close()
|
||||
|
||||
_, err = users.Create(userstore.CreateUserRequest{
|
||||
Username: "superadmin",
|
||||
Email: "superadmin@archivmail.local",
|
||||
Password: superPw,
|
||||
Role: userstore.RoleSuperAdmin,
|
||||
TenantID: nil, // global — no tenant restriction
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "migrate-tenants: create superadmin: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("╔══════════════════════════════════════════════════════════════════╗")
|
||||
fmt.Println("║ ARCHIVMAIL — TENANT MIGRATION ABGESCHLOSSEN ║")
|
||||
fmt.Println("║ Superadmin-Zugangsdaten (NUR EINMAL ANGEZEIGT): ║")
|
||||
fmt.Printf( "║ superadmin : %-52s ║\n", superPw)
|
||||
fmt.Println("║ Passwort sofort nach dem ersten Login ändern! ║")
|
||||
fmt.Println("╚══════════════════════════════════════════════════════════════════╝")
|
||||
fmt.Println()
|
||||
fmt.Printf("migrate-tenants: done — default tenant id=%d\n", defaultTenantID)
|
||||
}
|
||||
Reference in New Issue
Block a user