Skip to content

Commit 30895da

Browse files
authored
fix: enforce tenant scope on webhooks (#65)
* fix(webhooks): enforce tenant dispatch scope Carry tenant_id through webhook dispatch jobs and re-check scope in the worker so tenant-scoped webhooks cannot receive another tenant's payload. Allow worker and embedding backfill commands to use an explicitly configured local DATABASE_URL while still rejecting implicit defaults. Refs ENG-796 * fix(webhooks): enforce tenant dispatch scope Carry tenant_id through webhook dispatch jobs and re-check scope in the worker so tenant-scoped webhooks cannot receive another tenant's payload. Allow worker and embedding backfill commands to use an explicitly configured local DATABASE_URL while still rejecting implicit defaults. Refs ENG-796 * docs: fix AGENTS markdown spacing * test(webhooks): keep repository scope test out of unit CI * chore: restore tests workflow * fix: tighten webhook tenant isolation * chore: revert delete api * chore: allow deleting specific tenant id * chore: add gdpr exception * chore: address pr comments * chore: lint fix * fix: harden webhook delete payload dispatch
1 parent b22da19 commit 30895da

32 files changed

Lines changed: 2226 additions & 363 deletions

AGENTS.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@
2525
- Prefer Go naming conventions (CamelCase for exported, lowerCamel for unexported).
2626
- Keep package names short and domain-focused (e.g., `repository`, `service`).
2727

28+
## Multi-Tenancy & Data Isolation
29+
30+
- Treat `tenant_id` as a security boundary, not a convenience filter. Tenant-owned data must never be read, enqueued, dispatched, cached, searched, embedded, or deleted across tenants.
31+
- Exception: GDPR/right-to-erasure flows may intentionally delete all records for a data subject across tenants when that is the documented API contract. Make that all-tenant behavior explicit in the API docs, service/repository names or comments, logs, and tests; do not reuse it for normal tenant-owned workflows.
32+
- When making a model, migration, API request, or repository change involving tenant-owned data, audit every downstream path that carries or derives from that data: handlers, services, repositories, message publishers, River job args, workers, webhook payloads, search, embeddings, bulk operations, logs, and metrics.
33+
- Tenant access rules must be consistent across every path that can observe, mutate, derive from, or act on the same resource. If one API endpoint, repository method, search path, webhook dispatch path, worker, backfill, bulk operation, or export path requires tenant scope, every alternate path for that resource must enforce the same tenant boundary.
34+
- Do not model `tenant_id` as an optional filter for tenant-owned resources. Prefer required tenant parameters in service/repository method signatures (`tenantID string`, not `*string`) unless the domain explicitly supports global resources and documents that behavior.
35+
- Prefer tenant-aware repository/service methods for tenant-owned workflows. Avoid adding broad helpers that return all enabled/all matching resources when the caller is dispatching, processing, deriving, exporting, or exposing tenant data.
36+
- Async jobs must carry the tenant boundary when the source data has one, and workers must re-check tenant scope before doing side effects. Do not rely only on enqueue-time filtering.
37+
- Global resources may intentionally have `tenant_id = NULL` only when the domain explicitly documents them as non-tenant-owned. Webhooks are tenant-owned and must require a non-empty `tenant_id`; a missing tenant on event data must not match any webhook.
38+
- When changing access rules for a tenant-owned model, search for all alternate access paths by resource name and by derived side effects: list, get, update, delete, bulk delete, webhook fan-out, River jobs, workers, embeddings, search, exports, cache invalidation, logs, and metrics.
39+
- For any tenant-scoping change, include verification at the boundary where the leak could happen: database query behavior, service fan-out, worker execution, and API behavior when relevant. Include at least one alternate-path regression test proving that data allowed through the primary path cannot leak through async dispatch, bulk operations, derived indexes, exports, or background workers. Tests are the evidence; the invariant belongs in the architecture.
40+
2841
## Testing Guidelines
2942
- Tests live under `tests/` and are run with `go test ./tests/...`.
3043
- Name test files `*_test.go` and test functions `TestXxx`.

internal/api/handlers/feedback_records_handler.go

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"errors"
88
"fmt"
9+
"log/slog"
910
"net/http"
1011

1112
"github.com/google/uuid"
@@ -24,7 +25,7 @@ type FeedbackRecordsService interface {
2425
ListFeedbackRecords(ctx context.Context, filters *models.ListFeedbackRecordsFilters) (*models.ListFeedbackRecordsResponse, error)
2526
UpdateFeedbackRecord(ctx context.Context, id uuid.UUID, req *models.UpdateFeedbackRecordRequest) (*models.FeedbackRecord, error)
2627
DeleteFeedbackRecord(ctx context.Context, id uuid.UUID) error
27-
BulkDeleteFeedbackRecords(ctx context.Context, userID string, tenantID *string) (int, error)
28+
BulkDeleteFeedbackRecords(ctx context.Context, filters *models.BulkDeleteFilters) (int, error)
2829
}
2930

3031
// FeedbackRecordsHandler handles HTTP requests for feedback records.
@@ -59,6 +60,12 @@ func (h *FeedbackRecordsHandler) Create(w http.ResponseWriter, r *http.Request)
5960

6061
record, err := h.service.CreateFeedbackRecord(r.Context(), &req)
6162
if err != nil {
63+
if errors.Is(err, huberrors.ErrValidation) {
64+
validation.RespondValidationError(w, err)
65+
66+
return
67+
}
68+
6269
if errors.Is(err, huberrors.ErrNotFound) {
6370
response.RespondNotFound(w, "Feedback record not found")
6471

@@ -219,7 +226,7 @@ func (h *FeedbackRecordsHandler) Delete(w http.ResponseWriter, r *http.Request)
219226
w.WriteHeader(http.StatusNoContent)
220227
}
221228

222-
// BulkDelete handles DELETE /v1/feedback-records?user_id=<id>.
229+
// BulkDelete handles DELETE /v1/feedback-records?user_id=<id>[&tenant_id=<id>].
223230
func (h *FeedbackRecordsHandler) BulkDelete(w http.ResponseWriter, r *http.Request) {
224231
filters := &models.BulkDeleteFilters{}
225232

@@ -230,8 +237,27 @@ func (h *FeedbackRecordsHandler) BulkDelete(w http.ResponseWriter, r *http.Reque
230237
return
231238
}
232239

233-
deletedCount, err := h.service.BulkDeleteFeedbackRecords(r.Context(), filters.UserID, filters.TenantID)
240+
deletedCount, err := h.service.BulkDeleteFeedbackRecords(r.Context(), filters)
234241
if err != nil {
242+
if errors.Is(err, huberrors.ErrValidation) {
243+
validation.RespondValidationError(w, err)
244+
245+
return
246+
}
247+
248+
var tenantID string
249+
if filters.TenantID != nil {
250+
tenantID = *filters.TenantID
251+
}
252+
253+
slog.Error("Failed to bulk delete feedback records", // #nosec G706 -- slog key-values
254+
"method", r.Method,
255+
"path", r.URL.Path,
256+
"user_id", filters.UserID,
257+
"tenant_id", tenantID,
258+
"error", err,
259+
)
260+
235261
response.RespondInternalServerError(w, "An unexpected error occurred")
236262

237263
return

internal/api/handlers/feedback_records_handler_test.go

Lines changed: 125 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package handlers
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"net/http"
@@ -11,17 +12,23 @@ import (
1112
"github.com/stretchr/testify/assert"
1213
"github.com/stretchr/testify/require"
1314

15+
"github.com/formbricks/hub/internal/huberrors"
1416
"github.com/formbricks/hub/internal/models"
1517
)
1618

1719
// mockFeedbackRecordsService mocks FeedbackRecordsService for handler tests.
1820
type mockFeedbackRecordsService struct {
19-
bulkDeleteFunc func(ctx context.Context, userID string, tenantID *string) (int, error)
21+
createFunc func(ctx context.Context, req *models.CreateFeedbackRecordRequest) (*models.FeedbackRecord, error)
22+
bulkDeleteFunc func(ctx context.Context, filters *models.BulkDeleteFilters) (int, error)
2023
}
2124

2225
func (m *mockFeedbackRecordsService) CreateFeedbackRecord(
23-
context.Context, *models.CreateFeedbackRecordRequest,
26+
ctx context.Context, req *models.CreateFeedbackRecordRequest,
2427
) (*models.FeedbackRecord, error) {
28+
if m.createFunc != nil {
29+
return m.createFunc(ctx, req)
30+
}
31+
2532
return nil, nil
2633
}
2734

@@ -45,9 +52,11 @@ func (m *mockFeedbackRecordsService) DeleteFeedbackRecord(context.Context, uuid.
4552
return nil
4653
}
4754

48-
func (m *mockFeedbackRecordsService) BulkDeleteFeedbackRecords(ctx context.Context, userID string, tenantID *string) (int, error) {
55+
func (m *mockFeedbackRecordsService) BulkDeleteFeedbackRecords(
56+
ctx context.Context, filters *models.BulkDeleteFilters,
57+
) (int, error) {
4958
if m.bulkDeleteFunc != nil {
50-
return m.bulkDeleteFunc(ctx, userID, tenantID)
59+
return m.bulkDeleteFunc(ctx, filters)
5160
}
5261

5362
return 0, nil
@@ -67,11 +76,101 @@ func TestFeedbackRecordsHandler_List(t *testing.T) {
6776
})
6877
}
6978

79+
func TestFeedbackRecordsHandler_Create(t *testing.T) {
80+
t.Run("success returns created record", func(t *testing.T) {
81+
recordID := uuid.Must(uuid.NewV7())
82+
mock := &mockFeedbackRecordsService{
83+
createFunc: func(_ context.Context, req *models.CreateFeedbackRecordRequest) (*models.FeedbackRecord, error) {
84+
assert.Equal(t, "org-123", req.TenantID)
85+
86+
return &models.FeedbackRecord{
87+
ID: recordID,
88+
SourceType: req.SourceType,
89+
FieldID: req.FieldID,
90+
FieldType: req.FieldType,
91+
TenantID: req.TenantID,
92+
SubmissionID: req.SubmissionID,
93+
}, nil
94+
},
95+
}
96+
handler := NewFeedbackRecordsHandler(mock)
97+
98+
req := httptest.NewRequestWithContext(
99+
context.Background(), http.MethodPost, "http://test/v1/feedback-records", feedbackRecordCreateBody(t, "org-123"),
100+
)
101+
rec := httptest.NewRecorder()
102+
103+
handler.Create(rec, req)
104+
105+
assert.Equal(t, http.StatusCreated, rec.Code)
106+
107+
var got models.FeedbackRecord
108+
109+
err := json.Unmarshal(rec.Body.Bytes(), &got)
110+
require.NoError(t, err)
111+
assert.Equal(t, recordID, got.ID)
112+
assert.Equal(t, "org-123", got.TenantID)
113+
})
114+
115+
t.Run("service validation error returns bad request", func(t *testing.T) {
116+
mock := &mockFeedbackRecordsService{
117+
createFunc: func(_ context.Context, _ *models.CreateFeedbackRecordRequest) (*models.FeedbackRecord, error) {
118+
return nil, huberrors.NewValidationError("tenant_id", "tenant_id is required and cannot be empty")
119+
},
120+
}
121+
handler := NewFeedbackRecordsHandler(mock)
122+
123+
req := httptest.NewRequestWithContext(
124+
context.Background(), http.MethodPost, "http://test/v1/feedback-records", feedbackRecordCreateBody(t, " "),
125+
)
126+
rec := httptest.NewRecorder()
127+
128+
handler.Create(rec, req)
129+
130+
assert.Equal(t, http.StatusBadRequest, rec.Code)
131+
assert.Contains(t, rec.Header().Get("Content-Type"), "application/problem+json")
132+
})
133+
134+
t.Run("service conflict returns conflict", func(t *testing.T) {
135+
mock := &mockFeedbackRecordsService{
136+
createFunc: func(_ context.Context, _ *models.CreateFeedbackRecordRequest) (*models.FeedbackRecord, error) {
137+
return nil, huberrors.NewConflictError("duplicate feedback record")
138+
},
139+
}
140+
handler := NewFeedbackRecordsHandler(mock)
141+
142+
req := httptest.NewRequestWithContext(
143+
context.Background(), http.MethodPost, "http://test/v1/feedback-records", feedbackRecordCreateBody(t, "org-123"),
144+
)
145+
rec := httptest.NewRecorder()
146+
147+
handler.Create(rec, req)
148+
149+
assert.Equal(t, http.StatusConflict, rec.Code)
150+
})
151+
}
152+
153+
func feedbackRecordCreateBody(t *testing.T, tenantID string) *bytes.Reader {
154+
t.Helper()
155+
156+
body, err := json.Marshal(map[string]any{
157+
"source_type": "formbricks",
158+
"submission_id": "submission-1",
159+
"tenant_id": tenantID,
160+
"field_id": "feedback",
161+
"field_type": "text",
162+
})
163+
require.NoError(t, err)
164+
165+
return bytes.NewReader(body)
166+
}
167+
70168
func TestFeedbackRecordsHandler_BulkDelete(t *testing.T) {
71169
t.Run("success returns 200 with deleted_count and message", func(t *testing.T) {
72170
mock := &mockFeedbackRecordsService{
73-
bulkDeleteFunc: func(_ context.Context, userID string, _ *string) (int, error) {
74-
assert.Equal(t, "user-123", userID)
171+
bulkDeleteFunc: func(_ context.Context, filters *models.BulkDeleteFilters) (int, error) {
172+
assert.Equal(t, "user-123", filters.UserID)
173+
assert.Nil(t, filters.TenantID)
75174

76175
return 3, nil
77176
},
@@ -94,14 +193,12 @@ func TestFeedbackRecordsHandler_BulkDelete(t *testing.T) {
94193
assert.Equal(t, "Successfully deleted 3 feedback records", resp.Message)
95194
})
96195

97-
t.Run("success with tenant_id passes tenant to service", func(t *testing.T) {
98-
var capturedTenantID *string
99-
196+
t.Run("optional tenant_id query parameter is passed to service", func(t *testing.T) {
100197
mock := &mockFeedbackRecordsService{
101-
bulkDeleteFunc: func(_ context.Context, userID string, tenantID *string) (int, error) {
102-
assert.Equal(t, "user-456", userID)
103-
104-
capturedTenantID = tenantID
198+
bulkDeleteFunc: func(_ context.Context, filters *models.BulkDeleteFilters) (int, error) {
199+
assert.Equal(t, "user-456", filters.UserID)
200+
require.NotNil(t, filters.TenantID)
201+
assert.Equal(t, "tenant-a", *filters.TenantID)
105202

106203
return 1, nil
107204
},
@@ -115,8 +212,19 @@ func TestFeedbackRecordsHandler_BulkDelete(t *testing.T) {
115212
handler.BulkDelete(rec, req)
116213

117214
assert.Equal(t, http.StatusOK, rec.Code)
118-
require.NotNil(t, capturedTenantID)
119-
assert.Equal(t, "tenant-a", *capturedTenantID)
215+
})
216+
217+
t.Run("empty tenant_id returns bad request", func(t *testing.T) {
218+
mock := &mockFeedbackRecordsService{}
219+
handler := NewFeedbackRecordsHandler(mock)
220+
221+
req := httptest.NewRequestWithContext(context.Background(),
222+
http.MethodDelete, "http://test/v1/feedback-records?user_id=user-123&tenant_id=", http.NoBody)
223+
rec := httptest.NewRecorder()
224+
225+
handler.BulkDelete(rec, req)
226+
227+
assert.Equal(t, http.StatusBadRequest, rec.Code)
120228
})
121229

122230
t.Run("missing user_id returns bad request", func(t *testing.T) {
@@ -146,7 +254,7 @@ func TestFeedbackRecordsHandler_BulkDelete(t *testing.T) {
146254

147255
t.Run("service error returns 500", func(t *testing.T) {
148256
mock := &mockFeedbackRecordsService{
149-
bulkDeleteFunc: func(_ context.Context, _ string, _ *string) (int, error) {
257+
bulkDeleteFunc: func(_ context.Context, _ *models.BulkDeleteFilters) (int, error) {
150258
return 0, assert.AnError
151259
},
152260
}
@@ -163,7 +271,7 @@ func TestFeedbackRecordsHandler_BulkDelete(t *testing.T) {
163271

164272
t.Run("zero deleted returns 200 with deleted_count 0", func(t *testing.T) {
165273
mock := &mockFeedbackRecordsService{
166-
bulkDeleteFunc: func(_ context.Context, _ string, _ *string) (int, error) {
274+
bulkDeleteFunc: func(_ context.Context, _ *models.BulkDeleteFilters) (int, error) {
167275
return 0, nil
168276
},
169277
}

internal/models/events.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package models
2+
3+
import "github.com/google/uuid"
4+
5+
// DeletedIDsEventData is the tenant-aware payload for resource deletion events.
6+
type DeletedIDsEventData struct {
7+
TenantID string `json:"tenant_id"`
8+
IDs []uuid.UUID `json:"ids"`
9+
}

internal/models/feedback_records.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,11 +199,17 @@ type ListFeedbackRecordsResponse struct {
199199
// BulkDeleteFilters represents query parameters for bulk delete operation.
200200
type BulkDeleteFilters struct {
201201
UserID string `form:"user_id" validate:"required,no_null_bytes,min=1"`
202-
TenantID *string `form:"tenant_id" validate:"omitempty,no_null_bytes"`
202+
TenantID *string `form:"tenant_id" validate:"omitempty,no_null_bytes,min=1"`
203203
}
204204

205205
// BulkDeleteResponse represents the response for bulk delete operation.
206206
type BulkDeleteResponse struct {
207207
DeletedCount int64 `json:"deleted_count"`
208208
Message string `json:"message"`
209209
}
210+
211+
// DeletedFeedbackRecordsByTenant groups deleted feedback record IDs by tenant.
212+
type DeletedFeedbackRecordsByTenant struct {
213+
TenantID string
214+
IDs []uuid.UUID
215+
}

internal/models/webhooks.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ type Webhook struct {
3434
DisabledAt *time.Time `json:"disabled_at,omitempty"`
3535
}
3636

37+
// DeletedWebhook is the minimal data returned after deleting a webhook.
38+
type DeletedWebhook struct {
39+
ID uuid.UUID
40+
TenantID *string
41+
}
42+
3743
// MarshalJSON converts []datatypes.EventType to JSON string array.
3844
func (w *Webhook) MarshalJSON() ([]byte, error) {
3945
type Alias Webhook
@@ -183,7 +189,7 @@ type CreateWebhookRequest struct {
183189
URL string `json:"url" validate:"required,no_null_bytes,http_url,min=1,max=2048"`
184190
SigningKey string `json:"signing_key,omitempty" validate:"omitempty,max=255"`
185191
Enabled *bool `json:"enabled,omitempty"`
186-
TenantID *string `json:"tenant_id,omitempty" validate:"omitempty,no_null_bytes,max=255"`
192+
TenantID *string `json:"tenant_id" validate:"required,no_null_bytes,min=1,max=255"`
187193
EventTypes []datatypes.EventType `json:"event_types,omitempty"`
188194
}
189195

@@ -219,7 +225,7 @@ type UpdateWebhookRequest struct {
219225
URL *string `json:"url,omitempty" validate:"omitempty,no_null_bytes,http_url,min=1,max=2048"`
220226
SigningKey *string `json:"signing_key,omitempty" validate:"omitempty,no_null_bytes,min=1,max=255"`
221227
Enabled *bool `json:"enabled,omitempty"`
222-
TenantID *string `json:"tenant_id,omitempty" validate:"omitempty,no_null_bytes,max=255"`
228+
TenantID *string `json:"tenant_id,omitempty" validate:"omitempty,no_null_bytes,min=1,max=255"`
223229
EventTypes *[]datatypes.EventType `json:"event_types,omitempty"`
224230
DisabledReason *string `json:"-"` // read-only; set by system when disabling
225231
DisabledAt *time.Time `json:"-"` // read-only; set by system when disabling

internal/observability/names.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@ func AllowedEventTypes() []string {
4343

4444
// allowedProviderReasons for hub_webhook_provider_errors_total (bounded cardinality).
4545
var allowedProviderReasons = map[string]bool{
46-
"list_failed": true,
47-
"enqueue_failed": true,
46+
"list_failed": true,
47+
"enqueue_failed": true,
48+
"missing_tenant_id": true,
4849
}
4950

5051
// allowedDeliveryStatuses for hub_webhook_deliveries_total and hub_webhook_delivery_duration_seconds.
@@ -63,6 +64,8 @@ var allowedDisabledReasons = map[string]bool{
6364
// allowedDispatchReasons for hub_webhook_dispatch_errors_total.
6465
var allowedDispatchReasons = map[string]bool{
6566
"get_webhook_failed": true,
67+
"missing_tenant_id": true,
68+
"tenant_mismatch": true,
6669
}
6770

6871
// allowedEmbeddingProviderReasons for hub_embedding_provider_errors_total.

0 commit comments

Comments
 (0)