Skip to content

Commit b0d3721

Browse files
committed
fix(auth): exclude retired organization columns from adapter queries
1 parent e9d17ba commit b0d3721

5 files changed

Lines changed: 175 additions & 5 deletions

File tree

‎.github/workflows/test-build.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ jobs:
9393
lib/auth/oauth-provider-lifecycle.postgres.test.ts
9494
app/api/auth/oauth2/token/route.postgres.test.ts
9595
lib/auth/sim-auth-adapter.test.ts
96+
lib/auth/sim-auth-adapter.postgres.test.ts
9697
ee/scim/lib/managed-membership.postgres.test.ts
9798
lib/auth/sso/application/admit-sso-user.postgres.test.ts
9899
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import * as schema from '@sim/db/schema'
5+
import { withUtcTimestamps } from '@sim/db/timestamps'
6+
import { generateId } from '@sim/utils/id'
7+
import type { BetterAuthOptions } from 'better-auth'
8+
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
9+
import { organization } from 'better-auth/plugins'
10+
import { drizzle } from 'drizzle-orm/postgres-js'
11+
import postgres from 'postgres'
12+
import { describe, expect, it, vi } from 'vitest'
13+
import { createSimAuthAdapter } from '@/lib/auth/sim-auth-adapter'
14+
15+
vi.unmock('@sim/db/schema')
16+
vi.unmock('drizzle-orm')
17+
18+
/** The guard suites cover authorization; this exercises adapter SQL against the real table shape. */
19+
vi.mock('@/lib/auth/oauth-provider-adapter-guard', () => ({
20+
guardOAuthProviderWrites: (adapter: object) => adapter,
21+
}))
22+
vi.mock('@/lib/auth/stripe-adapter-guard', () => ({
23+
guardSubscriptionPlanWrites: (adapter: object) => adapter,
24+
}))
25+
26+
const OPTIONS: BetterAuthOptions = { plugins: [organization()] }
27+
const databaseUrl = process.env.OAUTH_TOKEN_FAMILY_TEST_DATABASE_URL
28+
type AdapterSurface = Omit<ReturnType<typeof createSimAuthAdapter>, 'transaction'>
29+
30+
async function exerciseOrganization(adapter: AdapterSurface) {
31+
const id = generateId()
32+
const data = { id, name: 'Example', slug: id, createdAt: new Date('2026-01-01T00:00:00Z') }
33+
const where = [{ field: 'id', value: id }]
34+
35+
await expect(
36+
adapter.create({ model: 'organization', data, forceAllowId: true })
37+
).resolves.toMatchObject(data)
38+
await expect(adapter.findOne({ model: 'organization', where })).resolves.toMatchObject(data)
39+
await expect(adapter.findMany({ model: 'organization', where })).resolves.toEqual([
40+
expect.objectContaining(data),
41+
])
42+
await expect(
43+
adapter.update({ model: 'organization', where, update: { name: 'Renamed' } })
44+
).resolves.toMatchObject({ id, name: 'Renamed' })
45+
await adapter.delete({ model: 'organization', where })
46+
await expect(adapter.findOne({ model: 'organization', where })).resolves.toBeNull()
47+
}
48+
49+
describe.skipIf(!databaseUrl)('Better Auth across the organization column drop', () => {
50+
it.each([false, true])('preserves CRUD with transaction=%s', async (transaction) => {
51+
const client = postgres(
52+
databaseUrl!,
53+
withUtcTimestamps({ max: 1, prepare: false, fetch_types: false })
54+
)
55+
try {
56+
/** The connection-local copy lets other integration suites keep using the public table. */
57+
await client`CREATE TEMP TABLE organization (LIKE public.organization INCLUDING ALL)`
58+
const database = drizzle(client, { schema })
59+
const adapter = createSimAuthAdapter(OPTIONS, database)
60+
const exercise = () =>
61+
transaction
62+
? adapter.transaction((tx) => exerciseOrganization(tx))
63+
: exerciseOrganization(adapter)
64+
65+
await exercise()
66+
await client`ALTER TABLE pg_temp.organization DROP COLUMN departed_member_usage`
67+
68+
const unprojected = drizzleAdapter(database, { provider: 'pg', schema })(OPTIONS)
69+
await expect(
70+
unprojected.findOne({ model: 'organization', where: [{ field: 'id', value: 'missing' }] })
71+
).rejects.toMatchObject({ cause: { code: '42703' } })
72+
73+
await exercise()
74+
} finally {
75+
await client.end()
76+
}
77+
})
78+
})
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import * as schema from '@sim/db/schema'
5+
import type { BetterAuthOptions } from 'better-auth'
6+
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
7+
import { organization } from 'better-auth/plugins'
8+
import { drizzle } from 'drizzle-orm/pg-proxy'
9+
import { describe, expect, it, vi } from 'vitest'
10+
import type { AuthDatabase } from '@/lib/auth/oauth-provider-adapter-guard'
11+
import { createSimAuthAdapter } from '@/lib/auth/sim-auth-adapter'
12+
13+
vi.unmock('@sim/db/schema')
14+
vi.unmock('drizzle-orm')
15+
16+
/** Guard behavior is covered separately; these tests exercise the real adapter and SQL builder. */
17+
vi.mock('@/lib/auth/oauth-provider-adapter-guard', () => ({
18+
guardOAuthProviderWrites: (adapter: object) => adapter,
19+
}))
20+
vi.mock('@/lib/auth/stripe-adapter-guard', () => ({
21+
guardSubscriptionPlanWrites: (adapter: object) => adapter,
22+
}))
23+
24+
const OPTIONS: BetterAuthOptions = { plugins: [organization()] }
25+
type Adapter = ReturnType<typeof createSimAuthAdapter>
26+
type AdapterSurface = Omit<Adapter, 'transaction'>
27+
const WHERE = [{ field: 'id', value: 'organization-1' }]
28+
const OPERATIONS: { name: string; run: (adapter: AdapterSurface) => Promise<unknown> }[] = [
29+
{
30+
name: 'findOne',
31+
run: (adapter) => adapter.findOne({ model: 'organization', where: WHERE }),
32+
},
33+
{
34+
name: 'findMany',
35+
run: (adapter) => adapter.findMany({ model: 'organization' }),
36+
},
37+
{
38+
name: 'create',
39+
run: (adapter) =>
40+
adapter.create({
41+
model: 'organization',
42+
data: { name: 'Example', slug: 'example', createdAt: new Date('2026-01-01T00:00:00Z') },
43+
}),
44+
},
45+
{
46+
name: 'update',
47+
run: (adapter) =>
48+
adapter.update({ model: 'organization', where: WHERE, update: { name: 'Updated' } }),
49+
},
50+
{
51+
name: 'delete',
52+
run: (adapter) => adapter.delete({ model: 'organization', where: WHERE }),
53+
},
54+
]
55+
56+
describe('Better Auth organization SQL', () => {
57+
it.each(OPERATIONS)('excludes retired columns from $name', async (operation) => {
58+
const execute = vi.fn(async (_query: string) => ({ rows: [] }))
59+
const database = drizzle(execute)
60+
const adapter = createSimAuthAdapter(OPTIONS, database as unknown as AuthDatabase)
61+
62+
await operation.run(adapter)
63+
64+
const queries = execute.mock.calls.map(([query]) => query)
65+
expect(
66+
queries.some((query) => query.includes('"organization"')),
67+
operation.name
68+
).toBe(true)
69+
for (const query of queries) {
70+
expect(query, operation.name).not.toContain('"departed_member_usage"')
71+
}
72+
})
73+
74+
it('retains the full migration schema while the unprojected adapter remains incompatible', async () => {
75+
const execute = vi.fn(async (_query: string) => ({ rows: [] }))
76+
const adapter = drizzleAdapter(drizzle(execute), { provider: 'pg', schema })(OPTIONS)
77+
78+
await adapter.findOne({ model: 'organization', where: WHERE })
79+
80+
expect(execute.mock.calls[0][0]).toContain('"departed_member_usage"')
81+
})
82+
})

