Skip to content

Instantly share code, notes, and snippets.

@TomMarius
Created February 25, 2020 15:53
Show Gist options
  • Select an option

  • Save TomMarius/4e32706db75c6795cda91fa5db6c876c to your computer and use it in GitHub Desktop.

Select an option

Save TomMarius/4e32706db75c6795cda91fa5db6c876c to your computer and use it in GitHub Desktop.
import 'reflect-metadata'
import { __decorate, __param, __metadata } from 'tslib'
import { ApolloServer, AuthenticationError, ForbiddenError } from 'apollo-server'
import {
EntityManager,
createConnection,
Entity,
PrimaryGeneratedColumn,
CreateDateColumn,
Column,
Index,
Brackets,
} from 'typeorm'
import {
Resolver,
FieldResolver,
Query,
Mutation,
ID,
Root,
InputType,
Field,
buildSchema,
Ctx,
ObjectType,
Arg,
ClassType,
} from 'type-graphql'
import { createHash } from 'crypto'
export enum CRUD {
Create,
RetrieveList,
RetrieveByID,
UpdateByID,
PatchByID,
DeleteByID,
// TODO fulltext search (also need to make one that is not bound to a single entity...
// or maybe an entity should be a root but it should be able to search in related entities as well?)
}
export const Primitives: { [key: string]: any } = {
string: String,
number: Number,
boolean: Boolean,
bool: Boolean,
text: String,
numeric: String,
}
export interface AuthObject {
readTokenOrThrow: (token: string) => Promise<{ userID: string }>
isTokenInvalidated: (token: string) => Promise<string>
getUserOrThrow: (userID: string) => Promise<any>
}
export interface AuthFuncs {
canUserReadField: (user: any, entity: any, key: string) => Promise<boolean>
canUserWriteField: (user: any, entity: any, key: string, value: any) => Promise<boolean>
canUserCreateEntity: (user: any, input: any) => Promise<boolean>
canUserListEntities: (user: any) => Promise<boolean>
canUserRetrieveEntity: (user: any, entity: any) => Promise<boolean>
canUserUpdateEntity: (user: any, entity: any, input: any) => Promise<boolean>
canUserDeleteEntity: (user: any, entity: any) => Promise<boolean>
}
export enum PermissionKind {
Read,
Write,
Create,
Retrieve,
List,
Update,
Delete,
All,
}
@Entity()
export class Permission {
@PrimaryGeneratedColumn('uuid')
id!: string
@CreateDateColumn()
createdAt!: Date
@Column('timestamp without time zone', { nullable: true })
deletedAt?: Date
@Index()
@Column('text')
roleID?: string
@Index()
@Column('text')
entityName!: string
@Index()
@Column('text', { nullable: true })
entityID!: string // limits permission to specific entity
@Index()
@Column('text', { nullable: true })
fieldName?: string
@Index()
@Column({
type: 'enum',
enum: PermissionKind,
})
kind!: PermissionKind
}
export function EntityResolver<T>(
entityManager: EntityManager,
constructor: ClassType,
options: {
tokenContextSelector: string
softDeleteSelector?: string
softDelete?: boolean
crud: CRUD[]
auth?: (AuthObject | undefined) & AuthFuncs
noAuth?: boolean
},
) {
const softDeleteSelector = options.softDeleteSelector
const tokenContextSelector = options.tokenContextSelector
// declare output class
@Resolver(() => constructor)
class Output {}
// obtain repository
const entityRepository = entityManager.getRepository<T>(constructor)
// entity metadata
const entityMetadata = entityRepository.metadata
const entityName = entityMetadata.name
// combine default auth checkers with custom ones
const permissionRepository = entityManager.getRepository(Permission)
// authentication
async function verifyTokenAndRetrieveUserOrThrow(token: string) {
// possibilities:
// - Ani zvalid
// - token is expired
// - token is invalidated
// - user does not exist
// - user is (soft) deleted
// - user is disabled
// 1) decrypt the token, fail if expired or otherwise invalid using the readTokenOrThrow
// 2) check if the token is invalidated using the isTokenInvalidated function
// 3) try to pull the user using the getUserOrThrow function
let message = ''
try {
message = 'Token is invalid'
const { userID } = await auth.readTokenOrThrow(token)
if (await auth.isTokenInvalidated(token)) {
message = 'Token is invalidated'
throw new AuthenticationError(message)
}
message = 'User not found'
const user = await auth.getUserOrThrow(userID)
return user
} catch (e) {
throw new AuthenticationError(message)
}
}
async function checkUserHasPermission(
token: string,
entity: any,
fieldName: string | null,
permissionKinds: PermissionKind[],
) {
if (options.noAuth) {
return true
}
const user = await verifyTokenAndRetrieveUserOrThrow(token)
const query = permissionRepository
.createQueryBuilder('permission')
.where('permission.deletedAt IS NULL')
.andWhere('permission.entityName = :entityName', { entityName })
.andWhere(
new Brackets((qb1) => {
// where
qb1.where('permission.entityID IS NULL').orWhere('permission.entityID = :entityID', {
entityID: entity.id,
})
}),
)
.andWhere(
new Brackets((qb1) => {
qb1.where('permission.userID = :userID', { userID: user.id }).orWhere('TODO groups')
}),
)
.andWhere('permission.kind IN (:...kinds)', {
kinds: permissionKinds,
})
// if there is fieldName in the permission row, add to conditions
if (fieldName != null) {
query.andWhere('permission.fieldName = :fieldName', { fieldName })
}
const permissionsCount = await query.getCount()
if (permissionsCount > 0) {
return true
}
return false
}
const auth: AuthObject & AuthFuncs = {
canUserReadField: async (user, entity, key) => checkUserHasPermission(user, entity, key, [PermissionKind.Read]),
canUserWriteField: async (user, entity, key) =>
checkUserHasPermission(user, entity, key, [PermissionKind.Write]),
canUserCreateEntity: async (user, input) => checkUserHasPermission(user, input, null, [PermissionKind.Create]),
canUserListEntities: async (user) => checkUserHasPermission(user, null, null, [PermissionKind.List]),
canUserRetrieveEntity: async (user, entity) =>
checkUserHasPermission(user, entity, null, [PermissionKind.Retrieve]),
canUserUpdateEntity: async (user, entity, input) =>
checkUserHasPermission(user, entity, null, [PermissionKind.Update]),
canUserDeleteEntity: async (user, entity) =>
checkUserHasPermission(user, entity, null, [PermissionKind.Delete]),
...options.auth!,
}
async function assertAuth(func: Function, error: string, ...args: any[]) {
if (await func(...args)) {
return true
}
throw new ForbiddenError(`Permission not found`)
}
// create field resolvers
for (const lazyRelation of entityMetadata.lazyRelations) {
let type: any = lazyRelation.type
if (typeof type === 'string') {
type = Primitives[type]
}
// turn type to TypeGraphQL array descriptor if necessary
if (lazyRelation.isManyToOne || lazyRelation.isManyToMany) {
type = [type]
}
// define field resolver property and decorate it
Object.defineProperty(
Output.prototype,
lazyRelation.propertyName,
__decorate(
[FieldResolver(() => type), __param(0, Ctx()), __param(1, Root())],
Output.prototype,
lazyRelation.propertyName,
{
value: async function(context: any, root: any) {
await assertAuth(
auth.canUserReadField,
`Can't read field ${lazyRelation.propertyName}`,
context[tokenContextSelector],
root,
lazyRelation.propertyName,
)
return root[lazyRelation.propertyName]
},
},
),
)
}
// create CRUD methods
const inputTypes: { [key: number]: ClassType } = {}
// create mutation
if (options.crud.includes(CRUD.Create)) {
// declare input type
@InputType()
class CreateInput {}
inputTypes[CRUD.Create] = CreateInput
// add columns to input type
for (const ownColumn of entityMetadata.columns) {
// if the field is generated, skip it
if (ownColumn.isGenerated || ownColumn.isCreateDate || ownColumn.isUpdateDate) {
continue
}
// determine field type
let type: any = ownColumn.type
if (typeof type === 'string') {
type = Primitives[type]
}
if (ownColumn.isArray) {
type = [type]
}
// determine field options
const options: any = {}
// if the column has a default value, make it nullable
if (ownColumn.default != null) {
options.nullable = true
}
// if the column is nullable, make it nullable
if (ownColumn.isNullable) {
options.nullable = true
}
// add the property to CreateInput and decorate it
Object.defineProperty(
CreateInput.prototype,
ownColumn.propertyName,
__decorate([Field(() => type, options)], CreateInput.prototype, ownColumn.propertyName, {
writable: true,
}),
)
}
// add owned relations (ID stand-ins) to input type
for (const ownedRelation of entityMetadata.relationsWithJoinColumns) {
const options: any = {}
// if the column is nullable, make it nullable
if (ownedRelation.isNullable) {
options.nullable = true
}
// add the property to CreateInput and decorate it
Object.defineProperty(
CreateInput.prototype,
`${ownedRelation.propertyName}ID`,
__decorate([Field(() => String, options)], CreateInput.prototype, `${ownedRelation.propertyName}ID`, {
writable: true,
}),
)
}
// add create method to Output
Object.defineProperty(
Output.prototype,
`create${entityName}`,
__decorate(
[
Mutation(() => constructor),
__param(0, Ctx()),
__param(1, Arg('input')),
__metadata('design:paramtypes', [undefined, CreateInput]),
],
Output.prototype,
`create${entityName}`,
{
value: async function(context: any, input: CreateInput) {
await assertAuth(
auth.canUserCreateEntity,
`Can't create entity ${entityName}`,
context[tokenContextSelector],
input,
)
const entity = entityRepository.create()
// TODO validations here
// handle columns
for (const columns of entityMetadata.columns) {
;(entity as any)[columns.propertyName] = (input as any)[columns.propertyName]
}
// handle owned relations
for (const ownedRelation of entityMetadata.relationsWithJoinColumns) {
const promise = entityManager
.getRepository(ownedRelation.type)
.findOneOrFail((input as any)[`${ownedRelation.propertyName}ID`])
;(entity as any)[ownedRelation.propertyName] = promise
}
// TODO audit here
await entityRepository.save(entity)
return entity
},
},
),
)
}
// retrieve list query
if (options.crud.includes(CRUD.RetrieveList)) {
Object.defineProperty(
Output.prototype,
`retrieve${entityName}List`,
__decorate([Query(() => [constructor]), __param(0, Ctx())], Output.prototype, `retrieve${entityName}List`, {
value: async function(context: any) {
await assertAuth(
auth.canUserListEntities,
`Can't list entities ${entityName}`,
context[tokenContextSelector],
)
// TODO pagination, filters, ...
return entityRepository.find()
},
}),
)
}
// retrieve by ID query
if (options.crud.includes(CRUD.RetrieveByID)) {
Object.defineProperty(
Output.prototype,
`retrieve${entityName}ByID`,
__decorate(
[
Query(() => constructor),
__param(0, Ctx()),
__param(1, Arg('id')),
__metadata('design:paramtypes', [undefined, String]),
],
Output.prototype,
`retrieve${entityName}ByID`,
{
value: async function(context: any, id: string) {
const entity = await entityRepository.findOneOrFail(id)
await assertAuth(
auth.canUserRetrieveEntity,
`Can't retrieve entity ${entityName}`,
context[tokenContextSelector],
entity,
)
return entity
},
},
),
)
}
// update by ID mutation
if (options.crud.includes(CRUD.UpdateByID)) {
// declare input type
@InputType()
class UpdateInput {}
inputTypes[CRUD.UpdateByID] = UpdateInput
// add columns to input type
for (const ownColumn of entityMetadata.columns) {
// if the field is generated, skip it
if (ownColumn.isGenerated || ownColumn.isCreateDate || ownColumn.isUpdateDate) {
continue
}
// determine field type
let type: any = ownColumn.type
if (typeof type === 'string') {
type = Primitives[type]
}
if (ownColumn.isArray) {
type = [type]
}
// determine field options
const options = { nullable: true }
// add the property to CreateInput and decorate it
Object.defineProperty(
UpdateInput.prototype,
ownColumn.propertyName,
__decorate([Field(() => type as any, options)], UpdateInput.prototype, ownColumn.propertyName, {
writable: true,
}),
)
}
// add owned relations (ID stand-ins) to input type
for (const ownedRelation of entityMetadata.relationsWithJoinColumns) {
const options = { nullable: true }
// add the property to the input and decorate it
Object.defineProperty(
UpdateInput.prototype,
`${ownedRelation.propertyName}ID`,
__decorate([Field(() => String, options)], UpdateInput.prototype, `${ownedRelation.propertyName}ID`, {
writable: true,
}),
)
}
// add update method to Output
Object.defineProperty(
Output.prototype,
`update${entityName}ByID`,
__decorate(
[
Mutation(() => constructor),
__param(0, Ctx()),
__param(1, Arg('id')),
__param(2, Arg('input')),
__metadata('design:paramtypes', [undefined, String, UpdateInput]),
],
Output.prototype,
`update${entityName}ByID`,
{
value: async function(context: any, id: string, input: UpdateInput) {
const entity = await entityRepository.findOneOrFail(id)
await assertAuth(
auth.canUserUpdateEntity,
`Can't update ${entityName}`,
context[tokenContextSelector],
entity,
input,
)
// TODO validations here
// TODO await assert auth - can write here
// handle columns
for (const column of entityMetadata.columns) {
if ((input as any)[column.propertyName] !== undefined) {
;(entity as any)[column.propertyName] = (input as any)[column.propertyName]
}
}
// handle owned relations
for (const ownedRelation of entityMetadata.relationsWithJoinColumns) {
if ((input as any)[`${ownedRelation.propertyName}ID`] !== undefined) {
;(entity as any)[`${ownedRelation.propertyName}ID`] = (input as any)[
`${ownedRelation.propertyName}ID`
]
}
const promise = entityManager
.getRepository(ownedRelation.type)
.findOneOrFail((input as any)[`${ownedRelation.propertyName}ID`])
;(entity as any)[ownedRelation.propertyName] = promise
}
// TODO audit here
await entityRepository.save(entity)
return entity
},
},
),
)
}
// delete by ID mutation
if (options.crud.includes(CRUD.DeleteByID)) {
let mutation: Function
if (options.softDelete) {
mutation = async function(context: any, id: string) {
const entity: any = await entityRepository.findOneOrFail(id)
await assertAuth(
auth.canUserDeleteEntity,
`Can't delete entity ${entityName}`,
context[tokenContextSelector],
entity,
)
entity[softDeleteSelector!] = new Date()
await entityRepository.save(entity)
return true
}
} else {
mutation = async function(context: any, id: string) {
const entity = await entityRepository.findOneOrFail(id)
await assertAuth(
auth.canUserDeleteEntity,
`Can't delete entity ${entityName}`,
context[tokenContextSelector],
entity,
)
await entityRepository.delete(id)
return true
}
}
Object.defineProperty(
Output.prototype,
`delete${entityName}ByID`,
__decorate(
[
Mutation(() => Boolean),
__param(0, Ctx()),
__param(1, Arg('id')),
__metadata('design:paramtypes', [undefined, String]),
],
Output.prototype,
`delete${entityName}ByID`,
{
value: mutation,
},
),
)
}
return Output
}
@ObjectType()
@Entity()
class Test {
@Field(() => ID)
@PrimaryGeneratedColumn('uuid')
id!: string
@Field(() => Date)
@CreateDateColumn({ type: 'timestamp without time zone' })
createdAt!: string
@Field(() => String)
@Column('text')
myField!: string
@Field(() => String, { nullable: true })
@Column('text', { nullable: true })
myNullableField!: string
}
async function main() {
try {
const connection = await createConnection({
type: 'postgres',
username: 'postgres',
password: 'postgres',
database: 'digitaldevmarket',
entities: [Permission, Test],
synchronize: true,
dropSchema: true,
})
const em = connection.createEntityManager()
// @EntityResolver(Test, {...})
// @StateMachineResolver(Test, {...})
@Resolver(() => Test)
class TestResolver extends EntityResolver(em, Test, {
crud: [CRUD.Create, CRUD.RetrieveByID, CRUD.UpdateByID, CRUD.RetrieveList, CRUD.DeleteByID],
softDelete: false,
tokenContextSelector: 'token',
noAuth: true,
}) {}
const schema = await buildSchema({
resolvers: [TestResolver],
})
const server = new ApolloServer({
schema,
playground: true,
})
// Start the server
const { url } = await server.listen(1337)
console.log('URL', url)
} catch (e) {
console.dir(e)
}
}
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment