diff --git a/api/directives/auth.js b/api/directives/auth.js new file mode 100644 index 0000000000..43c9dfa15e --- /dev/null +++ b/api/directives/auth.js @@ -0,0 +1,57 @@ +import gql from 'graphql-tag' +import { defaultFieldResolver } from 'graphql' +import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils' +import { GqlAuthorizationError } from '@/lib/error' + +const DIRECTIVE_NAME = 'auth' + +export const typeDef = gql` + directive @${DIRECTIVE_NAME}(allow: [Role!]!) on FIELD_DEFINITION + enum Role { + ADMIN + OWNER + USER + } +` + +export function apply (schema) { + return mapSchema(schema, { + [MapperKind.OBJECT_FIELD]: fieldConfig => { + const upperDirective = getDirective(schema, fieldConfig, DIRECTIVE_NAME)?.[0] + if (upperDirective) { + const { resolve = defaultFieldResolver } = fieldConfig + const { allow } = upperDirective + return { + ...fieldConfig, + resolve: async function (parent, args, context, info) { + checkFieldPermissions(allow, parent, args, context, info) + return await resolve(parent, args, context, info) + } + } + } + } + }) +} + +function checkFieldPermissions (allow, parent, args, { me }, { parentType }) { + // TODO: should admin users always have access to all fields? + + if (allow.indexOf('OWNER') >= 0) { + if (!me) { + throw new GqlAuthorizationError('you must be logged in to access this field') + } + + switch (parentType.name) { + case 'User': + if (me.id !== parent.id) { + throw new GqlAuthorizationError('you must be the owner to access this field') + } + break + default: + // we could just try the userId column and not care about the type + // but we want to be explicit and throw on unexpected types instead + // to catch potential issues in our authorization layer fast + throw new GqlAuthorizationError('failed to check owner: unknown type') + } + } +} diff --git a/api/directives/index.js b/api/directives/index.js new file mode 100644 index 0000000000..d900f45f93 --- /dev/null +++ b/api/directives/index.js @@ -0,0 +1,14 @@ +import { makeExecutableSchema } from '@graphql-tools/schema' + +import * as upper from './upper' +import * as auth from './auth' + +const DIRECTIVES = [upper, auth] + +export function makeExecutableSchemaWithDirectives (typeDefs, resolvers) { + const schema = makeExecutableSchema({ + typeDefs: [...typeDefs, ...DIRECTIVES.map(({ typeDef }) => typeDef)], + resolvers + }) + return DIRECTIVES.reduce((acc, directive) => directive.apply(acc), schema) +} diff --git a/api/directives/upper.js b/api/directives/upper.js new file mode 100644 index 0000000000..6c65ead729 --- /dev/null +++ b/api/directives/upper.js @@ -0,0 +1,30 @@ +import gql from 'graphql-tag' +import { defaultFieldResolver } from 'graphql' +import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils' + +/** Example schema directive that uppercases the value of the field before returning it to the client */ + +const DIRECTIVE_NAME = 'upper' + +export const typeDef = gql`directive @${DIRECTIVE_NAME} on FIELD_DEFINITION` + +export function apply (schema) { + return mapSchema(schema, { + [MapperKind.OBJECT_FIELD]: fieldConfig => { + const upperDirective = getDirective(schema, fieldConfig, DIRECTIVE_NAME)?.[0] + if (upperDirective) { + const { resolve = defaultFieldResolver } = fieldConfig + return { + ...fieldConfig, + resolve: async function (parent, args, context, info) { + const result = await resolve(parent, args, context, info) + if (typeof result === 'string') { + return result.toUpperCase() + } + return result + } + } + } + } + }) +} diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 056b5df300..49a76af393 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -936,10 +936,6 @@ export default { User: { privates: async (user, args, { me, models }) => { - if (!me || me.id !== user.id) { - return null - } - return user }, optional: user => user, diff --git a/api/ssrApollo.js b/api/ssrApollo.js index f83dd42167..bdc96ac78a 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -1,8 +1,6 @@ import { ApolloClient, InMemoryCache } from '@apollo/client' import { SchemaLink } from '@apollo/client/link/schema' -import { makeExecutableSchema } from '@graphql-tools/schema' -import resolvers from './resolvers' -import typeDefs from './typeDefs' +import { schema } from '@/pages/api/graphql' import models from './models' import { print } from 'graphql' import lnd from './lnd' @@ -22,10 +20,7 @@ export default async function getSSRApolloClient ({ req, res, me = null }) { const client = new ApolloClient({ ssrMode: true, link: new SchemaLink({ - schema: makeExecutableSchema({ - typeDefs, - resolvers - }), + schema, context: { models, me: session diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index c41bb2676f..48cc403b0b 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -79,7 +79,7 @@ export default gql` proportion: Float optional: UserOptional! - privates: UserPrivates + privates: UserPrivates @auth(allow: [OWNER]) meMute: Boolean! meSubscriptionPosts: Boolean! diff --git a/pages/api/graphql.js b/pages/api/graphql.js index f6ba066ef7..ff2d060d1b 100644 --- a/pages/api/graphql.js +++ b/pages/api/graphql.js @@ -1,18 +1,20 @@ import { ApolloServer } from '@apollo/server' +import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default' import { startServerAndCreateNextHandler } from '@as-integrations/next' -import resolvers from '@/api/resolvers' import models from '@/api/models' import lnd from '@/api/lnd' import typeDefs from '@/api/typeDefs' +import { makeExecutableSchemaWithDirectives } from '@/api/directives' +import resolvers from '@/api/resolvers' import { getServerSession } from 'next-auth/next' import { getAuthOptions } from './auth/[...nextauth]' import search from '@/api/search' import { multiAuthMiddleware } from '@/lib/auth' -import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled' + +export const schema = makeExecutableSchemaWithDirectives(typeDefs, resolvers) const apolloServer = new ApolloServer({ - typeDefs, - resolvers, + schema, introspection: true, allowBatchedHttpRequests: true, plugins: [{ @@ -42,7 +44,10 @@ const apolloServer = new ApolloServer({ } } } - }, ApolloServerPluginLandingPageDisabled()] + }, + process.env.NODE_ENV === 'development' && ApolloServerPluginLandingPageLocalDefault( + { embed: { endpointIsEditable: false, persistExplorerState: true, displayOptions: { theme: 'dark' } }, footer: false }) + ].filter(Boolean) }) export default startServerAndCreateNextHandler(apolloServer, {