Skip to content

Commit dcde02e

Browse files
mandariniclaude
andcommitted
feat(middleware): surface table-grants hint on 42501 in withPostgres
Append the caller-role grants hint to permission-denied errors and document the grants requirement in the withPostgres JSDoc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ed834f1 commit dcde02e

2 files changed

Lines changed: 50 additions & 0 deletions

File tree

src/middleware/postgres/index.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,45 @@ describe('withPostgres', () => {
120120
expect(h.issued).toContain('rollback')
121121
expect(h.release).toHaveBeenCalled()
122122
})
123+
124+
it('appends a grants hint to permission-denied (42501) errors', async () => {
125+
// begin, set_config, set role succeed; the user query hits missing grants.
126+
h.clientQuery
127+
.mockImplementationOnce(async (t: string) => {
128+
h.issued.push(t)
129+
return { rows: [] }
130+
})
131+
.mockImplementationOnce(async (t: string) => {
132+
h.issued.push(t)
133+
return { rows: [] }
134+
})
135+
.mockImplementationOnce(async (t: string) => {
136+
h.issued.push(t)
137+
return { rows: [] }
138+
})
139+
.mockImplementationOnce(async () => {
140+
const err = new Error('permission denied for table notes') as Error & {
141+
code: string
142+
}
143+
err.code = '42501'
144+
throw err
145+
})
146+
147+
const handler = withPostgres(async (_req, ctx) => {
148+
await ctx.postgres.query('select * from notes')
149+
return Response.json({ ok: true })
150+
})
151+
152+
await expect(
153+
handler(new Request('http://localhost'), {
154+
_runtime: runtime,
155+
jwtClaims: { role: 'authenticated' },
156+
}),
157+
).rejects.toThrow(
158+
/permission denied for table notes \(RLS-scoped queries run as the caller's role 'authenticated'/,
159+
)
160+
161+
expect(h.issued).toContain('rollback')
162+
expect(h.release).toHaveBeenCalled()
163+
})
123164
})

src/middleware/postgres/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ export interface WithPostgresConfig {
7171
* Standalone (no `withSupabase`), pair it with `withClaims` so `ctx.jwtClaims`
7272
* is present before it runs.
7373
*
74+
* > **Table grants.** Queries run as `authenticated` or `anon`, so those
75+
* > roles need explicit table privileges (e.g. `grant select, insert on
76+
* > <table> to authenticated`) in addition to RLS policies. A missing grant
77+
* > fails with `permission denied` (SQLSTATE 42501) before RLS is consulted.
78+
*
7479
* > **Runtime note.** `pg` needs raw TCP, so this runs on Node/Deno (including
7580
* > the Supabase Edge runtime), **not** on Workers-style isolates.
7681
*
@@ -114,6 +119,10 @@ export const withPostgres = defineMiddleware<
114119
return res.rows as T[]
115120
} catch (e) {
116121
await client.query('rollback')
122+
// 42501 insufficient_privilege: the role lacks table grants.
123+
if (e instanceof Error && (e as { code?: string }).code === '42501') {
124+
e.message += ` (RLS-scoped queries run as the caller's role '${role}' — grant that role the table privileges it needs, e.g. "grant select on <table> to ${role}")`
125+
}
117126
throw e
118127
} finally {
119128
client.release()

0 commit comments

Comments
 (0)