Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nestjs-query): effective authorizer on websocket connections #1160

2 changes: 1 addition & 1 deletion packages/query-graphql/src/auth/authorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface AuthorizationContext {

export interface Authorizer<DTO> {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any
authorize(context: any, authorizerContext: AuthorizationContext): Promise<Filter<DTO>>;
authorize(context: any, authorizerContext: AuthorizationContext): Promise<Filter<DTO>> | Promise<Filter<DTO>[]>;

authorizeRelation(
relationName: string,
Expand Down
49 changes: 35 additions & 14 deletions packages/query-graphql/src/resolvers/create.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
SubscriptionArgsType,
SubscriptionFilterInputType,
} from '../types';
import { createSubscriptionFilter } from './helpers';
import { createSubscriptionFilter, getUniqueNameForEvent } from './helpers';
import { BaseServiceResolver, ResolverClass, ServiceResolver, SubscriptionResolverOpts } from './resolver.interface';
import { OperationGroup } from '../auth';

Expand Down Expand Up @@ -80,9 +80,10 @@ const defaultCreateManyInput = <C>(dtoNames: DTONames, InputDTO: Class<C>): Clas
* @internal
* Mixin to add `create` graphql endpoints.
*/
export const Creatable =
<DTO, C, QS extends QueryService<DTO, C, unknown>>(DTOClass: Class<DTO>, opts: CreateResolverOpts<DTO, C>) =>
<B extends Class<ServiceResolver<DTO, QS>>>(BaseClass: B): Class<CreateResolver<DTO, C, QS>> & B => {
export const Creatable = <DTO, C, QS extends QueryService<DTO, C, unknown>>(
DTOClass: Class<DTO>,
opts: CreateResolverOpts<DTO, C>,
) => <B extends Class<ServiceResolver<DTO, QS>>>(BaseClass: B): Class<CreateResolver<DTO, C, QS>> & B => {
const dtoNames = getDTONames(DTOClass, opts);
const { baseName, pluralBaseName } = dtoNames;
const enableSubscriptions = opts.enableSubscriptions === true;
Expand Down Expand Up @@ -146,7 +147,7 @@ export const Creatable =
// Ignore `authorizeFilter` for now but give users the ability to throw an UnauthorizedException
const created = await this.service.createOne(input.input.input);
if (enableOneSubscriptions) {
await this.publishCreatedEvent(created);
await this.publishCreatedEvent(created, authorizeFilter);
}
return created;
}
Expand All @@ -170,36 +171,56 @@ export const Creatable =
operationGroup: OperationGroup.CREATE,
many: true,
}) // eslint-disable-next-line @typescript-eslint/no-unused-vars
authorizeFilter?: Filter<DTO>,
authorizeFilter?: Filter<DTO> | Filter<DTO>[],
): Promise<DTO[]> {
// Ignore `authorizeFilter` for now but give users the ability to throw an UnauthorizedException
const created = await this.service.createMany(input.input.input);
if (enableManySubscriptions) {
await Promise.all(created.map((c) => this.publishCreatedEvent(c)));
if (Array.isArray(authorizeFilter)) {
let i = -1;
await Promise.all(
created.map((c) => {
i += 1;
return this.publishCreatedEvent(c, authorizeFilter[i]);
}),
);
} else {
await Promise.all(created.map((c) => this.publishCreatedEvent(c, authorizeFilter)));
}
}
return created;
}

