Skip to content

Commit

Permalink
Support authentication on root custom resolver fields (neo4j#4816)
Browse files Browse the repository at this point in the history
  • Loading branch information
a-alle authored Mar 7, 2024
1 parent 720a5ee commit 6b547dd
Show file tree
Hide file tree
Showing 13 changed files with 689 additions and 69 deletions.
5 changes: 5 additions & 0 deletions .changeset/chilly-panthers-love.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@neo4j/graphql": minor
---

Adds support for the `@authentication` directive on custom resolved fields of root types Query and Mutation
31 changes: 19 additions & 12 deletions packages/graphql/src/classes/Neo4jGraphQL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { getDefinitionNodes } from "../schema/get-definition-nodes";
import { makeDocumentToAugment } from "../schema/make-document-to-augment";
import type { WrapResolverArguments } from "../schema/resolvers/composition/wrap-query-and-mutation";
import { wrapQueryAndMutation } from "../schema/resolvers/composition/wrap-query-and-mutation";
import { wrapSubscription } from "../schema/resolvers/composition/wrap-subscription";
import { wrapSubscription, type WrapSubscriptionArgs } from "../schema/resolvers/composition/wrap-subscription";
import { defaultFieldResolver } from "../schema/resolvers/field/defaultField";
import { validateDocument } from "../schema/validation";
import { validateUserDefinition } from "../schema/validation/schema-validation";
Expand All @@ -50,6 +50,7 @@ import { Neo4jGraphQLSubscriptionsDefaultEngine } from "./subscription/Neo4jGrap
import type { AssertIndexesAndConstraintsOptions } from "./utils/asserts-indexes-and-constraints";
import { assertIndexesAndConstraints } from "./utils/asserts-indexes-and-constraints";
import checkNeo4jCompat from "./utils/verify-database";
import { generateResolverComposition } from "./utils/generate-resolvers-composition";

type TypeDefinitions = string | DocumentNode | TypeDefinitions[] | (() => TypeDefinitions);

Expand Down Expand Up @@ -283,22 +284,28 @@ class Neo4jGraphQL {
authorization: this.authorization,
jwtPayloadFieldsMap: this.jwtFieldsMap,
};
const queryAndMutationWrappers = [wrapQueryAndMutation(wrapResolverArgs)];

const resolversComposition = {
"Query.*": [wrapQueryAndMutation(wrapResolverArgs)],
"Mutation.*": [wrapQueryAndMutation(wrapResolverArgs)],
const isSubscriptionEnabled = !!this.features.subscriptions;
const wrapSubscriptionResolverArgs = {
subscriptionsEngine: this.features.subscriptions,
schemaModel: this.schemaModel,
authorization: this.authorization,
jwtPayloadFieldsMap: this.jwtFieldsMap,
};
const subscriptionWrappers = isSubscriptionEnabled
? [wrapSubscription(wrapSubscriptionResolverArgs as WrapSubscriptionArgs)]
: [];

if (this.features.subscriptions) {
resolversComposition["Subscription.*"] = wrapSubscription({
subscriptionsEngine: this.features.subscriptions,
schemaModel: this.schemaModel,
authorization: this.authorization,
jwtPayloadFieldsMap: this.jwtFieldsMap,
});
}
const resolversComposition = generateResolverComposition({
schemaModel: this.schemaModel,
isSubscriptionEnabled,
queryAndMutationWrappers,
subscriptionWrappers,
});

// Merge generated and custom resolvers
// Merging must be done before composing because wrapper won't run otherwise
const mergedResolvers = mergeResolvers([...asArray(resolvers), ...asArray(this.resolvers)]);
return composeResolvers(mergedResolvers, resolversComposition);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright (c) "Neo4j"
* Neo4j Sweden AB [http://neo4j.com]
*
* This file is part of Neo4j.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import type { ResolversComposerMapping } from "@graphql-tools/resolvers-composition";
import type { IResolvers } from "@graphql-tools/utils";
import type { GraphQLResolveInfo } from "graphql";
import type { Neo4jGraphQLSchemaModel } from "../../schema-model/Neo4jGraphQLSchemaModel";
import { isAuthenticated } from "../../translate/authorization/check-authentication";

export function generateResolverComposition({
schemaModel,
isSubscriptionEnabled,
queryAndMutationWrappers,
subscriptionWrappers,
}: {
schemaModel: Neo4jGraphQLSchemaModel;
isSubscriptionEnabled: boolean;
queryAndMutationWrappers: ((next: any) => (root: any, args: any, context: any, info: any) => any)[];
subscriptionWrappers: ((next: any) => (root: any, args: any, context: any, info: any) => any)[];
}): ResolversComposerMapping<IResolvers<any, GraphQLResolveInfo, Record<string, any>, any>> {
const resolverComposition = {};
const {
userCustomResolverPattern: customResolverQueryPattern,
generatedResolverPattern: generatedResolverQueryPattern,
} = getPathMatcherForRootType("Query", schemaModel);
resolverComposition[`Query.${customResolverQueryPattern}`] = [
...queryAndMutationWrappers,
isAuthenticated(["READ"], schemaModel.operations.Query),
];
resolverComposition[`Query.${generatedResolverQueryPattern}`] = queryAndMutationWrappers;

const {
userCustomResolverPattern: customResolverMutationPattern,
generatedResolverPattern: generatedResolverMutationPattern,
} = getPathMatcherForRootType("Mutation", schemaModel);
resolverComposition[`Mutation.${customResolverMutationPattern}`] = [
...queryAndMutationWrappers,
isAuthenticated(["CREATE", "UPDATE", "DELETE"], schemaModel.operations.Mutation),
];
resolverComposition[`Mutation.${generatedResolverMutationPattern}`] = queryAndMutationWrappers;

if (isSubscriptionEnabled) {
resolverComposition["Subscription.*"] = subscriptionWrappers;
}
return resolverComposition;
}

function getPathMatcherForRootType(
rootType: "Query" | "Mutation",
schemaModel: Neo4jGraphQLSchemaModel
): {
userCustomResolverPattern: string;
generatedResolverPattern: string;
} {
const operation = schemaModel.operations[rootType];
if (!operation) {
return { userCustomResolverPattern: "*", generatedResolverPattern: "*" };
}
const userDefinedFields = Array.from(operation.userResolvedAttributes.keys());
if (!userDefinedFields.length) {
return { userCustomResolverPattern: "*", generatedResolverPattern: "*" };
}
const userCustomResolverPattern = `{${userDefinedFields.join(", ")}}`;
return { userCustomResolverPattern, generatedResolverPattern: `!${userCustomResolverPattern}` };
}
19 changes: 19 additions & 0 deletions packages/graphql/src/schema-model/Operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,18 @@ export class Operation {
public readonly name: string;
// only includes custom Cypher fields
public readonly attributes: Map<string, Attribute> = new Map();
public readonly userResolvedAttributes: Map<string, Attribute> = new Map();
public readonly annotations: Partial<Annotations>;

constructor({
name,
attributes = [],
userResolvedAttributes = [],
annotations = {},
}: {
name: string;
attributes?: Attribute[];
userResolvedAttributes?: Attribute[];
annotations?: Partial<Annotations>;
}) {
this.name = name;
Expand All @@ -43,16 +46,32 @@ export class Operation {
for (const attribute of attributes) {
this.addAttribute(attribute);
}
for (const attribute of userResolvedAttributes) {
this.addUserResolvedAttributes(attribute);
}
}

public findAttribute(name: string): Attribute | undefined {
return this.attributes.get(name);
}

public findUserResolvedAttributes(name: string): Attribute | undefined {
return this.userResolvedAttributes.get(name);
}

private addAttribute(attribute: Attribute): void {
if (this.attributes.has(attribute.name)) {
throw new Neo4jGraphQLSchemaValidationError(`Attribute ${attribute.name} already exists in ${this.name}`);
}
this.attributes.set(attribute.name, attribute);
}

private addUserResolvedAttributes(attribute: Attribute): void {
if (this.userResolvedAttributes.has(attribute.name)) {
throw new Neo4jGraphQLSchemaValidationError(
`User Resolved Attribute ${attribute.name} already exists in ${this.name}`
);
}
this.userResolvedAttributes.set(attribute.name, attribute);
}
}
8 changes: 8 additions & 0 deletions packages/graphql/src/schema-model/OperationAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ import type { Operation } from "./Operation";
export class OperationAdapter {
public readonly name: string;
public readonly attributes: Map<string, AttributeAdapter> = new Map();
public readonly userResolvedAttributes: Map<string, AttributeAdapter> = new Map();
public readonly annotations: Partial<Annotations>;

constructor(entity: Operation) {
this.name = entity.name;
this.initAttributes(entity.attributes);
this.initUserResolvedAttributes(entity.userResolvedAttributes);
this.annotations = entity.annotations;
}

Expand All @@ -39,6 +41,12 @@ export class OperationAdapter {
this.attributes.set(attributeName, attributeAdapter);
}
}
private initUserResolvedAttributes(attributes: Map<string, Attribute>) {
for (const [attributeName, attribute] of attributes.entries()) {
const attributeAdapter = new AttributeAdapter(attribute);
this.userResolvedAttributes.set(attributeName, attributeAdapter);
}
}

public get objectFields(): AttributeAdapter[] {
return Array.from(this.attributes.values()).filter((attribute) => attribute.isObjectField());
Expand Down
16 changes: 13 additions & 3 deletions packages/graphql/src/schema-model/generate-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,13 +543,23 @@ function generateOperation(
definition: ObjectTypeDefinitionNode,
definitionCollection: DefinitionCollection
): Operation {
const attributes = (definition.fields || [])
const { attributes, userResolvedAttributes } = (definition.fields || [])
.map((fieldDefinition) => parseAttribute(fieldDefinition, definitionCollection))
.filter((attribute) => attribute.annotations.cypher);

.reduce<{ attributes: Attribute[]; userResolvedAttributes: Attribute[] }>(
(acc, attribute) => {
if (attribute.annotations.cypher) {
acc.attributes.push(attribute);
} else {
acc.userResolvedAttributes.push(attribute);
}
return acc;
},
{ attributes: [], userResolvedAttributes: [] }
);
return new Operation({
name: definition.name.value,
attributes,
userResolvedAttributes,
annotations: parseAnnotations(definition.directives || []),
});
}
10 changes: 10 additions & 0 deletions packages/graphql/src/schema/make-augmented-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,16 @@ function makeAugmentedSchema({

objectComposer.addFields({ [attributeAdapter.name]: { ...composedField, ...customResolver } });
}

// this is to remove library directives from custom resolvers on root type fields in augmented schema
for (const attributeAdapter of operationAdapter.userResolvedAttributes.values()) {
const composedField = attributeAdapterToComposeFields([attributeAdapter], userDefinedFieldDirectives)[
attributeAdapter.name
];
if (composedField) {
objectComposer.addFields({ [attributeAdapter.name]: composedField });
}
}
});

if (!Object.values(composer.Mutation.getFields()).length) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ function noDirectivesAllowedAtLocation({
}
}

/** only the @cypher directive is valid on fields of Root types: Query, Mutation; no directives valid on fields of Subscription */
/** only the @cypher and @authentication directives are valid on fields of Root types: Query, Mutation; no directives valid on fields of Subscription */
function validFieldOfRootTypeLocation({
directiveNode,
traversedDef,
Expand All @@ -161,20 +161,14 @@ function validFieldOfRootTypeLocation({
// @cypher is valid
return;
}
if (directiveNode.name.value === "authentication") {
// @authentication is valid
return;
}
const isDirectiveCombinedWithCypher = traversedDef.directives?.some(
(directive) => directive.name.value === "cypher"
);
if (directiveNode.name.value === "authentication" && isDirectiveCombinedWithCypher) {
// @cypher @authentication combo is valid
return;
}
// explicitly checked for "enhanced" error messages
if (directiveNode.name.value === "authentication" && !isDirectiveCombinedWithCypher) {
throw new DocumentValidationError(
`Invalid directive usage: Directive @authentication is not supported on fields of the ${parentDef.name.value} type unless it is a @cypher field.`,
[`@${directiveNode.name.value}`]
);
}
if (directiveNode.name.value === "authorization" && isDirectiveCombinedWithCypher) {
throw new DocumentValidationError(
`Invalid directive usage: Directive @authorization is not supported on fields of the ${parentDef.name.value} type. Did you mean to use @authentication?`,
Expand Down
50 changes: 21 additions & 29 deletions packages/graphql/src/schema/validation/validate-document.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3436,35 +3436,6 @@ describe("validation 2.0", () => {
expect(errors[0]).toHaveProperty("path", ["Query", "someActors", "@relationship"]);
});

test("@authentication can't be used on the field of a root type", () => {
const doc = gql`
type Query {
someActors: [Actor!]! @authentication
}
type Actor {
name: String
}
`;

const executeValidate = () =>
validateDocument({
document: doc,
additionalDefinitions,
features: {},
});

const errors = getError(executeValidate);

expect(errors).toHaveLength(1);
expect(errors[0]).not.toBeInstanceOf(NoErrorThrownError);
expect(errors[0]).toHaveProperty(
"message",
"Invalid directive usage: Directive @authentication is not supported on fields of the Query type unless it is a @cypher field."
);
expect(errors[0]).toHaveProperty("path", ["Query", "someActors", "@authentication"]);
});

test("@authorization can't be used on the field of a root type", () => {
const doc = gql`
type Query {
Expand Down Expand Up @@ -3597,6 +3568,27 @@ describe("validation 2.0", () => {
expect(errors[0]).toHaveProperty("path", ["Query", "someActors", "@populatedBy"]);
});

test("@authentication ok to be used on the field of a root type", () => {
const doc = gql`
type Query {
someActors: [Actor!]! @authentication
}
type Actor {
name: String
}
`;

const executeValidate = () =>
validateDocument({
document: doc,
additionalDefinitions,
features: {},
});

expect(executeValidate).not.toThrow();
});

test("@authentication with @cypher ok to be used on the field of a root type", () => {
const doc = gql`
type Query {
Expand Down
Loading

0 comments on commit 6b547dd

Please sign in to comment.