sources.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  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. Q: q,
  73. Type: typeFilter,
  74. Status: statusFilter,
  75. Limit: limit,
  76. Offset: offset,
  77. CallerRole: claims.Role,
  78. CallerTenant: claims.TenantID,
  79. }
  80. items, total, err := ad.Store().ListSources(r.Context(), filter)
  81. if err != nil {
  82. logger.Error("list sources", "err", err, "actor", claims.UserID, "tenant_id", tenantID)
  83. writeErr(w, http.StatusInternalServerError, "internal", "list failed")
  84. return
  85. }
  86. if filter.Limit <= 0 {
  87. filter.Limit = 100
  88. }
  89. if filter.Limit > 500 {
  90. filter.Limit = 500
  91. }
  92. writeJSON(w, http.StatusOK, sourcesListResponse{
  93. Items: items, Total: total, Limit: filter.Limit, Offset: filter.Offset,
  94. })
  95. }
  96. }
  97. // createSourceRequest is the POST body. All fields except id,
  98. // name, type, and rate_limit_per_sec are optional. The hmac_secret
  99. // and api_key fields, if non-empty, are stored bcrypt-hashed and
  100. // returned ONCE in the response.
  101. type createSourceRequest struct {
  102. ID string `json:"id"`
  103. Name string `json:"name"`
  104. Type string `json:"type"`
  105. RateLimitPerSec int `json:"rate_limit_per_sec"`
  106. AllowedTargets json.RawMessage `json:"allowed_targets"`
  107. MatchExpr json.RawMessage `json:"match_expr"`
  108. Description string `json:"description"`
  109. MTLSRequired bool `json:"mtls_required"`
  110. HMACSecret string `json:"hmac_secret"`
  111. APIKey string `json:"api_key"`
  112. }
  113. // createSourceHandler wires POST /v1/tenants/{id}/sources.
  114. func createSourceHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  115. return func(w http.ResponseWriter, r *http.Request) {
  116. claims := authd.ClaimsFromContext(r.Context())
  117. if claims == nil {
  118. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  119. return
  120. }
  121. tenantID := r.PathValue("id")
  122. if !isUUID(tenantID) {
  123. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  124. return
  125. }
  126. if !canAccessTenant(claims, tenantID) {
  127. writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
  128. return
  129. }
  130. var req createSourceRequest
  131. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  132. writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
  133. return
  134. }
  135. in := authd.CreateSourceInput{
  136. ID: strings.TrimSpace(req.ID),
  137. Name: strings.TrimSpace(req.Name),
  138. Type: strings.TrimSpace(req.Type),
  139. RateLimitPerSec: req.RateLimitPerSec,
  140. AllowedTargets: req.AllowedTargets,
  141. MatchExpr: req.MatchExpr,
  142. Description: req.Description,
  143. MTLSRequired: req.MTLSRequired,
  144. HMACSecret: req.HMACSecret,
  145. APIKey: req.APIKey,
  146. }
  147. // Look up the auth tenant's display name so the
  148. // bridge INSERT into public.companies has a sensible
  149. // `name` value when the row is first created.
  150. tenant, err := ad.Store().GetTenant(r.Context(), tenantID)
  151. if err != nil {
  152. if errors.Is(err, authd.ErrTenantNotFound) {
  153. writeErr(w, http.StatusNotFound, "not_found", "tenant not found")
  154. return
  155. }
  156. logger.Error("create source: lookup tenant", "err", err, "tenant_id", tenantID)
  157. writeErr(w, http.StatusInternalServerError, "internal", "lookup failed")
  158. return
  159. }
  160. ip := clientIP(r)
  161. ua := r.UserAgent()
  162. src, secrets, err := ad.Store().CreateSource(r.Context(), tenantID, tenant.DisplayName, in, claims.UserID, ip, ua)
  163. if err != nil {
  164. switch {
  165. case errors.Is(err, authd.ErrSourceIDTaken):
  166. writeErr(w, http.StatusConflict, "id_taken", "source id already in use")
  167. case errors.Is(err, authd.ErrSourceInvalid):
  168. writeErr(w, http.StatusBadRequest, "invalid", err.Error())
  169. default:
  170. logger.Error("create source", "err", err, "tenant_id", tenantID, "actor", claims.UserID)
  171. writeErr(w, http.StatusInternalServerError, "internal", "create failed")
  172. }
  173. return
  174. }
  175. logger.Info("source created",
  176. "tenant_id", tenantID, "source_id", src.ID, "actor", claims.UserID,
  177. "hmac_set", src.HMACSet, "api_key_set", src.APIKeySet)
  178. writeJSON(w, http.StatusCreated, createOrRotateResponse{Source: src, Secrets: secrets})
  179. }
  180. }
  181. // getSourceHandler wires GET /v1/tenants/{id}/sources/{sid}.
  182. func getSourceHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  183. return func(w http.ResponseWriter, r *http.Request) {
  184. claims := authd.ClaimsFromContext(r.Context())
  185. if claims == nil {
  186. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  187. return
  188. }
  189. tenantID := r.PathValue("id")
  190. if !isUUID(tenantID) {
  191. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  192. return
  193. }
  194. if !canAccessTenant(claims, tenantID) {
  195. writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
  196. return
  197. }
  198. sourceID := r.PathValue("sid")
  199. if sourceID == "" {
  200. writeErr(w, http.StatusBadRequest, "bad_request", "source id is required")
  201. return
  202. }
  203. src, err := ad.Store().GetSource(r.Context(), tenantID, sourceID)
  204. if err != nil {
  205. if errors.Is(err, authd.ErrSourceNotFound) {
  206. writeErr(w, http.StatusNotFound, "not_found", "source not found")
  207. return
  208. }
  209. logger.Error("get source", "err", err, "tenant_id", tenantID, "source_id", sourceID)
  210. writeErr(w, http.StatusInternalServerError, "internal", "lookup failed")
  211. return
  212. }
  213. writeJSON(w, http.StatusOK, src)
  214. }
  215. }
  216. // updateSourceRequest is the PATCH body. All fields optional.
  217. type updateSourceRequest struct {
  218. Name *string `json:"name"`
  219. Type *string `json:"type"`
  220. RateLimitPerSec *int `json:"rate_limit_per_sec"`
  221. Description *string `json:"description"`
  222. MTLSRequired *bool `json:"mtls_required"`
  223. AllowedTargets json.RawMessage `json:"allowed_targets"`
  224. MatchExpr json.RawMessage `json:"match_expr"`
  225. }
  226. // updateSourceHandler wires PATCH /v1/tenants/{id}/sources/{sid}.
  227. func updateSourceHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  228. return func(w http.ResponseWriter, r *http.Request) {
  229. claims := authd.ClaimsFromContext(r.Context())
  230. if claims == nil {
  231. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  232. return
  233. }
  234. tenantID := r.PathValue("id")
  235. if !isUUID(tenantID) {
  236. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  237. return
  238. }
  239. if !canAccessTenant(claims, tenantID) {
  240. writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
  241. return
  242. }
  243. sourceID := r.PathValue("sid")
  244. if sourceID == "" {
  245. writeErr(w, http.StatusBadRequest, "bad_request", "source id is required")
  246. return
  247. }
  248. var req updateSourceRequest
  249. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  250. writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
  251. return
  252. }
  253. in := authd.UpdateSourceInput{
  254. Name: req.Name,
  255. Type: req.Type,
  256. RateLimitPerSec: req.RateLimitPerSec,
  257. Description: req.Description,
  258. MTLSRequired: req.MTLSRequired,
  259. AllowedTargets: req.AllowedTargets,
  260. MatchExpr: req.MatchExpr,
  261. }
  262. ip := clientIP(r)
  263. ua := r.UserAgent()
  264. src, err := ad.Store().UpdateSource(r.Context(), tenantID, sourceID, in, claims.UserID, ip, ua)
  265. if err != nil {
  266. switch {
  267. case errors.Is(err, authd.ErrSourceNotFound):
  268. writeErr(w, http.StatusNotFound, "not_found", "source not found")
  269. case errors.Is(err, authd.ErrSourceInvalid):
  270. writeErr(w, http.StatusBadRequest, "invalid", err.Error())
  271. default:
  272. logger.Error("update source", "err", err, "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
  273. writeErr(w, http.StatusInternalServerError, "internal", "update failed")
  274. }
  275. return
  276. }
  277. logger.Info("source updated",
  278. "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
  279. writeJSON(w, http.StatusOK, src)
  280. }
  281. }
  282. // setSourceStatusRequest is the POST /status body.
  283. type setSourceStatusRequest struct {
  284. Status string `json:"status"`
  285. }
  286. // setSourceStatusHandler wires POST /v1/tenants/{id}/sources/{sid}/status.
  287. func setSourceStatusHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  288. return func(w http.ResponseWriter, r *http.Request) {
  289. claims := authd.ClaimsFromContext(r.Context())
  290. if claims == nil {
  291. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  292. return
  293. }
  294. tenantID := r.PathValue("id")
  295. if !isUUID(tenantID) {
  296. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  297. return
  298. }
  299. if !canAccessTenant(claims, tenantID) {
  300. writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
  301. return
  302. }
  303. sourceID := r.PathValue("sid")
  304. if sourceID == "" {
  305. writeErr(w, http.StatusBadRequest, "bad_request", "source id is required")
  306. return
  307. }
  308. var req setSourceStatusRequest
  309. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  310. writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
  311. return
  312. }
  313. ip := clientIP(r)
  314. ua := r.UserAgent()
  315. src, err := ad.Store().SetSourceStatus(r.Context(), tenantID, sourceID, strings.TrimSpace(req.Status), claims.UserID, ip, ua)
  316. if err != nil {
  317. switch {
  318. case errors.Is(err, authd.ErrSourceNotFound):
  319. writeErr(w, http.StatusNotFound, "not_found", "source not found")
  320. case errors.Is(err, authd.ErrSourceInvalid):
  321. writeErr(w, http.StatusBadRequest, "invalid", err.Error())
  322. default:
  323. logger.Error("set source status", "err", err, "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
  324. writeErr(w, http.StatusInternalServerError, "internal", "update failed")
  325. }
  326. return
  327. }
  328. logger.Info("source status changed",
  329. "tenant_id", tenantID, "source_id", sourceID, "to", src.Status, "actor", claims.UserID)
  330. writeJSON(w, http.StatusOK, src)
  331. }
  332. }
  333. // rotateSourceSecretsHandler wires POST /v1/tenants/{id}/sources/{sid}/rotate-secrets.
  334. func rotateSourceSecretsHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  335. return func(w http.ResponseWriter, r *http.Request) {
  336. claims := authd.ClaimsFromContext(r.Context())
  337. if claims == nil {
  338. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  339. return
  340. }
  341. tenantID := r.PathValue("id")
  342. if !isUUID(tenantID) {
  343. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  344. return
  345. }
  346. if !canAccessTenant(claims, tenantID) {
  347. writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
  348. return
  349. }
  350. sourceID := r.PathValue("sid")
  351. if sourceID == "" {
  352. writeErr(w, http.StatusBadRequest, "bad_request", "source id is required")
  353. return
  354. }
  355. ip := clientIP(r)
  356. ua := r.UserAgent()
  357. src, secrets, err := ad.Store().RotateSecrets(r.Context(), tenantID, sourceID, claims.UserID, ip, ua)
  358. if err != nil {
  359. if errors.Is(err, authd.ErrSourceNotFound) {
  360. writeErr(w, http.StatusNotFound, "not_found", "source not found")
  361. return
  362. }
  363. logger.Error("rotate source secrets", "err", err, "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
  364. writeErr(w, http.StatusInternalServerError, "internal", "rotate failed")
  365. return
  366. }
  367. logger.Info("source secrets rotated",
  368. "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
  369. writeJSON(w, http.StatusOK, createOrRotateResponse{Source: src, Secrets: secrets})
  370. }
  371. }