‎apps/sim/lib/auth/sim-auth-adapter.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { db } from '@sim/db'
2+
import { withInsertColumns } from '@sim/db/insert-columns'
23
import * as schema from '@sim/db/schema'
34
import type { BetterAuthOptions } from 'better-auth'
45
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
@@ -11,6 +12,12 @@ import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard'
1112

1213
type BetterAuthAdapter = ReturnType<ReturnType<typeof drizzleAdapter>>
1314

15+
/** Better Auth's implicit reads, INSERT defaults, and RETURNING must use live columns. */
16+
const AUTH_SCHEMA = {
17+
...schema,
18+
organization: withInsertColumns(schema.organization, schema.organizationColumns),
19+
}
20+
1421
/**
1522
* Builds every Better Auth adapter surface, including transactional callbacks,
1623
* with Sim's write invariants applied to the actual Drizzle connection in use.
@@ -22,7 +29,7 @@ export function createSimAuthAdapter(
2229
): BetterAuthAdapter {
2330
const base = drizzleAdapter(database, {
2431
provider: 'pg',
25-
schema,
32+
schema: AUTH_SCHEMA,
2633
transaction: false,
2734
})(options)
2835
const guarded = guardSubscriptionPlanWrites(guardOAuthProviderWrites(base, database))

‎packages/db/schema.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1718,10 +1718,12 @@ export const organization = pgTable('organization', {
17181718
.notNull()
17191719
.default({}),
17201720
/**
1721-
* contract-pending(after #7134 and #7774 are fully deployed):
1722-
* DROP departed_member_usage. Reads and inserts use organizationColumns;
1723-
* #7134 removed the v1 admin exposure and cycle-close resets. The pending-drop
1724-
* audit enforces the same read and insert constraints as user_stats.
1721+
* contract-pending(after #7134, #7774, and the Better Auth schema projection are fully deployed):
1722+
* DROP departed_member_usage. Application reads and inserts use
1723+
* organizationColumns; createSimAuthAdapter also projects the table for Better
1724+
* Auth's implicit reads, INSERT defaults, and RETURNING. Its projection must
1725+
* already be deployed before the drop; the pending-drop audit cannot inspect
1726+
* queries generated inside the auth dependency.
17251727
*/
17261728
/** @deprecated No readers or writers; a departed member's ledger rows stay stamped to the org's period, so nothing needs capturing. */
17271729
departedMemberUsage: decimal('departed_member_usage').notNull().default('0'),

0 commit comments

Comments
 (0)