Skip to content

Instantly share code, notes, and snippets.

@beaucollins
Last active September 21, 2020 23:26
Show Gist options
  • Select an option

  • Save beaucollins/b39a1e5d3bc1f2b3462291cbc3eab4cf to your computer and use it in GitHub Desktop.

Select an option

Save beaucollins/b39a1e5d3bc1f2b3462291cbc3eab4cf to your computer and use it in GitHub Desktop.
type Brand<T, K> = T & { __brand: K };
type CommunityId = Brand<number, 'CommunityId'>;
type PostId = Brand<number, 'PostId'>;
type MemberId = Brand<number, 'MemberId'>;
type Post = {
communityId: CommunityId,
postId: PostId,
authorId: MemberId
};
type CommunityRole =
| { roleName: 'CommunityRole', communityRoleName: 'Manager', communityId: CommunityId, communityMemberId: MemberId }
| { roleName: 'CommunityRole', communityRoleName: 'Member', communityId: CommunityId, communityMemberId: MemberId }
| { roleName: 'CommunityRole', communityRoleName: 'Owner', communityId: CommunityId, communityMemberId: MemberId }
type Role =
| { roleName: 'Admin' }
| CommunityRole;
/**
* Our normal entity gateway
*/
interface EntityGateway {
getSomeEntity(): Promise<undefined| {id: number, title: string}>
lotsOfEntities(someArg: string): Promise<Array<{id: number, title: string}>>
getPost(postId: PostId): Promise<undefined| Post>
getComment(commentId: PostId): Promise<string>
}
/**
* An error when not authorized error
*/
class UnauthorizedError extends Error {
}
/**
* Similar to Result type, a union that can be identified by authorized: boolean
*/
type AuthResult<T> =
| { authorized: true, result: T, role: Role }
| { authorized: false, reason: UnauthorizedError}
/**
* Utility to infer the T of a Promise<T>
*/
type PromiseType<T> = T extends Promise<infer U> ? U : never;
/**
* A type to represent _how_ to deterimine if something is authorized
*/
type Authorizor<A extends unknown[]> = (gateway: EntityGateway, role: Readonly<Role>, ...args: A) => Promise<boolean>;
/**
* This turns any method on EntityGateway into one that also accepts and wraps the result in AuthResult
*
* For example:
*
* lotsOfEntities(someArg: string): Promise<Array<{id: number, title: string}>>
*
* Will become:
*
* lotsOfEntities(someArg: string): Promise<AuthResult<Array<{id: number, title: string}>>>
*/
type AuthorizedEntityMethod<A extends unknown[], R> = (...args: A) => Promise<AuthResult<R>>;
type AuthorizedEntityGateway = {
[K in keyof EntityGateway]: AuthorizedEntityMethod<Parameters<EntityGateway[K]>, PromiseType<ReturnType<EntityGateway[K]>>>
}
/**
* AuthorizedEntityGateway can also be used as an interface:
*/
class AuthorizedGateway implements AuthorizedEntityGateway {
constructor(private readonly gateway: EntityGateway){}
// Implement methods but they return Promise<AuthResult<T>>
getPost(postId: PostId): Promise<AuthResult<undefined | Post>> {
throw new Error('not implemented');
}
}
/**
* We need a specific rule for every method in EntityGateway
*/
type AuthorizationRules = { [K in keyof EntityGateway]: Authorizor<Parameters<EntityGateway[K]>> };
/**
* Usage example
*/
const gateway = new class implements EntityGateway {
getPost(_postId: Brand<number,"PostId">): Promise<Post|undefined> {
throw new Error("Method not implemented.");
}
getSomeEntity(): Promise<{ id: number; title: string; }|undefined> {
throw new Error("Method not implemented.");
}
lotsOfEntities(_someArg: string): Promise<{ id: number; title: string; }[]> {
throw new Error("Method not implemented.");
}
}()
const allowNone = () => Promise.resolve(false);
const allowAll = () => Promise.resolve(true);
const allowAdmin: Authorizor<any[]> = (_gateway, role) => Promise.resolve(role.roleName === 'Admin');
/**
* Given a lest of Authorizor<A> creates a singler Authorizor<A> that passes if
* one of the authorization passes.
*/
function requireOne<A extends unknown[]>(...authorizors: Array<Authorizor<A>>): Authorizor<A> {
return async (gateway, role, ...args) => {
for(const authorizor of authorizors) {
const authorized = await authorizor(gateway, role, ...args);
if (authorized) {
return true;
}
}
return false;
}
}
type Permission = 'read' | 'create' | 'update' | 'delete';
function communityAuthorizor<A extends unknown[]>(
getCommunityId: (gateway: EntityGateway, ...args: A) => Promise<[communityId: CommunityId, entityOwnerId: MemberId]>,
entityOwnerIsAuthorized: (gateway: EntityGateway, communityMember: CommunityRole, communityId: CommunityId, entityOwnerId: MemberId, ...args: A) => Promise<boolean> = () => Promise.resolve(false)
): Authorizor<A> {
return async (gateway, role, ...args) => {
if (role.roleName !== 'CommunityRole') {
return false;
}
const [communityId, entityOwnerId] = await getCommunityId(gateway, ...args);
if (communityId !== role.communityId) {
return false;
}
switch(role.communityRoleName) {
case 'Manager': {
return true;
}
case 'Owner': {
return true;
}
case 'Member': {
if (role.communityMemberId !== entityOwnerId) {
return false;
}
return await entityOwnerIsAuthorized(gateway, role, communityId, entityOwnerId, ...args);
}
default: {
assertNever(role, `Unhandled communityRoleName of ${JSON.stringify(role)}`);
}
}
}
}
function assertNever(_value: never, reason: string): never {
throw new Error(reason);
}
/**
* This is where we can define our rules.
*/
const RULES: AuthorizationRules = {
lotsOfEntities: allowAdmin,
getSomeEntity: allowNone,
/**
* Role check by composition, this is where we use our "can" function
*/
getPost: requireOne(
allowAdmin,
communityAuthorizor(
async (gateway, postId) => {
const post = await gateway.getPost(postId)
if (post == null) {
throw new Error('This is a 404, return null instead? ...');
}
return [post.communityId, post.authorId];
},
async (_gateway, _role, _postId, _communityId, _ownerId) => {
return false;
}
)
),
};
function createAuthorizedGateway(entityGateway: EntityGateway, role: Role, rules = RULES): AuthorizedEntityGateway {
const boundGateway = bindEntityGateway(entityGateway, role);
return {
lotsOfEntities: boundGateway(entityGateway.lotsOfEntities, rules.lotsOfEntities),
getSomeEntity: boundGateway(entityGateway.getSomeEntity, rules.getSomeEntity),
getPost: boundGateway(entityGateway.getPost, rules.getPost)
};
}
function bindEntityGateway(gateway: EntityGateway, role: Role) {
return <A extends unknown[], R>(method: (...args: A) => Promise<R>, authorizor: Authorizor<A>): AuthorizedEntityMethod<A, R> => {
return async (...args) => {
const authorized = await authorizor(gateway, role, ...args);
if (authorized) {
return method.apply(gateway, args).then(result => ({ authorized: true, result, role }));
} else {
return Promise.resolve({ authorized: false, reason: new UnauthorizedError('not special enough')})
}
}
}
}
const authorizedGateway = createAuthorizedGateway(gateway, { roleName: 'Admin'});
console.log('go');
// no top level async/await
const things = authorizedGateway.lotsOfEntities('some-arg').then(
(result) => {
if (result.authorized) {
// the entitygateway return vaule
const things = result.result;
console.log('got them things', things);
} else {
// If I'm a GraphQL Resolver, I know what to do for my scenario
console.log('Not authorized', result.reason);
}
},
(error) => console.log('oops', error)
);
console.log('done');
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment