diff --git a/graph/connectors.js b/graph/connectors.js index 7b9ec6699..d7c2f87d8 100644 --- a/graph/connectors.js +++ b/graph/connectors.js @@ -9,6 +9,7 @@ const subscriptions = require('./subscriptions'); const resolvers = require('./resolvers'); const mutators = require('./mutators'); const loaders = require('./loaders'); +const schema = require('./schema'); // Models. const Action = require('../models/action'); @@ -88,6 +89,7 @@ const defaultConnectors = { resolvers, mutators, loaders, + schema, }, }; diff --git a/graph/index.js b/graph/index.js index 2ccaaea11..6e836c184 100644 --- a/graph/index.js +++ b/graph/index.js @@ -2,6 +2,7 @@ const schema = require('./schema'); const Context = require('./context'); const { createSubscriptionManager } = require('./subscriptions'); const { ENABLE_TRACING } = require('../config'); +const connectors = require('./connectors'); module.exports = { createGraphOptions: req => ({ @@ -17,4 +18,5 @@ module.exports = { cacheControl: ENABLE_TRACING, }), createSubscriptionManager, + connectors, }; diff --git a/graph/resolvers/action.js b/graph/resolvers/action.js index 8a58cbbc0..ff827e481 100644 --- a/graph/resolvers/action.js +++ b/graph/resolvers/action.js @@ -1,4 +1,4 @@ -const { SEARCH_OTHER_USERS } = require('../../perms/constants'); +const { decorateUserField } = require('./util'); const Action = { __resolveType({ action_type }) { @@ -11,14 +11,8 @@ const Action = { return undefined; } }, - - // This will load the user for the specific action. We'll limit this to the - // admin users only or the current logged in user. - user({ user_id }, _, { loaders: { Users }, user }) { - if (user && (user.can(SEARCH_OTHER_USERS) || user_id === user.id)) { - return Users.getByID.load(user_id); - } - }, }; +decorateUserField(Action, 'user', 'user_id'); + module.exports = Action; diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js index 9949a5e2e..1a36bab0c 100644 --- a/graph/resolvers/comment.js +++ b/graph/resolvers/comment.js @@ -1,4 +1,6 @@ -const { decorateWithTags } = require('./util'); +const { property } = require('lodash'); +const { SEARCH_ACTIONS } = require('../../perms/constants'); +const { decorateWithTags, decorateWithPermissionCheck } = require('./util'); const Comment = { hasParent({ parent_id }) { @@ -29,15 +31,8 @@ const Comment = { return Comments.getByQuery(query); }, - replyCount({ reply_count }) { - // A simple remap from the underlying database model to the graph model. - return reply_count; - }, - actions({ id }, _, { user, loaders: { Actions } }) { - if (!user || !user.can('SEARCH_ACTIONS')) { - return null; - } - + replyCount: property('reply_count'), + actions({ id }, _, { loaders: { Actions } }) { return Actions.getByID.load(id); }, action_summaries({ id, action_summaries }, _, { loaders: { Actions } }) { @@ -65,4 +60,9 @@ const Comment = { // Decorate the Comment type resolver with a tags field. decorateWithTags(Comment); +// Protect direct action access. +decorateWithPermissionCheck(Comment, { + actions: [SEARCH_ACTIONS], +}); + module.exports = Comment; diff --git a/graph/resolvers/flag_action.js b/graph/resolvers/flag_action.js index f03fb6afe..b48d566c6 100644 --- a/graph/resolvers/flag_action.js +++ b/graph/resolvers/flag_action.js @@ -1,18 +1,11 @@ -const FlagAction = { - // Stored in the metadata, extract and return. - message({ metadata: { message } }) { - return message; - }, - reason({ group_id }) { - return group_id; - }, - user({ user_id }, _, { loaders: { Users } }) { - if (!user_id) { - return null; - } +const { decorateUserField } = require('./util'); +const { property } = require('lodash'); - return Users.getByID.load(user_id); - }, +const FlagAction = { + message: property('metadata.message'), + reason: property('group_id'), }; +decorateUserField(FlagAction, 'user', 'user_id'); + module.exports = FlagAction; diff --git a/graph/resolvers/flag_action_summary.js b/graph/resolvers/flag_action_summary.js index 9572d9743..e4c332b23 100644 --- a/graph/resolvers/flag_action_summary.js +++ b/graph/resolvers/flag_action_summary.js @@ -1,7 +1,7 @@ +const { property } = require('lodash'); + const FlagActionSummary = { - reason({ group_id }) { - return group_id; - }, + reason: property('group_id'), }; module.exports = FlagActionSummary; diff --git a/graph/resolvers/index.js b/graph/resolvers/index.js index 8da8859e1..74b006ebb 100644 --- a/graph/resolvers/index.js +++ b/graph/resolvers/index.js @@ -1,4 +1,4 @@ -const _ = require('lodash'); +const { merge } = require('lodash'); const debug = require('debug')('talk:graph:resolvers'); const Action = require('./action'); @@ -70,7 +70,7 @@ resolvers = plugins .reduce((acc, { plugin, resolvers }) => { debug(`added plugin '${plugin.name}'`); - return _.merge(acc, resolvers); + return merge(acc, resolvers); }, resolvers); module.exports = resolvers; diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index bf221610b..51173f34f 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -1,3 +1,4 @@ +const { decorateWithPermissionCheck, checkSelfField } = require('./util'); const { SEARCH_ASSETS, SEARCH_OTHERS_COMMENTS, @@ -5,11 +6,7 @@ const { } = require('../../perms/constants'); const RootQuery = { - assets(_, { query }, { loaders: { Assets }, user }) { - if (user == null || !user.can(SEARCH_ASSETS)) { - return null; - } - + assets(_, { query }, { loaders: { Assets } }) { return Assets.getByQuery(query); }, asset(_, query, { loaders: { Assets } }) { @@ -33,11 +30,7 @@ const RootQuery = { return Comments.get.load(id); }, - async commentCount(_, { query }, { user, loaders: { Comments, Assets } }) { - if (user == null || !user.can(SEARCH_OTHERS_COMMENTS)) { - return null; - } - + async commentCount(_, { query }, { loaders: { Comments, Assets } }) { const { asset_url, asset_id } = query; if ( (!asset_id || asset_id.length === 0) && @@ -53,11 +46,7 @@ const RootQuery = { return Comments.getCountByQuery(query); }, - async userCount(_, { query }, { user, loaders: { Users } }) { - if (user == null || !user.can(SEARCH_OTHER_USERS)) { - return null; - } - + async userCount(_, { query }, { loaders: { Users } }) { return Users.getCountByQuery(query); }, @@ -72,23 +61,37 @@ const RootQuery = { }, // this returns an arbitrary user - user(_, { id }, { user, loaders: { Users } }) { - if (user == null || !user.can(SEARCH_OTHER_USERS)) { - return null; - } - + user(_, { id }, { loaders: { Users } }) { return Users.getByID.load(id); }, // This endpoint is used for loading the user moderation queues (users whose username has been flagged), // so hide it in the event that we aren't an admin. - users(_, { query }, { user, loaders: { Users } }) { - if (user == null || !user.can(SEARCH_OTHER_USERS)) { - return null; - } - + users(_, { query }, { loaders: { Users } }) { return Users.getByQuery(query); }, }; +// Protect some query fields that are privileged. +decorateWithPermissionCheck(RootQuery, { + assets: [SEARCH_ASSETS], + users: [SEARCH_OTHER_USERS], + userCount: [SEARCH_OTHER_USERS], + commentCount: [SEARCH_OTHERS_COMMENTS], +}); + +// Protect the user field so only users who have permission to look up another +// user may do so as well as a user looking up themselves. +decorateWithPermissionCheck( + RootQuery, + { + user: [SEARCH_OTHER_USERS], + }, + (obj, { id }, { user }) => { + if (user && user.id === id) { + return true; + } + } +); + module.exports = RootQuery; diff --git a/graph/resolvers/subscription.js b/graph/resolvers/subscription.js index aa5d45565..ace5b1a0d 100644 --- a/graph/resolvers/subscription.js +++ b/graph/resolvers/subscription.js @@ -1,40 +1,23 @@ -const Subscription = { - commentAdded(comment) { - return comment; - }, - commentEdited(comment) { - return comment; - }, - commentAccepted(comment) { - return comment; - }, - commentRejected(comment) { - return comment; - }, - commentReset(comment) { - return comment; - }, - commentFlagged(comment) { - return comment; - }, - userBanned(user) { - return user; - }, - userSuspended(user) { - return user; - }, - usernameApproved(user) { - return user; - }, - usernameRejected(user) { - return user; - }, - usernameFlagged(user) { - return user; - }, - usernameChanged(payload) { - return payload; - }, -}; +const Subscription = {}; + +// All of the subscription endpoints need to have an object to serialize when +// pushing out via PubSub, this simply ensures that all these entries will +// return the root object from the subscription to the PubSub framework. +[ + 'commentAdded', + 'commentEdited', + 'commentAccepted', + 'commentRejected', + 'commentReset', + 'commentFlagged', + 'userBanned', + 'userSuspended', + 'usernameApproved', + 'usernameRejected', + 'usernameFlagged', + 'usernameChanged', +].forEach(field => { + Subscription[field] = obj => obj; +}); module.exports = Subscription; diff --git a/graph/resolvers/tag_link.js b/graph/resolvers/tag_link.js index 5df43d05b..b81d5c743 100644 --- a/graph/resolvers/tag_link.js +++ b/graph/resolvers/tag_link.js @@ -1,11 +1,7 @@ -const { SEARCH_OTHER_USERS } = require('../../perms/constants'); +const { decorateUserField } = require('./util'); -const TagLink = { - assigned_by({ assigned_by }, _, { user, loaders: { Users } }) { - if (user && user.can(SEARCH_OTHER_USERS) && assigned_by != null) { - return Users.getByID.load(assigned_by); - } - }, -}; +const TagLink = {}; + +decorateUserField(TagLink, 'assigned_by'); module.exports = TagLink; diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js index 11db4c366..b86b85fea 100644 --- a/graph/resolvers/user.js +++ b/graph/resolvers/user.js @@ -1,4 +1,8 @@ -const { decorateWithTags } = require('./util'); +const { + decorateWithTags, + decorateWithPermissionCheck, + checkSelfField, +} = require('./util'); const KarmaService = require('../../services/karma'); const { SEARCH_ACTIONS, @@ -7,86 +11,65 @@ const { VIEW_USER_ROLE, LIST_OWN_TOKENS, VIEW_USER_STATUS, + VIEW_USER_EMAIL, } = require('../../perms/constants'); +const { property } = require('lodash'); const User = { action_summaries({ id }, _, { loaders: { Actions } }) { return Actions.getSummariesByItemID.load(id); }, actions({ id }, _, { user, loaders: { Actions } }) { - // Only return the actions if the user is not an admin. - if (user && user.can(SEARCH_ACTIONS)) { - return Actions.getByID.load(id); - } + return Actions.getByID.load(id); }, - comments({ id }, { query }, { loaders: { Comments }, user }) { - // If there is no user, or there is a user, but they are requesting someone - // else's comments, and they aren't allowed, don't return then anything! - if (!user || (user.id !== id && !user.can(SEARCH_OTHERS_COMMENTS))) { - return null; - } - + comments({ id }, { query }, { loaders: { Comments } }) { // Set the author id on the query. query.author_id = id; return Comments.getByQuery(query); }, - profiles({ profiles }, _, { user }) { - // if the user is not an admin, do not return the profiles - if (user && user.can(SEARCH_OTHER_USERS)) { - return profiles; - } - - return null; - }, - tokens({ id, tokens }, args, { user }) { - if (!user || (user.id !== id && !user.can(LIST_OWN_TOKENS))) { - return null; - } - - return tokens; - }, - ignoredUsers({ id }, args, { user, loaders: { Users } }) { - // Only allow a logged in user that is either the current user or is a staff - // member to access the ignoredUsers of a given user. - if (!user || (user.id !== id && !user.can(SEARCH_OTHER_USERS))) { - return null; - } + ignoredUsers({ ignoresUsers }, args, { user, loaders: { Users } }) { // Return nothing if there is nothing to query for. if (!user.ignoresUsers || user.ignoresUsers.length <= 0) { return []; } - return Users.getByID.loadMany(user.ignoresUsers); - }, - role({ id, role }, _, { user }) { - // If the user is not an admin, only return the current user's roles. - if (user && (user.can(VIEW_USER_ROLE) || user.id === id)) { - return role; - } - - return null; + return Users.getByID.loadMany(ignoresUsers); }, // Extract the reliability from the user metadata if they have permission. - reliable(user, _, { user: requestingUser }) { - if (requestingUser && requestingUser.can(SEARCH_ACTIONS)) { - return KarmaService.model(user); - } - }, + reliable: user => KarmaService.model(user), - state(user, args, ctx) { - if ( - ctx.user && - (ctx.user.id === user.id || ctx.user.can(VIEW_USER_STATUS)) - ) { - return user; - } - }, + // The state requires the whole user object to make decisions. + state: user => user, + + // Get the first email on the user. + email: property('firstEmail'), }; // Decorate the User type resolver with a tags field. decorateWithTags(User); +// decorate the fields on the User resolver with a permission check where the +// current user can also get their own properties. +decorateWithPermissionCheck( + User, + { + actions: [SEARCH_ACTIONS], + email: [VIEW_USER_EMAIL], + state: [VIEW_USER_STATUS], + role: [VIEW_USER_ROLE], + ignoredUsers: [SEARCH_OTHER_USERS], + tokens: [LIST_OWN_TOKENS], + profiles: [SEARCH_OTHER_USERS], + comments: [SEARCH_OTHERS_COMMENTS], + }, + checkSelfField('id') +); + +// Decorate the fields on the User resolver where the current user has no impact +// on the resolvability of a field. +decorateWithPermissionCheck(User, { reliable: [SEARCH_ACTIONS] }); + module.exports = User; diff --git a/graph/resolvers/user_state.js b/graph/resolvers/user_state.js index 0436101e0..8d1843a43 100644 --- a/graph/resolvers/user_state.js +++ b/graph/resolvers/user_state.js @@ -1,14 +1,12 @@ +const { decorateWithPermissionCheck, checkSelfField } = require('./util'); const { VIEW_USER_STATUS } = require('../../perms/constants'); -const UserState = { - status: (user, args, ctx) => { - if ( - ctx.user && - (ctx.user.id === user.id || ctx.user.can(VIEW_USER_STATUS)) - ) { - return user.status; - } - }, -}; +const UserState = {}; + +decorateWithPermissionCheck( + UserState, + { status: [VIEW_USER_STATUS] }, + checkSelfField('id') +); module.exports = UserState; diff --git a/graph/resolvers/util.js b/graph/resolvers/util.js index 992148b4f..67a4b805d 100644 --- a/graph/resolvers/util.js +++ b/graph/resolvers/util.js @@ -2,59 +2,171 @@ const { ADD_COMMENT_TAG, SEARCH_OTHER_USERS, } = require('../../perms/constants'); -const property = require('lodash/property'); +const { property, isBoolean } = require('lodash'); /** - * Decorates the typeResolver with the tags field. + * getResolver will get the resolver from the typeResolver or apply the default + * resolver. + * + * @param {Object} typeResolver the type resolver + * @param {String} field the field name of the resolver we're getting */ -const decorateWithTags = typeResolver => { - typeResolver.tags = ({ tags = [] }, _, { user }) => { - if (user && user.can(ADD_COMMENT_TAG)) { - return tags; +const getResolver = (typeResolver, field) => { + if (field in typeResolver) { + return typeResolver[field]; + } + + return property(field); +}; + +/** + * + * @param {Object} typeResolver the type resolver + * @param {String} field the name of the field being wrapped + * @param {Function} customCheck the function that can return a boolean + * indicating the resolution status + * @param {Function} skipFieldResolver the optional skip resolver that can be used + * skip out from the oldFieldResolver. + */ +const wrapCheck = ( + typeResolver, + field, + customCheck, + skipFieldResolver = getResolver(typeResolver, field) +) => { + // Cache the old field resolver. In the event that the check does not return + // with a boolean, we'll use this. + const oldFieldResolver = getResolver(typeResolver, field); + + // Override the field resolver on the type resolver with this wrapped + // function. + typeResolver[field] = (obj, args, ctx, info) => { + const decision = customCheck(obj, args, ctx, info); + if (isBoolean(decision)) { + if (decision) { + // The custom check returns a boolean true, so we should execute the + // underlying field resolver (which may just be the old resolver). + return skipFieldResolver(obj, args, ctx, info); + } + + // The custom check returns a boolean false, then we should return null, + // because the check explicity said that we weren't allowed to access that + // field. + return null; } - return tags.filter(t => t.tag.permissions.public); + // The custom check yielded no decision, so we should just fall back to the + // oldFieldResolver. + return oldFieldResolver(obj, args, ctx, info); }; }; +/** + * checkPermissions checks that the current user has all the required + * permissions. + * + * @param {Object} ctx graph context + * @param {Array} permissions permissions that the user must have + */ +const checkPermissions = (ctx, permissions) => + !ctx.user || !ctx.user.can(...permissions); + +/** + * wrapCheckPermissions will wrap a specific field with a permission check. + * + * @param {Object} typeResolver the type resolver + * @param {String} field the field name of the resolver we're wrapping + * @param {Array} permissions array of permissions to check against + * @param {Function} fieldResolver base resolver for the field + */ +const wrapCheckPermissions = ( + typeResolver, + field, + permissions, + skipFieldResolver = getResolver(typeResolver, field) +) => + wrapCheck( + typeResolver, + field, + (obj, args, ctx) => { + if (checkPermissions(ctx, permissions)) { + return false; + } + }, + skipFieldResolver + ); + /** * decorateWithPermissionCheck will decorate the field resolver with * permission checks. * * @param {Object} typeResolver the type resolver * @param {Object} protect the object with field -> Array of permissions + * @param {Function} customCheck a function that can return a boolean based on a + * custom check */ -const decorateWithPermissionCheck = (typeResolver, protect) => { +const decorateWithPermissionCheck = ( + typeResolver, + protect, + customCheck = null +) => { for (const [field, permissions] of Object.entries(protect)) { - let fieldResolver = property(field); - if (field in typeResolver) { - fieldResolver = typeResolver[field]; + const baseFieldResolver = getResolver(typeResolver, field); + wrapCheckPermissions(typeResolver, field, permissions, baseFieldResolver); + + if (customCheck !== null) { + wrapCheck(typeResolver, field, customCheck, baseFieldResolver); } - - typeResolver[field] = (obj, args, ctx, info) => { - if (!ctx.user || !ctx.user.can(...permissions)) { - return null; - } - - return fieldResolver(obj, args, ctx, info); - }; } }; /** - * decorateUserField will decorate the user field accesses with correct - * permission checks. + * checkSelf will check if the current object is the same as the current user. + * + * @param {String} referenceField the field for the user id to check. + */ +const checkSelfField = referenceField => (obj, args, ctx) => { + if ( + ctx.user && + obj[referenceField] !== null && + ctx.user.id === obj[referenceField] + ) { + return true; + } +}; + +/** + * wrapCheckSelf wraps a typeResolver with a check for self (if the type is + * referencing the current user). * * @param {Object} typeResolver the type resolver * @param {String} field the field to decorate + * @param {String} referenceField the field to pull the user id from. + * @param {Function} fieldResolver base resolver for the field */ -const decorateUserField = (typeResolver, field) => { +const wrapCheckSelf = ( + typeResolver, + field, + referenceField = field, + fieldResolver = getResolver(typeResolver, field) +) => + wrapCheck(typeResolver, field, checkSelfField(referenceField), fieldResolver); + +/** + * decorateUserField will decorate the user field accesses with correct + * permission checks and will load the user. + * + * @param {Object} typeResolver the type resolver + * @param {String} field the field to decorate + * @param {String} referenceField the field to pull the user id from. + */ +const decorateUserField = (typeResolver, field, referenceField = field) => { // The default resolver for the user decorator is loading the user by id. let fieldResolver = (obj, args, ctx) => { - if (!obj[field]) { + if (!obj[referenceField]) { return null; } - return ctx.loaders.Users.getByID.load(obj[field]); + return ctx.loaders.Users.getByID.load(obj[referenceField]); }; // The resolver can be overridden however. This decorator will simply wrap the @@ -63,16 +175,39 @@ const decorateUserField = (typeResolver, field) => { fieldResolver = typeResolver[field]; } - typeResolver[field] = (obj, args, ctx, info) => { - if ( - !ctx.user || - obj[field] === null || - (ctx.user.id !== obj[field] && !ctx.user.can(SEARCH_OTHER_USERS)) - ) { - return null; + // Wrap the current fieldResolver with the resolver which will return the + // user. + wrapCheckPermissions( + typeResolver, + field, + [SEARCH_OTHER_USERS], + fieldResolver + ); + + // Wrap the checked resolver with a check to see if the current user is the + // one being retrieved, in which case we should allow it. + wrapCheckSelf(typeResolver, field, referenceField, fieldResolver); +}; + +/** + * decorateWithTags decorates a typeResolver with a tags getter that will + * sanitize the tag output for users without permission to see non-public + * tags. + * + * @param {Object} typeResolver base type resolver + * @param {Function} fieldResolver the field resolver that gets the tags + */ +const decorateWithTags = ( + typeResolver, + fieldResolver = getResolver(typeResolver, 'tags') +) => { + typeResolver.tags = async (obj, args, ctx, info) => { + const tags = await fieldResolver(obj, args, ctx, info); + if (checkPermissions(ctx, [ADD_COMMENT_TAG])) { + return tags; } - return fieldResolver(obj, args, ctx, info); + return tags.filter(t => t.tag.permissions.public); }; }; @@ -80,4 +215,5 @@ module.exports = { decorateUserField, decorateWithTags, decorateWithPermissionCheck, + checkSelfField, }; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index cec8e0015..f108a7ad4 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -63,7 +63,7 @@ type Token { } type UserProfile { - # the id is an identifier for the user profile (email, facebook id, etc) + # The id is an identifier for the user profile (email, facebook id, etc) id: String! # name of the provider attached to the authentication mode @@ -184,13 +184,17 @@ type User { # Actions completed on the parent. actions: [Action!] - # the current roles of the user. + # The current roles of the user. role: USER_ROLES - # the current profiles of the user. + # The current profiles of the user. profiles: [UserProfile] - # the tags on the user + # The primary email address of the user. Only accessible to Administrators or + # the current user. + email: String + + # The tags on the user. tags: [TagLink!] # ignored users. @@ -421,7 +425,7 @@ input CommentCountQuery { # The URL that the asset is located on. asset_url: String - # the parent of the comment that we want to retrieve. + # The parent of the comment that we want to retrieve. parent_id: ID # comments returned will only be ones which have at least one action of this @@ -479,13 +483,13 @@ type Comment { # The body history of the comment. body_history: [CommentBodyHistory!]! - # the tags on the comment + # The tags on the comment tags: [TagLink!] - # the user who authored the comment. + # The user who authored the comment. user: User - # the replies that were made to the comment. + # The replies that were made to the comment. replies(query: RepliesQuery = {}): CommentConnection! # replyCount is the number of replies with a depth of 1. Only direct replies @@ -765,7 +769,7 @@ type Settings { infoBoxContent: String # questionBoxEnable will enable the Question Box's content to be visible above - # the comment box. + # The comment box. questionBoxEnable: Boolean # questionBoxContent is the content of the Question Box. @@ -858,7 +862,7 @@ type Asset { # The date that the asset was created. created_at: Date - # the tags on the asset + # The tags on the asset tags: [TagLink!] # The author(s) of the asset. @@ -1091,7 +1095,7 @@ input AssetSettingsInput { moderation: MODERATION_MODE # questionBoxEnable will enable the Question Boxs' content to be visible above - # the comment box. + # The comment box. questionBoxEnable: Boolean # questionBoxContent is the content of the Question Box. @@ -1167,7 +1171,7 @@ input ModifyTagInput { item_type: TAGGABLE_ITEM_TYPE! # asset_id is used when the item_type is `COMMENTS`, the is needed to rectify - # the settings to get the asset specific tags/settings. + # The settings to get the asset specific tags/settings. asset_id: ID } @@ -1225,7 +1229,7 @@ input UpdateSettingsInput { infoBoxContent: String # questionBoxEnable will enable the Question Box's content to be visible above - # the comment box. + # The comment box. questionBoxEnable: Boolean # questionBoxContent is the content of the Question Box. diff --git a/perms/constants/query.js b/perms/constants/query.js index 4cc07109c..b846b15ce 100644 --- a/perms/constants/query.js +++ b/perms/constants/query.js @@ -9,4 +9,5 @@ module.exports = { VIEW_PROTECTED_SETTINGS: 'VIEW_PROTECTED_SETTINGS', LIST_OWN_TOKENS: 'LIST_OWN_TOKENS', VIEW_USER_ROLE: 'VIEW_USER_ROLE', + VIEW_USER_EMAIL: 'VIEW_USER_EMAIL', }; diff --git a/perms/reducers/query.js b/perms/reducers/query.js index 4fc5ba79e..0852d8e2b 100644 --- a/perms/reducers/query.js +++ b/perms/reducers/query.js @@ -8,11 +8,11 @@ module.exports = (user, perm) => { case types.SEARCH_ACTIONS: case types.SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: case types.SEARCH_OTHERS_COMMENTS: - return check(user, ['ADMIN', 'MODERATOR']); case types.SEARCH_COMMENT_STATUS_HISTORY: case types.VIEW_USER_STATUS: case types.VIEW_PROTECTED_SETTINGS: case types.VIEW_USER_ROLE: + case types.VIEW_USER_EMAIL: return check(user, ['ADMIN', 'MODERATOR']); case types.LIST_OWN_TOKENS: return check(user, ['ADMIN']); diff --git a/routes/index.js b/routes/index.js index 902a2138e..0eb5b6217 100644 --- a/routes/index.js +++ b/routes/index.js @@ -2,7 +2,6 @@ const SetupService = require('../services/setup'); const apollo = require('apollo-server-express'); const authentication = require('../middleware/authentication'); const cookieParser = require('cookie-parser'); -const debug = require('debug')('talk:routes'); const enabled = require('debug').enabled; const errors = require('../errors'); const express = require('express'); @@ -159,13 +158,8 @@ if (process.env.NODE_ENV !== 'production') { }); } -// Inject server route plugins. -plugins.get('server', 'router').forEach(plugin => { - debug(`added plugin '${plugin.plugin.name}'`); - - // Pass the root router to the plugin to mount it's routes. - plugin.router(router); -}); +// Mount the plugin routes. +router.use(require('./plugins')); //============================================================================== // ERROR HANDLING diff --git a/routes/plugins.js b/routes/plugins.js new file mode 100644 index 000000000..621cf172d --- /dev/null +++ b/routes/plugins.js @@ -0,0 +1,23 @@ +const express = require('express'); +const debug = require('debug')('talk:routes:plugins'); +const plugins = require('../services/plugins'); +const { connectors } = require('../graph'); +const { set } = require('lodash'); + +const router = express.Router(); + +// Mount the connectors on the router. +router.use((req, res, next) => { + set(req, 'talk.connectors', connectors); + next(); +}); + +// Inject server route plugins. +plugins.get('server', 'router').forEach(plugin => { + debug(`added plugin '${plugin.plugin.name}'`); + + // Pass the root router to the plugin to mount it's routes. + plugin.router(router); +}); + +module.exports = router; diff --git a/test/server/graph/queries/user.js b/test/server/graph/queries/user.js index c5727987e..3ec8abaf9 100644 --- a/test/server/graph/queries/user.js +++ b/test/server/graph/queries/user.js @@ -22,6 +22,53 @@ describe('graph.queries.user', () => { ); }); + describe('email', () => { + const query = ` + query User($user_id: ID!) { + user(id: $user_id) { + id + email + } + } + `; + + it('can query for your own email', async () => { + const ctx = new Context({ user }); + + const { data, errors } = await graphql(schema, query, {}, ctx, { + user_id: user.id, + }); + + expect(errors).to.be.undefined; + expect(data.user).to.not.be.null; + expect(data.user.email).to.be.equal(user.firstEmail); + }); + + [ + { role: 'COMMENTER', can: false }, + { role: 'STAFF', can: false }, + { role: 'MODERATOR', can: true }, + { role: 'ADMIN', can: true }, + ].forEach(({ role, can }) => { + it(`${can ? 'can' : 'can not'} query with role = ${role}`, async () => { + const actor = new UserModel({ role }); + const ctx = new Context({ user: actor }); + + const { data, errors } = await graphql(schema, query, {}, ctx, { + user_id: user.id, + }); + + expect(errors).to.be.undefined; + if (!can) { + expect(data.user).to.be.null; + } else { + expect(data.user).to.not.be.null; + expect(data.user.email).to.be.equal(user.firstEmail); + } + }); + }); + }); + describe('state', () => { const meQuery = ` query Me {