Files
2026-08-09 18:03:09 +01:00

312 lines
11 KiB
Go

// Package admin implements the admin portal REST API — domains (with DKIM
// key generation), tenants, users, list rules, outbound queue management,
// global quarantine, and dashboard stats. Reuses webtoken for sessions
// (same JWT scheme as webmail) but enforces role-based access: only
// global_admin and tenant_admin roles may authenticate here at all, and
// tenant_admin is scoped to their own tenant for every operation.
package admin
import (
"encoding/json"
"net/http"
"strings"
"time"
"gomail/internal/auth"
"gomail/internal/crypto"
"gomail/internal/db"
"gomail/internal/dkim"
"gomail/internal/webtoken"
"github.com/google/uuid"
)
const sessionTTL = 24 * time.Hour
type Handler struct {
database *db.DB
mk *crypto.MasterKey
jwtSecret string
}
func NewHandler(database *db.DB, mk *crypto.MasterKey, jwtSecret string) *Handler {
return &Handler{database: database, mk: mk, jwtSecret: jwtSecret}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/admin/auth/login", h.login)
mux.HandleFunc("/api/admin/stats", h.withAdmin(h.stats))
mux.HandleFunc("/api/admin/tenants", h.withAdmin(h.tenants))
mux.HandleFunc("/api/admin/domains", h.withAdmin(h.domains))
mux.HandleFunc("/api/admin/domains/", h.withAdmin(h.domainByID))
mux.HandleFunc("/api/admin/users", h.withAdmin(h.users))
mux.HandleFunc("/api/admin/users/", h.withAdmin(h.userByID))
mux.HandleFunc("/api/admin/list-rules", h.withAdmin(h.listRules))
mux.HandleFunc("/api/admin/list-rules/", h.withAdmin(h.listRuleByID))
mux.HandleFunc("/api/admin/queue", h.withAdmin(h.queue))
mux.HandleFunc("/api/admin/queue/", h.withAdmin(h.queueByID))
mux.HandleFunc("/api/admin/quarantine", h.withAdmin(h.quarantine))
mux.HandleFunc("/api/admin/quarantine/", h.withAdmin(h.quarantineByID))
}
// ── JSON helpers ──────────────────────────────────────────────────────────────
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
// ── Auth (admin-only roles) ─────────────────────────────────────────────────────
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req struct{ Email, Password string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
user, ok := auth.Authenticate(h.database, req.Email, req.Password, auth.ScopeIMAP)
if !ok {
writeErr(w, http.StatusUnauthorized, "invalid credentials")
return
}
if user.Role != db.RoleGlobalAdmin && user.Role != db.RoleTenantAdmin {
// Deliberately the same error as bad credentials — don't leak "this
// account exists but lacks admin rights" to an unauthenticated caller.
writeErr(w, http.StatusUnauthorized, "invalid credentials")
return
}
token, err := webtoken.Issue(h.jwtSecret, user.ID, user.TenantID, string(user.Role), sessionTTL)
if err != nil {
writeErr(w, http.StatusInternalServerError, "token generation failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"token": token,
"user": map[string]any{"id": user.ID, "email": user.Email, "role": user.Role},
})
}
func (h *Handler) withAdmin(next func(http.ResponseWriter, *http.Request, *db.User)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tokenStr := ""
if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") {
tokenStr = strings.TrimPrefix(authHeader, "Bearer ")
}
if tokenStr == "" {
writeErr(w, http.StatusUnauthorized, "missing token")
return
}
claims, err := webtoken.Verify(h.jwtSecret, tokenStr)
if err != nil {
writeErr(w, http.StatusUnauthorized, "invalid or expired token")
return
}
if claims.Role != string(db.RoleGlobalAdmin) && claims.Role != string(db.RoleTenantAdmin) {
writeErr(w, http.StatusForbidden, "admin role required")
return
}
user, err := h.database.GetUser(claims.Subject)
if err != nil || !user.Active {
writeErr(w, http.StatusUnauthorized, "user not found or inactive")
return
}
// Re-check role against the live DB row, not just the JWT claim — a
// demoted admin's existing token shouldn't keep working until it
// naturally expires.
if user.Role != db.RoleGlobalAdmin && user.Role != db.RoleTenantAdmin {
writeErr(w, http.StatusForbidden, "admin role required")
return
}
next(w, r, user)
}
}
func (h *Handler) encryptDKIMKey(domainID string, keyPEM []byte) ([]byte, error) {
return crypto.Encrypt(h.mk, domainID, "dkim-key", keyPEM)
}
// ── Dashboard ─────────────────────────────────────────────────────────────────
func (h *Handler) stats(w http.ResponseWriter, r *http.Request, user *db.User) {
s, err := h.database.GetStats()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, s)
}
// ── Tenants (global_admin only) ─────────────────────────────────────────────────
func (h *Handler) tenants(w http.ResponseWriter, r *http.Request, user *db.User) {
if user.Role != db.RoleGlobalAdmin {
writeErr(w, http.StatusForbidden, "only global_admin may manage tenants")
return
}
switch r.Method {
case http.MethodGet:
list, err := h.database.ListTenants()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
case http.MethodPost:
var req struct{ Name, DisplayName string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
writeErr(w, http.StatusBadRequest, "name is required")
return
}
t := &db.Tenant{ID: uuid.NewString(), Name: req.Name, DisplayName: req.DisplayName}
if err := h.database.CreateTenant(t); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, t)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// ── Domains ───────────────────────────────────────────────────────────────────
func (h *Handler) domains(w http.ResponseWriter, r *http.Request, user *db.User) {
switch r.Method {
case http.MethodGet:
all, err := h.database.ListDomains()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, filterDomainsByTenant(all, scopeTenant(user)))
case http.MethodPost:
var req struct{ Domain, TenantID string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" {
writeErr(w, http.StatusBadRequest, "domain is required")
return
}
tenantID := req.TenantID
if user.Role != db.RoleGlobalAdmin {
tenantID = user.TenantID
} else if tenantID == "" {
tenantID = user.TenantID // global_admin defaults to their own tenant if unspecified
}
if tenantID == "" {
writeErr(w, http.StatusBadRequest, "tenant_id is required")
return
}
domainID := uuid.NewString()
selector := "mail"
kp, err := dkim.GenerateKeyPair()
if err != nil {
writeErr(w, http.StatusInternalServerError, "DKIM key generation failed: "+err.Error())
return
}
keyEnc, err := h.encryptDKIMKey(domainID, kp.PrivateKeyPEM)
if err != nil {
writeErr(w, http.StatusInternalServerError, "DKIM key encryption failed: "+err.Error())
return
}
d := &db.Domain{ID: domainID, TenantID: tenantID, Domain: req.Domain, DKIMSelector: selector, DKIMPrivateKeyEnc: keyEnc}
if err := h.database.CreateDomain(d); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"domain": d, "dkim_dns_record": kp.DNSRecordValue, "dkim_dns_name": selector + "._domainkey." + req.Domain,
})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (h *Handler) domainByID(w http.ResponseWriter, r *http.Request, user *db.User) {
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/admin/domains/"), "/")
id := parts[0]
action := ""
if len(parts) > 1 {
action = parts[1]
}
d, err := h.database.GetDomain(id)
if err != nil {
writeErr(w, http.StatusNotFound, "domain not found")
return
}
if user.Role != db.RoleGlobalAdmin && d.TenantID != user.TenantID {
writeErr(w, http.StatusForbidden, "not your tenant's domain")
return
}
switch {
case r.Method == http.MethodPost && action == "dkim-rotate":
kp, err := dkim.GenerateKeyPair()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
encKey, err := h.encryptDKIMKey(d.ID, kp.PrivateKeyPEM)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if err := h.database.UpdateDomainDKIMKey(d.ID, d.DKIMSelector, encKey); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{
"dkim_dns_record": kp.DNSRecordValue, "dkim_dns_name": d.DKIMSelector + "._domainkey." + d.Domain,
})
case r.Method == http.MethodDelete && action == "":
if err := h.database.DeleteDomain(id); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "deleted"})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// scopeTenant returns the tenant ID a tenant_admin is restricted to, or ""
// for global_admin (meaning "all tenants, no filter").
func scopeTenant(user *db.User) string {
if user.Role == db.RoleGlobalAdmin {
return ""
}
return user.TenantID
}
func filterDomainsByTenant(all []db.Domain, tenantID string) []db.Domain {
if tenantID == "" {
return all
}
var out []db.Domain
for _, d := range all {
if d.TenantID == tenantID {
out = append(out, d)
}
}
return out
}