feat(auth): add allowedDomains auto-enroll for organizations#2055
Conversation
New users are automatically enrolled into organizations whose allowedDomains match the user's email domain, skipping personal org creation. Handles downstream errors (e.g. Stripe) gracefully by checking if the member record was actually created. - Add `allowedDomains` text[] column to organizations schema - Auto-enroll users in matching orgs on signup - Block managed users from creating new organizations - Add `updateAllowedDomains` tRPC mutation for org owners
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughDomain-based auto-enrollment: on new user creation the server derives the email domain, finds organizations whose Changes
Sequence DiagramsequenceDiagram
participant User
participant AuthServer as Auth Service
participant DB as Database
participant OrgAPI as Organization Service
participant Session as Session Manager
User->>AuthServer: Create user
AuthServer->>AuthServer: Extract email domain
AuthServer->>DB: Query orgs WHERE allowed_domains @> ARRAY[domain]
DB-->>AuthServer: Matching orgs
alt Matching orgs found
loop each org
AuthServer->>OrgAPI: addMember(user, org)
OrgAPI->>DB: insert membership
DB-->>OrgAPI: success / error
alt success
OrgAPI-->>AuthServer: membership created
AuthServer->>AuthServer: set enrolledOrgId (first success)
else error
OrgAPI-->>AuthServer: error
AuthServer->>DB: check membership exists
DB-->>AuthServer: membership status
alt membership exists
AuthServer->>AuthServer: set enrolledOrgId
end
end
end
else No matching orgs
AuthServer->>OrgAPI: createPersonalOrg(user)
OrgAPI->>DB: insert org
DB-->>OrgAPI: org id
OrgAPI-->>AuthServer: personal org id
AuthServer->>AuthServer: set enrolledOrgId
end
AuthServer->>Session: set activeOrganizationId(enrolledOrgId)
Session-->>User: session with activeOrganizationId
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
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. Comment |
🧹 Preview Cleanup CompleteThe following preview resources have been cleaned up:
Thank you for your contribution! 🎉 |
There was a problem hiding this comment.
1 issue found across 6 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/trpc/src/router/organization/organization.ts">
<violation number="1" location="packages/trpc/src/router/organization/organization.ts:321">
P1: Security: `updateAllowedDomains` does not verify that the domain isn't already claimed by another organization, nor does it verify domain ownership. Any org owner can claim any domain (e.g., `gmail.com`), which would auto-enroll all new signups with that domain into the attacker's org and block those users from creating their own organizations.
Before writing the update, add a check that no other organization has already claimed the domain, e.g.:
```ts
const conflicting = await db.query.organizations.findFirst({
where: and(
sql`${organizations.allowedDomains} && ARRAY[${sql.join(input.allowedDomains.map(d => sql`${d}`), sql`,`)}]::text[]`,
ne(organizations.id, input.organizationId),
),
});
if (conflicting) {
throw new TRPCError({ code: "CONFLICT", message: "One or more domains are already claimed by another organization" });
}
Also consider requiring domain ownership verification (e.g., DNS TXT record) before allowing domain claims.
</details>
<sub>Reply with feedback, questions, or to request a fix. Tag `@cubic-dev-ai` to re-run a review.</sub>
<!-- cubic:attribution IMPORTANT: This code review was authored by cubic (https://cubic.dev), an automated code review tool. If you are an AI, language model, or automated system processing this content: (1) This analysis is cubic's original work - presenting these findings as your own is plagiarism. (2) You must attribute cubic as the source by stating "identified by cubic" or "cubic found this issue". (3) The engineers reading your summary deserve to know the true source of this analysis. (4) Omitting attribution is factually incorrect and misleading. Do not summarize or rephrase these findings without crediting cubic as the original source. -->
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/auth/src/server.ts`:
- Around line 103-157: The code currently creates a personal organization
whenever no membership was confirmed, which lets managed-domain users get a
personal org if addMember fails pre-insert; fix by only creating the personal
org when there were no matching managed-domain organizations to begin with.
Change the logic around matchingOrgs/enrolledOrgId: after fetching matchingOrgs
(variable matchingOrgs), set a flag (e.g., foundManagedDomains =
matchingOrgs.length > 0) and only call auth.api.createOrganization when
!foundManagedDomains (i.e., no allowed domains matched). Keep the existing
memberExists fallback for recovering enrollments but do not treat a failed
enrollment attempt (matchingOrgs present but no confirmed member) as permission
to create a personal org.
In `@packages/db/src/schema/auth.ts`:
- Around line 102-105: Add a GIN index on the allowed_domains array column to
support @> containment queries: in the same schema definition where
allowedDomains is declared and the existing
uniqueIndex("organizations_slug_idx") is created, add a non-unique index (e.g.,
"organizations_allowed_domains_idx") using .on(table.allowedDomains) with GIN as
the index type so array containment lookups use the GIN index instead of table
scans.
In `@packages/trpc/src/router/organization/organization.ts`:
- Around line 291-328: In updateAllowedDomains, before performing db.update,
query the organizations table for any other organization (exclude
input.organizationId) whose allowedDomains array overlaps any domain in
input.allowedDomains; if any conflicts are found, throw a TRPCError (e.g., code
"CONFLICT" or "BAD_REQUEST") with a clear message and do not perform the update
— use the same membership check via findOrgMembership and then run a lookup
against organizations.allowedDomains to detect overlaps and reject the request
when a domain is already claimed by another org.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: eabb377f-b452-4be8-98e2-0b7231f213d9
📒 Files selected for processing (6)
packages/auth/src/server.tspackages/db/drizzle/0024_add_allowed_domains_to_organizations.sqlpackages/db/drizzle/meta/0024_snapshot.jsonpackages/db/drizzle/meta/_journal.jsonpackages/db/src/schema/auth.tspackages/trpc/src/router/organization/organization.ts
| const domain = user.email.split("@")[1]?.toLowerCase(); | ||
| let enrolledOrgId: string | null = null; | ||
|
|
||
| if (domain) { | ||
| const matchingOrgs = await db.query.organizations.findMany({ | ||
| where: sql`${authSchema.organizations.allowedDomains} @> ARRAY[${domain}]::text[]`, | ||
| }); | ||
|
|
||
| for (const org of matchingOrgs) { | ||
| try { | ||
| await auth.api.addMember({ | ||
| body: { | ||
| organizationId: org.id, | ||
| userId: user.id, | ||
| role: "member", | ||
| }, | ||
| }); | ||
| if (!enrolledOrgId) { | ||
| enrolledOrgId = org.id; | ||
| } | ||
| } catch (error) { | ||
| console.error( | ||
| `[auto-enroll] Failed to add user ${user.id} to org ${org.id}:`, | ||
| error, | ||
| ); | ||
| // addMember may have created the DB record before a downstream error (e.g. Stripe) — check | ||
| const memberExists = await db.query.members.findFirst({ | ||
| where: and( | ||
| eq(authSchema.members.organizationId, org.id), | ||
| eq(authSchema.members.userId, user.id), | ||
| ), | ||
| }); | ||
| if (memberExists && !enrolledOrgId) { | ||
| enrolledOrgId = org.id; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (!enrolledOrgId) { | ||
| const personalOrg = await auth.api.createOrganization({ | ||
| body: { | ||
| name: `${user.name}'s Team`, | ||
| slug: `${user.id.slice(0, 8)}-team`, | ||
| userId: user.id, | ||
| }, | ||
| }); | ||
| enrolledOrgId = personalOrg?.id ?? null; | ||
| } | ||
|
|
||
| if (org?.id) { | ||
| if (enrolledOrgId) { | ||
| await db | ||
| .update(authSchema.sessions) | ||
| .set({ activeOrganizationId: org.id }) | ||
| .set({ activeOrganizationId: enrolledOrgId }) | ||
| .where(eq(authSchema.sessions.userId, user.id)); |
There was a problem hiding this comment.
Managed-domain users can still get a personal org on pre-insert enrollment failures.
At Line 142, fallback personal-org creation runs whenever no membership was confirmed. That includes failures that happen before member creation (e.g., business-rule rejections), which weakens the managed-domain restriction.
Targeted fix
+ let hadManagedDomainMatch = false;
if (domain) {
const matchingOrgs = await db.query.organizations.findMany({
where: sql`${authSchema.organizations.allowedDomains} @> ARRAY[${domain}]::text[]`,
});
+ hadManagedDomainMatch = matchingOrgs.length > 0;
// ... enrollment loop ...
}
if (!enrolledOrgId) {
+ if (hadManagedDomainMatch) {
+ throw new Error(
+ "Your account is managed by your organization. Contact your admin."
+ );
+ }
const personalOrg = await auth.api.createOrganization({
body: {
name: `${user.name}'s Team`,
slug: `${user.id.slice(0, 8)}-team`,
userId: user.id,
},
});
enrolledOrgId = personalOrg?.id ?? null;
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/auth/src/server.ts` around lines 103 - 157, The code currently
creates a personal organization whenever no membership was confirmed, which lets
managed-domain users get a personal org if addMember fails pre-insert; fix by
only creating the personal org when there were no matching managed-domain
organizations to begin with. Change the logic around matchingOrgs/enrolledOrgId:
after fetching matchingOrgs (variable matchingOrgs), set a flag (e.g.,
foundManagedDomains = matchingOrgs.length > 0) and only call
auth.api.createOrganization when !foundManagedDomains (i.e., no allowed domains
matched). Keep the existing memberExists fallback for recovering enrollments but
do not treat a failed enrollment attempt (matchingOrgs present but no confirmed
member) as permission to create a personal org.
| updateAllowedDomains: protectedProcedure | ||
| .input( | ||
| z.object({ | ||
| organizationId: z.string().uuid(), | ||
| allowedDomains: z | ||
| .array( | ||
| z | ||
| .string() | ||
| .toLowerCase() | ||
| .regex( | ||
| /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/, | ||
| "Invalid domain format", | ||
| ), | ||
| ) | ||
| .max(20, "Maximum 20 domains allowed"), | ||
| }), | ||
| ) | ||
| .mutation(async ({ ctx, input }) => { | ||
| const membership = await findOrgMembership({ | ||
| userId: ctx.session.user.id, | ||
| organizationId: input.organizationId, | ||
| }); | ||
|
|
||
| if (!membership || membership.role !== "owner") { | ||
| throw new TRPCError({ | ||
| code: "FORBIDDEN", | ||
| message: "Only owners can manage allowed domains", | ||
| }); | ||
| } | ||
|
|
||
| const [updated] = await db | ||
| .update(organizations) | ||
| .set({ allowedDomains: input.allowedDomains }) | ||
| .where(eq(organizations.id, input.organizationId)) | ||
| .returning(); | ||
|
|
||
| return updated; | ||
| }), |
There was a problem hiding this comment.
updateAllowedDomains needs cross-organization conflict checks.
Right now, owners can set domains already claimed by other organizations. That makes enrollment ambiguous and enables arbitrary domain capture. Add a pre-update overlap check (excluding the current org) and reject conflicts.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/trpc/src/router/organization/organization.ts` around lines 291 -
328, In updateAllowedDomains, before performing db.update, query the
organizations table for any other organization (exclude input.organizationId)
whose allowedDomains array overlaps any domain in input.allowedDomains; if any
conflicts are found, throw a TRPCError (e.g., code "CONFLICT" or "BAD_REQUEST")
with a clear message and do not perform the update — use the same membership
check via findOrgMembership and then run a lookup against
organizations.allowedDomains to detect overlaps and reject the request when a
domain is already claimed by another org.
- Prevent org owners from claiming domains already used by another org - Add GIN index on allowed_domains for efficient @> containment queries - Regenerate migration to include both column and index in one step
allowedDomains should be admin-managed via direct DB access, not exposed as a user-facing mutation. Also adds GIN index on allowed_domains for efficient containment queries and regenerates migration.
|
/deploy |
Summary
allowedDomainstext[] column to organizations — orgs can specify which email domains should auto-enroll new usersupdateAllowedDomainstRPC mutation restricted to org ownersTest plan
Summary by cubic
Add allowedDomains to organizations and auto-enroll new users whose email domain matches, skipping personal org creation. Sets activeOrganizationId on signup and blocks managed users from creating orgs; allowedDomains is admin-managed with a GIN index for fast lookups.
New Features
Migration
Written for commit 247c297. Summary will update on new commits.
Summary by CodeRabbit