async publishCreatedEvent(dto: DTO): Promise<void> {
async publishCreatedEvent(dto: DTO, authorizeFilter?: Filter<DTO>): Promise<void> {
if (this.pubSub) {
await this.pubSub.publish(createdEvent, { [createdEvent]: dto });
const eventName = authorizeFilter != null ? getUniqueNameForEvent(createdEvent, authorizeFilter) : createdEvent;
await this.pubSub.publish(eventName, { [createdEvent]: dto });
}
}

@ResolverSubscription(() => DTOClass, { name: createdEvent, filter: subscriptionFilter }, commonResolverOpts, {
enableSubscriptions: enableOneSubscriptions || enableManySubscriptions,
interceptors: [AuthorizerInterceptor(DTOClass)],
})
// input required so graphql subscription filtering will work.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
createdSubscription(@Args() input?: SA): AsyncIterator<CreatedEvent<DTO>> {
createdSubscription(
@Args() input?: SA,
@AuthorizerFilter({
operationGroup: OperationGroup.CREATE,
operationName: 'onCreateOne',
many: false,
})
authorizeFilter?: Filter<DTO>,
): AsyncIterator<CreatedEvent<DTO>> {
if (!this.pubSub || !(enableManySubscriptions || enableOneSubscriptions)) {
throw new Error(`Unable to subscribe to ${createdEvent}`);
}
return this.pubSub.asyncIterator<CreatedEvent<DTO>>(createdEvent);

const eventName = authorizeFilter != null ? getUniqueNameForEvent(createdEvent, authorizeFilter) : createdEvent;
return this.pubSub.asyncIterator<CreatedEvent<DTO>>(eventName);
}
}
return CreateResolverBase;
};
};

/**
* Factory to create a new abstract class that can be extended to add `create` endpoints.
Expand Down
65 changes: 45 additions & 20 deletions packages/query-graphql/src/resolvers/delete.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
SubscriptionFilterInputType,
} from '../types';
import { MutationHookArgs, ResolverMutation, ResolverSubscription, AuthorizerFilter } from '../decorators';
import { createSubscriptionFilter } from './helpers';
import { createSubscriptionFilter, getUniqueNameForEvent } from './helpers';
import { AuthorizerInterceptor, HookInterceptor } from '../interceptors';
import { OperationGroup } from '../auth';

Expand All @@ -39,9 +39,12 @@ export interface DeleteResolver<DTO, QS extends QueryService<DTO, unknown, unkno
authorizeFilter?: Filter<DTO>,
): Promise<DeleteManyResponse>;

deletedOneSubscription(input?: SubscriptionArgsType<DTO>): AsyncIterator<DeletedEvent<Partial<DTO>>>;
deletedOneSubscription(
input?: SubscriptionArgsType<DTO>,
authorizeFilter?: Filter<DTO>,
): AsyncIterator<DeletedEvent<Partial<DTO>>>;

deletedManySubscription(): AsyncIterator<DeletedEvent<DeleteManyResponse>>;
deletedManySubscription(authorizeFilter?: Filter<DTO>): AsyncIterator<DeletedEvent<DeleteManyResponse>>;
}

/** @internal */
Expand All @@ -64,9 +67,10 @@ const defaultDeleteOneInput = <DTO>(dtoNames: DTONames, DTOClass: Class<DTO>): C
* @internal
* Mixin to add `delete` graphql endpoints.
*/
export const Deletable =
<DTO, QS extends QueryService<DTO, unknown, unknown>>(DTOClass: Class<DTO>, opts: DeleteResolverOpts<DTO>) =>
<B extends Class<ServiceResolver<DTO, QS>>>(BaseClass: B): Class<DeleteResolver<DTO, QS>> & B => {
export const Deletable = <DTO, QS extends QueryService<DTO, unknown, unknown>>(
DTOClass: Class<DTO>,
opts: DeleteResolverOpts<DTO>,
) => <B extends Class<ServiceResolver<DTO, QS>>>(BaseClass: B): Class<DeleteResolver<DTO, QS>> & B => {
const dtoNames = getDTONames(DTOClass, opts);
const { baseName, pluralBaseName } = dtoNames;
const enableSubscriptions = opts.enableSubscriptions === true;
Expand Down Expand Up @@ -123,7 +127,7 @@ export const Deletable =
): Promise<Partial<DTO>> {
const deletedResponse = await this.service.deleteOne(input.input.id, { filter: authorizeFilter ?? {} });
if (enableOneSubscriptions) {
await this.publishDeletedOneEvent(deletedResponse);
await this.publishDeletedOneEvent(deletedResponse, authorizeFilter);
}
return deletedResponse;
}
Expand All @@ -143,24 +147,26 @@ export const Deletable =
})
authorizeFilter?: Filter<DTO>,
): Promise<DeleteManyResponse> {
const deleteManyResponse = await this.service.deleteMany(
mergeFilter(input.input.filter, authorizeFilter ?? {}),
);
const deleteManyResponse = await this.service.deleteMany(mergeFilter(input.input.filter, authorizeFilter ?? {}));
if (enableManySubscriptions) {
await this.publishDeletedManyEvent(deleteManyResponse);
await this.publishDeletedManyEvent(deleteManyResponse, authorizeFilter);
}
return deleteManyResponse;
}

