api/dashboards' handler previously enforced only tenant-baseline role
(RoleEditor+), so any Editor could edit/delete any dashboard in their
tenant -- the matrix's "(own/granted)" qualifier was explicitly named
as unbuilt in this handler's own doc comment. This closes that gap.
New core interface api/dashboards.PermissionStore (nil-safe, same "not
wired == no-op" shape as authz.Authorizer) resolves a per-resource
dashboard_permissions grant. canEditDashboard now requires the
identity be Admin/Owner, the dashboard's creator, or hold a grant of at
least Editor; canManageGrants is deliberately stricter (creator or
Admin/Owner only, never grant-derived access) so a user who can edit a
dashboard only because of a grant can't extend or re-grant that access
to themselves or others. Wired handlers: PUT/DELETE
/dashboards/{id}/permissions/{userId}, GET .../permissions.
Two real bugs found and fixed while wiring this up, before any of it
touched a live database:
- handleCreate/handleImport never stamped created_by from the
authenticated identity, so every dashboard was owned by "anonymous"
regardless of who made it -- the ownership check would have been
meaningless. Also fixed: ImportDashboard trusted the exported JSON's
created_by verbatim, so re-importing someone else's export would
leave the actual importer unable to edit their own copy.
- metadata/migrations/0024_create_dashboard_permissions.sql's CHECK
constraint diverged from /docs/phase-4-rbac-design.md's schema
(allowed role='admin', nullable granted_by). Reconciled via
0033_restrict_dashboard_permissions_role.sql: Admin/Owner already
have tenant-wide access so a resource-level "admin" grant is
meaningless, and every real grant now always has an attributable
granter.
enterprise/internal/rbacstore gets the storage side: raw CRUD
(dashboard_permissions.go) plus DashboardPermissions
(dashboards_adapter.go), an adapter implementing
api/dashboards.PermissionStore -- same pattern as audit.QueryAPILogger
over queryapi.AuditLogger. Wired into enterprise/cmd/enterprise-api
only; plain api/cmd/api passes nil (ownership/Admin checks still work
via the nil-permissions fallback, just without the "granted" bonus).
Verified: the full own/granted/admin/creator matrix, including the
granted-editor-cannot-manage-grants regression, passes against a fake
PermissionStore (api/dashboards/handler_test.go, all existing tests
also still pass unmodified in behavior). Real integration tests exist
in enterprise/internal/rbacstore/rbacstore_test.go (skip-gated on
RBACSTORE_TEST_POSTGRES_ADDR, same convention as every other
Postgres-backed piece this phase) but have not run against a live
database in this environment -- disclosed in threat-model.md,
phase-4-runbook.md, and enterprise/README.md alongside every other
piece carrying the same gap. Also fixed a stale path in
phase-4-runbook.md's dashboards-tenant-scoping section
(./internal/dashboards/... -> ./dashboards/..., stale since that
package moved out of api/internal/ earlier in this phase).
451 lines
16 KiB
Go
451 lines
16 KiB
Go
package dashboards
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/sentry/sentry/api/authz"
|
|
)
|
|
|
|
// store is the narrow interface Handler depends on -- *Store (store.go)
|
|
// is the production implementation; tests use a fake, same pattern as
|
|
// queryapi's SQLRunner/SearchClient. Every method except Create/Import
|
|
// takes a tenantID -- see store.go's doc comment for why.
|
|
type store interface {
|
|
CreateDashboard(ctx context.Context, d *Dashboard) error
|
|
ListDashboards(ctx context.Context, tenantID string) ([]Dashboard, error)
|
|
GetDashboard(ctx context.Context, tenantID, id string) (*Dashboard, error)
|
|
UpdateDashboard(ctx context.Context, tenantID string, d *Dashboard) error
|
|
DeleteDashboard(ctx context.Context, tenantID, id string) error
|
|
AddPanel(ctx context.Context, tenantID, dashboardID string, p *Panel) error
|
|
UpdatePanel(ctx context.Context, tenantID string, p *Panel) error
|
|
DeletePanel(ctx context.Context, tenantID, dashboardID, panelID string) error
|
|
ImportDashboard(ctx context.Context, tenantID string, d *Dashboard) (*Dashboard, error)
|
|
}
|
|
|
|
type Handler struct {
|
|
logger *slog.Logger
|
|
store store
|
|
authorizer authz.Authorizer
|
|
permissions PermissionStore
|
|
}
|
|
|
|
func NewHandler(logger *slog.Logger, store store, authorizer authz.Authorizer, permissions PermissionStore) *Handler {
|
|
return &Handler{logger: logger, store: store, authorizer: authorizer, permissions: permissions}
|
|
}
|
|
|
|
// RegisterRoutes wires the RBAC minimum-role bar from
|
|
// /docs/phase-4-rbac-design.md's matrix, plus (as of Phase 4 task 5) the
|
|
// matrix's "(own/granted)" qualifier: RoleEditor is necessary but not
|
|
// sufficient for editing/deleting a dashboard or its panels -- see
|
|
// canEditDashboard. Tenant scoping itself -- a different, more basic
|
|
// property than the per-resource qualifier -- is enforced separately,
|
|
// via tenantID below and store.go's WHERE tenant_id = ... filtering.
|
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("POST /dashboards", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleCreate))
|
|
mux.HandleFunc("GET /dashboards", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleList))
|
|
mux.HandleFunc("POST /dashboards/import", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleImport))
|
|
mux.HandleFunc("GET /dashboards/{id}", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleGet))
|
|
mux.HandleFunc("PUT /dashboards/{id}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleUpdate))
|
|
mux.HandleFunc("DELETE /dashboards/{id}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleDelete))
|
|
mux.HandleFunc("GET /dashboards/{id}/export", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleExport))
|
|
mux.HandleFunc("POST /dashboards/{id}/panels", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleAddPanel))
|
|
mux.HandleFunc("PUT /dashboards/{id}/panels/{panelId}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleUpdatePanel))
|
|
mux.HandleFunc("DELETE /dashboards/{id}/panels/{panelId}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleDeletePanel))
|
|
mux.HandleFunc("GET /dashboards/{id}/permissions", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleListPermissions))
|
|
mux.HandleFunc("PUT /dashboards/{id}/permissions/{userId}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleSetPermission))
|
|
mux.HandleFunc("DELETE /dashboards/{id}/permissions/{userId}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleRevokePermission))
|
|
}
|
|
|
|
// canEditDashboard reports whether the request's authenticated identity
|
|
// may edit/delete d or its panels, per the matrix's Editor "(own/
|
|
// granted)" qualifier: Admin/Owner may act on any dashboard in their
|
|
// tenant (RequireRole already gated the RoleEditor floor); a plain
|
|
// Editor may act only on a dashboard they created, or one where a
|
|
// dashboard_permissions grant raises their effective role to at least
|
|
// Editor. A nil authorizer is the same no-op RequireRole already treats
|
|
// specially -- a single-tenant deployment has no identity to check
|
|
// ownership against, so this must not start rejecting Phase 0-3
|
|
// requests that never carried one. A nil permissions store (RBAC
|
|
// enforced, but no enterprise permission service wired -- e.g. plain
|
|
// api/cmd/api with ENTERPRISE_AUTH_URL set) still enforces own/Admin,
|
|
// just without the "granted" bonus.
|
|
func (h *Handler) canEditDashboard(ctx context.Context, d *Dashboard) bool {
|
|
if h.authorizer == nil {
|
|
return true
|
|
}
|
|
identity, ok := authz.IdentityFromContext(ctx)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if identity.Role.Satisfies(authz.RoleAdmin) {
|
|
return true
|
|
}
|
|
if identity.UserID != "" && identity.UserID == d.CreatedBy {
|
|
return true
|
|
}
|
|
if h.permissions == nil {
|
|
return false
|
|
}
|
|
role, granted, err := h.permissions.GrantedRole(ctx, d.ID, identity.UserID)
|
|
return err == nil && granted && role.Satisfies(authz.RoleEditor)
|
|
}
|
|
|
|
// canManageGrants is deliberately stricter than canEditDashboard: the
|
|
// matrix lists "manage a dashboard's per-user grants" as available to
|
|
// the creator or Admin/Owner only ("if creator", not "own/granted") --
|
|
// a user who themselves only has access *via* a grant must not be able
|
|
// to extend that access to others or re-grant themselves a persistent
|
|
// one, which a grant-based bypass here would allow.
|
|
func (h *Handler) canManageGrants(ctx context.Context, d *Dashboard) bool {
|
|
if h.authorizer == nil {
|
|
return true
|
|
}
|
|
identity, ok := authz.IdentityFromContext(ctx)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return identity.Role.Satisfies(authz.RoleAdmin) || (identity.UserID != "" && identity.UserID == d.CreatedBy)
|
|
}
|
|
|
|
const maxBodyBytes = 1 << 20 // 1 MiB, same cap as queryapi
|
|
|
|
// tenantID resolves the tenant to scope a request's store calls to --
|
|
// from the authenticated identity RequireRole attached to the request
|
|
// context, never from a client-supplied field (a Dashboard JSON body
|
|
// can set "tenant_id" to anything; store.go's methods only ever see the
|
|
// value this function returns, not that field). Falls back to
|
|
// "default" when no identity is present (nil authorizer -- matches
|
|
// every other nil-authorizer-is-Phase-0-3-single-tenant default in this
|
|
// codebase) or when a resolved identity somehow carries no tenant (only
|
|
// RoleService identities can, per authz.Identity's doc comment, and
|
|
// RequireRole -- unlike RequireRoleOrService -- never admits RoleService,
|
|
// so this branch is a defensive fallback, not an expected path).
|
|
func (h *Handler) tenantID(r *http.Request) string {
|
|
if id, ok := authz.IdentityFromContext(r.Context()); ok && id.TenantID != "" {
|
|
return id.TenantID
|
|
}
|
|
return "default"
|
|
}
|
|
|
|
// createdBy resolves the authenticated identity's UserID to stamp onto a
|
|
// newly-created dashboard, exactly like tenantID resolves TenantID --
|
|
// empty (falling back to store.go's "anonymous" default) when no
|
|
// identity is present, never from client-supplied JSON. This is what
|
|
// canEditDashboard's ownership check compares against later.
|
|
func (h *Handler) createdBy(r *http.Request) string {
|
|
if id, ok := authz.IdentityFromContext(r.Context()); ok {
|
|
return id.UserID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
|
var d Dashboard
|
|
if !decodeJSON(w, r, &d) {
|
|
return
|
|
}
|
|
if d.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "name must not be empty")
|
|
return
|
|
}
|
|
// Overrides any client-supplied tenant_id/created_by -- see
|
|
// tenantID's doc comment; same reasoning applies to created_by,
|
|
// which canEditDashboard later trusts for the ownership check.
|
|
d.TenantID = h.tenantID(r)
|
|
d.CreatedBy = h.createdBy(r)
|
|
if err := h.store.CreateDashboard(r.Context(), &d); err != nil {
|
|
h.logger.Error("creating dashboard", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "creating dashboard failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, d)
|
|
}
|
|
|
|
func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) {
|
|
list, err := h.store.ListDashboards(r.Context(), h.tenantID(r))
|
|
if err != nil {
|
|
h.logger.Error("listing dashboards", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "listing dashboards failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, list)
|
|
}
|
|
|
|
func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
|
|
d, err := h.store.GetDashboard(r.Context(), h.tenantID(r), r.PathValue("id"))
|
|
if err != nil {
|
|
h.writeStoreErr(w, err, "fetching dashboard")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, d)
|
|
}
|
|
|
|
func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) {
|
|
// Export is the same document GET /dashboards/{id} returns -- the
|
|
// import endpoint below consumes exactly this shape, and so does
|
|
// `sentryctl dashboards apply`, so there's one JSON contract used
|
|
// from every call site rather than a bespoke export format.
|
|
h.handleGet(w, r)
|
|
}
|
|
|
|
func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) {
|
|
var d Dashboard
|
|
if !decodeJSON(w, r, &d) {
|
|
return
|
|
}
|
|
if d.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "name must not be empty")
|
|
return
|
|
}
|
|
// Overrides any created_by the exported JSON carries -- store.go's
|
|
// ImportDashboard trusts d.CreatedBy verbatim (unlike TenantID,
|
|
// which it always overrides from the separate tenantID argument),
|
|
// so without this an imported dashboard would keep its *original*
|
|
// creator's ID, leaving the actual importer unable to edit their own
|
|
// freshly-imported copy once canEditDashboard's ownership check
|
|
// applies -- the same reasoning TestImportIgnoresExportedTenantID
|
|
// already established for tenant_id extends to created_by.
|
|
d.CreatedBy = h.createdBy(r)
|
|
imported, err := h.store.ImportDashboard(r.Context(), h.tenantID(r), &d)
|
|
if err != nil {
|
|
h.logger.Error("importing dashboard", "error", err)
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, imported)
|
|
}
|
|
|
|
func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
|
var d Dashboard
|
|
if !decodeJSON(w, r, &d) {
|
|
return
|
|
}
|
|
if d.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "name must not be empty")
|
|
return
|
|
}
|
|
d.ID = r.PathValue("id")
|
|
existing, err := h.store.GetDashboard(r.Context(), h.tenantID(r), d.ID)
|
|
if err != nil {
|
|
h.writeStoreErr(w, err, "fetching dashboard")
|
|
return
|
|
}
|
|
if !h.canEditDashboard(r.Context(), existing) {
|
|
writeError(w, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
if err := h.store.UpdateDashboard(r.Context(), h.tenantID(r), &d); err != nil {
|
|
h.writeStoreErr(w, err, "updating dashboard")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, d)
|
|
}
|
|
|
|
func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
existing, err := h.store.GetDashboard(r.Context(), h.tenantID(r), id)
|
|
if err != nil {
|
|
h.writeStoreErr(w, err, "fetching dashboard")
|
|
return
|
|
}
|
|
if !h.canEditDashboard(r.Context(), existing) {
|
|
writeError(w, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
if err := h.store.DeleteDashboard(r.Context(), h.tenantID(r), id); err != nil {
|
|
h.writeStoreErr(w, err, "deleting dashboard")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) handleAddPanel(w http.ResponseWriter, r *http.Request) {
|
|
var p Panel
|
|
if !decodeJSON(w, r, &p) {
|
|
return
|
|
}
|
|
if err := validatePanel(&p); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
dashboardID := r.PathValue("id")
|
|
existing, err := h.store.GetDashboard(r.Context(), h.tenantID(r), dashboardID)
|
|
if err != nil {
|
|
h.writeStoreErr(w, err, "fetching dashboard")
|
|
return
|
|
}
|
|
if !h.canEditDashboard(r.Context(), existing) {
|
|
writeError(w, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
if err := h.store.AddPanel(r.Context(), h.tenantID(r), dashboardID, &p); err != nil {
|
|
h.writeStoreErr(w, err, "adding panel")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, p)
|
|
}
|
|
|
|
func (h *Handler) handleUpdatePanel(w http.ResponseWriter, r *http.Request) {
|
|
var p Panel
|
|
if !decodeJSON(w, r, &p) {
|
|
return
|
|
}
|
|
if err := validatePanel(&p); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
p.ID = r.PathValue("panelId")
|
|
p.DashboardID = r.PathValue("id")
|
|
existing, err := h.store.GetDashboard(r.Context(), h.tenantID(r), p.DashboardID)
|
|
if err != nil {
|
|
h.writeStoreErr(w, err, "fetching dashboard")
|
|
return
|
|
}
|
|
if !h.canEditDashboard(r.Context(), existing) {
|
|
writeError(w, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
if err := h.store.UpdatePanel(r.Context(), h.tenantID(r), &p); err != nil {
|
|
h.writeStoreErr(w, err, "updating panel")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, p)
|
|
}
|
|
|
|
func (h *Handler) handleDeletePanel(w http.ResponseWriter, r *http.Request) {
|
|
dashboardID := r.PathValue("id")
|
|
existing, err := h.store.GetDashboard(r.Context(), h.tenantID(r), dashboardID)
|
|
if err != nil {
|
|
h.writeStoreErr(w, err, "fetching dashboard")
|
|
return
|
|
}
|
|
if !h.canEditDashboard(r.Context(), existing) {
|
|
writeError(w, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
if err := h.store.DeletePanel(r.Context(), h.tenantID(r), dashboardID, r.PathValue("panelId")); err != nil {
|
|
h.writeStoreErr(w, err, "deleting panel")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) handleListPermissions(w http.ResponseWriter, r *http.Request) {
|
|
existing, err := h.store.GetDashboard(r.Context(), h.tenantID(r), r.PathValue("id"))
|
|
if err != nil {
|
|
h.writeStoreErr(w, err, "fetching dashboard")
|
|
return
|
|
}
|
|
if !h.canManageGrants(r.Context(), existing) {
|
|
writeError(w, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
if h.permissions == nil {
|
|
writeError(w, http.StatusNotImplemented, "dashboard permission grants are not available on this deployment")
|
|
return
|
|
}
|
|
perms, err := h.permissions.ListPermissions(r.Context(), existing.ID)
|
|
if err != nil {
|
|
h.logger.Error("listing dashboard permissions", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "listing permissions failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, perms)
|
|
}
|
|
|
|
type setPermissionRequest struct {
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
func (h *Handler) handleSetPermission(w http.ResponseWriter, r *http.Request) {
|
|
existing, err := h.store.GetDashboard(r.Context(), h.tenantID(r), r.PathValue("id"))
|
|
if err != nil {
|
|
h.writeStoreErr(w, err, "fetching dashboard")
|
|
return
|
|
}
|
|
if !h.canManageGrants(r.Context(), existing) {
|
|
writeError(w, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
if h.permissions == nil {
|
|
writeError(w, http.StatusNotImplemented, "dashboard permission grants are not available on this deployment")
|
|
return
|
|
}
|
|
var body setPermissionRequest
|
|
if !decodeJSON(w, r, &body) {
|
|
return
|
|
}
|
|
role := authz.Role(body.Role)
|
|
if !validGrantRole(role) {
|
|
writeError(w, http.StatusBadRequest, "role must be \"viewer\" or \"editor\"")
|
|
return
|
|
}
|
|
identity, _ := authz.IdentityFromContext(r.Context())
|
|
if err := h.permissions.SetPermission(r.Context(), existing.ID, r.PathValue("userId"), role, identity.UserID); err != nil {
|
|
h.logger.Error("setting dashboard permission", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "granting permission failed")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) handleRevokePermission(w http.ResponseWriter, r *http.Request) {
|
|
existing, err := h.store.GetDashboard(r.Context(), h.tenantID(r), r.PathValue("id"))
|
|
if err != nil {
|
|
h.writeStoreErr(w, err, "fetching dashboard")
|
|
return
|
|
}
|
|
if !h.canManageGrants(r.Context(), existing) {
|
|
writeError(w, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
if h.permissions == nil {
|
|
writeError(w, http.StatusNotImplemented, "dashboard permission grants are not available on this deployment")
|
|
return
|
|
}
|
|
if err := h.permissions.RevokePermission(r.Context(), existing.ID, r.PathValue("userId")); err != nil {
|
|
h.logger.Error("revoking dashboard permission", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "revoking permission failed")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) {
|
|
if errors.Is(err, ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
h.logger.Error(action, "error", err)
|
|
writeError(w, http.StatusInternalServerError, action+" failed")
|
|
}
|
|
|
|
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
|
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
type errorResponse struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
|
|
}
|