Skip to content

Commit 3390208

Browse files
committed
fix(self-host): refuse instance-org resolution when the slug is ambiguous
organization.slug has no unique constraint, and the lookup took the first of however many matched. The choice is unordered, so two replicas could resolve different organizations and split new signups between them. Resolution is now three-state. Ambiguity is distinct from absence, so it both declines to adopt an arbitrary organization and declines to provision another one on top of the duplicates.
1 parent c4278bf commit 3390208

2 files changed

Lines changed: 63 additions & 10 deletions

File tree

apps/sim/lib/organizations/instance-org.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,22 @@ describe('instance organization', () => {
141141
expect(mockCreateOrganizationWithOwnerTx).not.toHaveBeenCalled()
142142
})
143143

144+
it('refuses when more than one organization shares the slug', async () => {
145+
/**
146+
* `organization.slug` has no unique constraint. Picking one of several
147+
* is unordered, so replicas could disagree and split signups across two
148+
* organizations — worse than declining until the operator disambiguates.
149+
*/
150+
queueRows([{ id: 'org_a' }, { id: 'org_b' }]) // pre-transaction lookup
151+
queueRows([{ id: 'org_a' }, { id: 'org_b' }]) // re-check under the lock
152+
153+
const result = await ensureInstanceOrganization('user-1')
154+
155+
expect(result).toBeNull()
156+
/** Must not add a third row to a set the operator already has to untangle. */
157+
expect(mockCreateOrganizationWithOwnerTx).not.toHaveBeenCalled()
158+
})
159+
144160
it('takes the advisory lock before creating', async () => {
145161
queueRows([])
146162
queueRows([])

apps/sim/lib/organizations/instance-org.ts

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
} from '@/lib/billing/organizations/create-organization'
2727
import { env } from '@/lib/core/config/env'
2828
import { isBillingEnabled } from '@/lib/core/config/env-flags'
29+
import type { DbOrTx } from '@/lib/db/types'
2930

3031
const logger = createLogger('InstanceOrganization')
3132

@@ -101,13 +102,48 @@ export async function getInstanceOrganizationId(): Promise<string | null> {
101102
const config = getInstanceOrganizationConfig()
102103
if (!config) return null
103104

104-
const [row] = await db
105+
const resolved = await resolveInstanceOrganizationBySlug(db, config.slug)
106+
return resolved.status === 'found' ? resolved.organizationId : null
107+
}
108+
109+
/**
110+
* Resolves the single organization holding this slug.
111+
*
112+
* Matching on slug is deliberate — it is what lets the mode adopt an
113+
* organization that already exists, such as one the consolidate script created
114+
* before `INSTANCE_ORG_NAME` was set. But `organization.slug` carries no unique
115+
* constraint, so duplicates are possible, and taking the first of several would
116+
* be worse than wrong: the choice is unordered, so two replicas could resolve
117+
* different organizations and split new signups between them.
118+
*
119+
* Refuses instead. Instance-organization mode stays off until the operator
120+
* renames the duplicate or pins `INSTANCE_ORG_SLUG` at the one they mean, which
121+
* is recoverable — silently sorting users into two organizations is not.
122+
*/
123+
type SlugResolution =
124+
| { status: 'found'; organizationId: string }
125+
| { status: 'none' }
126+
| { status: 'ambiguous' }
127+
128+
async function resolveInstanceOrganizationBySlug(
129+
executor: DbOrTx,
130+
slug: string
131+
): Promise<SlugResolution> {
132+
const rows = await executor
105133
.select({ id: organization.id })
106134
.from(organization)
107-
.where(eq(organization.slug, config.slug))
108-
.limit(1)
135+
.where(eq(organization.slug, slug))
136+
.limit(2)
109137

110-
return row?.id ?? null
138+
if (rows.length > 1) {
139+
logger.error(
140+
'Refusing to resolve the instance organization: more than one organization uses this slug. Rename the duplicate or set INSTANCE_ORG_SLUG to the intended one.',
141+
{ slug }
142+
)
143+
return { status: 'ambiguous' }
144+
}
145+
146+
return rows[0] ? { status: 'found', organizationId: rows[0].id } : { status: 'none' }
111147
}
112148

113149
/**
@@ -178,12 +214,13 @@ export async function ensureInstanceOrganization(
178214
sql`select pg_advisory_xact_lock(hashtextextended(${`instance-organization:${config.slug}`}, 0))`
179215
)
180216

181-
const [row] = await tx
182-
.select({ id: organization.id })
183-
.from(organization)
184-
.where(eq(organization.slug, config.slug))
185-
.limit(1)
186-
if (row) return row.id
217+
const resolved = await resolveInstanceOrganizationBySlug(tx, config.slug)
218+
if (resolved.status === 'found') return resolved.organizationId
219+
/**
220+
* Never create while the slug is ambiguous — that would add a third row
221+
* to a set the operator already has to untangle.
222+
*/
223+
if (resolved.status === 'ambiguous') return null
187224

188225
/**
189226
* The owner must not already belong to another organization — a user can

0 commit comments

Comments
 (0)