async publishDeletedOneEvent(dto: DeleteOneResponse): Promise<void> {
async publishDeletedOneEvent(dto: DeleteOneResponse, authorizeFilter?: Filter<DTO>): Promise<void> {
if (this.pubSub) {
await this.pubSub.publish(deletedOneEvent, { [deletedOneEvent]: dto });
const eventName =
authorizeFilter != null ? getUniqueNameForEvent(deletedOneEvent, authorizeFilter) : deletedOneEvent;
await this.pubSub.publish(eventName, { [deletedOneEvent]: dto });
}
}

async publishDeletedManyEvent(dmr: DeleteManyResponse): Promise<void> {
async publishDeletedManyEvent(dmr: DeleteManyResponse, authorizeFilter?: Filter<DTO>): Promise<void> {
if (this.pubSub) {
await this.pubSub.publish(deletedManyEvent, { [deletedManyEvent]: dmr });
const eventName =
authorizeFilter != null ? getUniqueNameForEvent(deletedManyEvent, authorizeFilter) : deletedManyEvent;
await this.pubSub.publish(eventName, { [deletedManyEvent]: dmr });
}
}

Expand All @@ -174,25 +180,44 @@ export const Deletable =
)
// input required so graphql subscription filtering will work.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
deletedOneSubscription(@Args() input?: DOSA): AsyncIterator<DeletedEvent<DeleteOneResponse>> {
deletedOneSubscription(
@Args() input?: DOSA,
@AuthorizerFilter({
operationGroup: OperationGroup.DELETE,
operationName: 'onDeleteOne',
many: false,
})
authorizeFilter?: Filter<DTO>,
): AsyncIterator<DeletedEvent<DeleteOneResponse>> {
if (!enableOneSubscriptions || !this.pubSub) {
throw new Error(`Unable to subscribe to ${deletedOneEvent}`);
}
return this.pubSub.asyncIterator(deletedOneEvent);
const eventName =
authorizeFilter != null ? getUniqueNameForEvent(deletedOneEvent, authorizeFilter) : deletedOneEvent;
return this.pubSub.asyncIterator(eventName);
}

@ResolverSubscription(() => DMR, { name: deletedManyEvent }, commonResolverOpts, {
enableSubscriptions: enableManySubscriptions,
})
deletedManySubscription(): AsyncIterator<DeletedEvent<DeleteManyResponse>> {
deletedManySubscription(
@AuthorizerFilter({
operationGroup: OperationGroup.DELETE,
operationName: 'onDeleteMany',
many: true,
})
authorizeFilter?: Filter<DTO>,
): AsyncIterator<DeletedEvent<DeleteManyResponse>> {
if (!enableManySubscriptions || !this.pubSub) {
throw new Error(`Unable to subscribe to ${deletedManyEvent}`);
}
return this.pubSub.asyncIterator(deletedManyEvent);
const eventName =
authorizeFilter != null ? getUniqueNameForEvent(deletedManyEvent, authorizeFilter) : deletedManyEvent;
return this.pubSub.asyncIterator(eventName);
}
}
return DeleteResolverBase;
};
};
// eslint-disable-next-line @typescript-eslint/no-redeclare -- intentional
export const DeleteResolver = <
DTO,
Expand Down
17 changes: 16 additions & 1 deletion packages/query-graphql/src/resolvers/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { applyFilter, Class } from '@nestjs-query/core';
import { applyFilter, Class, Filter } from '@nestjs-query/core';
import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator';
import { BadRequestException } from '@nestjs/common';
Expand Down Expand Up @@ -34,3 +34,18 @@ export const createSubscriptionFilter =
}
return true;
};

