Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
18 changes: 18 additions & 0 deletions .changeset/auto-provision-verified-domain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
---

Auto-provision users into verified-domain orgs more aggressively, give org admins a self-service way to manage members by email, and let them opt out per org.

Closes the gap surfaced by the Triton Digital escalation: an org owner tried to promote someone with a verified-domain email to admin, but the system 404'd because the user wasn't yet a member of the org in WorkOS.

## Server changes

- `autoLinkByVerifiedDomain` runs on every authenticated request. The helper short-circuits internally when the user already has a row in the candidate org's local membership cache, so the cost stays close to one indexed query. Always provisions as `member`; the existing race-safe `upsertOrganizationMembership` SQL handles auto-promotion to `owner` for ownerless orgs (atomically, against the live table — no cache-skew risk).
- New `user.created` webhook step provisions users with verified emails into their employer's verified-domain org proactively, instead of lazily on first API hit.
- New `auto_provision_verified_domain` column on `organizations` (default `true`) — org owners and admins can flip it via `PATCH /api/organizations/:orgId/settings` to require explicit invites only.
- New `POST /api/organizations/:orgId/members/by-email` endpoint walks the four-state machine for callers (invite / create membership / update role / no-op). Authz mirrors the existing patterns: org admin/owner OR AAO super-admin can add members; only org owner OR AAO super-admin can change an existing member's role; only owner or AAO super-admin can assign the owner role.
- The invite path always invites as `member` regardless of the requested role (matching the bearer-credential downgrade discipline used in `routes/invites.ts`); admins promote after acceptance via the same endpoint.

## Migration

`433_auto_provision_verified_domain.sql` adds the opt-out flag with default `TRUE`.
45 changes: 29 additions & 16 deletions server/src/db/membership-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,13 +207,16 @@ export interface DomainLinkResult {
}

