Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/team-scoped-trigger-uniqueness.md
Original file line number Diff line number Diff line change
@@ -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/<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.
Original file line number Diff line number Diff line change
@@ -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/<path> 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';
15 changes: 8 additions & 7 deletions packages/engine/internal/server/trigger.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ package server
import (
"context"
"database/sql"

"github.com/dvflw/mantle/internal/auth"
)

// TriggerRecord represents a registered trigger in the database.
Expand Down Expand Up @@ -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/<path> 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep authenticated webhook lookups team-scoped

In the normal mantle serve path srv.AuthStore is always set and Start wraps apiMux (including /hooks/) in AuthMiddleware, so requests that reach this function already have a caller team in ctx. This path-only lookup ignores that team and the handler then executes under the matched row's team_id, which lets an API key from team A POST to any known webhook path owned by team B and run B's workflow/credentials; keep the team predicate for authenticated requests or explicitly bypass auth only for public webhook mode with separate authorization.

Useful? React with 👍 / 👎.

).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
}
Expand Down
56 changes: 56 additions & 0 deletions packages/engine/internal/server/trigger_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 10 additions & 3 deletions packages/engine/internal/server/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"io"
"net/http"
"strings"

"github.com/dvflw/mantle/internal/auth"
)

// WebhookHandler handles incoming webhook requests and triggers workflow executions.
Expand Down Expand Up @@ -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,
Expand Down
59 changes: 59 additions & 0 deletions packages/engine/internal/workflow/triggers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Loading