Skip to content

fix: team-scope workflow trigger uniqueness#165

Merged
michaelmcnees merged 2 commits into
mainfrom
claude/office-assistant-mantle-feasibility-n8f4bk
Jul 2, 2026
Merged

fix: team-scope workflow trigger uniqueness#165
michaelmcnees merged 2 commits into
mainfrom
claude/office-assistant-mantle-feasibility-n8f4bk

Conversation

@michaelmcnees

@michaelmcnees michaelmcnees commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Now that trigger registration is live (#162), a latent multi-tenant bug in workflow_triggers is reachable: its uniqueness indexes were global, not team-scoped, so two teams couldn't register a workflow with the same name + cron schedule (the second team's mantle apply failed with a unique-constraint violation). Non-default-team webhooks also never routed, because the webhook lookup was scoped to the request's team (always the default team for unauthenticated inbound requests).

Changes

  • Migration 022 rebuilds the cron uniqueness index as (team_id, workflow_name, schedule). Workflow names are team-scoped (workflow_definitions is UNIQUE(team_id, name, version)), so two teams may legitimately share a name + schedule.
  • Webhook routing is now tenant-correct. Webhook paths stay a global routing key — an inbound POST /hooks/<path> carries no team identity, so the path must be unique across all teams. LookupWebhookTrigger no longer filters by the request's team; it resolves by path and returns the owning team_id from the matched row, and the webhook handler runs the workflow under that team. This also fixes non-default-team webhooks, which previously 404'd.
  • Email triggers have no uniqueness index and are unaffected.

Design note

Two valid models existed for webhook multi-tenancy: team-prefixed paths, or a global path namespace with the team derived from the registration. I chose the latter — it matches how public webhook URLs work (the path is the routing key), needs no path-scheme change, and keeps a single global uniqueness guarantee. If you'd prefer team-prefixed paths, happy to switch.

Testing

  • make test — testcontainer tests (added below) run in CI; couldn't run locally (no Docker in the dev sandbox)
  • make lintgo build ./..., go vet, and golangci-lint run pass
  • New tests added:
    • TestSave_CronTriggersUniquePerTeam — two teams register the same workflow name + cron schedule (previously failed on the second apply)
    • TestLookupWebhookTrigger_GlobalPathReturnsOwningTeam — a non-default-team webhook resolves via a team-agnostic path lookup and reports the owning team

Related Issues

Closes #164


Generated by Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Cron-based workflows can now use the same name and schedule across different teams without conflicts.
    • Webhook-triggered workflows now run under the team that owns the matched webhook, improving routing for non-default teams.
    • Webhook lookups now work consistently by path, regardless of the active team context.

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
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@michaelmcnees, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 52 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6fb95806-0e2b-4b74-a283-ee6a4a9ebf3d

📥 Commits

Reviewing files that changed from the base of the PR and between 2289607 and ee7b3fb.

📒 Files selected for processing (5)
  • .changeset/team-scoped-trigger-uniqueness.md
  • packages/engine/internal/db/migrations/022_team_scoped_trigger_uniqueness.sql
  • packages/engine/internal/server/trigger.go
  • packages/engine/internal/server/trigger_test.go
  • packages/engine/internal/server/webhook.go
📝 Walkthrough

Walkthrough

This PR scopes cron trigger uniqueness to (team_id, workflow_name, schedule) via a new migration, changes webhook trigger lookup to be global by path (no longer filtered by team_id), and executes webhook-triggered workflows under the team context resolved from the matched trigger row. Tests cover both behaviors.

Changes

Team-scoped trigger uniqueness and webhook routing

Layer / File(s) Summary
Cron trigger uniqueness migration
packages/engine/internal/db/migrations/022_team_scoped_trigger_uniqueness.sql, .changeset/team-scoped-trigger-uniqueness.md
New migration replaces the global cron unique index with a partial unique index on (team_id, workflow_name, schedule), with a downgrade path restoring the old index; changeset documents the multi-tenant fix.
Global path-based webhook trigger lookup
packages/engine/internal/server/trigger.go, packages/engine/internal/server/trigger_test.go
LookupWebhookTrigger drops team-scoped filtering, queries solely by path, and returns team_id in the result; a new test confirms lookup returns the owning team's trigger regardless of caller context.
Team-scoped webhook execution
packages/engine/internal/server/webhook.go
Webhook handler imports auth, logs the trigger's team_id, and executes the workflow under an execCtx built via auth.WithUser with the trigger's TeamID instead of the raw request context.
Cron trigger uniqueness per-team test
packages/engine/internal/workflow/triggers_test.go
New test applies an identical workflow (same name/schedule) under two different teams and asserts exactly one cron trigger row exists per team.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant WebhookHandler
  participant LookupWebhookTrigger
  participant Database
  participant Auth
  participant ExecuteWorkflow

  Client->>WebhookHandler: POST /hooks/path
  WebhookHandler->>LookupWebhookTrigger: lookup by path only
  LookupWebhookTrigger->>Database: SELECT ... WHERE path = ?
  Database-->>LookupWebhookTrigger: trigger row (team_id, workflow_name)
  LookupWebhookTrigger-->>WebhookHandler: TriggerRecord
  WebhookHandler->>Auth: WithUser(trigger.TeamID)
  Auth-->>WebhookHandler: execCtx
  WebhookHandler->>ExecuteWorkflow: executeWorkflow(execCtx, workflow)
Loading

Related Issues: #164 (workflow trigger uniqueness indexes are not team-scoped, and webhook routing does not resolve team correctly for non-default teams)

Suggested labels: bug, multi-tenancy, database

Suggested reviewers: dvflw

Poem

A rabbit hops through team by team,
Each hutch its own cron dream,
Webhooks now find their rightful nest,
By path alone, they pass the test.
No more collisions, no more strife—
🐇 multi-tenant burrows, full of life!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning For [#164], cron uniqueness is fixed, but webhook paths remain globally unique, so the same-path multi-tenant collision is still unresolved. Scope webhook uniqueness by team or add a per-team routing token, and add tests for duplicate webhook registrations across teams.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately describes the main change: making workflow trigger uniqueness team-scoped.
Out of Scope Changes check ✅ Passed All changes relate to trigger uniqueness, webhook lookup, and regression tests; no unrelated scope creep is evident.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/office-assistant-mantle-feasibility-n8f4bk

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2289607969

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
packages/engine/internal/db/migrations/022_team_scoped_trigger_uniqueness.sql (2)

19-23: 🗄️ Data Integrity & Integration | 🔵 Trivial

Down-migration can fail if cross-team duplicates already exist.

If two teams register the same (workflow_name, schedule) cron pair after the Up migration runs (which is the whole point of this change), reverting to the global (workflow_name, schedule) unique index will fail with a duplicate-key violation. Worth documenting this as an accepted rollback limitation (or adding a pre-check) so an operator isn't surprised mid-rollback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/engine/internal/db/migrations/022_team_scoped_trigger_uniqueness.sql`
around lines 19 - 23, The Down migration for the workflow_triggers unique index
can fail on rollback if multiple teams have already created the same cron pair.
Update the migration comment or rollback path around the
idx_workflow_triggers_cron DROP/CREATE UNIQUE INDEX block to explicitly document
this as a rollback limitation, or add a pre-check before recreating the global
unique index so operators get a clear failure mode instead of a surprise
duplicate-key error.

14-17: 🚀 Performance & Scalability | 🔵 Trivial

Consider index-creation locking for larger tables.

CREATE UNIQUE INDEX (without CONCURRENTLY) takes an exclusive lock on workflow_triggers for the duration of the build. Likely fine given current table size, but if this table grows or the migration runs against a live production system with meaningful trigger volume, consider CONCURRENTLY with -- +goose NO TRANSACTION.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/engine/internal/db/migrations/022_team_scoped_trigger_uniqueness.sql`
around lines 14 - 17, The unique index build in the migration is taking an
exclusive lock on workflow_triggers, so update the index creation to use CREATE
UNIQUE INDEX CONCURRENTLY if this migration may run on a live or larger table.
Because CONCURRENTLY cannot run inside a transaction, adjust the goose migration
markers accordingly with NO TRANSACTION, and keep the existing
idx_workflow_triggers_cron definition and workflow_triggers target so the
uniqueness behavior stays the same.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@packages/engine/internal/db/migrations/022_team_scoped_trigger_uniqueness.sql`:
- Around line 19-23: The Down migration for the workflow_triggers unique index
can fail on rollback if multiple teams have already created the same cron pair.
Update the migration comment or rollback path around the
idx_workflow_triggers_cron DROP/CREATE UNIQUE INDEX block to explicitly document
this as a rollback limitation, or add a pre-check before recreating the global
unique index so operators get a clear failure mode instead of a surprise
duplicate-key error.
- Around line 14-17: The unique index build in the migration is taking an
exclusive lock on workflow_triggers, so update the index creation to use CREATE
UNIQUE INDEX CONCURRENTLY if this migration may run on a live or larger table.
Because CONCURRENTLY cannot run inside a transaction, adjust the goose migration
markers accordingly with NO TRANSACTION, and keep the existing
idx_workflow_triggers_cron definition and workflow_triggers target so the
uniqueness behavior stays the same.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: eb8007f4-af5d-419a-9a9f-94c9f17645c2

📥 Commits

Reviewing files that changed from the base of the PR and between dbb5acd and 2289607.

📒 Files selected for processing (6)
  • .changeset/team-scoped-trigger-uniqueness.md
  • packages/engine/internal/db/migrations/022_team_scoped_trigger_uniqueness.sql
  • packages/engine/internal/server/trigger.go
  • packages/engine/internal/server/trigger_test.go
  • packages/engine/internal/server/webhook.go
  • packages/engine/internal/workflow/triggers_test.go

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnKgCRfMvFcZAkoVbr8k79

Copy link
Copy Markdown
Collaborator Author

Good catch — fixed in ee7b3fb, and it reverses the webhook design I described in the original PR body.

Security (Codex P1) + webhook multi-tenancy (CodeRabbit linked-issues warning) — both are the same root issue and are now resolved together. I'd wrongly assumed /hooks/ was a public/anonymous endpoint; it's actually behind AuthMiddleware (only /healthz//readyz are exempt), so every webhook request carries the caller's authenticated team. My path-only lookup + execute-under-the-row's-team therefore let an authenticated team-A caller trigger team-B's webhook under B's credentials — a cross-tenant escalation. Corrected to:

  • LookupWebhookTrigger stays scoped to the caller's team; the handler runs under the request context (caller's team), as before — the tenant boundary is preserved.
  • To still satisfy [Bug] Workflow trigger uniqueness indexes are not team-scoped #164's "two teams can share a webhook path", migration 022 now team-scopes the webhook path index too (team_id, path), not just cron. The caller's team disambiguates at lookup, so there's no ambiguity or leak.
  • New test asserts two teams register the same path, each caller resolves only its own trigger, and a third team resolves nothing.

Migration nitpicks:

  • Down-migration can fail on cross-team duplicates — agreed; documented it in the -- +goose Down block as an accepted rollback limitation (operator must resolve duplicates before downgrading).
  • CREATE INDEX CONCURRENTLY — skipping. workflow_triggers is small, migrations run at startup, and all existing indexes in this repo are built non-concurrently; CONCURRENTLY + NO TRANSACTION adds an invalid-index-on-failure mode that isn't worth it at this scale. Easy to revisit if the table grows.

Also updated the PR description's design note to match (team-scoped path namespace, not global).


Generated by Claude Code

@michaelmcnees michaelmcnees merged commit bbdaf9e into main Jul 2, 2026
11 checks passed
@michaelmcnees michaelmcnees deleted the claude/office-assistant-mantle-feasibility-n8f4bk branch July 2, 2026 03:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Workflow trigger uniqueness indexes are not team-scoped

2 participants