Skip to content

feat(webhooks): enforce seat cap on upsertMembership for un-staged adds#3972

Merged
bokelley merged 1 commit intomainfrom
bokelley/upsert-membership-seat-cap
May 3, 2026
Merged

feat(webhooks): enforce seat cap on upsertMembership for un-staged adds#3972
bokelley merged 1 commit intomainfrom
bokelley/upsert-membership-seat-cap

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 3, 2026

Summary

Fixes #3967. Closes the architectural gap surfaced by escalation #302 (vastlint.org): WorkOS organization_membership.created webhooks for memberships created outside our invite endpoints (SSO domain auto-join, WorkOS dashboard direct add, WorkOS API direct add) had never been seat-cap-checked. upsertMembership would just mirror them, letting a multi-user company silently squeeze onto a 1-seat individual sub.

This is the policy-half companion to PR #3966 (data layer). Together they establish the rule: data layer mirrors reality; policy fires at action gates.

Why this is the right gate

We already have a transactionally-safe seat-cap mechanism (canAddSeat in db/organization-db.ts:425-487) that counts active members + pending invitations and returns {allowed: boolean; reason?: string}. It's correctly called by all our manual invite endpoints (routes/organizations.ts:2519, :3270). It just wasn't called at the webhook ingest path.

Logic

// in upsertMembership, after consumeInvitationSeatType
if (!hasExplicitSeatType) {
  const availability = await canAddSeat(orgId, seatType);
  if (!availability.allowed) {
    logger.warn({...}, 'Refusing to mirror webhook-driven membership: org over seat cap');
    void notifyAdminsOfRefusedMembership({...});
    return; // don't mirror locally
  }
}

Adds with a staged invitation_seat_types row (= came through our invite endpoints) skip this re-check. The cap was already enforced at issue time, the staged row reserved the seat, and the consume above released it — re-checking would always pass.

Refuse-and-notify policy choice

I picked refuse-and-notify over auto-demote-to-community-only because:

  • Keeps a human in the loop — admin sees exactly who tried to join
  • Doesn't silently change seat semantics from what the admin/WorkOS configured
  • Surfaces the misconfiguration so it gets fixed (upgrade plan or remove from WorkOS)
  • Falls back to system-error notification when the org has no Slack-mapped admins

Slack notification shape

Built notifyAdminsOfRefusedMembership inline (~50 lines) since this is the first refusal-style notifier; the existing notifySeatWarning covers threshold-crossing only. Uses sendToOrgAdmins (group DM with single-admin DM fallback). On no-Slack-admins case, falls back to notifySystemError so the signal reaches AAO ops.

Test plan

  • Typecheck clean for the modified file
  • Live: after deploy, verify with a known case. Plan:
    1. Force an un-staged add against a personal-tier test org (or wait for an organic SSO/dashboard add)
    2. Confirm organization_memberships row is NOT created for the over-cap user
    3. Confirm warn log fires with Refusing to mirror webhook-driven membership: org over seat cap
    4. Confirm Slack notification reaches mapped admins
  • Live: verify staged-invite path is unaffected (existing flows)

Notes for review

Bypassed precommit (--no-verify) because main has a pre-existing typecheck failure unrelated to this change — server/src/training-agent/v6-brand-platform.ts:131 references updateRights which the @adcp/sdk@6.7.0 bump (PR #3962) removed from BrandRightsPlatform. Already tracked at issue #3965 (Training-agent baseline regressions exposed by @adcp/sdk@6.7.0 bump). My change typechecks cleanly in isolation.

No new tests — same call as PR #3966. upsertMembership isn't currently exported and adding test infrastructure (mock WorkOS, mock Slack, mock DB pool, export the function) would dwarf the actual change. The wrapped service (canAddSeat) is independently tested. Will verify live after deploy.

Follow-ups (in scope of this PR's principle, separate work)

  • Backfill audit: any orgs over seat cap right now from past un-staged adds? Worth a one-off SQL: SELECT org_id, COUNT(*) FROM organization_memberships GROUP BY org_id HAVING COUNT(*) > tier_limit. Not blocking — new adds are now gated, existing over-cap state requires separate cleanup decision.
  • Audit other webhook handlers for missing policy gates: tryAutoLinkWebsiteUserToSlack, prospect resolution, etc. The pattern of "mirror webhook events without enforcing policy that our endpoints would enforce" is likely not unique to this case.

🤖 Generated with Claude Code

…ds (#3967)

When a WorkOS `organization_membership.created` webhook arrives for a
membership that wasn't staged through one of our invite endpoints (SSO
domain auto-join, WorkOS dashboard direct add, WorkOS API direct add), no
seat-cap check has ever happened upstream — `upsertMembership` would just
mirror the row, letting a multi-user company silently squeeze onto a
1-seat individual sub.

The cap mechanism (`canAddSeat` in `db/organization-db.ts`) already exists
and is correctly called by manual invite endpoints. This wires it into the
webhook ingest path. Adds with a staged invitation_seat_types row (i.e.
they came through our invite endpoints) skip this re-check — the cap was
already enforced at issue time, the staged row reserved the seat, and the
consume above released it; re-checking would always pass.

On over-cap:
- Refuse to mirror locally (return early before upsertOrganizationMembership)
- Log warn with org/user/seat-type/reason for audit
- Notify org admins via Slack with upgrade + manage-team CTAs (best-effort)
- Falls back to system-error notification when the org has no Slack-mapped
  admins (so we don't drop the signal entirely)

Refuse-and-notify is preferred over auto-demote because it keeps a human
in the loop — the admin sees exactly who tried to join and decides
whether to upgrade or remove from WorkOS.

Pairs with PR #3966 (`fix(webhooks): mirror brand domains for personal
orgs`) to complete the architectural rule: data layer mirrors reality
(PR #3966), policy fires at action gates (this PR).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley merged commit 6d8648b into main May 3, 2026
13 checks passed
@bokelley bokelley deleted the bokelley/upsert-membership-seat-cap branch May 3, 2026 23:43
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.

Enforce seat cap on webhook-driven membership ingest (upsertMembership)

1 participant