GraphQL authorization layer, flexible, (not only) directive-based, compatible with all modern GraphQL architectures.
Flexible modern way of adding an authorization layer on top of your existing GraphQL microservices or monolith backend systems.
Full overview can be found in this blog post: https://the-guild.dev/blog/graphql-authz
Check examples in examples
folder:
To integrate graphql-authz into the project you can follow several steps depending on your architecture:
Read more
yarn add @graphql-authz/core
Create rule and rules
object
import { preExecRule } from "@graphql-authz/core";
const IsAuthenticated = preExecRule()(context => !!context.user);
const rules = {
IsAuthenticated
} as const;
Alternatively you can create a rule as a class
import { PreExecutionRule } from "@graphql-authz/core";
class IsAuthenticated extends PreExecutionRule {
public execute(context) {
if (!context.user) {
throw this.error;
}
}
}
const rules = {
IsAuthenticated
} as const;
Add rules and directive definition to the schema
# this is custom list of your rules
enum AuthZRules {
IsAuthenticated
IsAdmin
CanReadPost
CanPublishPost
}
# this is a common boilerplate
input AuthZDirectiveCompositeRulesInput {
and: [AuthZRules]
or: [AuthZRules]
not: AuthZRules
}
# this is a common boilerplate
input AuthZDirectiveDeepCompositeRulesInput {
id: AuthZRules
and: [AuthZDirectiveDeepCompositeRulesInput]
or: [AuthZDirectiveDeepCompositeRulesInput]
not: AuthZDirectiveDeepCompositeRulesInput
}
# this is a common boilerplate
directive @authz(
rules: [AuthZRules]
compositeRules: [AuthZDirectiveCompositeRulesInput]
deepCompositeRules: [AuthZDirectiveDeepCompositeRulesInput]
) on FIELD_DEFINITION | OBJECT | INTERFACE
Alternatively generate rules and directive definition
import { directiveTypeDefs } from "@graphql-authz/core";
import { authZGraphQLDirective } from "@graphql-authz/directive";
const directive = authZGraphQLDirective(rules);
const authZDirectiveTypeDefs = directiveTypeDefs(directive);
const typeDefs = gql`
${authZDirectiveTypeDefs}
...
`;
Pass directive to schema options to generate rules and directive definition
import { authZGraphQLDirective } from "@graphql-authz/directive";
new GraphQLSchema({
...
directives: [authZGraphQLDirective(rules)]
...
});
Add authz directive to Query/Mutation/Object/Interface/Field you need to perform authorization on
interface TestInterface @authz(rules: [Rule01]) {
testField1: String! @authz(rules: [Rule02])
}
type TestType implements TestInterface @authz(rules: [Rule01]) {
testField1: String!
testField2: Float! @authz(rules: [Rule02])
}
type Query {
testQuery: TestType! @authz(rules: [Rule01, Rule02])
}
type Mutation {
testMutation: TestType! @authz(rules: [Rule01, Rule02])
}
Using TypeGraphQL or NestJS decorators
@InterfaceType()
@Directive(`@authz(rules: [Rule01]`)
class TestInterface {
@Field()
@Directive(`@authz(rules: [Rule02])`)
public testField1!: string;
}
@ObjectType({ implements: TestInterface })
@Directive(`@authz(rules: [Rule01])`)
class TestType {
@Field()
public testField1!: string;
@Field()
@Directive(`@authz(rules: [Rule02])`)
public testField2!: number;
}
@Resolver()
class ResolverClass {
@Query(() => TestType)
@Directive(`@authz(rules: [Rule01, Rule02])`)
public testQuery() {
...
}
@Mutation(() => TestType)
@Directive(`@authz(rules: [Rule01, Rule02])`)
public testMutation() {
...
}
}
Alternatively custom decorator-wrapper can be used
import { IAuthConfig } from "@graphql-authz/core";
function AuthZ(config: IAuthConfig<typeof rules>) {
const args = Object.keys(config)
.map(
key => `${key}: ${JSON.stringify(config[key]).replace(/"/g, '')}`
)
.join(', ');
const directiveArgs = `(${args})`;
return Directive(`@authz${directiveArgs}`);
}
@InterfaceType()
@AuthZ({ rules: ['Rule01'] })
class TestInterface {
@Field()
@AuthZ({ rules: ['Rule02'] })
public testField1!: string;
}
It looks pretty similar to the directive usage with decorators
import { IAuthConfig } from "@graphql-authz/core";
function AuthZ(args: IAuthConfig<typeof rules>) {
return Extensions({
authz: {
directives: [
{
name: 'authz',
arguments: args
}
]
}
});
}
@InterfaceType()
@AuthZ({ rules: ['Rule01'] })
class TestInterface {
@Field()
@AuthZ({ rules: ['Rule02'] })
public testField1!: string;
}
import { IAuthConfig } from "@graphql-authz/core";
function createAuthZExtensions(args: IAuthConfig<typeof rules>) {
return {
authz: {
directives: [
{
name: 'authz',
arguments: args
}
]
}
};
}
const Post = new GraphQLObjectType({
name: 'Post',
extensions: createAuthZExtensions({
rules: ['CanReadPost']
}),
fields: () => ({
...
})
});
const Query = new GraphQLObjectType({
name: 'Query',
fields: {
post: {
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Post))),
extensions: createAuthZExtensions({
rules: ['IsAuthenticated']
}),
...
}
}
});
import SchemaBuilder from '@giraphql/core';
import AuthzPlugin from '@giraphql/plugin-authz';
const builder = new SchemaBuilder<{
AuthZRule: keyof typeof rules;
}>({
plugins: [AuthzPlugin],
});
builder.queryType({
fields: (t) => ({
users: t.field({
type: [User],
authz: {
rules: ['IsAuthenticated'],
},
resolve: () => users,
}),
}),
});
const Post = builder.objectRef<IPost>('Post');
Post.implement({
authz: {
rules: ['CanReadPost'],
},
fields: (t) => ({
id: t.exposeID('id'),
}),
});
GiraphQL provides a native GraphQL Authz plugin @giraphql/plugin-authz that helps to attach auth rules to Queries/Mutations/Objects/Interfaces/Fields in a type-safe way
const authSchema = {
Post: { __authz: { rules: ['CanReadPost'] } },
User: {
email: { __authz: { rules: ['IsAdmin'] } }
},
Mutation: {
publishPost: { __authz: { rules: ['CanPublishPost'] } }
},
Query: {
users: { __authz: { rules: ['IsAuthenticated'] } }
}
};
Pre execution rules are executed before any resolvers are called and have context
and fieldArgs
as arguments.
context
is GraphQL context
fieldArgs
is an object that has all arguments passed to Query
/Mutation
/Field
the rule is applied to. If the rule is applied to ObjectType
or InterfaceType
then fieldArgs
is an empty object.
If no other data is needed to perform authorization (e.g. actual data from the database or actual response to the request) then the Pre execution rule should be used.
To create simple Pre execution rule preExecRule
function should be used
import { preExecRule } from "@graphql-authz/core";
const IsAuthenticated = preExecRule()(context => !!context.user);
Alternatively new class extended from PreExecutionRule
could be created
import { PreExecutionRule } from "@graphql-authz/core";
class IsAuthenticated extends PreExecutionRule {
public execute(context) {
if (!context.user) {
throw this.error;
}
}
}
Post execution rules are executed after all resolvers logic is executed and the result response is ready. Because of that fact, Post execution rules have access to the actual result of query execution. This can resolve authorization cases when the decision depends on attributes of the requested entity, for example, if only the author of the post can see the post if it is in draft status.
In order to create Post execution rule postExecRule
function should be used
const CanReadPost = postExecRule({
selectionSet: '{ status author { id } }'
})(
(
context: IContext,
fieldArgs: unknown,
post: { status: string; author: { id: string } },
parent: unknown
) =>
post.status === 'public' || context.user?.id === post.author.id
);
Alternatively a new class extended from PostExecutionRule
could be created
class CanReadPost extends PostExecutionRule {
public execute(
context: IContext,
fieldArgs: any,
post: Post,
parent: unknown
) {
if (post.status !== "public" && context.user?.id !== post.author.id) {
throw this.error;
}
}
public selectionSet = "{ status author { id } }";
}
By adding selectionSet
we ensure that even if the client originally didn't request fields that are needed to perform authorization these fields are present in the result value that comes to a rule as an argument.
Please note that with post execution rules resolvers are executed even if the rule throws an unauthorized error, so such rules are not suitable for Mutations or Queries that update some counters (count of views for example)
All rules can be async so for cases where authorization depends on real data and should be performed strictly before resolvers execution (for Mutation or Queries that update some counters) you can query DB right inside pre execution rule
const CanPublishPost = preExecRule()(
async (
context: IContext,
fieldArgs: { postId: string }
) => {
const post = await db.posts.get(fieldArgs.postId);
return post.authorId === context.user?.id;
}
);
graphql-authz doesn't mutate executable schema so you can query schema itself right inside Pre execution rule
Note: you need to pass schema to context to access it inside rules
const CanPublishPost = preExecRule()(
async (
context: IContext,
fieldArgs: { postId: string }
) => {
// query executable schema from rules
const graphQLResult = await graphql({
schema: context.schema,
source: `query post { post(id: ${fieldArgs.postId}) { author { id } } }`
});
const post = graphQLResult.data?.post;
return post && post.author.id === context.user?.id;
}
);
Attaching rules using wildcards is supported by authSchema. Wildcard can be used as a name of Object and as a name of field in different combinations. Here are some examples:
// Reject rule is attached to every Object
const authSchema = {
'*': { __authz: { rules: ['Reject'] } }
};
// Reject rule is attached to every Field of every Object
const authSchema = {
'*': {
'*': { __authz: { rules: ['Reject'] } }
}
};
// Reject rule is attached to every Object AND every Field of every Object
const authSchema = {
'*': {
{ __authz: { rules: ['Reject'] } },
'*': { __authz: { rules: ['Reject'] } }
}
};
// Reject rule is attached to every Field of User Object
const authSchema = {
User: {
'*': { __authz: { rules: ['Reject'] } }
}
};
// Reject rule is attached to createdAt Field of every Object
const authSchema = {
'*': {
createdAt: { __authz: { rules: ['Reject'] } }
}
};
Wildcard rules are attached to Object/Field only if no other rules are attached to it so to overwrite wildcard rule it's only necessary to attach any other rule to the Object/Field. Wildcard rules can be overwritten by rules attached using directive or extensions as well.
// Reject rule is attached to all fields of the User object except of the id field. IsAuthenticated rule is attached to the id field.
const authSchema = {
User: {
'*': { __authz: { rules: ['Reject'] } },
id: { __authz: { rules: ['IsAuthenticated'] } }
}
};
// Reject rule is attached to all Objects except of the User Object. IsAuthenticated rule is attached to the User Object.
const authSchema = {
'*': { __authz: { rules: ['Reject'] } },
User: { __authz: { rules: ['IsAuthenticated'] } }
};
Wildcard rules are not composing with each other. Only one wildcard rule is attached to the certain Object/Field. Wildcard rules are attached to a Field in following priority:
const authSchema = {
User: {
'*': { __authz: { rules: ['Reject'] } }
}
};
const authSchema = {
'*': {
createdAt: { __authz: { rules: ['Reject'] } }
}
};
const authSchema = {
'*': {
'*': { __authz: { rules: ['Reject'] } }
}
};
With following example:
const authSchema = {
'*': {
'*': { __authz: { rules: ['Rule01'] } },
id: { __authz: { rules: ['Rule02'] } }
},
User: {
'*': { __authz: { rules: ['Rule03'] } }
}
};
only Rule03 is attached to the id field of the User object
Rules that are passed to @authz
directive as rules
list are composing with AND logical operator. So @authz(rules: [Rule01, Rule02])
means that authorization will be passed only if Rule01
AND Rule02
passed.
To create different composition rules and
, or
, not
functions should be used
import { and, or, not } from "@graphql-authz/core";
const TestAndRule = and([Rule01, Rule02, Rule03]);
const TestOrRule = or([Rule01, Rule02, Rule03]);
const TestNotRule = not([Rule01, Rule02, Rule03]);
Alternatively new class extended from AndRule
, OrRule
or NotRule
can be created
import {
AndRule,
OrRule,
NotRule
} from "@graphql-authz/core";
class TestAndRule extends AndRule {
public getRules() {
return [Rule01, Rule02, Rule03];
}
}
class TestOrRule extends OrRule {
public getRules() {
return [Rule01, Rule02, Rule03];
}
}
class TestNotRule extends NotRule {
public getRules() {
return [Rule01, Rule02, Rule03];
}
}
With such code
TestAndRule
will pass only if all of Rule01
, Rule02
, Rule03
pass
TestOrRule
will pass if any of Rule01
, Rule02
, Rule03
pass
TestNotRule
will pass only if every of Rule01
, Rule02
, Rule03
fail
Composition rules can be used just like regular rules
@authz(rules: [TestAndRule])
@authz(rules: [TestOrRule])
@authz(rules: [TestNotRule])
Also, composite rules can be composed of other composite rules
class TestOrRule02 extends OrRule {
public getRules() {
return [TestAndRule, TestOrRule, TestNotRule];
}
}
Rules can be composed in an inline way by using compositeRules
and deepCompositeRules
parameters. The difference between them is compositeRules
supports only one level of inline composing. deepCompositeRules
supports any levels of composing but it requires id
key for the existing rule identificator.
Inline rules composition works with directives, extensions and authSchema
@authz(compositeRules: [{
or: [Rule01, Rule03],
not: [Rule02]
}])
@authz(deepCompositeRules: [{
or: [
{
and: [{ id: Rule01 }, { id: Rule02 }]
},
{
or: [{ id: Rule03 }, { id: Rule04 }]
}
]
}])
const authSchema = {
User: {
__authz: {
compositeRules: [{
or: ['Rule01', 'Rule03'],
not: ['Rule02']
}]
}
},
Post: {
body: {
__authz: {
deepCompositeRules: [{
or: [
{
and: [{ id: 'Rule01' }, { id: 'Rule02' }]
},
{
or: [{ id: 'Rule03' }, { id: 'Rule04' }]
}
]
}]
}
}
}
}
Pre and Post execution rules can be mixed in any way inside all types of composite rules.
To provide custom error for rule the error
option should be provided
import { UnauthorizedError } from '@graphql-authz/core';
const SomeRule = postExecRule({
error: 'User is not authenticated'
})(() => { /* rule body */ });
Using rule class
import { UnauthorizedError } from '@graphql-authz/core';
class SomeRule extends PreExecutionRule {
public error = new UnauthorizedError("User is not authenticated");
public execute() {
throw this.error;
}
}
It's important to throw an instance of UnauthorizedError
imported from @graphql-authz/core
to enable composite rules to correctly handle errors thrown from rules. If an instance of UnauthorizedError
is thrown it's treated as a certain rule didn't pass. If the rule is wrapped with NotRule
then execution should continue. Any other errors are treated as runtime errors and are thrown up, so if any other error is thrown from the rule that is wrapped with NotRule
it will fail the entire request.