From 22896079694d20dfa9030f411529be39e92f81e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 02:31:02 +0000 Subject: [PATCH 1/2] 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/ 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 Claude-Session: https://claude.ai/code/session_01WnKgCRfMvFcZAkoVbr8k79 --- .changeset/team-scoped-trigger-uniqueness.md | 5 ++ .../022_team_scoped_trigger_uniqueness.sql | 23 ++++++++ packages/engine/internal/server/trigger.go | 15 ++--- .../engine/internal/server/trigger_test.go | 56 ++++++++++++++++++ packages/engine/internal/server/webhook.go | 13 +++- .../engine/internal/workflow/triggers_test.go | 59 +++++++++++++++++++ 6 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 .changeset/team-scoped-trigger-uniqueness.md create mode 100644 packages/engine/internal/db/migrations/022_team_scoped_trigger_uniqueness.sql create mode 100644 packages/engine/internal/server/trigger_test.go diff --git a/.changeset/team-scoped-trigger-uniqueness.md b/.changeset/team-scoped-trigger-uniqueness.md new file mode 100644 index 00000000..bb6caee2 --- /dev/null +++ b/.changeset/team-scoped-trigger-uniqueness.md @@ -0,0 +1,5 @@ +--- +"@mantle/engine": patch +--- + +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/` 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. diff --git a/packages/engine/internal/db/migrations/022_team_scoped_trigger_uniqueness.sql b/packages/engine/internal/db/migrations/022_team_scoped_trigger_uniqueness.sql new file mode 100644 index 00000000..dbff6bbd --- /dev/null +++ b/packages/engine/internal/db/migrations/022_team_scoped_trigger_uniqueness.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- 022_team_scoped_trigger_uniqueness: cron trigger uniqueness must be per-team. +-- +-- Workflow names are team-scoped (workflow_definitions is UNIQUE(team_id, name, +-- version)), so two teams may legitimately own a workflow of the same name on +-- the same schedule. The original index on (workflow_name, schedule) was global, +-- so the second team's apply failed with a unique-constraint violation once +-- trigger registration went live. Scope the uniqueness by team. +-- +-- The webhook path index is intentionally left global: an inbound +-- POST /hooks/ request carries no team identity, so the path is the global +-- routing key and must be unique across all teams. Email triggers have no +-- uniqueness index and are unaffected. +DROP INDEX IF EXISTS idx_workflow_triggers_cron; +CREATE UNIQUE INDEX idx_workflow_triggers_cron + ON workflow_triggers (team_id, workflow_name, schedule) + WHERE type = 'cron'; + +-- +goose Down +DROP INDEX IF EXISTS idx_workflow_triggers_cron; +CREATE UNIQUE INDEX idx_workflow_triggers_cron + ON workflow_triggers (workflow_name, schedule) + WHERE type = 'cron'; diff --git a/packages/engine/internal/server/trigger.go b/packages/engine/internal/server/trigger.go index f7308d8a..f2289611 100644 --- a/packages/engine/internal/server/trigger.go +++ b/packages/engine/internal/server/trigger.go @@ -3,8 +3,6 @@ package server import ( "context" "database/sql" - - "github.com/dvflw/mantle/internal/auth" ) // TriggerRecord represents a registered trigger in the database. @@ -43,14 +41,17 @@ func ListCronTriggers(ctx context.Context, db *sql.DB) ([]TriggerRecord, error) return scanTriggers(rows) } -// LookupWebhookTrigger finds the trigger matching a webhook path, scoped to the team. +// LookupWebhookTrigger finds the enabled webhook trigger for a path. Webhook +// paths are a global routing key — an inbound POST /hooks/ carries no team +// identity — so the lookup is not team-scoped and the path index is unique +// across all teams. The owning team is read from the matched row so the caller +// can run the workflow under the correct tenant. func LookupWebhookTrigger(ctx context.Context, db *sql.DB, path string) (*TriggerRecord, error) { - teamID := auth.TeamIDFromContext(ctx) var t TriggerRecord err := db.QueryRowContext(ctx, - `SELECT id, workflow_name, workflow_version, type, COALESCE(schedule, ''), COALESCE(path, ''), COALESCE(secret, ''), enabled - FROM workflow_triggers WHERE type = 'webhook' AND path = $1 AND enabled = true AND team_id = $2`, path, teamID, - ).Scan(&t.ID, &t.WorkflowName, &t.WorkflowVersion, &t.Type, &t.Schedule, &t.Path, &t.Secret, &t.Enabled) + `SELECT id, workflow_name, workflow_version, type, COALESCE(schedule, ''), COALESCE(path, ''), COALESCE(secret, ''), enabled, team_id + FROM workflow_triggers WHERE type = 'webhook' AND path = $1 AND enabled = true`, path, + ).Scan(&t.ID, &t.WorkflowName, &t.WorkflowVersion, &t.Type, &t.Schedule, &t.Path, &t.Secret, &t.Enabled, &t.TeamID) if err == sql.ErrNoRows { return nil, nil } diff --git a/packages/engine/internal/server/trigger_test.go b/packages/engine/internal/server/trigger_test.go new file mode 100644 index 00000000..91eeee6f --- /dev/null +++ b/packages/engine/internal/server/trigger_test.go @@ -0,0 +1,56 @@ +package server + +import ( + "context" + "testing" + + "github.com/dvflw/mantle/internal/auth" +) + +// TestLookupWebhookTrigger_GlobalPathReturnsOwningTeam verifies that a webhook +// registered by a non-default team is found by an unauthenticated inbound +// lookup (which carries no team identity) and reports the owning team, so the +// handler can run the workflow under the correct tenant. Previously the lookup +// was team-scoped to the default team, so non-default-team webhooks never +// routed. +func TestLookupWebhookTrigger_GlobalPathReturnsOwningTeam(t *testing.T) { + db, _ := setupWebhookTest(t) + ctx := context.Background() + + teamB := "00000000-0000-0000-0000-0000000000b2" + if _, err := db.ExecContext(ctx, + `INSERT INTO teams (id, name) VALUES ($1, 'team-b')`, teamB); err != nil { + t.Fatalf("creating team-b: %v", err) + } + if _, err := db.ExecContext(ctx, + `INSERT INTO workflow_triggers (workflow_name, workflow_version, type, path, team_id) + VALUES ('wf', 1, 'webhook', '/hooks/tb', $1)`, teamB); err != nil { + t.Fatalf("seeding webhook trigger: %v", err) + } + + // No team in context, as an inbound webhook request would be. + tr, err := LookupWebhookTrigger(ctx, db, "/hooks/tb") + if err != nil { + t.Fatalf("LookupWebhookTrigger: %v", err) + } + if tr == nil { + t.Fatal("expected trigger, got nil") + } + if tr.TeamID != teamB { + t.Errorf("TeamID = %q, want %q", tr.TeamID, teamB) + } + if tr.WorkflowName != "wf" { + t.Errorf("WorkflowName = %q, want wf", tr.WorkflowName) + } + + // A default-team context must not change the result — the path is a global + // routing key, not team-scoped. + ctxDefault := auth.WithUser(ctx, &auth.User{TeamID: auth.DefaultTeamID}) + tr2, err := LookupWebhookTrigger(ctxDefault, db, "/hooks/tb") + if err != nil { + t.Fatalf("LookupWebhookTrigger (default ctx): %v", err) + } + if tr2 == nil || tr2.TeamID != teamB { + t.Errorf("global lookup should be team-agnostic; got %+v", tr2) + } +} diff --git a/packages/engine/internal/server/webhook.go b/packages/engine/internal/server/webhook.go index c92b7ba6..56af49b2 100644 --- a/packages/engine/internal/server/webhook.go +++ b/packages/engine/internal/server/webhook.go @@ -9,6 +9,8 @@ import ( "io" "net/http" "strings" + + "github.com/dvflw/mantle/internal/auth" ) // WebhookHandler handles incoming webhook requests and triggers workflow executions. @@ -84,9 +86,14 @@ func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.server.Logger.Info("webhook: triggering workflow", "path", path, "workflow", trigger.WorkflowName, - "version", trigger.WorkflowVersion) - - execID, err := h.server.executeWorkflow(r.Context(), trigger.WorkflowName, trigger.WorkflowVersion, inputs) + "version", trigger.WorkflowVersion, + "team_id", trigger.TeamID) + + // Webhook requests are unauthenticated, so the tenant comes from the matched + // trigger row rather than the request context. Run under that team so + // workflow/credential resolution is correctly scoped. + execCtx := auth.WithUser(r.Context(), &auth.User{TeamID: trigger.TeamID}) + execID, err := h.server.executeWorkflow(execCtx, trigger.WorkflowName, trigger.WorkflowVersion, inputs) if err != nil { h.server.Logger.Error("webhook: execution failed", "workflow", trigger.WorkflowName, diff --git a/packages/engine/internal/workflow/triggers_test.go b/packages/engine/internal/workflow/triggers_test.go index 3641c0d5..81d8bd1c 100644 --- a/packages/engine/internal/workflow/triggers_test.go +++ b/packages/engine/internal/workflow/triggers_test.go @@ -4,6 +4,8 @@ import ( "context" "database/sql" "testing" + + "github.com/dvflw/mantle/internal/auth" ) // countTriggers returns the number of workflow_triggers rows for a workflow, @@ -190,6 +192,63 @@ func TestSave_BackfillsTriggersOnUnchangedApply(t *testing.T) { } } +// TestSave_CronTriggersUniquePerTeam verifies that two teams can each register +// a workflow with the same name and cron schedule. Before the uniqueness index +// was team-scoped, the second team's apply failed with a unique-constraint +// violation on (workflow_name, schedule). +func TestSave_CronTriggersUniquePerTeam(t *testing.T) { + database := setupTestDB(t) + ctx := context.Background() + + // A second team — both workflow_definitions.team_id and + // workflow_triggers.team_id are foreign keys to teams(id). + teamB := "00000000-0000-0000-0000-0000000000b2" + if _, err := database.ExecContext(ctx, + `INSERT INTO teams (id, name) VALUES ($1, 'team-b')`, teamB); err != nil { + t.Fatalf("creating team-b: %v", err) + } + + yaml := []byte(`name: shared-name +triggers: + - type: cron + schedule: "*/5 * * * *" +steps: + - name: s + action: http/request + params: + method: GET + url: "https://example.com" +`) + result, err := ParseBytes(yaml) + if err != nil { + t.Fatalf("ParseBytes: %v", err) + } + + // Default team. + if _, err := Save(ctx, database, result, yaml); err != nil { + t.Fatalf("Save (default team): %v", err) + } + // team-b — same workflow name and schedule. + ctxB := auth.WithUser(ctx, &auth.User{TeamID: teamB}) + if _, err := Save(ctxB, database, result, yaml); err != nil { + t.Fatalf("Save (team-b): %v", err) + } + + // Each team owns exactly one cron trigger for the workflow. + for _, tid := range []string{auth.DefaultTeamID, teamB} { + var n int + if err := database.QueryRowContext(ctx, + `SELECT COUNT(*) FROM workflow_triggers + WHERE workflow_name = 'shared-name' AND type = 'cron' AND team_id = $1`, tid, + ).Scan(&n); err != nil { + t.Fatalf("counting triggers for %s: %v", tid, err) + } + if n != 1 { + t.Errorf("team %s cron triggers = %d, want 1", tid, n) + } + } +} + // TestSave_BackfillPreservesDisabledState verifies that backfilling triggers // for a currently-disabled workflow inserts them disabled, so a pruned // workflow does not start firing on a re-apply. From ee7b3fb6d7de24059aa8701cb2fe98e270b4c8fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 02:39:57 +0000 Subject: [PATCH 2/2] fix: keep webhook lookup team-scoped; team-scope webhook path index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects the webhook approach from the previous commit. The /hooks/ endpoint is behind AuthMiddleware, so every webhook request carries the caller's authenticated team — it is not an anonymous public endpoint. Looking webhooks up by path alone (and running under the matched row's team) let an authenticated caller from team A trigger team B's webhook under B's credentials, a cross-tenant escalation (flagged in review). - LookupWebhookTrigger stays scoped to the caller's team; the handler runs the workflow under the request context (the caller's team), as before. - To still let two teams register the same webhook path, migration 022 now team-scopes the webhook path index too: (team_id, path). The caller's team disambiguates at lookup, so there is no ambiguity or leak. - Documented the down-migration limitation: recreating the global unique indexes fails if cross-team duplicates exist (operator must resolve them before downgrading). - Test rewritten: two teams register the same path; each caller resolves only its own trigger; a third team resolves nothing (no cross-team leak). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WnKgCRfMvFcZAkoVbr8k79 --- .changeset/team-scoped-trigger-uniqueness.md | 2 +- .../022_team_scoped_trigger_uniqueness.sql | 32 +++++--- packages/engine/internal/server/trigger.go | 16 ++-- .../engine/internal/server/trigger_test.go | 80 ++++++++++++------- packages/engine/internal/server/webhook.go | 16 ++-- 5 files changed, 88 insertions(+), 58 deletions(-) diff --git a/.changeset/team-scoped-trigger-uniqueness.md b/.changeset/team-scoped-trigger-uniqueness.md index bb6caee2..c327c271 100644 --- a/.changeset/team-scoped-trigger-uniqueness.md +++ b/.changeset/team-scoped-trigger-uniqueness.md @@ -2,4 +2,4 @@ "@mantle/engine": patch --- -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/` 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. +Make workflow trigger uniqueness multi-tenant-safe. Both the cron uniqueness index (`team_id, workflow_name, schedule`) and the webhook path index (`team_id, path`) are now scoped by team, so two teams can each register a workflow with the same name/schedule or the same webhook path instead of the second team's apply failing with a unique-constraint violation. Webhook lookup stays scoped to the caller's authenticated team (the `/hooks/` endpoint is authenticated), so a caller can only trigger its own team's webhooks even when another team uses the same path. Email triggers have no uniqueness index and are unaffected. Fixes #164. diff --git a/packages/engine/internal/db/migrations/022_team_scoped_trigger_uniqueness.sql b/packages/engine/internal/db/migrations/022_team_scoped_trigger_uniqueness.sql index dbff6bbd..ad440617 100644 --- a/packages/engine/internal/db/migrations/022_team_scoped_trigger_uniqueness.sql +++ b/packages/engine/internal/db/migrations/022_team_scoped_trigger_uniqueness.sql @@ -1,22 +1,34 @@ -- +goose Up --- 022_team_scoped_trigger_uniqueness: cron trigger uniqueness must be per-team. +-- 022_team_scoped_trigger_uniqueness: trigger uniqueness must be per-team. -- -- Workflow names are team-scoped (workflow_definitions is UNIQUE(team_id, name, --- version)), so two teams may legitimately own a workflow of the same name on --- the same schedule. The original index on (workflow_name, schedule) was global, --- so the second team's apply failed with a unique-constraint violation once --- trigger registration went live. Scope the uniqueness by team. --- --- The webhook path index is intentionally left global: an inbound --- POST /hooks/ request carries no team identity, so the path is the global --- routing key and must be unique across all teams. Email triggers have no --- uniqueness index and are unaffected. +-- version)) and the /hooks/ endpoint is authenticated, so trigger lookups always +-- run under the caller's team. The original uniqueness indexes were global, so +-- once trigger registration went live a second team registering the same cron +-- (workflow_name, schedule) or the same webhook path would fail its apply with a +-- unique-constraint violation. Scope both by team_id; the caller's team +-- disambiguates at lookup time. Email triggers have no uniqueness index. DROP INDEX IF EXISTS idx_workflow_triggers_cron; CREATE UNIQUE INDEX idx_workflow_triggers_cron ON workflow_triggers (team_id, workflow_name, schedule) WHERE type = 'cron'; +DROP INDEX IF EXISTS idx_workflow_triggers_webhook_path; +CREATE UNIQUE INDEX idx_workflow_triggers_webhook_path + ON workflow_triggers (team_id, path) + WHERE type = 'webhook' AND enabled = true; + -- +goose Down +-- NOTE: this rollback recreates the original GLOBAL unique indexes. If two teams +-- have registered the same cron (workflow_name, schedule) or webhook path while +-- the team-scoped indexes were in effect, recreating the global indexes will +-- fail with a duplicate-key violation. That is an accepted rollback limitation: +-- an operator must resolve the cross-team duplicates before downgrading. +DROP INDEX IF EXISTS idx_workflow_triggers_webhook_path; +CREATE UNIQUE INDEX idx_workflow_triggers_webhook_path + ON workflow_triggers (path) + WHERE type = 'webhook' AND enabled = true; + DROP INDEX IF EXISTS idx_workflow_triggers_cron; CREATE UNIQUE INDEX idx_workflow_triggers_cron ON workflow_triggers (workflow_name, schedule) diff --git a/packages/engine/internal/server/trigger.go b/packages/engine/internal/server/trigger.go index f2289611..3d52a117 100644 --- a/packages/engine/internal/server/trigger.go +++ b/packages/engine/internal/server/trigger.go @@ -3,6 +3,8 @@ package server import ( "context" "database/sql" + + "github.com/dvflw/mantle/internal/auth" ) // TriggerRecord represents a registered trigger in the database. @@ -41,16 +43,18 @@ func ListCronTriggers(ctx context.Context, db *sql.DB) ([]TriggerRecord, error) return scanTriggers(rows) } -// LookupWebhookTrigger finds the enabled webhook trigger for a path. Webhook -// paths are a global routing key — an inbound POST /hooks/ carries no team -// identity — so the lookup is not team-scoped and the path index is unique -// across all teams. The owning team is read from the matched row so the caller -// can run the workflow under the correct tenant. +// LookupWebhookTrigger finds the enabled webhook trigger matching a path, +// scoped to the caller's team. The /hooks/ endpoint is authenticated +// (AuthMiddleware), so the caller's team is always present in ctx and forms a +// tenant boundary: a caller may only trigger webhooks registered by its own +// team, even if another team registered the same path. Webhook path uniqueness +// is therefore team-scoped (see migration 022), not global. func LookupWebhookTrigger(ctx context.Context, db *sql.DB, path string) (*TriggerRecord, error) { + teamID := auth.TeamIDFromContext(ctx) var t TriggerRecord err := db.QueryRowContext(ctx, `SELECT id, workflow_name, workflow_version, type, COALESCE(schedule, ''), COALESCE(path, ''), COALESCE(secret, ''), enabled, team_id - FROM workflow_triggers WHERE type = 'webhook' AND path = $1 AND enabled = true`, path, + FROM workflow_triggers WHERE type = 'webhook' AND path = $1 AND enabled = true AND team_id = $2`, path, teamID, ).Scan(&t.ID, &t.WorkflowName, &t.WorkflowVersion, &t.Type, &t.Schedule, &t.Path, &t.Secret, &t.Enabled, &t.TeamID) if err == sql.ErrNoRows { return nil, nil diff --git a/packages/engine/internal/server/trigger_test.go b/packages/engine/internal/server/trigger_test.go index 91eeee6f..98e74018 100644 --- a/packages/engine/internal/server/trigger_test.go +++ b/packages/engine/internal/server/trigger_test.go @@ -7,50 +7,68 @@ import ( "github.com/dvflw/mantle/internal/auth" ) -// TestLookupWebhookTrigger_GlobalPathReturnsOwningTeam verifies that a webhook -// registered by a non-default team is found by an unauthenticated inbound -// lookup (which carries no team identity) and reports the owning team, so the -// handler can run the workflow under the correct tenant. Previously the lookup -// was team-scoped to the default team, so non-default-team webhooks never -// routed. -func TestLookupWebhookTrigger_GlobalPathReturnsOwningTeam(t *testing.T) { +// TestLookupWebhookTrigger_TeamScoped verifies that webhook path uniqueness and +// lookup are per-team: two teams may register the same path, and a lookup under +// one team's context resolves only that team's trigger — never the other's. The +// /hooks/ endpoint is authenticated, so the caller's team is a tenant boundary; +// a caller must not be able to trigger another team's webhook that happens to +// share a path. +func TestLookupWebhookTrigger_TeamScoped(t *testing.T) { db, _ := setupWebhookTest(t) ctx := context.Background() + teamA := auth.DefaultTeamID teamB := "00000000-0000-0000-0000-0000000000b2" if _, err := db.ExecContext(ctx, `INSERT INTO teams (id, name) VALUES ($1, 'team-b')`, teamB); err != nil { t.Fatalf("creating team-b: %v", err) } - if _, err := db.ExecContext(ctx, - `INSERT INTO workflow_triggers (workflow_name, workflow_version, type, path, team_id) - VALUES ('wf', 1, 'webhook', '/hooks/tb', $1)`, teamB); err != nil { - t.Fatalf("seeding webhook trigger: %v", err) - } - // No team in context, as an inbound webhook request would be. - tr, err := LookupWebhookTrigger(ctx, db, "/hooks/tb") - if err != nil { - t.Fatalf("LookupWebhookTrigger: %v", err) - } - if tr == nil { - t.Fatal("expected trigger, got nil") + // Both teams register the same webhook path pointing at their own workflow. + for _, tc := range []struct{ team, wf string }{ + {teamA, "wf-a"}, + {teamB, "wf-b"}, + } { + if _, err := db.ExecContext(ctx, + `INSERT INTO workflow_triggers (workflow_name, workflow_version, type, path, team_id) + VALUES ($1, 1, 'webhook', '/hooks/shared', $2)`, tc.wf, tc.team); err != nil { + t.Fatalf("seeding webhook trigger for %s: %v", tc.team, err) + } } - if tr.TeamID != teamB { - t.Errorf("TeamID = %q, want %q", tr.TeamID, teamB) - } - if tr.WorkflowName != "wf" { - t.Errorf("WorkflowName = %q, want wf", tr.WorkflowName) + + // Each caller resolves only its own team's trigger. + for _, tc := range []struct{ team, wantWF string }{ + {teamA, "wf-a"}, + {teamB, "wf-b"}, + } { + callerCtx := auth.WithUser(ctx, &auth.User{TeamID: tc.team}) + tr, err := LookupWebhookTrigger(callerCtx, db, "/hooks/shared") + if err != nil { + t.Fatalf("lookup for %s: %v", tc.team, err) + } + if tr == nil { + t.Fatalf("lookup for %s: expected trigger, got nil", tc.team) + } + if tr.WorkflowName != tc.wantWF { + t.Errorf("team %s resolved workflow %q, want %q", tc.team, tr.WorkflowName, tc.wantWF) + } + if tr.TeamID != tc.team { + t.Errorf("team %s resolved team_id %q, want %q", tc.team, tr.TeamID, tc.team) + } } - // A default-team context must not change the result — the path is a global - // routing key, not team-scoped. - ctxDefault := auth.WithUser(ctx, &auth.User{TeamID: auth.DefaultTeamID}) - tr2, err := LookupWebhookTrigger(ctxDefault, db, "/hooks/tb") + // A caller from a team with no matching path gets nothing — no cross-team leak. + teamC := "00000000-0000-0000-0000-0000000000c3" + if _, err := db.ExecContext(ctx, + `INSERT INTO teams (id, name) VALUES ($1, 'team-c')`, teamC); err != nil { + t.Fatalf("creating team-c: %v", err) + } + callerCtx := auth.WithUser(ctx, &auth.User{TeamID: teamC}) + tr, err := LookupWebhookTrigger(callerCtx, db, "/hooks/shared") if err != nil { - t.Fatalf("LookupWebhookTrigger (default ctx): %v", err) + t.Fatalf("lookup for team-c: %v", err) } - if tr2 == nil || tr2.TeamID != teamB { - t.Errorf("global lookup should be team-agnostic; got %+v", tr2) + if tr != nil { + t.Errorf("team-c should resolve no trigger, got %+v", tr) } } diff --git a/packages/engine/internal/server/webhook.go b/packages/engine/internal/server/webhook.go index 56af49b2..b6921e9a 100644 --- a/packages/engine/internal/server/webhook.go +++ b/packages/engine/internal/server/webhook.go @@ -9,8 +9,6 @@ import ( "io" "net/http" "strings" - - "github.com/dvflw/mantle/internal/auth" ) // WebhookHandler handles incoming webhook requests and triggers workflow executions. @@ -86,14 +84,12 @@ func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.server.Logger.Info("webhook: triggering workflow", "path", path, "workflow", trigger.WorkflowName, - "version", trigger.WorkflowVersion, - "team_id", trigger.TeamID) - - // Webhook requests are unauthenticated, so the tenant comes from the matched - // trigger row rather than the request context. Run under that team so - // workflow/credential resolution is correctly scoped. - execCtx := auth.WithUser(r.Context(), &auth.User{TeamID: trigger.TeamID}) - execID, err := h.server.executeWorkflow(execCtx, trigger.WorkflowName, trigger.WorkflowVersion, inputs) + "version", trigger.WorkflowVersion) + + // The /hooks/ endpoint is authenticated, so r.Context() already carries the + // caller's team — the same team the trigger was looked up under. Running with + // it keeps workflow/credential resolution scoped to that tenant. + execID, err := h.server.executeWorkflow(r.Context(), trigger.WorkflowName, trigger.WorkflowVersion, inputs) if err != nil { h.server.Logger.Error("webhook: execution failed", "workflow", trigger.WorkflowName,