Skip to content

feat(auth): add allowedDomains auto-enroll for organizations#2055

Merged
saddlepaddle merged 4 commits into
mainfrom
saddlepaddle/add-an-option-for-organization
Mar 5, 2026
Merged

feat(auth): add allowedDomains auto-enroll for organizations#2055
saddlepaddle merged 4 commits into
mainfrom
saddlepaddle/add-an-option-for-organization

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented Mar 5, 2026

Summary

  • Add allowedDomains text[] column to organizations — orgs can specify which email domains should auto-enroll new users
  • On signup, new users matching an org's allowedDomains are automatically enrolled as members, skipping personal org creation
  • Gracefully handle downstream errors (e.g. Stripe seat update failures) by verifying the member record was actually created before falling through to personal org creation
  • Block managed users (whose domain matches an org) from creating new organizations via tRPC
  • Add updateAllowedDomains tRPC mutation restricted to org owners

Test plan

  • User with matching email domain only gets into the org (no personal org created)
  • User with non-matching domain still gets a personal org
  • Owner can update allowedDomains via tRPC
  • Non-owner cannot update allowedDomains
  • Managed user is blocked from creating a new org

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

    • Auto-enroll on signup for matching domains; only creates a personal org if no membership was created.
    • Block org creation for managed users via tRPC (checks allowedDomains with containment queries).
    • Add allowedDomains text[] on organizations with a GIN index; removed public updateAllowedDomains mutation (manage via DB/admin).
  • Migration

    • Run drizzle migration 0024_add_allowed_domains_to_organizations (adds column and GIN index).

Written for commit 247c297. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Organizations can list allowed email domains to enable domain-based auto-enrollment for new users.
    • New users are auto-enrolled into matching organizations; a personal organization is created if none match.
  • Behavior Changes
    • Prevents creating a new organization when an existing organization already covers the user's email domain.
  • Bug Fixes
    • Improved handling of membership failures and ensures the user's active organization is set when enrolled.

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
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 5, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3d6d5a9d-c7d7-4d5f-a083-52ae362876c0

📥 Commits

Reviewing files that changed from the base of the PR and between 5ce06bc and 247c297.

📒 Files selected for processing (1)
  • packages/db/src/schema/auth.ts

📝 Walkthrough

Walkthrough

Domain-based auto-enrollment: on new user creation the server derives the email domain, finds organizations whose allowedDomains include that domain, attempts to add the user to those orgs (with membership/error checks), falls back to a personal org, and sets the session's activeOrganizationId.

Changes

Cohort / File(s) Summary
Database schema & migrations
packages/db/drizzle/0024_add_allowed_domains_to_organizations.sql, packages/db/drizzle/meta/_journal.json, packages/db/src/schema/auth.ts
Add allowed_domains (text[], default '{}') to auth.organizations, create GIN index organizations_allowed_domains_idx, and add migration journal entry.
Auth server — user enrollment flow
packages/auth/src/server.ts
Replace direct personal-org creation with domain-based auto-enrollment: extract email domain, query orgs whose allowedDomains contain that domain (using sql helper), call addMember for matches with error handling and membership existence checks, fallback to creating a personal org, and set session activeOrganizationId to the enrolled org.
TRPC org router — creation guard
packages/trpc/src/router/organization/organization.ts
Add pre-check that forbids creating an organization when the requestor's email domain is already allowlisted by an existing org (queries allowedDomains via sql).

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 I sniff the mail, I find the domain,
I hop to orgs where the lists remain,
If matches bloom, I add with care,
Else I build a home just for you there,
Hooray — a rabbit's gentle enroll plan!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The PR description covers the key changes and includes a test plan, but several required template sections are incomplete or missing. Complete the template sections: add Related Issues (with GitHub keywords), explicitly mark Type of Change checkboxes, provide Testing details for all test cases, and expand Additional Notes if needed.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main feature: adding allowedDomains for auto-enrollment of new users in organizations.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch saddlepaddle/add-an-option-for-organization

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 and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 5, 2026

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch
  • ✅ Electric Fly.io app

Thank you for your contribution! 🎉

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

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. -->

Comment thread packages/trpc/src/router/organization/organization.ts Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 295e6a8 and c7e5b94.

📒 Files selected for processing (6)
  • packages/auth/src/server.ts
  • packages/db/drizzle/0024_add_allowed_domains_to_organizations.sql
  • packages/db/drizzle/meta/0024_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema/auth.ts
  • packages/trpc/src/router/organization/organization.ts

Comment on lines +103 to 157
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));
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.

⚠️ Potential issue | 🟠 Major

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.

Comment thread packages/db/src/schema/auth.ts
Comment on lines +291 to +328
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;
}),
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.

⚠️ Potential issue | 🟠 Major

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.
@saddlepaddle
Copy link
Copy Markdown
Collaborator Author

/deploy

@saddlepaddle saddlepaddle merged commit c86cd1e into main Mar 5, 2026
29 of 31 checks passed
@Kitenite Kitenite deleted the saddlepaddle/add-an-option-for-organization branch March 5, 2026 18:49
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.

1 participant