Skip to content

Commit 0eacd3c

Browse files
feat: Add resource-specific public access control and column filtering for API endpoints.
1 parent b162687 commit 0eacd3c

File tree

9 files changed

+313
-54
lines changed

9 files changed

+313
-54
lines changed

scripts/test-api.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ async function startServer(playgroundDir, port) {
1717
})
1818

1919
// Wait for server to be ready (naive wait, better to check output or poll)
20-
await new Promise((resolve) => setTimeout(resolve, 5000))
20+
await new Promise((resolve) => setTimeout(resolve, 10000))
2121
return server
2222
}
2323

src/module.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,26 @@ export interface ModuleOptions {
3434
*/
3535
authorization?: boolean
3636
}
37+
38+
/**
39+
* Resource-specific configuration
40+
* Define public access and column visibility
41+
*/
42+
resources?: {
43+
[modelName: string]: {
44+
/**
45+
* Actions allowed without authentication
46+
* true = all actions
47+
* array = specific actions ('list', 'create', 'read', 'update', 'delete')
48+
*/
49+
public?: boolean | ('list' | 'create' | 'read' | 'update' | 'delete')[]
50+
/**
51+
* Columns to return for unauthenticated requests
52+
* If not specified, all columns (except hidden ones) are returned
53+
*/
54+
publicColumns?: string[]
55+
}
56+
}
3757
}
3858

