Skip to content

Commit 2289607

Browse files
committed
fix: team-scope workflow trigger uniqueness (#164)
Trigger registration surfaced a latent multi-tenant bug: the uniqueness indexes on workflow_triggers were global, not team-scoped. - Migration 022 rebuilds the cron uniqueness index as (team_id, workflow_name, schedule). Workflow names are team-scoped, so two teams may legitimately share a name + schedule; the global index made the second team's apply fail with a unique-constraint violation. - Webhook paths stay a global routing key (an inbound POST /hooks/<path> carries no team identity, so the path must be globally unique). LookupWebhookTrigger no longer filters by the request's team; it resolves by path and returns the owning team from the matched row, and the webhook handler runs the workflow under that team. This also fixes non-default-team webhooks, which previously never routed because the lookup was scoped to the default team. - Email triggers have no uniqueness index and are unaffected. Tests: two teams registering the same workflow name + cron schedule; a non-default-team webhook resolved by a team-agnostic path lookup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnKgCRfMvFcZAkoVbr8k79
1 parent dbb5acd commit 2289607

6 files changed

Lines changed: 161 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@mantle/engine": patch
3+
---
4+
5+
Make workflow trigger uniqueness multi-tenant-safe. The cron trigger uniqueness index is now scoped by team (`team_id, workflow_name, schedule`), so two teams can each register a workflow with the same name and schedule instead of the second team's apply failing with a unique-constraint violation. Webhook paths remain a global routing key (an inbound `POST /hooks/<path>` carries no team identity), and the webhook lookup is no longer team-scoped: it resolves by path and runs the workflow under the owning team from the matched trigger row, so webhooks registered by non-default teams now route correctly. Fixes #164.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-- +goose Up
2+
-- 022_team_scoped_trigger_uniqueness: cron trigger uniqueness must be per-team.
3+
--
4+
-- Workflow names are team-scoped (workflow_definitions is UNIQUE(team_id, name,
5+
-- version)), so two teams may legitimately own a workflow of the same name on
6+
-- the same schedule. The original index on (workflow_name, schedule) was global,
7+
-- so the second team's apply failed with a unique-constraint violation once
8+
-- trigger registration went live. Scope the uniqueness by team.
9+
--
10+
-- The webhook path index is intentionally left global: an inbound
11+
-- POST /hooks/<path> request carries no team identity, so the path is the global
12+
-- routing key and must be unique across all teams. Email triggers have no
13+
-- uniqueness index and are unaffected.
14+
DROP INDEX IF EXISTS idx_workflow_triggers_cron;
15+
CREATE UNIQUE INDEX idx_workflow_triggers_cron
16+
ON workflow_triggers (team_id, workflow_name, schedule)
17+
WHERE type = 'cron';
18+
19+
-- +goose Down
20+
DROP INDEX IF EXISTS idx_workflow_triggers_cron;
21+
CREATE UNIQUE INDEX idx_workflow_triggers_cron
22+
ON workflow_triggers (workflow_name, schedule)
23+
WHERE type = 'cron';

packages/engine/internal/server/trigger.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ package server
33
import (
44
"context"
55
"database/sql"
6-
7-
"github.com/dvflw/mantle/internal/auth"
86
)
97

108
// TriggerRecord represents a registered trigger in the database.
@@ -43,14 +41,17 @@ func ListCronTriggers(ctx context.Context, db *sql.DB) ([]TriggerRecord, error)
4341
return scanTriggers(rows)
4442
}
4543

