// 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 }