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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user