From aefac7be03740986d0fdad5b4239d2a1c0a4d446 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Wed, 13 Mar 2024 17:16:48 +0800 Subject: [PATCH] Implement resource permissions Set permissions per resource. --- examples/basic/server.ts | 19 +++- src/core.ts | 189 ++++++++++++++++++++++++++++++++------- src/handlers.ts | 178 ++++++++---------------------------- src/utils.ts | 38 ++++---- 4 files changed, 231 insertions(+), 193 deletions(-) diff --git a/examples/basic/server.ts b/examples/basic/server.ts index 2324482..b7007c9 100644 --- a/examples/basic/server.ts +++ b/examples/basic/server.ts @@ -19,9 +19,13 @@ const Piano = resource(v.object( generationStrategy: autoIncrement, serialize: (id) => id?.toString() ?? '0', deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, - }); - -// TODO implement authentication and RBAC on each resource + }) + .allowFetchItem() + .allowFetchCollection() + .allowCreate() + .allowEmplace() + .allowPatch() + .allowDelete(); const User = resource(v.object( { @@ -57,3 +61,12 @@ const server = app.createServer({ }); server.listen(3000); + +setTimeout(() => { + // Allow user operations after 5 seconds from startup + User + .allowFetchItem() + .allowFetchCollection() + .allowCreate() + .allowPatch(); +}, 5000); diff --git a/src/core.ts b/src/core.ts index 4a978e1..eaa1988 100644 --- a/src/core.ts +++ b/src/core.ts @@ -38,6 +38,12 @@ interface ResourceFactory { name(n: string): this; collection(n: string): this; route(n: string): this; + allowFetchCollection(): this; + allowFetchItem(): this; + allowCreate(): this; + allowPatch(): this; + allowEmplace(): this; + allowDelete(): this; } export interface ResourceData { @@ -53,7 +59,16 @@ export interface ResourceData { idDeserializer: NonNullable; } -export type Resource = ResourceData & ResourceFactory; +export interface ResourcePermissions { + canCreate: boolean; + canFetchCollection: boolean; + canFetchItem: boolean; + canPatch: boolean; + canEmplace: boolean; + canDelete: boolean; +} + +export type Resource = ResourceData & ResourceFactory & ResourcePermissions; export interface ResourceWithDataSource extends Resource { dataSource: DataSource; @@ -69,6 +84,34 @@ interface IdParams { deserialize?: (id: string) => unknown; } +const getAllowedMiddlewares = (resource: Resource, mainResourceId: string) => { + const middlewares = [] as [string, Middleware][]; + if (mainResourceId === '') { + if (resource.canFetchCollection) { + middlewares.push(['GET', handleGetCollection]); + } + if (resource.canCreate) { + middlewares.push(['POST', handleCreateItem]); + } + return middlewares; + } + + if (resource.canFetchItem) { + middlewares.push(['GET', handleGetItem]); + } + if (resource.canEmplace) { + middlewares.push(['PUT', handleEmplaceItem]); + } + if (resource.canPatch) { + middlewares.push(['PATCH', handlePatchItem]); + } + if (resource.canDelete) { + middlewares.push(['DELETE', handleDeleteItem]); + } + + return middlewares; +}; + export const resource = (schema: T): Resource => { let theIdAttr: string; let theItemName: string; @@ -80,8 +123,56 @@ export const resource = (schema: T): Resource => { let throw404OnDeletingNotFound = true; let checkSerializersOnDelete = false; const fullTextAttrs = new Set(); + let canCreate = false; + let canFetchCollection = false; + let canFetchItem = false; + let canPatch = false; + let canEmplace = false; + let canDelete = false; return { + allowFetchCollection() { + canFetchCollection = true; + return this; + }, + allowFetchItem() { + canFetchItem = true; + return this; + }, + allowCreate() { + canCreate = true; + return this; + }, + allowPatch() { + canPatch = true; + return this; + }, + allowEmplace() { + canEmplace = true; + return this; + }, + allowDelete() { + canDelete = true; + return this; + }, + get canCreate() { + return canCreate; + }, + get canFetchCollection() { + return canFetchCollection; + }, + get canFetchItem() { + return canFetchItem; + }, + get canPatch() { + return canPatch; + }, + get canEmplace() { + return canEmplace; + }, + get canDelete() { + return canDelete; + }, shouldCheckSerializersOnDelete(b = true) { checkSerializersOnDelete = b; return this; @@ -197,8 +288,8 @@ interface MiddlewareArgs { requestBodyEncodingPair: EncodingPair; responseBodySerializerPair: SerializerPair; responseMediaType: string; - method: string; - url: string; + resource: ResourceWithDataSource; + resourceId: string; query: URLSearchParams; } @@ -256,16 +347,16 @@ export const application = (appParams: ApplicationParams): Application => { const negotiator = new Negotiator(req); const availableMediaTypes = Array.from(appState.serializers.keys()); - const mediaType = negotiator.mediaType(availableMediaTypes); + const responseMediaType = negotiator.mediaType(availableMediaTypes); - if (typeof mediaType === 'undefined') { + if (typeof responseMediaType === 'undefined') { res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; res.end(); return; } - const serializer = appState.serializers.get(mediaType); - if (typeof serializer === 'undefined') { + const responseBodySerializerPair = appState.serializers.get(responseMediaType); + if (typeof responseBodySerializerPair === 'undefined') { res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; res.end(); return; @@ -280,42 +371,71 @@ export const application = (appParams: ApplicationParams): Application => { return; } - const encodingPair = appState.encodings.get(encoding); - if (typeof encodingPair === 'undefined') { + const requestBodyEncodingPair = appState.encodings.get(encoding); + if (typeof requestBodyEncodingPair === 'undefined') { res.statusCode = constants.HTTP_STATUS_NOT_ACCEPTABLE; res.end(); return; } - // TODO return method not allowed error when operations are not allowed on a resource - const middlewareState = await [ - handleHasMethodAndUrl, - handleGetRoot, - handleGetCollection, - handleGetItem, - handleCreateItem, - handleEmplaceItem, - handlePatchItem, - handleDeleteItem, - ] + const middlewareArgs: Omit = { + handlerState: { + handled: false + }, + appState, + appParams, + serverParams, + responseBodySerializerPair, + responseMediaType, + query, + requestBodyEncodingPair, + }; + + const methodAndUrl = await handleHasMethodAndUrl(middlewareArgs as MiddlewareArgs)(req, res); + if (methodAndUrl.handled) { + return; + } + + if (url === '/') { + const middlewareState = await handleGetRoot(middlewareArgs as MiddlewareArgs)(req, res); + if (middlewareState.handled) { + return; + } + + res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { + Allow: 'HEAD, GET' + }); + res.end(); + return; + } + + const [, resourceRouteName, resourceId = ''] = url.split('/'); + const resource = Array.from(appState.resources).find((r) => r.routeName === resourceRouteName); + if (typeof resource === 'undefined') { + res.statusCode = constants.HTTP_STATUS_NOT_FOUND; + res.statusMessage = 'URL Not Found'; + res.end(); + return; + } + + const middlewares = getAllowedMiddlewares(resource, resourceId); + const middlewareState = await middlewares .reduce( - async (currentHandlerStatePromise, middleware) => { + async (currentHandlerStatePromise, [middlewareMethod, middleware]) => { const currentHandlerState = await currentHandlerStatePromise; + if (method !== middlewareMethod) { + return currentHandlerState; + } + if (currentHandlerState.handled) { return currentHandlerState; } return middleware({ + ...middlewareArgs, handlerState: currentHandlerState, - appState, - appParams, - serverParams, - responseBodySerializerPair: serializer, - responseMediaType: mediaType, - method, - url, - query, - requestBodyEncodingPair: encodingPair, + resource, + resourceId: resourceId, })(req, res); }, Promise.resolve({ @@ -327,9 +447,18 @@ export const application = (appParams: ApplicationParams): Application => { return; } + if (middlewares.length > 0) { + res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { + Allow: middlewares.map((m) => m[0]).join(', ') + }); + res.end(); + return; + } + res.statusCode = constants.HTTP_STATUS_NOT_FOUND; res.statusMessage = 'URL Not Found'; res.end(); + return; }); return server; diff --git a/src/handlers.ts b/src/handlers.ts index 2c54435..6718e05 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -7,7 +7,7 @@ import {IncomingMessage, ServerResponse} from 'http'; export const handleHasMethodAndUrl: Middleware = ({}) => (req: IncomingMessage, res: ServerResponse) => { if (!req.method) { res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { - 'Allow': 'HEAD,GET,POST,PUT,PATCH,DELETE' // TODO check with resources on allowed methods + 'Allow': 'HEAD, GET, POST, PUT, PATCH, DELETE' }); res.end(); return { @@ -34,21 +34,7 @@ export const handleGetRoot: Middleware = ({ responseBodySerializerPair, serverParams, responseMediaType, - method, - url, }) => (_req: IncomingMessage, res: ServerResponse) => { - if (method !== 'GET') { - return { - handled: false - }; - } - - if (url !== '/') { - return { - handled: false - }; - } - const singleResDatum = { name: appParams.name }; @@ -76,13 +62,6 @@ export const handleGetCollection: Middleware = ({ responseBodySerializerPair, responseMediaType, }) => async (req: IncomingMessage, res: ServerResponse) => { - const method = getMethod(req); - if (method !== 'GET') { - return { - handled: false - }; - } - const baseUrl = serverParams.baseUrl ?? ''; const { url } = getUrl(req, baseUrl); @@ -102,6 +81,7 @@ export const handleGetCollection: Middleware = ({ try { await theResource.dataSource.initialize(); + // TODO querying mechanism const resData = await theResource.dataSource.getMultiple(); // TODO paginated responses per resource const theFormatted = responseBodySerializerPair.serialize(resData); @@ -179,36 +159,15 @@ export const handleGetItem: Middleware = ({ export const handleDeleteItem: Middleware = ({ - appState, - method, - url, + resource, + resourceId, }) => async (_req: IncomingMessage, res: ServerResponse) => { - if (method !== 'DELETE') { - return { - handled: false - }; - } - - const [, mainResourceRouteName, mainResourceId = ''] = url.split('/'); - if (mainResourceId === '') { - return { - handled: false - } - } - - const theResource = Array.from(appState.resources).find((r) => r.routeName === mainResourceRouteName); - if (typeof theResource === 'undefined') { - return { - handled: false - }; - } - try { - await theResource.dataSource.initialize(); - const response = await theResource.dataSource.delete(mainResourceId); - if (typeof response !== 'undefined' && !response && theResource.throws404OnDeletingNotFound) { + await resource.dataSource.initialize(); + const response = await resource.dataSource.delete(resourceId); + if (typeof response !== 'undefined' && !response && resource.throws404OnDeletingNotFound) { res.statusCode = constants.HTTP_STATUS_NOT_FOUND; - res.statusMessage = `${theResource.itemName} Not Found`; + res.statusMessage = `${resource.itemName} Not Found`; } else { res.statusCode = constants.HTTP_STATUS_NO_CONTENT; } @@ -230,31 +189,11 @@ export const handleDeleteItem: Middleware = ({ export const handlePatchItem: Middleware = ({ appState, - method, - url, responseBodySerializerPair, responseMediaType, + resource, + resourceId, }) => async (req: IncomingMessage, res: ServerResponse) => { - if (method !== 'PATCH') { - return { - handled: false - }; - } - - const [, mainResourceRouteName, mainResourceId = ''] = url.split('/'); - if (mainResourceId === '') { - return { - handled: false - } - } - - const theResource = Array.from(appState.resources).find((r) => r.routeName === mainResourceRouteName); - if (typeof theResource === 'undefined') { - return { - handled: false - }; - } - const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; @@ -264,11 +203,11 @@ export const handlePatchItem: Middleware = ({ }; } - await theResource.dataSource.initialize(); - const existing = await theResource.dataSource.getSingle(mainResourceId); + await resource.dataSource.initialize(); + const existing = await resource.dataSource.getSingle(resourceId); if (!existing) { res.statusCode = constants.HTTP_STATUS_NOT_FOUND; - res.statusMessage = `${theResource.itemName} Not Found`; + res.statusMessage = `${resource.itemName} Not Found`; res.end(); return { handled: true @@ -277,7 +216,7 @@ export const handlePatchItem: Middleware = ({ let bodyDeserialized: unknown; try { - const schema = theResource.schema.type === 'object' ? theResource.schema as v.ObjectSchema : theResource.schema + const schema = resource.schema.type === 'object' ? resource.schema as v.ObjectSchema : resource.schema bodyDeserialized = await getBody( req, requestBodyDeserializerPair, @@ -293,7 +232,7 @@ export const handlePatchItem: Middleware = ({ } catch (errRaw) { const err = errRaw as v.ValiError; res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; - res.statusMessage = `Invalid ${theResource.itemName}`; + res.statusMessage = `Invalid ${resource.itemName}`; if (Array.isArray(err.issues)) { // TODO better error reporting, localizable messages @@ -313,8 +252,8 @@ export const handlePatchItem: Middleware = ({ try { const params = bodyDeserialized as Record; - await theResource.dataSource.initialize(); - const newObject = await theResource.dataSource.patch(mainResourceId, params); + await resource.dataSource.initialize(); + const newObject = await resource.dataSource.patch(resourceId, params); const theFormatted = responseBodySerializerPair.serialize(newObject); res.writeHead(constants.HTTP_STATUS_OK, { 'Content-Type': responseMediaType, @@ -322,7 +261,7 @@ export const handlePatchItem: Middleware = ({ res.end(theFormatted); } catch { res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; - res.statusMessage = `Could Not Return ${theResource.itemName}`; + res.statusMessage = `Could Not Return ${resource.itemName}`; res.end(); } return { @@ -333,31 +272,10 @@ export const handlePatchItem: Middleware = ({ export const handleCreateItem: Middleware = ({ appState, serverParams, - method, - url, responseMediaType, responseBodySerializerPair, + resource, }) => async (req: IncomingMessage, res: ServerResponse) => { - if (method !== 'POST') { - return { - handled: false - }; - } - - const [, mainResourceRouteName, mainResourceId = ''] = url.split('/'); - if (mainResourceId !== '') { - return { - handled: false - } - } - - const theResource = Array.from(appState.resources).find((r) => r.routeName === mainResourceRouteName); - if (typeof theResource === 'undefined') { - return { - handled: false - }; - } - const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; @@ -369,11 +287,11 @@ export const handleCreateItem: Middleware = ({ let bodyDeserialized: unknown; try { - bodyDeserialized = await getBody(req, requestBodyDeserializerPair, requestBodyEncodingPair, theResource.schema); + bodyDeserialized = await getBody(req, requestBodyDeserializerPair, requestBodyEncodingPair, resource.schema); } catch (errRaw) { const err = errRaw as v.ValiError; res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; - res.statusMessage = `Invalid ${theResource.itemName}`; + res.statusMessage = `Invalid ${resource.itemName}`; if (Array.isArray(err.issues)) { // TODO better error reporting, localizable messages @@ -392,20 +310,20 @@ export const handleCreateItem: Middleware = ({ } try { - await theResource.dataSource.initialize(); - const newId = await theResource.newId(theResource.dataSource); + await resource.dataSource.initialize(); + const newId = await resource.newId(resource.dataSource); const params = bodyDeserialized as Record; - params[theResource.idAttr] = newId; - const newObject = await theResource.dataSource.create(params); + params[resource.idAttr] = newId; + const newObject = await resource.dataSource.create(params); const theFormatted = responseBodySerializerPair.serialize(newObject); res.writeHead(constants.HTTP_STATUS_OK, { 'Content-Type': responseMediaType, - 'Location': `${serverParams.baseUrl}/${theResource.routeName}/${newId}` + 'Location': `${serverParams.baseUrl}/${resource.routeName}/${newId}` }); res.end(theFormatted); } catch { res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; - res.statusMessage = `Could Not Return ${theResource.itemName}`; + res.statusMessage = `Could Not Return ${resource.itemName}`; res.end(); } return { @@ -416,31 +334,11 @@ export const handleCreateItem: Middleware = ({ export const handleEmplaceItem: Middleware = ({ appState, serverParams, - method, - url, responseBodySerializerPair, responseMediaType, + resource, + resourceId, }) => async (req: IncomingMessage, res: ServerResponse) => { - if (method !== 'PUT') { - return { - handled: false - }; - } - - const [, mainResourceRouteName, mainResourceId = ''] = url.split('/'); - if (mainResourceId === '') { - return { - handled: false - } - } - - const theResource = Array.from(appState.resources).find((r) => r.routeName === mainResourceRouteName); - if (typeof theResource === 'undefined') { - return { - handled: false - }; - } - const { deserializerPair: requestBodyDeserializerPair, encodingPair: requestBodyEncodingPair } = getDeserializerObjects(appState, req); if (typeof requestBodyDeserializerPair === 'undefined' || typeof requestBodyEncodingPair === 'undefined') { res.statusCode = constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE; @@ -452,7 +350,7 @@ export const handleEmplaceItem: Middleware = ({ let bodyDeserialized: unknown; try { - const schema = theResource.schema.type === 'object' ? theResource.schema as v.ObjectSchema : theResource.schema + const schema = resource.schema.type === 'object' ? resource.schema as v.ObjectSchema : resource.schema //console.log(schema); bodyDeserialized = await getBody( req, @@ -462,10 +360,10 @@ export const handleEmplaceItem: Middleware = ({ ? v.merge([ schema as v.ObjectSchema, v.object({ - [theResource.idAttr]: v.transform( + [resource.idAttr]: v.transform( v.any(), - input => theResource.idSerializer(input), - v.literal(mainResourceId) + input => resource.idSerializer(input), + v.literal(resourceId) ) }) ]) @@ -474,7 +372,7 @@ export const handleEmplaceItem: Middleware = ({ } catch (errRaw) { const err = errRaw as v.ValiError; res.statusCode = constants.HTTP_STATUS_BAD_REQUEST; - res.statusMessage = `Invalid ${theResource.itemName}`; + res.statusMessage = `Invalid ${resource.itemName}`; if (Array.isArray(err.issues)) { // TODO better error reporting, localizable messages @@ -493,14 +391,14 @@ export const handleEmplaceItem: Middleware = ({ } try { - await theResource.dataSource.initialize(); + await resource.dataSource.initialize(); const params = bodyDeserialized as Record; - const [newObject, isCreated] = await theResource.dataSource.emplace(mainResourceId, params); + const [newObject, isCreated] = await resource.dataSource.emplace(resourceId, params); const theFormatted = responseBodySerializerPair.serialize(newObject); if (isCreated) { res.writeHead(constants.HTTP_STATUS_CREATED, { 'Content-Type': responseMediaType, - 'Location': `${serverParams.baseUrl}/${theResource.routeName}/${mainResourceId}` + 'Location': `${serverParams.baseUrl}/${resource.routeName}/${resourceId}` }); } else { res.writeHead(constants.HTTP_STATUS_OK, { @@ -510,7 +408,7 @@ export const handleEmplaceItem: Middleware = ({ res.end(theFormatted); } catch { res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR; - res.statusMessage = `Could Not Return ${theResource.itemName}`; + res.statusMessage = `Could Not Return ${resource.itemName}`; res.end(); } return { diff --git a/src/utils.ts b/src/utils.ts index e167644..b8841ed 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -31,24 +31,22 @@ export const getBody = ( deserializer: SerializerPair, encodingPair: EncodingPair, schema: BaseSchema -) => { - return new Promise((resolve, reject) => { - let body = Buffer.from(''); - req.on('data', (chunk) => { - body = Buffer.concat([body, chunk]); - }); - req.on('end', async () => { - const bodyStr = encodingPair.decode(body); - try { - const bodyDeserialized = await parseAsync( - schema, - deserializer.deserialize(bodyStr), - {abortEarly: false}, - ); - resolve(bodyDeserialized); - } catch (err) { - reject(err); - } - }); +) => new Promise((resolve, reject) => { + let body = Buffer.from(''); + req.on('data', (chunk) => { + body = Buffer.concat([body, chunk]); }); -}; + req.on('end', async () => { + const bodyStr = encodingPair.decode(body); + try { + const bodyDeserialized = await parseAsync( + schema, + deserializer.deserialize(bodyStr), + {abortEarly: false}, + ); + resolve(bodyDeserialized); + } catch (err) { + reject(err); + } + }); +});