Skip to content

Commit 8f1852c

Browse files
committed
PoC for authorization with schema directives
This is only a proof of concept how to guard fields with schema directives so it is only applied to UserPrivates. It can be tested using this GraphQL query: ``` fragment UserFields on User { id name privates { sats } } query users { me { ...UserFields } user(id: 624) { ...UserFields } } ``` However, I am not sure how schema directives can support conditional validation like we need for fields that are conditionally private. Conditional validation might need to continue exist as custom code in the resolvers. Next steps could be to also apply these directives to limit API key usage.
1 parent 6349842 commit 8f1852c

File tree

7 files changed

+109
-15
lines changed

7 files changed

+109
-15
lines changed

api/directives/auth.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import gql from 'graphql-tag'
2+
import { defaultFieldResolver } from 'graphql'
3+
import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils'
4+
import { GqlAuthorizationError } from '@/lib/error'
5+
6+
const DIRECTIVE_NAME = 'auth'
7+
8+
export const typeDef = gql`
9+
directive @${DIRECTIVE_NAME}(allow: [Role!]!) on FIELD_DEFINITION
10+
enum Role {
11+
ADMIN
12+
OWNER
13+
USER
14+
}
15+
`
16+
17+
export function apply (schema) {
18+
return mapSchema(schema, {
19+
[MapperKind.OBJECT_FIELD]: fieldConfig => {
20+
const upperDirective = getDirective(schema, fieldConfig, DIRECTIVE_NAME)?.[0]
21+
if (upperDirective) {
22+
const { resolve = defaultFieldResolver } = fieldConfig
23+
const { allow } = upperDirective
24+
return {
25+
...fieldConfig,
26+
resolve: async function (parent, args, context, info) {
27+
checkFieldPermissions(allow, parent, args, context, info)
28+
return await resolve(parent, args, context, info)
29+
}
30+
}
31+
}
32+
}
33+
})
34+
}
35+
36+
function checkFieldPermissions (allow, parent, args, { me }, { parentType }) {
37+
// TODO: should admin users always have access to all fields?
38+
39+
if (allow.indexOf('OWNER') >= 0) {
40+
if (!me) {
41+
throw new GqlAuthorizationError('you must be logged in to access this field')
42+
}
43+
44+
switch (parentType.name) {
45+
case 'User':
46+
if (me.id !== parent.id) {
47+
throw new GqlAuthorizationError('you must be the owner to access this field')
48+
}
49+
break
50+
default:
51+
// we could just try the userId column and not care about the type
52+
// but we want to be explicit and throw on unexpected types instead
53+
// to catch potential issues in our authorization layer fast
54+
throw new GqlAuthorizationError('failed to check owner: unknown type')
55+
}
56+
}
57+
}

api/directives/index.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { makeExecutableSchema } from '@graphql-tools/schema'
2+
3+
import * as upper from './upper'
4+
import * as auth from './auth'
5+
6+
const DIRECTIVES = [upper, auth]
7+
8+
export function makeExecutableSchemaWithDirectives (typeDefs, resolvers) {
9+
const schema = makeExecutableSchema({
10+
typeDefs: [...typeDefs, ...DIRECTIVES.map(({ typeDef }) => typeDef)],
11+
resolvers
12+
})
13+
return DIRECTIVES.reduce((acc, directive) => directive.apply(acc), schema)
14+
}

api/directives/upper.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import gql from 'graphql-tag'
2+
import { defaultFieldResolver } from 'graphql'
3+
import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils'
4+
5+
/** Example schema directive that uppercases the value of the field before returning it to the client */
6+
7+
const DIRECTIVE_NAME = 'upper'
8+
9+
export const typeDef = gql`directive @${DIRECTIVE_NAME} on FIELD_DEFINITION`
10+
11+
export function apply (schema) {
12+
return mapSchema(schema, {
13+
[MapperKind.OBJECT_FIELD]: fieldConfig => {
14+
const upperDirective = getDirective(schema, fieldConfig, DIRECTIVE_NAME)?.[0]
15+
if (upperDirective) {
16+
const { resolve = defaultFieldResolver } = fieldConfig
17+
return {
18+
...fieldConfig,
19+
resolve: async function (parent, args, context, info) {
20+
const result = await resolve(parent, args, context, info)
21+
if (typeof result === 'string') {
22+
return result.toUpperCase()
23+
}
24+
return result
25+
}
26+
}
27+
}
28+
}
29+
})
30+
}

api/resolvers/user.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -936,10 +936,6 @@ export default {
936936

937937
User: {
938938
privates: async (user, args, { me, models }) => {
939-
if (!me || me.id !== user.id) {
940-
return null
941-
}
942-
943939
return user
944940
},
945941
optional: user => user,

api/ssrApollo.js

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { ApolloClient, InMemoryCache } from '@apollo/client'
22
import { SchemaLink } from '@apollo/client/link/schema'
3-
import { makeExecutableSchema } from '@graphql-tools/schema'
4-
import resolvers from './resolvers'
5-
import typeDefs from './typeDefs'
3+
import { schema } from '@/pages/api/graphql'
64
import models from './models'
75
import { print } from 'graphql'
86
import lnd from './lnd'
@@ -22,10 +20,7 @@ export default async function getSSRApolloClient ({ req, res, me = null }) {
2220
const client = new ApolloClient({
2321
ssrMode: true,
2422
link: new SchemaLink({
25-
schema: makeExecutableSchema({
26-
typeDefs,
27-
resolvers
28-
}),
23+
schema,
2924
context: {
3025
models,
3126
me: session

api/typeDefs/user.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export default gql`
7979
proportion: Float
8080
8181
optional: UserOptional!
82-
privates: UserPrivates
82+
privates: UserPrivates @auth(allow: [OWNER])
8383
8484
meMute: Boolean!
8585
meSubscriptionPosts: Boolean!

pages/api/graphql.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import { ApolloServer } from '@apollo/server'
22
import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default'
33
import { startServerAndCreateNextHandler } from '@as-integrations/next'
4-
import resolvers from '@/api/resolvers'
54
import models from '@/api/models'
65
import lnd from '@/api/lnd'
76
import typeDefs from '@/api/typeDefs'
7+
import { makeExecutableSchemaWithDirectives } from '@/api/directives'
8+
import resolvers from '@/api/resolvers'
89
import { getServerSession } from 'next-auth/next'
910
import { getAuthOptions } from './auth/[...nextauth]'
1011
import search from '@/api/search'
1112
import { multiAuthMiddleware } from '@/lib/auth'
1213

14+
export const schema = makeExecutableSchemaWithDirectives(typeDefs, resolvers)
15+
1316
const apolloServer = new ApolloServer({
14-
typeDefs,
15-
resolvers,
17+
schema,
1618
introspection: true,
1719
allowBatchedHttpRequests: true,
1820
plugins: [{

0 commit comments

Comments
 (0)