46-
// LookupWebhookTrigger finds the trigger matching a webhook path, scoped to the team.
44+
// LookupWebhookTrigger finds the enabled webhook trigger for a path. Webhook
45+
// paths are a global routing key — an inbound POST /hooks/<path> carries no team
46+
// identity — so the lookup is not team-scoped and the path index is unique
47+
// across all teams. The owning team is read from the matched row so the caller
48+
// can run the workflow under the correct tenant.
4749
func LookupWebhookTrigger(ctx context.Context, db *sql.DB, path string) (*TriggerRecord, error) {
48-
teamID := auth.TeamIDFromContext(ctx)
4950
var t TriggerRecord
5051
err := db.QueryRowContext(ctx,
51-
`SELECT id, workflow_name, workflow_version, type, COALESCE(schedule, ''), COALESCE(path, ''), COALESCE(secret, ''), enabled
52-
FROM workflow_triggers WHERE type = 'webhook' AND path = $1 AND enabled = true AND team_id = $2`, path, teamID,
53-
).Scan(&t.ID, &t.WorkflowName, &t.WorkflowVersion, &t.Type, &t.Schedule, &t.Path, &t.Secret, &t.Enabled)
52+
`SELECT id, workflow_name, workflow_version, type, COALESCE(schedule, ''), COALESCE(path, ''), COALESCE(secret, ''), enabled, team_id
53+
FROM workflow_triggers WHERE type = 'webhook' AND path = $1 AND enabled = true`, path,
54+
).Scan(&t.ID, &t.WorkflowName, &t.WorkflowVersion, &t.Type, &t.Schedule, &t.Path, &t.Secret, &t.Enabled, &t.TeamID)
5455
if err == sql.ErrNoRows {
5556
return nil, nil
5657
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/dvflw/mantle/internal/auth"
8+
)
9+
10+
// TestLookupWebhookTrigger_GlobalPathReturnsOwningTeam verifies that a webhook
11+
// registered by a non-default team is found by an unauthenticated inbound
12+
// lookup (which carries no team identity) and reports the owning team, so the
13+
// handler can run the workflow under the correct tenant. Previously the lookup
14+
// was team-scoped to the default team, so non-default-team webhooks never
15+
// routed.
16+
func TestLookupWebhookTrigger_GlobalPathReturnsOwningTeam(t *testing.T) {
17+
db, _ := setupWebhookTest(t)
18+
ctx := context.Background()
19+
20+
teamB := "00000000-0000-0000-0000-0000000000b2"
21+
if _, err := db.ExecContext(ctx,
22+
`INSERT INTO teams (id, name) VALUES ($1, 'team-b')`, teamB); err != nil {
23+
t.Fatalf("creating team-b: %v", err)
24+
}
25+
if _, err := db.ExecContext(ctx,
26+
`INSERT INTO workflow_triggers (workflow_name, workflow_version, type, path, team_id)
27+
VALUES ('wf', 1, 'webhook', '/hooks/tb', $1)`, teamB); err != nil {
28+
t.Fatalf("seeding webhook trigger: %v", err)
29+
}
30+
31+
// No team in context, as an inbound webhook request would be.
32+
tr, err := LookupWebhookTrigger(ctx, db, "/hooks/tb")
33+
if err != nil {
34+
t.Fatalf("LookupWebhookTrigger: %v", err)
35+
}
36+
if tr == nil {
37+
t.Fatal("expected trigger, got nil")
38+
}
39+
if tr.TeamID != teamB {
40+
t.Errorf("TeamID = %q, want %q", tr.TeamID, teamB)
41+
}
42+
if tr.WorkflowName != "wf" {
43+
t.Errorf("WorkflowName = %q, want wf", tr.WorkflowName)
44+
}
45+
46+
// A default-team context must not change the result — the path is a global
47+
// routing key, not team-scoped.
48+
ctxDefault := auth.WithUser(ctx, &auth.User{TeamID: auth.DefaultTeamID})
49+
tr2, err := LookupWebhookTrigger(ctxDefault, db, "/hooks/tb")
50+
if err != nil {
51+
t.Fatalf("LookupWebhookTrigger (default ctx): %v", err)
52+
}
53+
if tr2 == nil || tr2.TeamID != teamB {
54+
t.Errorf("global lookup should be team-agnostic; got %+v", tr2)
55+
}
56+
}

packages/engine/internal/server/webhook.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"io"
1010
"net/http"
1111
"strings"
12+
13+
"github.com/dvflw/mantle/internal/auth"
1214
)
1315

