Skip to content

Commit 31932ae

Browse files
authored
Add organization and customer table and add api_key auth (#294)
* Add customer and org table * apikey auth success * regenerate migration with api key unique * use dot instead of key_ prefix to be backwards compat
1 parent 7eeefb9 commit 31932ae

File tree

9 files changed

+1411
-30
lines changed

9 files changed

+1411
-30
lines changed

packages-v1/api-v1/__tests__/auth.spec.ts

+29-14
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {CustomerId, Viewer} from '@openint/cdk'
22
import {schema} from '@openint/db'
33
import {describeEachDatabase} from '@openint/db/__tests__/test-utils'
44
import {initDbPGLite} from '@openint/db/db.pglite'
5+
import {makeUlid} from '@openint/util'
56
import {createTRPCCaller} from '../trpc/handlers'
67
import {getTestTRPCClient} from './test-utils'
78

@@ -26,33 +27,47 @@ describe('authentication', () => {
2627
await db.$end()
2728
})
2829

29-
test.each(Object.entries(viewers))(
30-
'authenticating as %s',
31-
async (_desc, viewer) => {
32-
const client = getTestTRPCClient({db}, viewer)
33-
const res = await client.viewer.query()
34-
expect(res).toEqual(viewer ?? {role: 'anon'})
35-
36-
const caller = createTRPCCaller({db}, viewer)
37-
const res2 = await caller.viewer()
38-
expect(res2).toEqual(viewer ?? {role: 'anon'})
39-
},
40-
)
30+
test.each(Object.entries(viewers))('via jwt as %s', async (_desc, viewer) => {
31+
const client = getTestTRPCClient({db}, viewer)
32+
const res = await client.viewer.query()
33+
expect(res).toEqual(viewer ?? {role: 'anon'})
34+
35+
const caller = createTRPCCaller({db}, viewer)
36+
const res2 = await caller.viewer()
37+
expect(res2).toEqual(viewer ?? {role: 'anon'})
38+
})
4139
})
4240

