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:
sysops
2026-03-17 21:03:40 +01:00
parent 5250ffcd52
commit 479c27e5a8
16 changed files with 966 additions and 158 deletions
+135
View File
@@ -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)
}