tenants.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. // tenants.go — HTTP handlers for the /v1/tenants/* routes (M13b W1).
  2. //
  3. // Routes (all require a valid Bearer access JWT):
  4. //
  5. // GET /v1/tenants — list (super_admin: all, tenant_admin: own only)
  6. // POST /v1/tenants — create (super_admin only)
  7. // GET /v1/tenants/{id} — detail (super_admin any, tenant_admin own only)
  8. // PATCH /v1/tenants/{id} — update (super_admin: any field, tenant_admin: own + display_name/contact_email only)
  9. // POST /v1/tenants/{id}/status — set status (super_admin only)
  10. //
  11. // Errors:
  12. // 400 — bad input (validation, JSON parse)
  13. // 401 — handled by RequireAuth middleware (no body rewrite here)
  14. // 403 — role not allowed, or tenant_admin trying to access another tenant
  15. // 404 — tenant id not found
  16. // 409 — duplicate slug on create
  17. // 500 — unexpected DB error
  18. package main
  19. import (
  20. "encoding/json"
  21. "errors"
  22. "log/slog"
  23. "net/http"
  24. "strconv"
  25. "strings"
  26. "git3.techno-world.net/lrosales/broad-announce/internal/authd"
  27. )
  28. // tenantsListResponse is the wire shape for GET /v1/tenants.
  29. // `total` is included for pagination (we cap limit at 500 for
  30. // now; the UI shows total so operators know how many pages there
  31. // are even if it caps the per-page count).
  32. type tenantsListResponse struct {
  33. Items []authd.Tenant `json:"items"`
  34. Total int `json:"total"`
  35. Limit int `json:"limit"`
  36. Offset int `json:"offset"`
  37. }
  38. // listTenantsHandler wires GET /v1/tenants.
  39. func listTenantsHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  40. return func(w http.ResponseWriter, r *http.Request) {
  41. claims := authd.ClaimsFromContext(r.Context())
  42. if claims == nil {
  43. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  44. return
  45. }
  46. q := strings.TrimSpace(r.URL.Query().Get("q"))
  47. status := strings.TrimSpace(r.URL.Query().Get("status"))
  48. limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
  49. offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
  50. filter := authd.TenantFilter{
  51. Q: q,
  52. Status: status,
  53. Limit: limit,
  54. Offset: offset,
  55. CallerRole: claims.Role,
  56. CallerTenantID: claims.TenantID,
  57. }
  58. items, total, err := ad.Store().ListTenants(r.Context(), filter)
  59. if err != nil {
  60. logger.Error("list tenants", "err", err, "actor", claims.UserID)
  61. writeErr(w, http.StatusInternalServerError, "internal", "list failed")
  62. return
  63. }
  64. if filter.Limit <= 0 {
  65. filter.Limit = 100
  66. }
  67. if filter.Limit > 500 {
  68. filter.Limit = 500
  69. }
  70. writeJSON(w, http.StatusOK, tenantsListResponse{
  71. Items: items, Total: total, Limit: filter.Limit, Offset: filter.Offset,
  72. })
  73. }
  74. }
  75. // createTenantRequest is the POST /v1/tenants body. Mirrors the
  76. // store's CreateTenantInput but with snake_case JSON tags.
  77. type createTenantRequest struct {
  78. Slug string `json:"slug"`
  79. DisplayName string `json:"display_name"`
  80. ContactEmail string `json:"contact_email"`
  81. RateLimitPerSec *int `json:"rate_limit_per_sec"`
  82. FCMShared *bool `json:"fcm_shared"`
  83. }
  84. // createTenantHandler wires POST /v1/tenants. Super_admin only —
  85. // RequireRole is applied in main()'s mux.Handle call.
  86. func createTenantHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  87. return func(w http.ResponseWriter, r *http.Request) {
  88. claims := authd.ClaimsFromContext(r.Context())
  89. if claims == nil {
  90. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  91. return
  92. }
  93. var req createTenantRequest
  94. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  95. writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
  96. return
  97. }
  98. in := authd.CreateTenantInput{
  99. Slug: strings.TrimSpace(req.Slug),
  100. DisplayName: strings.TrimSpace(req.DisplayName),
  101. ContactEmail: strings.TrimSpace(req.ContactEmail),
  102. FCMShared: req.FCMShared,
  103. }
  104. if req.RateLimitPerSec != nil {
  105. in.RateLimitPerSec = *req.RateLimitPerSec
  106. }
  107. ip := clientIP(r)
  108. ua := r.UserAgent()
  109. t, err := ad.Store().CreateTenant(r.Context(), in, claims.UserID, ip, ua)
  110. if err != nil {
  111. switch {
  112. case errors.Is(err, authd.ErrTenantSlugTaken):
  113. writeErr(w, http.StatusConflict, "slug_taken", "tenant slug already in use")
  114. case errors.Is(err, authd.ErrTenantInvalid):
  115. writeErr(w, http.StatusBadRequest, "invalid", err.Error())
  116. default:
  117. logger.Error("create tenant", "err", err, "actor", claims.UserID)
  118. writeErr(w, http.StatusInternalServerError, "internal", "create failed")
  119. }
  120. return
  121. }
  122. logger.Info("tenant created",
  123. "tenant_id", t.ID, "slug", t.Slug, "actor", claims.UserID)
  124. writeJSON(w, http.StatusCreated, t)
  125. }
  126. }
  127. // getTenantHandler wires GET /v1/tenants/{id}. tenant_admin may
  128. // only fetch their own tenant.
  129. func getTenantHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  130. return func(w http.ResponseWriter, r *http.Request) {
  131. claims := authd.ClaimsFromContext(r.Context())
  132. if claims == nil {
  133. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  134. return
  135. }
  136. id := r.PathValue("id")
  137. if !isUUID(id) {
  138. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  139. return
  140. }
  141. if !canAccessTenant(claims, id) {
  142. writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
  143. return
  144. }
  145. t, err := ad.Store().GetTenant(r.Context(), id)
  146. if err != nil {
  147. if errors.Is(err, authd.ErrTenantNotFound) {
  148. writeErr(w, http.StatusNotFound, "not_found", "tenant not found")
  149. return
  150. }
  151. logger.Error("get tenant", "err", err, "tenant_id", id)
  152. writeErr(w, http.StatusInternalServerError, "internal", "lookup failed")
  153. return
  154. }
  155. writeJSON(w, http.StatusOK, t)
  156. }
  157. }
  158. // updateTenantRequest is the PATCH body. All fields optional.
  159. type updateTenantRequest struct {
  160. DisplayName *string `json:"display_name"`
  161. ContactEmail *string `json:"contact_email"`
  162. RateLimitPerSec *int `json:"rate_limit_per_sec"`
  163. FCMShared *bool `json:"fcm_shared"`
  164. }
  165. // updateTenantHandler wires PATCH /v1/tenants/{id}. tenant_admin
  166. // may only update display_name + contact_email on their own tenant.
  167. func updateTenantHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  168. return func(w http.ResponseWriter, r *http.Request) {
  169. claims := authd.ClaimsFromContext(r.Context())
  170. if claims == nil {
  171. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  172. return
  173. }
  174. id := r.PathValue("id")
  175. if !isUUID(id) {
  176. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  177. return
  178. }
  179. if !canAccessTenant(claims, id) {
  180. writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
  181. return
  182. }
  183. var req updateTenantRequest
  184. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  185. writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
  186. return
  187. }
  188. in := authd.UpdateTenantInput{
  189. DisplayName: req.DisplayName,
  190. ContactEmail: req.ContactEmail,
  191. RateLimitPerSec: req.RateLimitPerSec,
  192. FCMShared: req.FCMShared,
  193. }
  194. // tenant_admin is restricted to display_name + contact_email
  195. // (enforced in the store via actorScopeAll).
  196. scopeAll := claims.Role == "super_admin"
  197. ip := clientIP(r)
  198. ua := r.UserAgent()
  199. t, err := ad.Store().UpdateTenant(r.Context(), id, in, scopeAll, claims.UserID, ip, ua)
  200. if err != nil {
  201. switch {
  202. case errors.Is(err, authd.ErrTenantNotFound):
  203. writeErr(w, http.StatusNotFound, "not_found", "tenant not found")
  204. case errors.Is(err, authd.ErrTenantInvalid):
  205. writeErr(w, http.StatusBadRequest, "invalid", err.Error())
  206. default:
  207. logger.Error("update tenant", "err", err, "tenant_id", id, "actor", claims.UserID)
  208. writeErr(w, http.StatusInternalServerError, "internal", "update failed")
  209. }
  210. return
  211. }
  212. logger.Info("tenant updated", "tenant_id", id, "actor", claims.UserID)
  213. writeJSON(w, http.StatusOK, t)
  214. }
  215. }
  216. // setTenantStatusRequest is the POST /v1/tenants/{id}/status body.
  217. type setTenantStatusRequest struct {
  218. Status string `json:"status"`
  219. }
  220. // setTenantStatusHandler wires POST /v1/tenants/{id}/status.
  221. // super_admin only — RequireRole is applied in main()'s mux.Handle
  222. // call.
  223. func setTenantStatusHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  224. return func(w http.ResponseWriter, r *http.Request) {
  225. claims := authd.ClaimsFromContext(r.Context())
  226. if claims == nil {
  227. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  228. return
  229. }
  230. id := r.PathValue("id")
  231. if !isUUID(id) {
  232. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  233. return
  234. }
  235. var req setTenantStatusRequest
  236. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  237. writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
  238. return
  239. }
  240. ip := clientIP(r)
  241. ua := r.UserAgent()
  242. t, err := ad.Store().SetTenantStatus(r.Context(), id, strings.TrimSpace(req.Status), claims.UserID, ip, ua)
  243. if err != nil {
  244. switch {
  245. case errors.Is(err, authd.ErrTenantNotFound):
  246. writeErr(w, http.StatusNotFound, "not_found", "tenant not found")
  247. case errors.Is(err, authd.ErrTenantInvalid):
  248. writeErr(w, http.StatusBadRequest, "invalid", err.Error())
  249. default:
  250. logger.Error("set tenant status", "err", err, "tenant_id", id, "actor", claims.UserID)
  251. writeErr(w, http.StatusInternalServerError, "internal", "update failed")
  252. }
  253. return
  254. }
  255. logger.Info("tenant status changed",
  256. "tenant_id", id, "to", t.Status, "actor", claims.UserID)
  257. writeJSON(w, http.StatusOK, t)
  258. }
  259. }
  260. // canAccessTenant returns true if the caller's role + tenant_id
  261. // grant access to the given tenant id. super_admin may access any.
  262. // tenant_admin / viewer may only access their own.
  263. func canAccessTenant(claims *authd.AccessClaims, tenantID string) bool {
  264. if claims == nil {
  265. return false
  266. }
  267. if claims.Role == "super_admin" {
  268. return true
  269. }
  270. return claims.TenantID == tenantID
  271. }
  272. // isUUID is a lenient UUID format check (any 8-4-4-4-12 hex
  273. // blob). Postgres will reject malformed values on the actual
  274. // query; this is just to keep the 400s out of the 500s.
  275. func isUUID(s string) bool {
  276. if len(s) != 36 {
  277. return false
  278. }
  279. for i, c := range s {
  280. switch i {
  281. case 8, 13, 18, 23:
  282. if c != '-' {
  283. return false
  284. }
  285. default:
  286. if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
  287. return false
  288. }
  289. }
  290. }
  291. return true
  292. }