Skip to content

Commit

Permalink
[IAM-1155] - provide thrown error details in return from computePermi…
Browse files Browse the repository at this point in the history
…ssions so that clients ingesting might be better able to respond
  • Loading branch information
reidblomquist committed Jan 11, 2024
1 parent ace9b4f commit c0e8a43
Show file tree
Hide file tree
Showing 7 changed files with 88 additions and 23 deletions.
33 changes: 33 additions & 0 deletions features/advanced.feature
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,36 @@ Scenario: deprecate.rewrite get todos
And the response headers at deprecated-for is "GET /todos/${U.username}"
And the response headers at location contains "/todos/${U.username}"

# Compute permissions

Scenario: get positive TODO permissions
Given a user U with { "username": "${random}", "name": "Ringo ${random}" }
And a todo T with { "owner": "${U.username}", "item": "Autograph a photo for Marge" }
When GET /todo-permissions/${T.id}/${U.username}
Then the response is 200 and the payload includes
"""
{
"view": true,
"admin": true
}
"""

Scenario: get negative TODO permissions with errors
Given a user U with { "username": "${random}-1", "name": "Ringo ${random}" }
And a user O with { "username": "${random}-2", "name": "John ${random}" }
And a todo T with { "owner": "${O.username}", "item": "Autograph a photo for Marge" }
When GET /todo-permissions/${T.id}/${U.username}
Then the response is 200 and the payload includes
"""
{
"view": false,
"admin": false,
"$errors": {
"admin": {
"statusCode": 401,
"error": "NOT_AUTHORIZED",
"extra": {}
}
}
}
"""
2 changes: 1 addition & 1 deletion features/analyze.feature
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ Scenario: analyze basic app
Scenario: analyze advanced app succeeds
Given an advanced app
When analyzing the current app
Then the analyzed routes at length is 17
Then the analyzed routes at length is 18
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "async-app",
"version": "4.8.1",
"version": "4.9.0",
"description": "An express wrapper for handling async middlewares, order middlewares, schema validator, and other stuff",
"type": "commonjs",
"main": "dist/index.js",
Expand Down
12 changes: 10 additions & 2 deletions src/examples/advanced/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import bodyParser from 'body-parser';
import express from 'express';
import { join } from 'path';

import { createCustomResponse, deprecate } from '../..';
import { computePermissions, createCustomResponse, deprecate } from '../..';
import createApp, { Req } from './async-app';
import can from './can';
import can, { entities } from './can';
import { addTodo, addUser, getTodosForUser } from './db';
import load from './load';
import purgeUser from './purge-user';
Expand Down Expand Up @@ -125,6 +125,14 @@ app.get(
(req: Req<'todo'>) => req.todo,
);

app.get(
'/todo-permissions/:todoId/:username',
'Returns computed permissions for the specified TODO and user',
load.todo.fromParams(),
load.user.fromParams(),
(req: Req<'todo' | 'user'>) => computePermissions(entities, 'todo', { todo: req.todo, user: req.user }),
)

app.get('/echo1', () => 'echo');

app.get(
Expand Down
21 changes: 15 additions & 6 deletions src/examples/advanced/can.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@
// +========================================================================+ //

// In your case this is `from 'async-app'`
import { createPermissions } from '../..';
import { CustomError, createPermissions } from '../..';
import { ExampleEntities } from './async-app';
const todo = {
view: ({ user, todo }: Pick<ExampleEntities, 'user' | 'todo'>) => todo.owner === user.username,
admin: ({ user, todo }: Pick<ExampleEntities, 'user' | 'todo'>) => {
if (todo.owner === user.username) {
return true
}
throw new CustomError(401, 'NOT_AUTHORIZED')
}
}

const can = createPermissions<ExampleEntities>({
todo: {
view: ({ user, todo }) => todo.owner === user.username,
},
});
export const entities = {
todo
}

const can = createPermissions<ExampleEntities>(entities);

export default can;
37 changes: 26 additions & 11 deletions src/permissions/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import {
} from './types';
import { getKeys } from './util';

interface Permissions {
[name: string]: boolean;
}
type Permissions<T extends string = string> = {
[key in T]: key extends '$errors' ? Record<string, unknown> : boolean;
} & {
$errors: Record<string, unknown>;
};

const areEqual = <T>(arr1: T[], arr2: T[]) =>
arr1.length === arr2.length && arr1.every(item => arr2.includes(item));
Expand Down Expand Up @@ -54,9 +56,9 @@ const tryPermission = <TEntities>(
requiredModels: TEntities,
) => {
try {
return permissionFn(requiredModels);
} catch (_) {
return false;
return { access: permissionFn(requiredModels) };
} catch (error) {
return { access: false, error };
}
};

Expand All @@ -68,6 +70,8 @@ const assertEntity = <TEntities>(
return entity;
};

// Accepting an optional tryFunction lets users override the default try/catch
// which means you can in theory pull off the `extra` key in thrown errors etc
export const computePermissions = <TEntities>(
entities: PermissionMap<TEntities>,
entityName: keyof TEntities,
Expand All @@ -77,25 +81,36 @@ export const computePermissions = <TEntities>(

checkExpectedModels(entity, entityName, requiredModels);

const permissions = {} as Permissions;
const permissions = {
$errors: {},
} as Permissions;

Object.keys(entity).forEach((action) => {
const spec = entity[action];

// load permission on entire entity, e.g.: $permissions.delete = true
if (isPermissionFn(spec)) {
const permission = tryPermission(spec, requiredModels);
permissions[action] = permission;
const { access, error } = tryPermission(spec, requiredModels);
permissions[action] = access;

if (error) {
(permissions.$errors)[action] = error;
}
}

// load subpermissions, e.g.: $permissions['delete.editHash'] = true
if (isPermissionEntity(spec)) {
Object.keys(spec).forEach((subaction) => {
const permissionFn = spec[subaction];
if (isPermissionFn(permissionFn)) {
const permission = tryPermission(permissionFn, requiredModels);
const { access, error } = tryPermission(permissionFn, requiredModels);
const permKey = `${action}.${subaction}`;

permissions[permKey] = access;

permissions[`${action}.${subaction}`] = permission;
if (error) {
(permissions.$errors)[permKey] = error;
}
}
});
}
Expand Down

0 comments on commit c0e8a43

Please sign in to comment.