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