Browse Source

Implement resource permissions

Set permissions per resource.
master
TheoryOfNekomata 9 months ago
parent
commit
aefac7be03
4 changed files with 231 additions and 193 deletions
  1. +16
    -3
      examples/basic/server.ts
  2. +159
    -30
      src/core.ts
  3. +38
    -140
      src/handlers.ts
  4. +18
    -20
      src/utils.ts

+ 16
- 3
examples/basic/server.ts View File

@@ -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);

+ 159
- 30
src/core.ts View File

@@ -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<T extends BaseSchema> {
@@ -53,7 +59,16 @@ export interface ResourceData<T extends BaseSchema> {
idDeserializer: NonNullable<IdParams['deserialize']>;
}

export type Resource<T extends BaseSchema = any> = ResourceData<T> & ResourceFactory;
export interface ResourcePermissions {
canCreate: boolean;
canFetchCollection: boolean;
canFetchItem: boolean;
canPatch: boolean;
canEmplace: boolean;
canDelete: boolean;
}

export type Resource<T extends BaseSchema = any> = ResourceData<T> & ResourceFactory & ResourcePermissions;

export interface ResourceWithDataSource<T extends BaseSchema = any> extends Resource<T> {
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 = <T extends BaseSchema>(schema: T): Resource<T> => {
let theIdAttr: string;
let theItemName: string;
@@ -80,8 +123,56 @@ export const resource = <T extends BaseSchema>(schema: T): Resource<T> => {
let throw404OnDeletingNotFound = true;
let checkSerializersOnDelete = false;
const fullTextAttrs = new Set<string>();
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<MiddlewareArgs, 'resource' | 'resourceId'> = {
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<HandlerState>({
@@ -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;


+ 38
- 140
src/handlers.ts View File

@@ -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<any> : theResource.schema
const schema = resource.schema.type === 'object' ? resource.schema as v.ObjectSchema<any> : 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<string, unknown>;
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<string, unknown>;
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<any> : theResource.schema
const schema = resource.schema.type === 'object' ? resource.schema as v.ObjectSchema<any> : resource.schema
//console.log(schema);
bodyDeserialized = await getBody(
req,
@@ -462,10 +360,10 @@ export const handleEmplaceItem: Middleware = ({
? v.merge([
schema as v.ObjectSchema<any>,
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<string, unknown>;
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 {


+ 18
- 20
src/utils.ts View File

@@ -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);
}
});
});

Loading…
Cancel
Save