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
+55 -25
View File
@@ -12,9 +12,11 @@ import (
)
const (
RoleUser = "user"
RoleAdmin = "admin"
RoleAuditor = "auditor"
RoleUser = "user"
RoleAdmin = "admin"
RoleAuditor = "auditor"
RoleDomainAdmin = "domain_admin"
RoleSuperAdmin = "superadmin"
bcryptCost = 12
)
@@ -28,6 +30,7 @@ type User struct {
Source string // "local" or "ldap"
Active bool
CreatedAt time.Time
TenantID *int64 `json:"tenant_id,omitempty"`
}
// CreateUserRequest holds parameters for creating a new user.
@@ -36,6 +39,7 @@ type CreateUserRequest struct {
Email string
Password string
Role string
TenantID *int64
}
// UpdateUserRequest holds optional fields for updating a user.
@@ -75,13 +79,14 @@ func (s *Store) initSchema(ctx context.Context) error {
username VARCHAR(100) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL DEFAULT '',
role VARCHAR(20) NOT NULL CHECK (role IN ('user','auditor','admin')),
role VARCHAR(20) NOT NULL DEFAULT 'user',
source VARCHAR(20) NOT NULL DEFAULT 'local',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ
);
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id BIGINT;
CREATE TABLE IF NOT EXISTS token_blacklist (
jti VARCHAR(255) PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL
@@ -92,6 +97,7 @@ func (s *Store) initSchema(ctx context.Context) error {
attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_username_time ON login_attempts (username, attempted_at);
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users (tenant_id);
`)
return err
}
@@ -112,10 +118,10 @@ func (s *Store) Create(req CreateUserRequest) (*User, error) {
ctx := context.Background()
var id int64
err = s.pool.QueryRow(ctx,
`INSERT INTO users (username, email, password_hash, role, source, active, created_at)
VALUES ($1, $2, $3, $4, 'local', true, NOW())
`INSERT INTO users (username, email, password_hash, role, source, active, created_at, tenant_id)
VALUES ($1, $2, $3, $4, 'local', true, NOW(), $5)
RETURNING id`,
req.Username, req.Email, string(hash), req.Role,
req.Username, req.Email, string(hash), req.Role, req.TenantID,
).Scan(&id)
if err != nil {
return nil, fmt.Errorf("userstore: create: %w", err)
@@ -128,7 +134,7 @@ func (s *Store) Create(req CreateUserRequest) (*User, error) {
func (s *Store) GetByID(id int64) (*User, error) {
ctx := context.Background()
row := s.pool.QueryRow(ctx,
`SELECT id, username, email, role, source, active, created_at FROM users WHERE id = $1`, id,
`SELECT id, username, email, role, source, active, created_at, tenant_id FROM users WHERE id = $1`, id,
)
return scanUser(row)
}
@@ -137,7 +143,7 @@ func (s *Store) GetByID(id int64) (*User, error) {
func (s *Store) GetByUsername(username string) (*User, error) {
ctx := context.Background()
row := s.pool.QueryRow(ctx,
`SELECT id, username, email, role, source, active, created_at FROM users WHERE username = $1`, username,
`SELECT id, username, email, role, source, active, created_at, tenant_id FROM users WHERE username = $1`, username,
)
return scanUser(row)
}
@@ -147,13 +153,13 @@ func (s *Store) GetByUsername(username string) (*User, error) {
func (s *Store) VerifyPassword(username, password string) (*User, error) {
ctx := context.Background()
row := s.pool.QueryRow(ctx,
`SELECT id, username, email, role, source, active, created_at, password_hash FROM users WHERE username = $1`,
`SELECT id, username, email, role, source, active, created_at, tenant_id, password_hash FROM users WHERE username = $1`,
username,
)
var u User
var hash string
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &hash)
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID, &hash)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errors.New("userstore: user not found")
}
@@ -225,10 +231,10 @@ func (s *Store) List(role string) ([]*User, error) {
if role == "" {
rows, err = s.pool.Query(ctx,
`SELECT id, username, email, role, source, active, created_at FROM users ORDER BY id`)
`SELECT id, username, email, role, source, active, created_at, tenant_id FROM users ORDER BY id`)
} else {
rows, err = s.pool.Query(ctx,
`SELECT id, username, email, role, source, active, created_at FROM users WHERE role = $1 ORDER BY id`, role)
`SELECT id, username, email, role, source, active, created_at, tenant_id FROM users WHERE role = $1 ORDER BY id`, role)
}
if err != nil {
return nil, fmt.Errorf("userstore: list: %w", err)
@@ -246,6 +252,28 @@ func (s *Store) List(role string) ([]*User, error) {
return users, rows.Err()
}
// ListByTenant returns all users belonging to a specific tenant.
func (s *Store) ListByTenant(ctx context.Context, tenantID int64) ([]*User, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, username, email, role, source, active, created_at, tenant_id FROM users WHERE tenant_id = $1 ORDER BY id`,
tenantID,
)
if err != nil {
return nil, fmt.Errorf("userstore: list by tenant: %w", err)
}
defer rows.Close()
var users []*User
for rows.Next() {
u, err := scanUserRow(rows)
if err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
// BlacklistToken adds a JWT ID to the token blacklist.
func (s *Store) BlacklistToken(jti string, expires time.Time) error {
ctx := context.Background()
@@ -295,12 +323,12 @@ func (s *Store) CountRecentFailures(username string, window time.Duration) (int,
return count, err
}
// AdminCount returns the number of active admin users.
// AdminCount returns the number of active privileged users (admin, domain_admin, superadmin).
func (s *Store) AdminCount() (int, error) {
ctx := context.Background()
var count int
err := s.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = true`,
`SELECT COUNT(*) FROM users WHERE role IN ('admin','domain_admin','superadmin') AND active = true`,
).Scan(&count)
return count, err
}
@@ -311,7 +339,7 @@ func (s *Store) DeleteSafe(id int64) error {
if err != nil {
return err
}
if user.Role == RoleAdmin {
if user.Role == RoleAdmin || user.Role == RoleDomainAdmin || user.Role == RoleSuperAdmin {
count, err := s.AdminCount()
if err != nil {
return fmt.Errorf("userstore: admin count: %w", err)
@@ -331,16 +359,18 @@ func (s *Store) CleanExpiredTokens() error {
}
// UpsertLDAPUser creates or updates an LDAP-sourced user.
func (s *Store) UpsertLDAPUser(username, email, role string) (*User, error) {
// tenantID may be nil for users not associated with a specific tenant.
func (s *Store) UpsertLDAPUser(username, email, role string, tenantID *int64) (*User, error) {
ctx := context.Background()
_, err := s.pool.Exec(ctx, `
INSERT INTO users (username, email, password_hash, role, source, active, created_at)
VALUES ($1, $2, '', $3, 'ldap', true, NOW())
INSERT INTO users (username, email, password_hash, role, source, active, created_at, tenant_id)
VALUES ($1, $2, '', $3, 'ldap', true, NOW(), $4)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
role = EXCLUDED.role,
source = 'ldap'
`, username, email, role)
email = EXCLUDED.email,
role = EXCLUDED.role,
source = 'ldap',
tenant_id = COALESCE(EXCLUDED.tenant_id, users.tenant_id)
`, username, email, role, tenantID)
if err != nil {
return nil, fmt.Errorf("userstore: upsert ldap: %w", err)
}
@@ -351,7 +381,7 @@ func (s *Store) UpsertLDAPUser(username, email, role string) (*User, error) {
func scanUser(row pgx.Row) (*User, error) {
var u User
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt)
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("userstore: not found")
}
@@ -363,7 +393,7 @@ func scanUser(row pgx.Row) (*User, error) {
func scanUserRow(rows pgx.Rows) (*User, error) {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt); err != nil {
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.Source, &u.Active, &u.CreatedAt, &u.TenantID); err != nil {
return nil, fmt.Errorf("userstore: scan row: %w", err)
}
return &u, nil