sources.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. // sources.go — HTTP handlers for the /v1/tenants/{id}/sources/* routes (M13b W2).
  2. //
  3. // Routes (all require a valid Bearer access JWT):
  4. //
  5. // GET /v1/tenants/{id}/sources — list
  6. // POST /v1/tenants/{id}/sources — create
  7. // GET /v1/tenants/{id}/sources/{sid} — detail
  8. // PATCH /v1/tenants/{id}/sources/{sid} — update
  9. // POST /v1/tenants/{id}/sources/{sid}/status — set status
  10. // POST /v1/tenants/{id}/sources/{sid}/rotate-secrets — rotate HMAC + API key
  11. //
  12. // Errors:
  13. // 400 — bad input (validation, JSON parse)
  14. // 401 — handled by RequireAuth middleware (no body rewrite here)
  15. // 403 — role not allowed, or tenant_admin trying to access another tenant
  16. // 404 — tenant or source id not found
  17. // 409 — duplicate source id on create
  18. // 500 — unexpected DB error
  19. //
  20. // Responses:
  21. // The Create + RotateSecrets handlers return
  22. // { "source": {...}, "secrets": { "hmac_secret": "...", "api_key": "..." } }
  23. // on success when secrets were generated. The `secrets` field
  24. // is OMITTED if the caller did not request secrets (so the
  25. // UI knows not to render the one-time modal). The shape is
  26. // always { source, secrets? } so the UI can destructure.
  27. package main
  28. import (
  29. "encoding/json"
  30. "errors"
  31. "log/slog"
  32. "net/http"
  33. "strconv"
  34. "strings"
  35. "git3.techno-world.net/lrosales/broad-announce/internal/authd"
  36. )
  37. // sourcesListResponse is the wire shape for GET /v1/tenants/{id}/sources.
  38. type sourcesListResponse struct {
  39. Items []authd.Source `json:"items"`
  40. Total int `json:"total"`
  41. Limit int `json:"limit"`
  42. Offset int `json:"offset"`
  43. }
  44. // createOrRotateResponse is the shape returned by Create and Rotate.
  45. type createOrRotateResponse struct {
  46. Source *authd.Source `json:"source"`
  47. Secrets *authd.SecretsPayload `json:"secrets,omitempty"`
  48. }
  49. // listSourcesHandler wires GET /v1/tenants/{id}/sources.
  50. func listSourcesHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  51. return func(w http.ResponseWriter, r *http.Request) {
  52. claims := authd.ClaimsFromContext(r.Context())
  53. if claims == nil {
  54. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  55. return
  56. }
  57. tenantID := r.PathValue("id")
  58. if !isUUID(tenantID) {
  59. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  60. return
  61. }
  62. if !canAccessTenant(claims, tenantID) {
  63. writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
  64. return
  65. }
  66. q := strings.TrimSpace(r.URL.Query().Get("q"))
  67. typeFilter := strings.TrimSpace(r.URL.Query().Get("type"))
  68. statusFilter := strings.TrimSpace(r.URL.Query().Get("status"))
  69. limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
  70. offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
  71. filter := authd.SourceFilter{
  72. CompanyID: tenantID,
  73. Q: q,
  74. Type: typeFilter,
  75. Status: statusFilter,
  76. Limit: limit,
  77. Offset: offset,
  78. CallerRole: claims.Role,
  79. CallerTenant: claims.TenantID,
  80. }
  81. items, total, err := ad.Store().ListSources(r.Context(), filter)
  82. if err != nil {
  83. logger.Error("list sources", "err", err, "actor", claims.UserID, "tenant_id", tenantID)
  84. writeErr(w, http.StatusInternalServerError, "internal", "list failed")
  85. return
  86. }
  87. if filter.Limit <= 0 {
  88. filter.Limit = 100
  89. }
  90. if filter.Limit > 500 {
  91. filter.Limit = 500
  92. }
  93. writeJSON(w, http.StatusOK, sourcesListResponse{
  94. Items: items, Total: total, Limit: filter.Limit, Offset: filter.Offset,
  95. })
  96. }
  97. }
  98. // createSourceRequest is the POST body. All fields except id,
  99. // name, type, and rate_limit_per_sec are optional. The hmac_secret
  100. // and api_key fields, if non-empty, are stored bcrypt-hashed and
  101. // returned ONCE in the response.
  102. type createSourceRequest struct {
  103. ID string `json:"id"`
  104. Name string `json:"name"`
  105. Type string `json:"type"`
  106. RateLimitPerSec int `json:"rate_limit_per_sec"`
  107. AllowedTargets json.RawMessage `json:"allowed_targets"`
  108. MatchExpr json.RawMessage `json:"match_expr"`
  109. Description string `json:"description"`
  110. MTLSRequired bool `json:"mtls_required"`
  111. HMACSecret string `json:"hmac_secret"`
  112. APIKey string `json:"api_key"`
  113. }
  114. // createSourceHandler wires POST /v1/tenants/{id}/sources.
  115. func createSourceHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  116. return func(w http.ResponseWriter, r *http.Request) {
  117. claims := authd.ClaimsFromContext(r.Context())
  118. if claims == nil {
  119. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  120. return
  121. }
  122. tenantID := r.PathValue("id")
  123. if !isUUID(tenantID) {
  124. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  125. return
  126. }
  127. if !canAccessTenant(claims, tenantID) {
  128. writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
  129. return
  130. }
  131. var req createSourceRequest
  132. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  133. writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
  134. return
  135. }
  136. in := authd.CreateSourceInput{
  137. ID: strings.TrimSpace(req.ID),
  138. Name: strings.TrimSpace(req.Name),
  139. Type: strings.TrimSpace(req.Type),
  140. RateLimitPerSec: req.RateLimitPerSec,
  141. AllowedTargets: req.AllowedTargets,
  142. MatchExpr: req.MatchExpr,
  143. Description: req.Description,
  144. MTLSRequired: req.MTLSRequired,
  145. HMACSecret: req.HMACSecret,
  146. APIKey: req.APIKey,
  147. }
  148. // Look up the auth tenant's display name so the
  149. // bridge INSERT into public.companies has a sensible
  150. // `name` value when the row is first created.
  151. tenant, err := ad.Store().GetTenant(r.Context(), tenantID)
  152. if err != nil {
  153. if errors.Is(err, authd.ErrTenantNotFound) {
  154. writeErr(w, http.StatusNotFound, "not_found", "tenant not found")
  155. return
  156. }
  157. logger.Error("create source: lookup tenant", "err", err, "tenant_id", tenantID)
  158. writeErr(w, http.StatusInternalServerError, "internal", "lookup failed")
  159. return
  160. }
  161. ip := clientIP(r)
  162. ua := r.UserAgent()
  163. src, secrets, err := ad.Store().CreateSource(r.Context(), tenantID, tenant.DisplayName, in, claims.UserID, ip, ua)
  164. if err != nil {
  165. switch {
  166. case errors.Is(err, authd.ErrSourceIDTaken):
  167. writeErr(w, http.StatusConflict, "id_taken", "source id already in use")
  168. case errors.Is(err, authd.ErrSourceInvalid):
  169. writeErr(w, http.StatusBadRequest, "invalid", err.Error())
  170. default:
  171. logger.Error("create source", "err", err, "tenant_id", tenantID, "actor", claims.UserID)
  172. writeErr(w, http.StatusInternalServerError, "internal", "create failed")
  173. }
  174. return
  175. }
  176. logger.Info("source created",
  177. "tenant_id", tenantID, "source_id", src.ID, "actor", claims.UserID,
  178. "hmac_set", src.HMACSet, "api_key_set", src.APIKeySet)
  179. writeJSON(w, http.StatusCreated, createOrRotateResponse{Source: src, Secrets: secrets})
  180. }
  181. }
  182. // getSourceHandler wires GET /v1/tenants/{id}/sources/{sid}.
  183. func getSourceHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  184. return func(w http.ResponseWriter, r *http.Request) {
  185. claims := authd.ClaimsFromContext(r.Context())
  186. if claims == nil {
  187. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  188. return
  189. }
  190. tenantID := r.PathValue("id")
  191. if !isUUID(tenantID) {
  192. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  193. return
  194. }
  195. if !canAccessTenant(claims, tenantID) {
  196. writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
  197. return
  198. }
  199. sourceID := r.PathValue("sid")
  200. if sourceID == "" {
  201. writeErr(w, http.StatusBadRequest, "bad_request", "source id is required")
  202. return
  203. }
  204. src, err := ad.Store().GetSource(r.Context(), tenantID, sourceID)
  205. if err != nil {
  206. if errors.Is(err, authd.ErrSourceNotFound) {
  207. writeErr(w, http.StatusNotFound, "not_found", "source not found")
  208. return
  209. }
  210. logger.Error("get source", "err", err, "tenant_id", tenantID, "source_id", sourceID)
  211. writeErr(w, http.StatusInternalServerError, "internal", "lookup failed")
  212. return
  213. }
  214. writeJSON(w, http.StatusOK, src)
  215. }
  216. }
  217. // updateSourceRequest is the PATCH body. All fields optional.
  218. type updateSourceRequest struct {
  219. Name *string `json:"name"`
  220. Type *string `json:"type"`
  221. RateLimitPerSec *int `json:"rate_limit_per_sec"`
  222. Description *string `json:"description"`
  223. MTLSRequired *bool `json:"mtls_required"`
  224. AllowedTargets json.RawMessage `json:"allowed_targets"`
  225. MatchExpr json.RawMessage `json:"match_expr"`
  226. }
  227. // updateSourceHandler wires PATCH /v1/tenants/{id}/sources/{sid}.
  228. func updateSourceHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  229. return func(w http.ResponseWriter, r *http.Request) {
  230. claims := authd.ClaimsFromContext(r.Context())
  231. if claims == nil {
  232. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  233. return
  234. }
  235. tenantID := r.PathValue("id")
  236. if !isUUID(tenantID) {
  237. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  238. return
  239. }
  240. if !canAccessTenant(claims, tenantID) {
  241. writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
  242. return
  243. }
  244. sourceID := r.PathValue("sid")
  245. if sourceID == "" {
  246. writeErr(w, http.StatusBadRequest, "bad_request", "source id is required")
  247. return
  248. }
  249. var req updateSourceRequest
  250. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  251. writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
  252. return
  253. }
  254. in := authd.UpdateSourceInput{
  255. Name: req.Name,
  256. Type: req.Type,
  257. RateLimitPerSec: req.RateLimitPerSec,
  258. Description: req.Description,
  259. MTLSRequired: req.MTLSRequired,
  260. AllowedTargets: req.AllowedTargets,
  261. MatchExpr: req.MatchExpr,
  262. }
  263. ip := clientIP(r)
  264. ua := r.UserAgent()
  265. src, err := ad.Store().UpdateSource(r.Context(), tenantID, sourceID, in, claims.UserID, ip, ua)
  266. if err != nil {
  267. switch {
  268. case errors.Is(err, authd.ErrSourceNotFound):
  269. writeErr(w, http.StatusNotFound, "not_found", "source not found")
  270. case errors.Is(err, authd.ErrSourceInvalid):
  271. writeErr(w, http.StatusBadRequest, "invalid", err.Error())
  272. default:
  273. logger.Error("update source", "err", err, "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
  274. writeErr(w, http.StatusInternalServerError, "internal", "update failed")
  275. }
  276. return
  277. }
  278. logger.Info("source updated",
  279. "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
  280. writeJSON(w, http.StatusOK, src)
  281. }
  282. }
  283. // setSourceStatusRequest is the POST /status body.
  284. type setSourceStatusRequest struct {
  285. Status string `json:"status"`
  286. }
  287. // setSourceStatusHandler wires POST /v1/tenants/{id}/sources/{sid}/status.
  288. func setSourceStatusHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  289. return func(w http.ResponseWriter, r *http.Request) {
  290. claims := authd.ClaimsFromContext(r.Context())
  291. if claims == nil {
  292. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  293. return
  294. }
  295. tenantID := r.PathValue("id")
  296. if !isUUID(tenantID) {
  297. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  298. return
  299. }
  300. if !canAccessTenant(claims, tenantID) {
  301. writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
  302. return
  303. }
  304. sourceID := r.PathValue("sid")
  305. if sourceID == "" {
  306. writeErr(w, http.StatusBadRequest, "bad_request", "source id is required")
  307. return
  308. }
  309. var req setSourceStatusRequest
  310. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  311. writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
  312. return
  313. }
  314. ip := clientIP(r)
  315. ua := r.UserAgent()
  316. src, err := ad.Store().SetSourceStatus(r.Context(), tenantID, sourceID, strings.TrimSpace(req.Status), claims.UserID, ip, ua)
  317. if err != nil {
  318. switch {
  319. case errors.Is(err, authd.ErrSourceNotFound):
  320. writeErr(w, http.StatusNotFound, "not_found", "source not found")
  321. case errors.Is(err, authd.ErrSourceInvalid):
  322. writeErr(w, http.StatusBadRequest, "invalid", err.Error())
  323. default:
  324. logger.Error("set source status", "err", err, "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
  325. writeErr(w, http.StatusInternalServerError, "internal", "update failed")
  326. }
  327. return
  328. }
  329. logger.Info("source status changed",
  330. "tenant_id", tenantID, "source_id", sourceID, "to", src.Status, "actor", claims.UserID)
  331. writeJSON(w, http.StatusOK, src)
  332. }
  333. }
  334. // rotateSourceSecretsHandler wires POST /v1/tenants/{id}/sources/{sid}/rotate-secrets.
  335. func rotateSourceSecretsHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  336. return func(w http.ResponseWriter, r *http.Request) {
  337. claims := authd.ClaimsFromContext(r.Context())
  338. if claims == nil {
  339. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  340. return
  341. }
  342. tenantID := r.PathValue("id")
  343. if !isUUID(tenantID) {
  344. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  345. return
  346. }
  347. if !canAccessTenant(claims, tenantID) {
  348. writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
  349. return
  350. }
  351. sourceID := r.PathValue("sid")
  352. if sourceID == "" {
  353. writeErr(w, http.StatusBadRequest, "bad_request", "source id is required")
  354. return
  355. }
  356. ip := clientIP(r)
  357. ua := r.UserAgent()
  358. src, secrets, err := ad.Store().RotateSecrets(r.Context(), tenantID, sourceID, claims.UserID, ip, ua)
  359. if err != nil {
  360. if errors.Is(err, authd.ErrSourceNotFound) {
  361. writeErr(w, http.StatusNotFound, "not_found", "source not found")
  362. return
  363. }
  364. logger.Error("rotate source secrets", "err", err, "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
  365. writeErr(w, http.StatusInternalServerError, "internal", "rotate failed")
  366. return
  367. }
  368. logger.Info("source secrets rotated",
  369. "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
  370. writeJSON(w, http.StatusOK, createOrRotateResponse{Source: src, Secrets: secrets})
  371. }
  372. }