export function flattenFilter<T>(o: Filter<T> | Filter<T>[] | undefined, prefix = ''): any {
if (Array.isArray(o)) {
const reduced = o.reduce((current, item) => `${current}${current ? '-' : ''}${flattenFilter(item)}`, '');
return `${prefix}${reduced}`;
} else {
return Object.entries(o as Filter<T>).flatMap(([k, v]) =>
Object(v) === v ? flattenFilter(v, `${prefix}${k}-`) : `${prefix}${k}-${v}`,
).join('-');
}
}

export function getUniqueNameForEvent<T>(eventName: string, authorizeFilter: Filter<T>): string {
return `${eventName}-${flattenFilter(authorizeFilter) as string}`;
}
60 changes: 43 additions & 17 deletions packages/query-graphql/src/resolvers/update.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
} from '../types';
import { BaseServiceResolver, ResolverClass, ServiceResolver, SubscriptionResolverOpts } from './resolver.interface';
import { AuthorizerFilter, MutationHookArgs, ResolverMutation, ResolverSubscription } from '../decorators';
import { createSubscriptionFilter } from './helpers';
import { createSubscriptionFilter, getUniqueNameForEvent } from './helpers';
import { AuthorizerInterceptor, HookInterceptor } from '../interceptors';
import { OperationGroup } from '../auth';

Expand All @@ -42,9 +42,9 @@ export interface UpdateResolver<DTO, U, QS extends QueryService<DTO, unknown, U>
authFilter?: Filter<DTO>,
): Promise<UpdateManyResponse>;

updatedOneSubscription(input?: SubscriptionArgsType<DTO>): AsyncIterator<UpdatedEvent<DTO>>;
updatedOneSubscription(input?: SubscriptionArgsType<DTO>, authFilter?: Filter<DTO>): AsyncIterator<UpdatedEvent<DTO>>;

updatedManySubscription(): AsyncIterator<UpdatedEvent<DeleteManyResponse>>;
updatedManySubscription(authFilter?: Filter<DTO>): AsyncIterator<UpdatedEvent<DeleteManyResponse>>;
}

