Last active
September 21, 2020 23:26
-
-
Save beaucollins/b39a1e5d3bc1f2b3462291cbc3eab4cf to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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