4341
describeEachDatabase({drivers: 'rls', migrate: true}, (db) => {
44-
function getClient(viewer: Viewer) {
45-
return getTestTRPCClient({db}, viewer)
42+
function getClient(viewerOrKey: Viewer | {api_key: string}) {
43+
return getTestTRPCClient({db}, viewerOrKey)
4644
}
4745
function getCaller(viewer: Viewer) {
4846
return createTRPCCaller({db}, viewer)
4947
}
5048

49+
const apiKey = `key_${makeUlid()}`
50+
5151
beforeAll(async () => {
5252
await db.$truncateAll()
5353
await db
5454
.insert(schema.connector_config)
5555
.values({org_id: 'org_123', id: 'ccfg_123'})
56+
await db.insert(schema.organization).values({
57+
id: 'org_123',
58+
api_key: apiKey,
59+
})
60+
})
61+
62+
test('apikey auth success', async () => {
63+
const client = getTestTRPCClient({db}, {api_key: apiKey})
64+
const res = await client.viewer.query()
65+
expect(res).toEqual({role: 'org', orgId: 'org_123'})
66+
})
67+
68+
test('apikey auth failure', async () => {
69+
const client = getTestTRPCClient({db}, {api_key: 'key_badddd'})
70+
await expect(client.viewer.query()).rejects.toThrow('Invalid API key')
5671
})
5772

5873
test('anon user has no access to connector_config', async () => {

packages-v1/api-v1/__tests__/test-utils.ts

+7-6
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@ import type {Viewer} from '@openint/cdk'
33
import {makeJwtClient} from '@openint/cdk'
44
import {envRequired} from '@openint/env'
55
import {createApp} from '../app'
6-
import {
7-
CreateFetchHandlerOptions,
8-
createFetchHandlerTRPC,
9-
} from '../trpc/handlers'
6+
import type {CreateFetchHandlerOptions} from '../trpc/handlers'
7+
import {createFetchHandlerTRPC} from '../trpc/handlers'
108
import {type AppRouter} from '../trpc/routers'
119

1210
export function headersForViewer(viewer: Viewer) {
@@ -19,7 +17,7 @@ export function headersForViewer(viewer: Viewer) {
1917
/** Prefer to operate at the highest level of stack possible while still bienbeing performant */
2018
export function getTestTRPCClient(
2119
{router, ...opts}: Omit<CreateFetchHandlerOptions, 'endpoint'>,
22-
viewer: Viewer,
20+
viewerOrKey: Viewer | {api_key: string},
2321
) {
2422
const handler = router
2523
? createFetchHandlerTRPC({...opts, router, endpoint: '/api/v1/trpc'})
@@ -30,7 +28,10 @@ export function getTestTRPCClient(
3028
httpLink({
3129
url: 'http://localhost/api/v1/trpc',
3230
fetch: (input, init) => handler(new Request(input, init)),
33-
headers: headersForViewer(viewer),
31+
headers:
32+
'api_key' in viewerOrKey
33+
? {authorization: `Bearer ${viewerOrKey.api_key}`}
34+
: headersForViewer(viewerOrKey),
3435
}),
3536
],
3637
})

packages-v1/api-v1/trpc/context.ts

+24-5
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,42 @@
1-
import type {Viewer} from '@openint/cdk'
1+
import {TRPCError} from '@trpc/server'
2+
import type {Id, Viewer} from '@openint/cdk'
23
import {makeJwtClient} from '@openint/cdk'
3-
import {AnyDatabase} from '@openint/db/db'
4+
import {eq, schema} from '@openint/db'
5+
import type {AnyDatabase} from '@openint/db/db'
46
import {envRequired} from '@openint/env'
57
import type {RouterContext, ViewerContext} from './_base'
68

79
const jwt = makeJwtClient({
810
secretOrPublicKey: envRequired.JWT_SECRET,
911
})
1012

11-
export function viewerFromRequest(req: Request): Viewer {
13+
export async function viewerFromRequest(
14+
ctx: {db: AnyDatabase},
15+
req: Request,
16+
): Promise<Viewer> {
1217
const token = req.headers.get('authorization')?.match(/^Bearer (.+)/)?.[1]
18+
// JWT always include a dot. Without a dot we assume it's an API key
19+
if (token && !token.includes('.')) {
20+
const org = await ctx.db.query.organization.findFirst({
21+
columns: {id: true},
22+
where: eq(schema.organization.api_key, token),
23+
})
24+
if (!org) {
25+
throw new TRPCError({code: 'UNAUTHORIZED', message: 'Invalid API key'})
26+
}
27+
return {role: 'org', orgId: org.id as Id['org']}
28+
}
1329
return jwt.verifyViewer(token)
1430
}
1531

16-
export function routerContextFromRequest({
32+
export async function routerContextFromRequest({
1733
req,
1834
...ctx
1935
}: {req: Request} & Omit<CreateRouterContextOptions, 'viewer'>) {
20-
return routerContextFromViewer({...ctx, viewer: viewerFromRequest(req)})
36+
return routerContextFromViewer({
37+
...ctx,
38+
viewer: await viewerFromRequest(ctx, req),
39+
})
2140
}
2241

2342
interface CreateRouterContextOptions {

packages/db/db.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import path from 'node:path'
12
import type {Assume, DrizzleConfig, SQLWrapper} from 'drizzle-orm'
23
import type {MigrationConfig} from 'drizzle-orm/migrator'
3-
import {Viewer} from '@openint/cdk'
4+
import type {Viewer} from '@openint/cdk'
45
import type {initDbNeon} from './db.neon'
56
import type {initDbPg, initDbPgDirect} from './db.pg'
67
import type {initDbPGLite, initDbPGLiteDirect} from './db.pglite'
@@ -41,7 +42,8 @@ export function getDrizzleConfig(
4142
export function getMigrationConfig(): MigrationConfig {
4243
const config: Config = drizzleKitConfig
4344
return {
44-
migrationsFolder: drizzleKitConfig.out,
45+
// WARNING This only works if config is in the same folder as current file
46+
migrationsFolder: path.join(__dirname, drizzleKitConfig.out),
4547
migrationsSchema: config.migrations?.schema,
4648
migrationsTable: config.migrations?.table,
4749
}

packages/db/drizzle.config.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import path from 'node:path'
21
import type {Config} from 'drizzle-kit'
32
import {env} from '@openint/env'
43

54
export default {
6-
out: path.join(__dirname, './migrations'),
5+
out: './migrations',
76
dialect: 'postgresql',
8-
schema: path.join(__dirname, './schema/schema.ts'),
7+
schema: './schema/schema.ts',
98
dbCredentials: {url: env.DATABASE_URL_UNPOOLED ?? env.DATABASE_URL},
109
introspect: {casing: 'preserve'},
1110
migrations: {},
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
CREATE TABLE "customer" (
2+
"org_id" varchar NOT NULL,
3+
"id" varchar DEFAULT 'concat(''cus_'', generate_ulid())' NOT NULL,
4+
"metadata" jsonb,
5+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
6+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
7+
CONSTRAINT "customer_org_id_id_pk" PRIMARY KEY("org_id","id")
8+
);
9+
--> statement-breakpoint
10+
ALTER TABLE "customer" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
11+
CREATE TABLE "organization" (
12+
"id" varchar PRIMARY KEY DEFAULT 'concat(''org_'', generate_ulid())' NOT NULL,
13+
"api_key" varchar,
14+
"name" varchar,
15+
"slug" varchar,
16+
"metadata" jsonb,
17+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
18+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
19+
CONSTRAINT "organization_api_key_unique" UNIQUE("api_key")
20+
);
21+
--> statement-breakpoint
22+
ALTER TABLE "organization" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
23+
ALTER TABLE "customer" ADD CONSTRAINT "customer_org_id_organization_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
24+
CREATE POLICY "org_access" ON "customer" AS PERMISSIVE FOR ALL TO "org" USING (org_id = jwt_org_id()) WITH CHECK (org_id = jwt_org_id());--> statement-breakpoint
25+
CREATE POLICY "org_member_access" ON "customer" AS PERMISSIVE FOR ALL TO "authenticated" USING (org_id = jwt_org_id()) WITH CHECK (org_id = jwt_org_id());--> statement-breakpoint
26+
CREATE POLICY "customer_read" ON "customer" AS PERMISSIVE FOR ALL TO "customer" USING (org_id = jwt_org_id() AND id = jwt_customer_id());--> statement-breakpoint
27+
CREATE POLICY "org_read" ON "organization" AS PERMISSIVE FOR SELECT TO "org" USING (id = jwt_org_id());--> statement-breakpoint
28+
CREATE POLICY "org_member_read" ON "organization" AS PERMISSIVE FOR SELECT TO "authenticated" USING (id = jwt_org_id());

0 commit comments

Comments
 (0)