/**
* When a user has no WorkOS organization memberships, check whether their
* email domain matches a verified domain on an organization with an active
* subscription. If so, create the WorkOS membership automatically.
* Check whether a user's email domain matches a verified domain on an
* organization with an active subscription. If so, create a WorkOS membership
* for them.
*
* This closes the gap where a subscription is purchased for an org but the
* user was never added as a member in WorkOS (e.g. webhook failure, manual
* provisioning that skipped the membership step).
* Idempotent: short-circuits when the user is already in the candidate org's
* local membership cache, and treats `organization_membership_already_exists`
* from WorkOS as success. Safe to call on every authenticated request.
*
* Honors the per-org `auto_provision_verified_domain` opt-out: orgs that
* prefer explicit invites only set this to false.
*/
export async function autoLinkByVerifiedDomain(
workos: WorkOS,
Expand All @@ -224,43 +227,53 @@ export async function autoLinkByVerifiedDomain(
const emailDomain = email.split('@')[1]?.toLowerCase();
if (!emailDomain) return null;

// Find an org with a verified domain matching the user's email and an active subscription
const result = await pool.query<{
workos_organization_id: string;
org_name: string;
has_admin: boolean;
user_already_member: boolean;
}>(`
SELECT
od.workos_organization_id,
o.name AS org_name,
EXISTS (
SELECT 1 FROM organization_memberships om
WHERE om.workos_organization_id = od.workos_organization_id
AND om.role IN ('admin', 'owner')
) AS has_admin
AND om.workos_user_id = $2
) AS user_already_member
FROM organization_domains od
JOIN organizations o ON o.workos_organization_id = od.workos_organization_id
WHERE LOWER(od.domain) = $1
AND od.verified = true
AND o.subscription_status = 'active'
AND o.subscription_canceled_at IS NULL
AND COALESCE(o.auto_provision_verified_domain, true) = true
LIMIT 1
`, [emailDomain]);
`, [emailDomain, userId]);

if (result.rows.length === 0) return null;

const { workos_organization_id: orgId, org_name: orgName, has_admin: hasAdmin } = result.rows[0];
const role = hasAdmin ? 'member' : 'owner';
const {
workos_organization_id: orgId,
org_name: orgName,
user_already_member: userAlreadyMember,
} = result.rows[0];

if (userAlreadyMember) return null;

// Always create as member. Auto-promotion to owner for ownerless orgs is
// handled atomically by upsertOrganizationMembership when the
// organization_membership.created webhook fires — that path uses a NOT EXISTS
// subquery against the live membership table, which is race-safe and not
// vulnerable to the local-cache skew that a `has_admin` lookup here would be.
try {
await workos.userManagement.createOrganizationMembership({
userId,
organizationId: orgId,
roleSlug: role,
roleSlug: 'member',
});

logger.info({ userId, email, orgId, orgName, role }, 'Auto-linked user to organization via verified domain');
return { organizationId: orgId, organizationName: orgName, role };
logger.info({ userId, email, orgId, orgName }, 'Auto-linked user to organization via verified domain');
return { organizationId: orgId, organizationName: orgName, role: 'member' };
} catch (err: any) {
if (err?.code === 'organization_membership_already_exists') {
// Membership exists but wasn't returned by list — return as success
Expand Down
12 changes: 12 additions & 0 deletions server/src/db/migrations/433_auto_provision_verified_domain.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- Auto-provision verified-domain users into orgs.
--
-- When a user signs in with an email whose domain is verified on an
-- organization with an active subscription, autoLinkByVerifiedDomain creates
-- a WorkOS membership for them. This flag lets an org owner opt out — useful
-- for orgs that prefer explicit invites only (e.g. regulated industries).

ALTER TABLE organizations
ADD COLUMN IF NOT EXISTS auto_provision_verified_domain BOOLEAN NOT NULL DEFAULT TRUE;

COMMENT ON COLUMN organizations.auto_provision_verified_domain IS
'When true (default), users whose email domain is verified on this org are auto-added as members on first authenticated request or user.created webhook. When false, only explicit invites grant membership.';
2 changes: 2 additions & 0 deletions server/src/db/organization-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export interface Organization {
stripe_coupon_id: string | null;
stripe_promotion_code: string | null;
billing_address: BillingAddress | null;
auto_provision_verified_domain: boolean;
created_at: Date;
updated_at: Date;
}
Expand Down Expand Up @@ -859,6 +860,7 @@ export class OrganizationDatabase {
stripe_coupon_id: 'stripe_coupon_id',
stripe_promotion_code: 'stripe_promotion_code',
billing_address: 'billing_address',
auto_provision_verified_domain: 'auto_provision_verified_domain',
};

const setClauses: string[] = [];
Expand Down
17 changes: 8 additions & 9 deletions server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6685,15 +6685,14 @@ ${p.category ? `<category>${p.category}</category>\n` : ''}<url>${publishedUrl}<
statuses: ['active'],
});

// Auto-link: if no memberships, check for verified domain match
if (memberships.data.length === 0) {
const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email);
if (linked) {
memberships = await workos!.userManagement.listOrganizationMemberships({
userId: user.id,
statuses: ['active'],
});
}
// Auto-link any verified-domain orgs the user isn't yet in.
// Helper short-circuits when the user is already a cached member.
const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email);
if (linked) {
memberships = await workos!.userManagement.listOrganizationMemberships({
userId: user.id,
statuses: ['active'],
});
}

// Map memberships to organization details with roles
Expand Down
46 changes: 21 additions & 25 deletions server/src/routes/member-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,13 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
userId: user.id,
});

// Auto-link: if no memberships, check for verified domain match
if (memberships.data.length === 0) {
const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email);
if (linked) {
// Re-fetch memberships after auto-link
memberships = await workos!.userManagement.listOrganizationMemberships({
userId: user.id,
});
}
// Auto-link any verified-domain orgs the user isn't yet in.
// Helper short-circuits when the user is already a cached member.
const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email);
if (linked) {
memberships = await workos!.userManagement.listOrganizationMemberships({
userId: user.id,
});
}

if (memberships.data.length === 0) {
Expand Down Expand Up @@ -250,14 +248,13 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
userId: user.id,
});

// Auto-link: if no memberships, check for verified domain match
if (memberships.data.length === 0) {
const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email);
if (linked) {
memberships = await workos!.userManagement.listOrganizationMemberships({
userId: user.id,
});
}
// Auto-link any verified-domain orgs the user isn't yet in.
// Helper short-circuits when the user is already a cached member.
const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email);
if (linked) {
memberships = await workos!.userManagement.listOrganizationMemberships({
userId: user.id,
});
}

if (memberships.data.length === 0) {
Expand Down Expand Up @@ -465,14 +462,13 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
userId: user.id,
});

// Auto-link: if no memberships, check for verified domain match
if (memberships.data.length === 0) {
const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email);
if (linked) {
memberships = await workos!.userManagement.listOrganizationMemberships({
userId: user.id,
});
}
// Auto-link any verified-domain orgs the user isn't yet in.
// Helper short-circuits when the user is already a cached member.
const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email);
if (linked) {
memberships = await workos!.userManagement.listOrganizationMemberships({
userId: user.id,
});
}

if (memberships.data.length === 0) {
Expand Down
Loading
Loading