312 lines
11 KiB
Go
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
|
|
}
|