| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386 |
- // sources.go — HTTP handlers for the /v1/tenants/{id}/sources/* routes (M13b W2).
- //
- // Routes (all require a valid Bearer access JWT):
- //
- // GET /v1/tenants/{id}/sources — list
- // POST /v1/tenants/{id}/sources — create
- // GET /v1/tenants/{id}/sources/{sid} — detail
- // PATCH /v1/tenants/{id}/sources/{sid} — update
- // POST /v1/tenants/{id}/sources/{sid}/status — set status
- // POST /v1/tenants/{id}/sources/{sid}/rotate-secrets — rotate HMAC + API key
- //
- // 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 or source id not found
- // 409 — duplicate source id on create
- // 500 — unexpected DB error
- //
- // Responses:
- // The Create + RotateSecrets handlers return
- // { "source": {...}, "secrets": { "hmac_secret": "...", "api_key": "..." } }
- // on success when secrets were generated. The `secrets` field
- // is OMITTED if the caller did not request secrets (so the
- // UI knows not to render the one-time modal). The shape is
- // always { source, secrets? } so the UI can destructure.
- package main
- import (
- "encoding/json"
- "errors"
- "log/slog"
- "net/http"
- "strconv"
- "strings"
- "git3.techno-world.net/lrosales/broad-announce/internal/authd"
- )
- // sourcesListResponse is the wire shape for GET /v1/tenants/{id}/sources.
- type sourcesListResponse struct {
- Items []authd.Source `json:"items"`
- Total int `json:"total"`
- Limit int `json:"limit"`
- Offset int `json:"offset"`
- }
- // createOrRotateResponse is the shape returned by Create and Rotate.
- type createOrRotateResponse struct {
- Source *authd.Source `json:"source"`
- Secrets *authd.SecretsPayload `json:"secrets,omitempty"`
- }
- // listSourcesHandler wires GET /v1/tenants/{id}/sources.
- func listSourcesHandler(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
- }
- tenantID := r.PathValue("id")
- if !isUUID(tenantID) {
- writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
- return
- }
- if !canAccessTenant(claims, tenantID) {
- writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
- return
- }
- q := strings.TrimSpace(r.URL.Query().Get("q"))
- typeFilter := strings.TrimSpace(r.URL.Query().Get("type"))
- statusFilter := 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.SourceFilter{
- CompanyID: tenantID,
- Q: q,
- Type: typeFilter,
- Status: statusFilter,
- Limit: limit,
- Offset: offset,
- CallerRole: claims.Role,
- CallerTenant: claims.TenantID,
- }
- items, total, err := ad.Store().ListSources(r.Context(), filter)
- if err != nil {
- logger.Error("list sources", "err", err, "actor", claims.UserID, "tenant_id", tenantID)
- 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, sourcesListResponse{
- Items: items, Total: total, Limit: filter.Limit, Offset: filter.Offset,
- })
- }
- }
- // createSourceRequest is the POST body. All fields except id,
- // name, type, and rate_limit_per_sec are optional. The hmac_secret
- // and api_key fields, if non-empty, are stored bcrypt-hashed and
- // returned ONCE in the response.
- type createSourceRequest struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Type string `json:"type"`
- RateLimitPerSec int `json:"rate_limit_per_sec"`
- AllowedTargets json.RawMessage `json:"allowed_targets"`
- MatchExpr json.RawMessage `json:"match_expr"`
- Description string `json:"description"`
- MTLSRequired bool `json:"mtls_required"`
- HMACSecret string `json:"hmac_secret"`
- APIKey string `json:"api_key"`
- }
- // createSourceHandler wires POST /v1/tenants/{id}/sources.
- func createSourceHandler(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
- }
- tenantID := r.PathValue("id")
- if !isUUID(tenantID) {
- writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
- return
- }
- if !canAccessTenant(claims, tenantID) {
- writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
- return
- }
- var req createSourceRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
- return
- }
- in := authd.CreateSourceInput{
- ID: strings.TrimSpace(req.ID),
- Name: strings.TrimSpace(req.Name),
- Type: strings.TrimSpace(req.Type),
- RateLimitPerSec: req.RateLimitPerSec,
- AllowedTargets: req.AllowedTargets,
- MatchExpr: req.MatchExpr,
- Description: req.Description,
- MTLSRequired: req.MTLSRequired,
- HMACSecret: req.HMACSecret,
- APIKey: req.APIKey,
- }
- // Look up the auth tenant's display name so the
- // bridge INSERT into public.companies has a sensible
- // `name` value when the row is first created.
- tenant, err := ad.Store().GetTenant(r.Context(), tenantID)
- if err != nil {
- if errors.Is(err, authd.ErrTenantNotFound) {
- writeErr(w, http.StatusNotFound, "not_found", "tenant not found")
- return
- }
- logger.Error("create source: lookup tenant", "err", err, "tenant_id", tenantID)
- writeErr(w, http.StatusInternalServerError, "internal", "lookup failed")
- return
- }
- ip := clientIP(r)
- ua := r.UserAgent()
- src, secrets, err := ad.Store().CreateSource(r.Context(), tenantID, tenant.DisplayName, in, claims.UserID, ip, ua)
- if err != nil {
- switch {
- case errors.Is(err, authd.ErrSourceIDTaken):
- writeErr(w, http.StatusConflict, "id_taken", "source id already in use")
- case errors.Is(err, authd.ErrSourceInvalid):
- writeErr(w, http.StatusBadRequest, "invalid", err.Error())
- default:
- logger.Error("create source", "err", err, "tenant_id", tenantID, "actor", claims.UserID)
- writeErr(w, http.StatusInternalServerError, "internal", "create failed")
- }
- return
- }
- logger.Info("source created",
- "tenant_id", tenantID, "source_id", src.ID, "actor", claims.UserID,
- "hmac_set", src.HMACSet, "api_key_set", src.APIKeySet)
- writeJSON(w, http.StatusCreated, createOrRotateResponse{Source: src, Secrets: secrets})
- }
- }
- // getSourceHandler wires GET /v1/tenants/{id}/sources/{sid}.
- func getSourceHandler(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
- }
- tenantID := r.PathValue("id")
- if !isUUID(tenantID) {
- writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
- return
- }
- if !canAccessTenant(claims, tenantID) {
- writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
- return
- }
- sourceID := r.PathValue("sid")
- if sourceID == "" {
- writeErr(w, http.StatusBadRequest, "bad_request", "source id is required")
- return
- }
- src, err := ad.Store().GetSource(r.Context(), tenantID, sourceID)
- if err != nil {
- if errors.Is(err, authd.ErrSourceNotFound) {
- writeErr(w, http.StatusNotFound, "not_found", "source not found")
- return
- }
- logger.Error("get source", "err", err, "tenant_id", tenantID, "source_id", sourceID)
- writeErr(w, http.StatusInternalServerError, "internal", "lookup failed")
- return
- }
- writeJSON(w, http.StatusOK, src)
- }
- }
- // updateSourceRequest is the PATCH body. All fields optional.
- type updateSourceRequest struct {
- Name *string `json:"name"`
- Type *string `json:"type"`
- RateLimitPerSec *int `json:"rate_limit_per_sec"`
- Description *string `json:"description"`
- MTLSRequired *bool `json:"mtls_required"`
- AllowedTargets json.RawMessage `json:"allowed_targets"`
- MatchExpr json.RawMessage `json:"match_expr"`
- }
- // updateSourceHandler wires PATCH /v1/tenants/{id}/sources/{sid}.
- func updateSourceHandler(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
- }
- tenantID := r.PathValue("id")
- if !isUUID(tenantID) {
- writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
- return
- }
- if !canAccessTenant(claims, tenantID) {
- writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
- return
- }
- sourceID := r.PathValue("sid")
- if sourceID == "" {
- writeErr(w, http.StatusBadRequest, "bad_request", "source id is required")
- return
- }
- var req updateSourceRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
- return
- }
- in := authd.UpdateSourceInput{
- Name: req.Name,
- Type: req.Type,
- RateLimitPerSec: req.RateLimitPerSec,
- Description: req.Description,
- MTLSRequired: req.MTLSRequired,
- AllowedTargets: req.AllowedTargets,
- MatchExpr: req.MatchExpr,
- }
- ip := clientIP(r)
- ua := r.UserAgent()
- src, err := ad.Store().UpdateSource(r.Context(), tenantID, sourceID, in, claims.UserID, ip, ua)
- if err != nil {
- switch {
- case errors.Is(err, authd.ErrSourceNotFound):
- writeErr(w, http.StatusNotFound, "not_found", "source not found")
- case errors.Is(err, authd.ErrSourceInvalid):
- writeErr(w, http.StatusBadRequest, "invalid", err.Error())
- default:
- logger.Error("update source", "err", err, "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
- writeErr(w, http.StatusInternalServerError, "internal", "update failed")
- }
- return
- }
- logger.Info("source updated",
- "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
- writeJSON(w, http.StatusOK, src)
- }
- }
- // setSourceStatusRequest is the POST /status body.
- type setSourceStatusRequest struct {
- Status string `json:"status"`
- }
- // setSourceStatusHandler wires POST /v1/tenants/{id}/sources/{sid}/status.
- func setSourceStatusHandler(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
- }
- tenantID := r.PathValue("id")
- if !isUUID(tenantID) {
- writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
- return
- }
- if !canAccessTenant(claims, tenantID) {
- writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
- return
- }
- sourceID := r.PathValue("sid")
- if sourceID == "" {
- writeErr(w, http.StatusBadRequest, "bad_request", "source id is required")
- return
- }
- var req setSourceStatusRequest
- 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()
- src, err := ad.Store().SetSourceStatus(r.Context(), tenantID, sourceID, strings.TrimSpace(req.Status), claims.UserID, ip, ua)
- if err != nil {
- switch {
- case errors.Is(err, authd.ErrSourceNotFound):
- writeErr(w, http.StatusNotFound, "not_found", "source not found")
- case errors.Is(err, authd.ErrSourceInvalid):
- writeErr(w, http.StatusBadRequest, "invalid", err.Error())
- default:
- logger.Error("set source status", "err", err, "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
- writeErr(w, http.StatusInternalServerError, "internal", "update failed")
- }
- return
- }
- logger.Info("source status changed",
- "tenant_id", tenantID, "source_id", sourceID, "to", src.Status, "actor", claims.UserID)
- writeJSON(w, http.StatusOK, src)
- }
- }
- // rotateSourceSecretsHandler wires POST /v1/tenants/{id}/sources/{sid}/rotate-secrets.
- func rotateSourceSecretsHandler(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
- }
- tenantID := r.PathValue("id")
- if !isUUID(tenantID) {
- writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
- return
- }
- if !canAccessTenant(claims, tenantID) {
- writeErr(w, http.StatusForbidden, "forbidden", "not your tenant")
- return
- }
- sourceID := r.PathValue("sid")
- if sourceID == "" {
- writeErr(w, http.StatusBadRequest, "bad_request", "source id is required")
- return
- }
- ip := clientIP(r)
- ua := r.UserAgent()
- src, secrets, err := ad.Store().RotateSecrets(r.Context(), tenantID, sourceID, claims.UserID, ip, ua)
- if err != nil {
- if errors.Is(err, authd.ErrSourceNotFound) {
- writeErr(w, http.StatusNotFound, "not_found", "source not found")
- return
- }
- logger.Error("rotate source secrets", "err", err, "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
- writeErr(w, http.StatusInternalServerError, "internal", "rotate failed")
- return
- }
- logger.Info("source secrets rotated",
- "tenant_id", tenantID, "source_id", sourceID, "actor", claims.UserID)
- writeJSON(w, http.StatusOK, createOrRotateResponse{Source: src, Secrets: secrets})
- }
- }
|