// tenants.go — HTTP handlers for the /v1/tenants/* routes (M13b W1). // // Routes (all require a valid Bearer access JWT): // // GET /v1/tenants — list (super_admin: all, tenant_admin: own only) // POST /v1/tenants — create (super_admin only) // GET /v1/tenants/{id} — detail (super_admin any, tenant_admin own only) // PATCH /v1/tenants/{id} — update (super_admin: any field, tenant_admin: own + display_name/contact_email only) // POST /v1/tenants/{id}/status — set status (super_admin only) // // Errors: // 400 — bad input (validation, JSON parse) // 401 — handled by RequireAuth middleware (no body rewrite here) // 403 — role not allowed, or tenant_admin trying to access another tenant // 404 — tenant id not found // 409 — duplicate slug on create // 500 — unexpected DB error package main import ( "encoding/json" "errors" "log/slog" "net/http" "strconv" "strings" "git3.techno-world.net/lrosales/broad-announce/internal/authd" ) // tenantsListResponse is the wire shape for GET /v1/tenants. // `total` is included for pagination (we cap limit at 500 for // now; the UI shows total so operators know how many pages there // are even if it caps the per-page count). type tenantsListResponse struct { Items []authd.Tenant `json:"items"` Total int `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` } // listTenantsHandler wires GET /v1/tenants. func listTenantsHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := authd.ClaimsFromContext(r.Context()) if claims == nil { writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing") return } q := strings.TrimSpace(r.URL.Query().Get("q")) status := strings.TrimSpace(r.URL.Query().Get("status")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) filter := authd.TenantFilter{ Q: q, Status: status, Limit: limit, Offset: offset, CallerRole: claims.Role, CallerTenantID: claims.TenantID, } items, total, err := ad.Store().ListTenants(r.Context(), filter) if err != nil { logger.Error("list tenants", "err", err, "actor", claims.UserID) writeErr(w, http.StatusInternalServerError, "internal", "list failed") return } if filter.Limit <= 0 { filter.Limit = 100 } if filter.Limit > 500 { filter.Limit = 500 } writeJSON(w, http.StatusOK, tenantsListResponse{ Items: items, Total: total, Limit: filter.Limit, Offset: filter.Offset, }) } } // createTenantRequest is the POST /v1/tenants body. Mirrors the // store's CreateTenantInput but with snake_case JSON tags. type createTenantRequest struct { Slug string `json:"slug"` DisplayName string `json:"display_name"` ContactEmail string `json:"contact_email"` RateLimitPerSec *int `json:"rate_limit_per_sec"` FCMShared *bool `json:"fcm_shared"` } // createTenantHandler wires POST /v1/tenants. Super_admin only — // RequireRole is applied in main()'s mux.Handle call. func createTenantHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := authd.ClaimsFromContext(r.Context()) if claims == nil { writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing") return } var req createTenantRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") return } in := authd.CreateTenantInput{ Slug: strings.TrimSpace(req.Slug), DisplayName: strings.TrimSpace(req.DisplayName), ContactEmail: strings.TrimSpace(req.ContactEmail), FCMShared: req.FCMShared, } if req.RateLimitPerSec != nil { in.RateLimitPerSec = *req.RateLimitPerSec } ip := clientIP(r) ua := r.UserAgent() t, err := ad.Store().CreateTenant(r.Context(), in, claims.UserID, ip, ua) if err != nil { switch { case errors.Is(err, authd.ErrTenantSlugTaken): writeErr(w, http.StatusConflict, "slug_taken", "tenant slug already in use") case errors.Is(err, authd.ErrTenantInvalid): writeErr(w, http.StatusBadRequest, "invalid", err.Error()) default: logger.Error("create tenant", "err", err, "actor", claims.UserID) writeErr(w, http.StatusInternalServerError, "internal", "create failed") } return } logger.Info("tenant created", "tenant_id", t.ID, "slug", t.Slug, "actor", claims.UserID) writeJSON(w, http.StatusCreated, t) } } // getTenantHandler wires GET /v1/tenants/{id}. tenant_admin may // only fetch their own tenant. func getTenantHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := authd.ClaimsFromContext(r.Context()) if claims == nil { writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing") return } id := r.PathValue("id") if !isUUID(id) { writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID") return } if !canAccessTenant(claims, id) { writeErr(w, http.StatusForbidden, "forbidden", "not your tenant") return } t, err := ad.Store().GetTenant(r.Context(), id) if err != nil { if errors.Is(err, authd.ErrTenantNotFound) { writeErr(w, http.StatusNotFound, "not_found", "tenant not found") return } logger.Error("get tenant", "err", err, "tenant_id", id) writeErr(w, http.StatusInternalServerError, "internal", "lookup failed") return } writeJSON(w, http.StatusOK, t) } } // updateTenantRequest is the PATCH body. All fields optional. type updateTenantRequest struct { DisplayName *string `json:"display_name"` ContactEmail *string `json:"contact_email"` RateLimitPerSec *int `json:"rate_limit_per_sec"` FCMShared *bool `json:"fcm_shared"` } // updateTenantHandler wires PATCH /v1/tenants/{id}. tenant_admin // may only update display_name + contact_email on their own tenant. func updateTenantHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := authd.ClaimsFromContext(r.Context()) if claims == nil { writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing") return } id := r.PathValue("id") if !isUUID(id) { writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID") return } if !canAccessTenant(claims, id) { writeErr(w, http.StatusForbidden, "forbidden", "not your tenant") return } var req updateTenantRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") return } in := authd.UpdateTenantInput{ DisplayName: req.DisplayName, ContactEmail: req.ContactEmail, RateLimitPerSec: req.RateLimitPerSec, FCMShared: req.FCMShared, } // tenant_admin is restricted to display_name + contact_email // (enforced in the store via actorScopeAll). scopeAll := claims.Role == "super_admin" ip := clientIP(r) ua := r.UserAgent() t, err := ad.Store().UpdateTenant(r.Context(), id, in, scopeAll, claims.UserID, ip, ua) if err != nil { switch { case errors.Is(err, authd.ErrTenantNotFound): writeErr(w, http.StatusNotFound, "not_found", "tenant not found") case errors.Is(err, authd.ErrTenantInvalid): writeErr(w, http.StatusBadRequest, "invalid", err.Error()) default: logger.Error("update tenant", "err", err, "tenant_id", id, "actor", claims.UserID) writeErr(w, http.StatusInternalServerError, "internal", "update failed") } return } logger.Info("tenant updated", "tenant_id", id, "actor", claims.UserID) writeJSON(w, http.StatusOK, t) } } // setTenantStatusRequest is the POST /v1/tenants/{id}/status body. type setTenantStatusRequest struct { Status string `json:"status"` } // setTenantStatusHandler wires POST /v1/tenants/{id}/status. // super_admin only — RequireRole is applied in main()'s mux.Handle // call. func setTenantStatusHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := authd.ClaimsFromContext(r.Context()) if claims == nil { writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing") return } id := r.PathValue("id") if !isUUID(id) { writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID") return } var req setTenantStatusRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") return } ip := clientIP(r) ua := r.UserAgent() t, err := ad.Store().SetTenantStatus(r.Context(), id, strings.TrimSpace(req.Status), claims.UserID, ip, ua) if err != nil { switch { case errors.Is(err, authd.ErrTenantNotFound): writeErr(w, http.StatusNotFound, "not_found", "tenant not found") case errors.Is(err, authd.ErrTenantInvalid): writeErr(w, http.StatusBadRequest, "invalid", err.Error()) default: logger.Error("set tenant status", "err", err, "tenant_id", id, "actor", claims.UserID) writeErr(w, http.StatusInternalServerError, "internal", "update failed") } return } logger.Info("tenant status changed", "tenant_id", id, "to", t.Status, "actor", claims.UserID) writeJSON(w, http.StatusOK, t) } } // canAccessTenant returns true if the caller's role + tenant_id // grant access to the given tenant id. super_admin may access any. // tenant_admin / viewer may only access their own. func canAccessTenant(claims *authd.AccessClaims, tenantID string) bool { if claims == nil { return false } if claims.Role == "super_admin" { return true } return claims.TenantID == tenantID } // isUUID is a lenient UUID format check (any 8-4-4-4-12 hex // blob). Postgres will reject malformed values on the actual // query; this is just to keep the 400s out of the 500s. func isUUID(s string) bool { if len(s) != 36 { return false } for i, c := range s { switch i { case 8, 13, 18, 23: if c != '-' { return false } default: if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { return false } } } return true }