// 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{ 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}) } }