1416
// WebhookHandler handles incoming webhook requests and triggers workflow executions.
@@ -84,9 +86,14 @@ func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
8486
h.server.Logger.Info("webhook: triggering workflow",
8587
"path", path,
8688
"workflow", trigger.WorkflowName,
87-
"version", trigger.WorkflowVersion)
88-
89-
execID, err := h.server.executeWorkflow(r.Context(), trigger.WorkflowName, trigger.WorkflowVersion, inputs)
89+
"version", trigger.WorkflowVersion,
90+
"team_id", trigger.TeamID)
91+
92+
// Webhook requests are unauthenticated, so the tenant comes from the matched
93+
// trigger row rather than the request context. Run under that team so
94+
// workflow/credential resolution is correctly scoped.
95+
execCtx := auth.WithUser(r.Context(), &auth.User{TeamID: trigger.TeamID})
96+
execID, err := h.server.executeWorkflow(execCtx, trigger.WorkflowName, trigger.WorkflowVersion, inputs)
9097
if err != nil {
9198
h.server.Logger.Error("webhook: execution failed",
9299
"workflow", trigger.WorkflowName,

packages/engine/internal/workflow/triggers_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"context"
55
"database/sql"
66
"testing"
7+
8+
"github.com/dvflw/mantle/internal/auth"
79
)
810

911
// countTriggers returns the number of workflow_triggers rows for a workflow,
@@ -190,6 +192,63 @@ func TestSave_BackfillsTriggersOnUnchangedApply(t *testing.T) {
190192
}
191193
}
192194

195+
// TestSave_CronTriggersUniquePerTeam verifies that two teams can each register
196+
// a workflow with the same name and cron schedule. Before the uniqueness index
197+
// was team-scoped, the second team's apply failed with a unique-constraint
198+
// violation on (workflow_name, schedule).
199+
func TestSave_CronTriggersUniquePerTeam(t *testing.T) {
200+
database := setupTestDB(t)
201+
ctx := context.Background()
202+
203+
// A second team — both workflow_definitions.team_id and
204+
// workflow_triggers.team_id are foreign keys to teams(id).
205+
teamB := "00000000-0000-0000-0000-0000000000b2"
206+
if _, err := database.ExecContext(ctx,
207+
`INSERT INTO teams (id, name) VALUES ($1, 'team-b')`, teamB); err != nil {
208+
t.Fatalf("creating team-b: %v", err)
209+
}
210+
211+
yaml := []byte(`name: shared-name
212+
triggers:
213+
- type: cron
214+
schedule: "*/5 * * * *"
215+
steps:
216+
- name: s
217+
action: http/request
218+
params:
219+
method: GET
220+
url: "https://example.com"
221+
`)
222+
result, err := ParseBytes(yaml)
223+
if err != nil {
224+
t.Fatalf("ParseBytes: %v", err)
225+
}
226+
227+
// Default team.
228+
if _, err := Save(ctx, database, result, yaml); err != nil {
229+
t.Fatalf("Save (default team): %v", err)
230+
}
231+
// team-b — same workflow name and schedule.
232+
ctxB := auth.WithUser(ctx, &auth.User{TeamID: teamB})
233+
if _, err := Save(ctxB, database, result, yaml); err != nil {
234+
t.Fatalf("Save (team-b): %v", err)
235+
}
236+
237+
// Each team owns exactly one cron trigger for the workflow.
238+
for _, tid := range []string{auth.DefaultTeamID, teamB} {
239+
var n int
240+
if err := database.QueryRowContext(ctx,
241+
`SELECT COUNT(*) FROM workflow_triggers
242+
WHERE workflow_name = 'shared-name' AND type = 'cron' AND team_id = $1`, tid,
243+
).Scan(&n); err != nil {
244+
t.Fatalf("counting triggers for %s: %v", tid, err)
245+
}
246+
if n != 1 {
247+
t.Errorf("team %s cron triggers = %d, want 1", tid, n)
248+
}
249+
}
250+
}
251+
193252
// TestSave_BackfillPreservesDisabledState verifies that backfilling triggers
194253
// for a currently-disabled workflow inserts them disabled, so a pruned
195254
// workflow does not start firing on a re-apply.

0 commit comments

Comments
 (0)