/** @internal */
Expand Down Expand Up @@ -89,9 +89,10 @@ const defaultUpdateManyInput = <DTO, U>(
* @internal
* Mixin to add `update` graphql endpoints.
*/
export const Updateable =
<DTO, U, QS extends QueryService<DTO, unknown, U>>(DTOClass: Class<DTO>, opts: UpdateResolverOpts<DTO, U>) =>
<B extends Class<ServiceResolver<DTO, QS>>>(BaseClass: B): Class<UpdateResolver<DTO, U, QS>> & B => {
export const Updateable = <DTO, U, QS extends QueryService<DTO, unknown, U>>(
DTOClass: Class<DTO>,
opts: UpdateResolverOpts<DTO, U>,
) => <B extends Class<ServiceResolver<DTO, QS>>>(BaseClass: B): Class<UpdateResolver<DTO, U, QS>> & B => {
const dtoNames = getDTONames(DTOClass, opts);
const { baseName, pluralBaseName } = dtoNames;
const UMR = UpdateManyResponseType();
Expand Down Expand Up @@ -157,7 +158,7 @@ export const Updateable =
const { id, update } = input.input;
const updateResult = await this.service.updateOne(id, update, { filter: authorizeFilter ?? {} });
if (enableOneSubscriptions) {
await this.publishUpdatedOneEvent(updateResult);
await this.publishUpdatedOneEvent(updateResult, authorizeFilter);
}
return updateResult;
}
Expand Down Expand Up @@ -185,20 +186,24 @@ export const Updateable =
const { update, filter } = input.input;
const updateManyResponse = await this.service.updateMany(update, mergeFilter(filter, authorizeFilter ?? {}));
if (enableManySubscriptions) {
await this.publishUpdatedManyEvent(updateManyResponse);
await this.publishUpdatedManyEvent(updateManyResponse, authorizeFilter);
}
return updateManyResponse;
}

async publishUpdatedOneEvent(dto: DTO): Promise<void> {
async publishUpdatedOneEvent(dto: DTO, authorizeFilter?: Filter<DTO>): Promise<void> {
if (this.pubSub) {
await this.pubSub.publish(updateOneEvent, { [updateOneEvent]: dto });
const eventName =
authorizeFilter != null ? getUniqueNameForEvent(updateOneEvent, authorizeFilter) : updateOneEvent;
await this.pubSub.publish(eventName, { [updateOneEvent]: dto });
}
}

async publishUpdatedManyEvent(umr: UpdateManyResponse): Promise<void> {
async publishUpdatedManyEvent(umr: UpdateManyResponse, authorizeFilter?: Filter<DTO>): Promise<void> {
if (this.pubSub) {
await this.pubSub.publish(updateManyEvent, { [updateManyEvent]: umr });
const eventName =
authorizeFilter != null ? getUniqueNameForEvent(updateManyEvent, authorizeFilter) : updateManyEvent;
await this.pubSub.publish(eventName, { [updateManyEvent]: umr });
}
}

Expand All @@ -208,30 +213,51 @@ export const Updateable =
commonResolverOpts,
{
enableSubscriptions: enableOneSubscriptions,
interceptors: [AuthorizerInterceptor(DTOClass)],
},
)
// input required so graphql subscription filtering will work.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
updatedOneSubscription(@Args() input?: UOSA): AsyncIterator<UpdatedEvent<DTO>> {
updatedOneSubscription(
@Args() input?: UOSA,
@AuthorizerFilter({
operationGroup: OperationGroup.UPDATE,
operationName: 'onUpdateOne',
many: false,
})
authorizeFilter?: Filter<DTO>,
): AsyncIterator<UpdatedEvent<DTO>> {
if (!enableOneSubscriptions || !this.pubSub) {
throw new Error(`Unable to subscribe to ${updateOneEvent}`);
}
return this.pubSub.asyncIterator(updateOneEvent);
const eventName =
authorizeFilter != null ? getUniqueNameForEvent(updateOneEvent, authorizeFilter) : updateOneEvent;
return this.pubSub.asyncIterator(eventName);
}

@ResolverSubscription(() => UMR, { name: updateManyEvent }, commonResolverOpts, {
enableSubscriptions: enableManySubscriptions,
interceptors: [AuthorizerInterceptor(DTOClass)],
})
updatedManySubscription(): AsyncIterator<UpdatedEvent<DeleteManyResponse>> {
updatedManySubscription(
@AuthorizerFilter({
operationGroup: OperationGroup.UPDATE,
operationName: 'onUpdateMany',
many: true,
})
authorizeFilter?: Filter<DTO>,
): AsyncIterator<UpdatedEvent<DeleteManyResponse>> {
if (!enableManySubscriptions || !this.pubSub) {
throw new Error(`Unable to subscribe to ${updateManyEvent}`);
}
return this.pubSub.asyncIterator(updateManyEvent);
const eventName =
authorizeFilter != null ? getUniqueNameForEvent(updateManyEvent, authorizeFilter) : updateManyEvent;
return this.pubSub.asyncIterator(eventName);
}
}

return UpdateResolverBase;
};
};
// eslint-disable-next-line @typescript-eslint/no-redeclare -- intentional
export const UpdateResolver = <
DTO,
Expand Down
Loading