3959
export default defineNuxtModule<ModuleOptions>({
@@ -64,7 +84,11 @@ export default defineNuxtModule<ModuleOptions>({
6484

6585
// 3. Pass options to runtimeConfig
6686
nuxt.options.runtimeConfig.autoCrud = {
67-
auth: options.auth || { enabled: false, authorization: false },
87+
auth: {
88+
enabled: options.auth?.enabled ?? false,
89+
authorization: options.auth?.authorization ?? false,
90+
},
91+
resources: options.resources || {},
6892
}
6993

7094
// 2. Register the API routes

src/runtime/server/api/[model]/[id].delete.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,53 @@
11
// server/api/[model]/[id].delete.ts
22
import { eventHandler, getRouterParams, createError } from 'h3'
33
import { eq } from 'drizzle-orm'
4-
import { getTableForModel, getModelSingularName, filterHiddenFields } from '../../utils/modelMapper'
4+
import { getTableForModel, getModelSingularName, filterHiddenFields, filterPublicColumns } from '../../utils/modelMapper'
55
import { useRuntimeConfig } from '#imports'
66

77
import type { TableWithId } from '../../types'
88
// @ts-expect-error - #site/drizzle is an alias defined by the module
99
import { useDrizzle } from '#site/drizzle'
1010

1111
export default eventHandler(async (event) => {
12-
const { auth } = useRuntimeConfig().autoCrud
12+
const { auth, resources } = useRuntimeConfig().autoCrud
13+
let isAdmin = false
14+
1315
if (auth?.enabled) {
1416
// Try using global auto-import
1517
// @ts-ignore
1618
if (typeof requireUserSession === 'function') {
17-
// @ts-ignore
18-
await requireUserSession(event)
19+
try {
20+
// @ts-ignore
21+
await requireUserSession(event)
22+
isAdmin = true
23+
} catch (e) {
24+
isAdmin = false
25+
}
1926
} else {
2027
throw new Error('requireUserSession is not available')
2128
}
29+
} else {
30+
isAdmin = true
2231
}
2332

2433
const { model, id } = getRouterParams(event)
34+
35+
// Check public access if not admin
36+
if (!isAdmin) {
37+
const resourceConfig = resources?.[model]
38+
const isPublic = resourceConfig?.public === true || (Array.isArray(resourceConfig?.public) && resourceConfig.public.includes('delete'))
39+
40+
if (!isPublic) {
41+
throw createError({
42+
statusCode: 401,
43+
message: 'Unauthorized',
44+
})
45+
}
46+
}
47+
2548
const table = getTableForModel(model) as TableWithId
2649

27-
if (auth?.authorization) {
50+
if (isAdmin && auth?.authorization) {
2851
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
2952
// @ts-ignore - #authorization is an optional module
3053
const { authorize } = await import('#authorization')
@@ -45,5 +68,9 @@ export default eventHandler(async (event) => {
4568
})
4669
}
4770

48-
return filterHiddenFields(model, deletedRecord as Record<string, unknown>)
71+
if (isAdmin) {
72+
return filterHiddenFields(model, deletedRecord as Record<string, unknown>)
73+
} else {
74+
return filterPublicColumns(model, deletedRecord as Record<string, unknown>)
75+
}
4976
})

src/runtime/server/api/[model]/[id].get.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,53 @@
11
// server/api/[model]/[id].get.ts
22
import { eventHandler, getRouterParams, createError } from 'h3'
33
import { eq } from 'drizzle-orm'
4-
import { getTableForModel, getModelSingularName, filterHiddenFields } from '../../utils/modelMapper'
4+
import { getTableForModel, getModelSingularName, filterHiddenFields, filterPublicColumns } from '../../utils/modelMapper'
55
import { useRuntimeConfig } from '#imports'
66

77
import type { TableWithId } from '../../types'
88
// @ts-expect-error - #site/drizzle is an alias defined by the module
99
import { useDrizzle } from '#site/drizzle'
1010

1111
export default eventHandler(async (event) => {
12-
const { auth } = useRuntimeConfig().autoCrud
12+
const { auth, resources } = useRuntimeConfig().autoCrud
13+
let isAdmin = false
14+
1315
if (auth?.enabled) {
1416
// Try using global auto-import
1517
// @ts-ignore
1618
if (typeof requireUserSession === 'function') {
17-
// @ts-ignore
18-
await requireUserSession(event)
19+
try {
20+
// @ts-ignore
21+
await requireUserSession(event)
22+
isAdmin = true
23+
} catch (e) {
24+
isAdmin = false
25+
}
1926
} else {
2027
throw new Error('requireUserSession is not available')
2128
}
29+
} else {
30+
isAdmin = true
2231
}
2332

2433
const { model, id } = getRouterParams(event)
34+
35+
// Check public access if not admin
36+
if (!isAdmin) {
37+
const resourceConfig = resources?.[model]
38+
const isPublic = resourceConfig?.public === true || (Array.isArray(resourceConfig?.public) && resourceConfig.public.includes('read'))
39+
40+
if (!isPublic) {
41+
throw createError({
42+
statusCode: 401,
43+
message: 'Unauthorized',
44+
})
45+
}
46+
}
47+
2548
const table = getTableForModel(model) as TableWithId
2649

27-
if (auth?.authorization) {
50+
if (isAdmin && auth?.authorization) {
2851
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
2952
// @ts-ignore - #authorization is an optional module
3053
const { authorize } = await import('#authorization')
@@ -41,9 +64,13 @@ export default eventHandler(async (event) => {
4164
if (!record) {
4265
throw createError({
4366
statusCode: 404,
44-
message: `${singularName} not found`,
67+
message: 'Record not found',
4568
})
4669
}
4770

48-
return filterHiddenFields(model, record as Record<string, unknown>)
71+
if (isAdmin) {
72+
return filterHiddenFields(model, record as Record<string, unknown>)
73+
} else {
74+
return filterPublicColumns(model, record as Record<string, unknown>)
75+
}
4976
})
Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,79 @@
11
// server/api/[model]/[id].patch.ts
2-
import { eventHandler, getRouterParams, readBody } from 'h3'
2+
import { eventHandler, getRouterParams, readBody, createError } from 'h3'
33
import { eq } from 'drizzle-orm'
4-
import { getTableForModel, filterUpdatableFields, filterHiddenFields } from '../../utils/modelMapper'
4+
import { getTableForModel, filterUpdatableFields, filterHiddenFields, filterPublicColumns } from '../../utils/modelMapper'
55
import { useRuntimeConfig } from '#imports'
66

77
import type { TableWithId } from '../../types'
88
// @ts-expect-error - #site/drizzle is an alias defined by the module
99
import { useDrizzle } from '#site/drizzle'
1010

1111
export default eventHandler(async (event) => {
12-
const { auth } = useRuntimeConfig().autoCrud
12+
const { auth, resources } = useRuntimeConfig().autoCrud
13+
let isAdmin = false
14+
1315
if (auth?.enabled) {
1416
// Try using global auto-import
1517
// @ts-ignore
1618
if (typeof requireUserSession === 'function') {
17-
// @ts-ignore
18-
await requireUserSession(event)
19+
try {
20+
// @ts-ignore
21+
await requireUserSession(event)
22+
isAdmin = true
23+
} catch (e) {
24+
isAdmin = false
25+
}
1926
} else {
2027
throw new Error('requireUserSession is not available')
2128
}
29+
} else {
30+
isAdmin = true
2231
}
2332

2433
const { model, id } = getRouterParams(event)
34+
35+
// Check public access if not admin
36+
if (!isAdmin) {
37+
const resourceConfig = resources?.[model]
38+
const isPublic = resourceConfig?.public === true || (Array.isArray(resourceConfig?.public) && resourceConfig.public.includes('update'))
39+
40+
if (!isPublic) {
41+
throw createError({
42+
statusCode: 401,
43+
message: 'Unauthorized',
44+
})
45+
}
46+
}
47+
2548
const table = getTableForModel(model) as TableWithId
2649

27-
if (auth?.authorization) {
50+
if (isAdmin && auth?.authorization) {
2851
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
2952
// @ts-ignore - #authorization is an optional module
3053
const { authorize } = await import('#authorization')
3154
await authorize(model, 'update')
3255
}
33-
const body = await readBody(event)
3456

35-
// Filter to only allow updatable fields for this model
36-
const updateData = filterUpdatableFields(model, body)
57+
const body = await readBody(event)
58+
const payload = filterUpdatableFields(model, body)
3759

38-
const record = await useDrizzle()
60+
const updatedRecord = await useDrizzle()
3961
.update(table)
40-
.set(updateData)
62+
.set(payload)
4163
.where(eq(table.id, Number(id)))
4264
.returning()
4365
.get()
4466

45-
return filterHiddenFields(model, record as Record<string, unknown>)
67+
if (!updatedRecord) {
68+
throw createError({
69+
statusCode: 404,
70+
message: 'Record not found',
71+
})
72+
}
73+
74+
if (isAdmin) {
75+
return filterHiddenFields(model, updatedRecord as Record<string, unknown>)
76+
} else {
77+
return filterPublicColumns(model, updatedRecord as Record<string, unknown>)
78+
}
4679
})
Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,71 @@
11
// server/api/[model]/index.get.ts
2-
import { eventHandler, getRouterParams } from 'h3'
3-
import { getTableForModel, filterHiddenFields } from '../../utils/modelMapper'
2+
import { eventHandler, getRouterParams, createError } from 'h3'
3+
import { getTableForModel, filterHiddenFields, filterPublicColumns } from '../../utils/modelMapper'
44
import { useRuntimeConfig } from '#imports'
55

66
// @ts-expect-error - #site/drizzle is an alias defined by the module
77
import { useDrizzle } from '#site/drizzle'
88

99
export default eventHandler(async (event) => {
10-
const { auth } = useRuntimeConfig().autoCrud
10+
console.log('[GET] Request received', event.path)
11+
const { auth, resources } = useRuntimeConfig().autoCrud
12+
let isAdmin = false
13+
1114
if (auth?.enabled) {
1215
// Try using global auto-import
1316
// @ts-ignore
1417
if (typeof requireUserSession === 'function') {
15-
// @ts-ignore
16-
await requireUserSession(event)
18+
try {
19+
// @ts-ignore
20+
await requireUserSession(event)
21+
isAdmin = true
22+
} catch (e) {
23+
// Not authenticated
24+
isAdmin = false
25+
}
1726
} else {
1827
console.warn('requireUserSession is not available globally')
1928
// Fallback or error?
2029
// If auth is enabled, we expect it to be available.
2130
// But if it's not, we might want to throw to fail the test.
2231
throw new Error('requireUserSession is not available')
2332
}
33+
} else {
34+
// Auth disabled = everyone is admin (or public with full access)
35+
isAdmin = true
2436
}
2537

2638
const { model } = getRouterParams(event)
39+
40+
// Check public access if not admin
41+
if (!isAdmin) {
42+
const resourceConfig = resources?.[model]
43+
const isPublic = resourceConfig?.public === true || (Array.isArray(resourceConfig?.public) && resourceConfig.public.includes('list'))
44+
45+
if (!isPublic) {
46+
throw createError({
47+
statusCode: 401,
48+
message: 'Unauthorized',
49+
})
50+
}
51+
}
52+
2753
const table = getTableForModel(model)
2854

29-
if (auth?.authorization) {
55+
if (isAdmin && auth?.authorization) {
3056
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
3157
// @ts-ignore - #authorization is an optional module
3258
const { authorize } = await import('#authorization')
33-
await authorize(model, 'read')
59+
await authorize(model, 'list')
3460
}
3561

36-
const records = await useDrizzle().select().from(table).all()
62+
const results = await useDrizzle().select().from(table).all()
3763

38-
return records.map((record: Record<string, unknown>) => filterHiddenFields(model, record))
64+
return results.map((item: Record<string, unknown>) => {
65+
if (isAdmin) {
66+
return filterHiddenFields(model, item as Record<string, unknown>)
67+
} else {
68+
return filterPublicColumns(model, item as Record<string, unknown>)
69+
}
70+
})
3971
})

0 commit comments

Comments
 (0)