diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js index e233f3112..29971e294 100644 --- a/client/coral-admin/src/actions/auth.js +++ b/client/coral-admin/src/actions/auth.js @@ -20,8 +20,7 @@ export const handleLogin = (email, password, recaptchaResponse) => (dispatch) => return dispatch(checkLoginFailure('not logged in')); } dispatch(handleAuthToken(token)); - const isAdmin = !!user.roles.filter((i) => i === 'ADMIN').length; - dispatch(checkLoginSuccess(user, isAdmin)); + dispatch(checkLoginSuccess(user)); }) .catch((error) => { if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') { @@ -86,8 +85,7 @@ export const checkLogin = () => (dispatch) => { return dispatch(checkLoginFailure('not logged in')); } - const isAdmin = !!user.roles.filter((i) => i === 'ADMIN').length; - dispatch(checkLoginSuccess(user, isAdmin)); + dispatch(checkLoginSuccess(user)); }) .catch((error) => { console.error(error); diff --git a/client/coral-admin/src/components/ui/Drawer.js b/client/coral-admin/src/components/ui/Drawer.js index 5ac55045c..2fcb5aafe 100644 --- a/client/coral-admin/src/components/ui/Drawer.js +++ b/client/coral-admin/src/components/ui/Drawer.js @@ -4,10 +4,11 @@ import {IndexLink, Link} from 'react-router'; import styles from './Drawer.css'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../../translations.json'; +import {can} from 'coral-framework/services/perms'; -const CoralDrawer = ({handleLogout, restricted = false}) => ( +const CoralDrawer = ({handleLogout, auth}) => ( - { !restricted ? + { auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
( activeClassName={styles.active}> {lang.t('configure.dashboard')} - - {lang.t('configure.moderate')} - - + {lang.t('configure.moderate')} + + ) + } + {lang.t('configure.stories')} + to="/admin/community" + activeClassName={styles.active}> {lang.t('configure.community')} - - {lang.t('configure.configure')} - + { + can(auth.user, 'UPDATE_CONFIG') && + ( + + {lang.t('configure.configure')} + + ) + } Sign Out {`v${process.env.VERSION}`} diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index 6418efb10..f7ba83491 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -5,50 +5,66 @@ import styles from './Header.css'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../../translations.json'; import {Logo} from './Logo'; +import {can} from 'coral-framework/services/perms'; -const CoralHeader = ({handleLogout, showShortcuts = () => {}, restricted = false}) => ( +const CoralHeader = ({ + handleLogout, + showShortcuts = () => {}, + auth +}) => (
- { - !restricted ?
- - - {lang.t('configure.dashboard')} - - - {lang.t('configure.moderate')} - - - {lang.t('configure.stories')} - - - {lang.t('configure.community')} - - - {lang.t('configure.configure')} - - + { + auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ? + + + {lang.t('configure.dashboard')} + + { + can(auth.user, 'MODERATE_COMMENTS') && ( + + {lang.t('configure.moderate')} + + ) + } + + {lang.t('configure.stories')} + + + {lang.t('configure.community')} + + { + can(auth.user, 'UPDATE_CONFIG') && ( + + {lang.t('configure.configure')} + + ) + } + + : + null + }
  • @@ -66,16 +82,13 @@ const CoralHeader = ({handleLogout, showShortcuts = () => {}, restricted = false
- : - null - }
); CoralHeader.propTypes = { + auth: PropTypes.object, showShortcuts: PropTypes.func, - handleLogout: PropTypes.func.isRequired, - restricted: PropTypes.bool // hide elemnts from a user that's logged out + handleLogout: PropTypes.func.isRequired }; const lang = new I18n(translations); diff --git a/client/coral-admin/src/components/ui/Layout.js b/client/coral-admin/src/components/ui/Layout.js index 6bf9661b7..11432e570 100644 --- a/client/coral-admin/src/components/ui/Layout.js +++ b/client/coral-admin/src/components/ui/Layout.js @@ -4,12 +4,16 @@ import Header from './Header'; import Drawer from './Drawer'; import styles from './Layout.css'; -const Layout = ({children, handleLogout = () => {}, toggleShortcutModal, restricted = false, ...props}) => ( +const Layout = ({ + children, + handleLogout = () => {}, + toggleShortcutModal, + restricted = false, + ...props}) => (
diff --git a/client/coral-admin/src/containers/Community/Table.js b/client/coral-admin/src/containers/Community/Table.js index 44c06eed4..5ee8e7f92 100644 --- a/client/coral-admin/src/containers/Community/Table.js +++ b/client/coral-admin/src/containers/Community/Table.js @@ -65,6 +65,7 @@ class Table extends Component { label={lang.t('community.role')} onChange={(role) => this.onRoleChange(row.id, role)}> + diff --git a/client/coral-admin/src/containers/Configure/Configure.js b/client/coral-admin/src/containers/Configure/Configure.js index e041edfe3..d22a19997 100644 --- a/client/coral-admin/src/containers/Configure/Configure.js +++ b/client/coral-admin/src/containers/Configure/Configure.js @@ -15,6 +15,7 @@ import translations from 'coral-admin/src/translations.json'; import StreamSettings from './StreamSettings'; import ModerationSettings from './ModerationSettings'; import TechSettings from './TechSettings'; +import {can} from 'coral-framework/services/perms'; class Configure extends Component { constructor (props) { @@ -118,6 +119,11 @@ class Configure extends Component { render () { const {activeSection} = this.state; const section = this.getSection(activeSection); + const {auth: {user}} = this.props; + + if (!can(user, 'UPDATE_CONFIG')) { + return

You must be an administrator to access config settings. Please find the nearest Admin and ask them to level you up!

; + } const showSave = Object.keys(this.state.errors).reduce( (bool, error) => this.state.errors[error] ? false : bool, this.state.changed); @@ -172,6 +178,7 @@ class Configure extends Component { } const mapStateToProps = (state) => ({ + auth: state.auth.toJS(), settings: state.settings.toJS() }); export default connect(mapStateToProps)(Configure); diff --git a/client/coral-admin/src/containers/LayoutContainer.js b/client/coral-admin/src/containers/LayoutContainer.js index 31110d3bd..340a18e3b 100644 --- a/client/coral-admin/src/containers/LayoutContainer.js +++ b/client/coral-admin/src/containers/LayoutContainer.js @@ -7,6 +7,7 @@ import {logout} from 'coral-framework/actions/auth'; import {FullLoading} from '../components/FullLoading'; import {toggleModal as toggleShortcutModal} from '../actions/moderation'; import {checkLogin, handleLogin, requestPasswordReset} from '../actions/auth'; +import {can} from 'coral-framework/services/perms'; class LayoutContainer extends Component { componentWillMount() { @@ -17,7 +18,7 @@ class LayoutContainer extends Component { } render() { const { - isAdmin, + user, loggedIn, loadingUser, loginError, @@ -33,7 +34,7 @@ class LayoutContainer extends Component { if (loadingUser) { return ; } - if (!isAdmin) { + if (!loggedIn) { return ( ); } - if (isAdmin && loggedIn) { + if (can(user, 'ACCESS_ADMIN') && loggedIn) { return ( ); + } else if (loggedIn) { + return ( + +

This page is for team use only. Please contact an administrator if you want to join this team.

+
+ ); } return ; } diff --git a/client/coral-admin/src/reducers/auth.js b/client/coral-admin/src/reducers/auth.js index 1e080d37e..a60d73cec 100644 --- a/client/coral-admin/src/reducers/auth.js +++ b/client/coral-admin/src/reducers/auth.js @@ -4,7 +4,6 @@ import * as actions from '../constants/auth'; const initialState = Map({ loggedIn: false, user: null, - isAdmin: false, loginError: null, loginMaxExceeded: false, passwordRequestSuccess: null @@ -24,7 +23,6 @@ export default function auth (state = initialState, action) { return state .set('loggedIn', true) .set('loadingUser', false) - .set('isAdmin', action.isAdmin) .set('user', action.user); case actions.LOGOUT: return initialState; diff --git a/client/coral-admin/src/translations.json b/client/coral-admin/src/translations.json index 2cb228dd4..68072f649 100644 --- a/client/coral-admin/src/translations.json +++ b/client/coral-admin/src/translations.json @@ -10,6 +10,7 @@ "newsroom_role": "Newsroom Role", "admin": "Administrator", "moderator": "Moderator", + "staff": "Staff", "role": "Select role...", "no-results": "No users found with that user name or email address. They're hiding!", "status": "Status", @@ -207,6 +208,7 @@ "newsroom_role": "Rol en la redacción", "admin": "Administradora", "moderator": "Moderadora", + "staff": "Miembro", "role": "Seleccionar rol...", "no-results": "No se encontraron usuarixs con ese nombre de usuario o e-mail.", "status": "Estado", diff --git a/client/coral-embed-stream/src/components/Embed.js b/client/coral-embed-stream/src/components/Embed.js index 331cfd9a1..5f27293d7 100644 --- a/client/coral-embed-stream/src/components/Embed.js +++ b/client/coral-embed-stream/src/components/Embed.js @@ -1,6 +1,7 @@ import React from 'react'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from 'coral-framework/translations'; +import {can} from 'coral-framework/services/perms'; const lang = new I18n(translations); import {TabBar, Tab, TabContent, Button} from 'coral-ui'; @@ -37,7 +38,7 @@ export default class Embed extends React.Component { render () { const {activeTab, logout, viewAllComments, commentId} = this.props; const {asset: {totalCommentCount}} = this.props.root; - const {loggedIn, isAdmin, user} = this.props.auth; + const {loggedIn, user} = this.props.auth; const userBox = ; @@ -47,7 +48,7 @@ export default class Embed extends React.Component { {lang.t('myProfile')} - Configure Stream + Configure Stream { commentId && diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index 93bc516c4..be47b5c90 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -12,6 +12,7 @@ import IgnoredCommentTombstone from './IgnoredCommentTombstone'; import SuspendedAccount from './SuspendedAccount'; import RestrictedMessageBox from 'coral-framework/components/RestrictedMessageBox'; +import {can} from 'coral-framework/services/perms'; import ChangeUsernameContainer from 'coral-sign-in/containers/ChangeUsernameContainer'; import I18n from 'coral-framework/modules/i18n/i18n'; @@ -42,7 +43,7 @@ class Stream extends React.Component { removeCommentTag, pluginProps, ignoreUser, - auth: {loggedIn, isAdmin, user}, + auth: {loggedIn, user}, commentCountCache, editName } = this.props; @@ -124,7 +125,7 @@ class Stream extends React.Component { {loggedIn && user && } - {loggedIn && } + {loggedIn && } {/* the highlightedComment is isolated after the user followed a permalink */} {highlightedComment diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index 7da153019..6b71fa9dd 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -296,8 +296,7 @@ export const checkLogin = () => (dispatch) => { throw new Error('Not logged in'); } - const isAdmin = !!result.user.roles.filter((i) => i === 'ADMIN').length; - dispatch(checkLoginSuccess(result.user, isAdmin)); + dispatch(checkLoginSuccess(result.user)); }) .catch((error) => { console.error(error); diff --git a/client/coral-framework/reducers/auth.js b/client/coral-framework/reducers/auth.js index 0796f7574..8fa4ed75b 100644 --- a/client/coral-framework/reducers/auth.js +++ b/client/coral-framework/reducers/auth.js @@ -4,7 +4,6 @@ import * as actions from '../constants/auth'; const initialState = Map({ isLoading: false, loggedIn: false, - isAdmin: false, user: null, showSignInDialog: false, showCreateUsernameDialog: false, @@ -76,12 +75,10 @@ export default function auth (state = initialState, action) { return state .set('checkedInitialLogin', true) .set('loggedIn', true) - .set('isAdmin', action.isAdmin) .set('user', purge(action.user)); case actions.FETCH_SIGNIN_SUCCESS: return state .set('loggedIn', true) - .set('isAdmin', action.isAdmin) .set('user', purge(action.user)); case actions.FETCH_SIGNIN_FAILURE: return state @@ -117,8 +114,7 @@ export default function auth (state = initialState, action) { return state .set('user', null) .set('isLoading', false) - .set('loggedIn', false) - .set('isAdmin', false); + .set('loggedIn', false); case actions.INVALID_FORM: return state .set('error', action.error); diff --git a/client/coral-framework/services/perms.js b/client/coral-framework/services/perms.js new file mode 100644 index 000000000..e93744506 --- /dev/null +++ b/client/coral-framework/services/perms.js @@ -0,0 +1,34 @@ +import intersection from 'lodash/intersection'; + +const basicRoles = { + HAS_STAFF_TAG: ['ADMIN', 'MODERATOR', 'STAFF'] +}; + +const queryRoles = { + UPDATE_CONFIG: ['ADMIN'], + ACCESS_ADMIN: ['ADMIN', 'MODERATOR'], + VIEW_USER_EMAILS: ['ADMIN'] +}; + +const mutationRoles = { + CHANGE_ROLES: ['ADMIN'], + MODERATE_COMMENTS: ['ADMIN', 'MODERATOR'] +}; + +const roles = {...basicRoles, ...queryRoles, ...mutationRoles}; + +export const can = (user, ...perms) => { + + if (!user) { + return false; + } + + return perms.every((perm) => { + const role = roles[perm]; + if (typeof role === 'undefined') { + throw new Error(`${perm} is not a valid role`); + } + + return intersection(role, user.roles).length > 0; + }); +}; diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js index 96f0e8f73..92483c0f7 100644 --- a/graph/loaders/comments.js +++ b/graph/loaders/comments.js @@ -4,6 +4,10 @@ const { arrayJoinBy } = require('./util'); const DataLoader = require('dataloader'); +const { + SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS, + SEARCH_OTHERS_COMMENTS +} = require('../../perms/constants'); const CommentModel = require('../../models/comment'); const UsersService = require('../../services/users'); @@ -230,7 +234,7 @@ const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, a // Only administrators can search for comments with statuses that are not // `null`, or `'ACCEPTED'`. - if (user != null && user.hasRoles('ADMIN') && statuses) { + if (user != null && user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS) && statuses) { comments = comments.where({ status: { $in: statuses @@ -253,7 +257,7 @@ const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, a } // Only let an admin request any user or the current user request themself. - if (user && (user.hasRoles('ADMIN') || user.id === author_id) && author_id != null) { + if (user && (user.can(SEARCH_OTHERS_COMMENTS) || user.id === author_id) && author_id != null) { comments = comments.where({author_id}); } @@ -403,7 +407,7 @@ const genRecentComments = (_, ids) => { */ const genComments = ({user}, ids) => { let comments; - if (user && user.hasRoles('ADMIN')) { + if (user && user.can(SEARCH_OTHERS_COMMENTS)) { comments = CommentModel.find({ id: { $in: ids diff --git a/graph/mutators/action.js b/graph/mutators/action.js index c5f225e33..0d1cd49f8 100644 --- a/graph/mutators/action.js +++ b/graph/mutators/action.js @@ -2,6 +2,7 @@ const ActionModel = require('../../models/action'); const ActionsService = require('../../services/actions'); const UsersService = require('../../services/users'); const errors = require('../../errors'); +const {CREATE_ACTION, DELETE_ACTION} = require('../../perms/constants'); /** * Creates an action on a item. If the item is a user flag, sets the user's status to @@ -45,7 +46,7 @@ const deleteAction = ({user}, {id}) => { }; module.exports = (context) => { - if (context.user && context.user.can('mutation:createAction', 'mutation:deleteAction')) { + if (context.user && context.user.can(CREATE_ACTION, DELETE_ACTION)) { return { Action: { create: (action) => createAction(context, action), diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 621494db0..8acb2f8ed 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -9,6 +9,13 @@ const KarmaService = require('../../services/karma'); const linkify = require('linkify-it')(); const Wordlist = require('../../services/wordlist'); +const { + CREATE_COMMENT, + SET_COMMENT_STATUS, + ADD_COMMENT_TAG, + REMOVE_COMMENT_TAG, + EDIT_COMMENT +} = require('../../perms/constants'); /** * adjustKarma will adjust the affected user's karma depending on the moderators @@ -101,7 +108,7 @@ const createComment = async ({user, loaders: {Comments}, pubsub}, {body, asset_i tags = tags.map((tag) => ({name: tag})); // If admin or moderator, adding STAFF tag - if (user.hasRoles('ADMIN') || user.hasRoles('MODERATOR')) { + if (user.isStaff()) { tags.push({name: 'STAFF'}); } @@ -179,7 +186,7 @@ const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {}, if (wordlist.banned) { return 'REJECTED'; } - + if (settings.premodLinksEnable && linkify.test(body)) { return 'PREMOD'; } @@ -328,7 +335,7 @@ const edit = async (context, {id, asset_id, edit: {body}}) => { const [wordlist, settings] = await filterNewComment(context, {asset_id, body}); // Determine the new status of the comment. - const status = await resolveNewCommentStatus(context, {asset_id, body}, wordlist, settings); + const status = await resolveNewCommentStatus(context, {asset_id, body}, wordlist, settings); // Execute the edit. await CommentsService.edit(id, context.user.id, {body, status}); @@ -347,23 +354,23 @@ module.exports = (context) => { } }; - if (context.user && context.user.can('mutation:createComment')) { + if (context.user && context.user.can(CREATE_COMMENT)) { mutators.Comment.create = (comment) => createPublicComment(context, comment); } - if (context.user && context.user.can('mutation:setCommentStatus')) { + if (context.user && context.user.can(SET_COMMENT_STATUS)) { mutators.Comment.setStatus = (action) => setStatus(context, action); } - if (context.user && context.user.can('mutation:addCommentTag')) { + if (context.user && context.user.can(ADD_COMMENT_TAG)) { mutators.Comment.addCommentTag = (action) => addCommentTag(context, action); } - if (context.user && context.user.can('mutation:removeCommentTag')) { + if (context.user && context.user.can(REMOVE_COMMENT_TAG)) { mutators.Comment.removeCommentTag = (action) => removeCommentTag(context, action); } - if (context.user && context.user.can('mutation:editComment')) { + if (context.user && context.user.can(EDIT_COMMENT)) { mutators.Comment.edit = (action) => edit(context, action); } diff --git a/graph/mutators/user.js b/graph/mutators/user.js index 34cfd4355..cf0106a2c 100644 --- a/graph/mutators/user.js +++ b/graph/mutators/user.js @@ -1,5 +1,6 @@ const errors = require('../../errors'); const UsersService = require('../../services/users'); +const {SET_USER_STATUS, SUSPEND_USER, REJECT_USERNAME} = require('../../perms/constants'); const setUserStatus = ({user}, {id, status}) => { return UsersService.setStatus(id, status); @@ -32,15 +33,15 @@ module.exports = (context) => { } }; - if (context.user && context.user.can('mutation:setUserStatus')) { + if (context.user && context.user.can(SET_USER_STATUS)) { mutators.User.setUserStatus = (action) => setUserStatus(context, action); } - if (context.user && context.user.can('mutation:suspendUser')) { + if (context.user && context.user.can(SUSPEND_USER)) { mutators.User.suspendUser = (action) => suspendUser(context, action); } - if (context.user && context.user.can('mutation:rejectUsername')) { + if (context.user && context.user.can(REJECT_USERNAME)) { mutators.User.rejectUsername = (action) => rejectUsername(context, action); } diff --git a/graph/resolvers/action.js b/graph/resolvers/action.js index 393024fe1..8ca05daf8 100644 --- a/graph/resolvers/action.js +++ b/graph/resolvers/action.js @@ -1,3 +1,5 @@ +const {SEARCH_OTHER_USERS} = require('../../perms/constants'); + const Action = { __resolveType({action_type}) { switch (action_type) { @@ -11,7 +13,7 @@ const Action = { // 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.hasRole('ADMIN') || user_id === user.id)) { + if (user && (user.can(SEARCH_OTHER_USERS) || user_id === user.id)) { return Users.getByID.load(user_id); } } diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js index 7273bf58a..2e8412169 100644 --- a/graph/resolvers/comment.js +++ b/graph/resolvers/comment.js @@ -31,8 +31,7 @@ const Comment = { }, actions({id}, _, {user, loaders: {Actions}}) { - // Only return the actions if the user is not an admin. - if (user && user.hasRoles('ADMIN')) { + if (user && user.can('SEARCH_ACTIONS')) { return Actions.getByID.load(id); } diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index 8c1552b5f..9f3a5e157 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -1,6 +1,13 @@ +const { + SEARCH_ASSETS, + SEARCH_OTHERS_COMMENTS, + SEARCH_COMMENT_METRICS, + SEARCH_OTHER_USERS +} = require('../../perms/constants'); + const RootQuery = { assets(_, args, {loaders: {Assets}, user}) { - if (user == null || !user.hasRoles('ADMIN')) { + if (user == null || !user.can(SEARCH_ASSETS)) { return null; } @@ -22,7 +29,7 @@ const RootQuery = { async comments(_, {query}, {user, loaders: {Comments, Actions}}) { let {action_type} = query; - if (user != null && user.hasRoles('ADMIN') && action_type) { + if (user != null && user.can(SEARCH_OTHERS_COMMENTS) && action_type) { query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'}); } @@ -34,7 +41,7 @@ const RootQuery = { }, async commentCount(_, {query}, {user, loaders: {Actions, Comments}}) { - if (user == null || !user.hasRoles('ADMIN')) { + if (user == null || !user.can(SEARCH_OTHERS_COMMENTS)) { return null; } @@ -48,7 +55,7 @@ const RootQuery = { }, assetMetrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics: {Assets}}}) { - if (user == null || !user.hasRoles('ADMIN')) { + if (user == null || !user.can(SEARCH_ASSETS)) { return null; } @@ -60,7 +67,7 @@ const RootQuery = { }, commentMetrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics: {Comments}}}) { - if (user == null || !user.hasRoles('ADMIN')) { + if (user == null || !user.can(SEARCH_COMMENT_METRICS)) { return null; } @@ -89,7 +96,7 @@ const RootQuery = { // 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. async users(_, {query}, {user, loaders: {Users, Actions}}) { - if (user == null || !user.hasRoles('ADMIN')) { + if (user == null || !user.can(SEARCH_OTHER_USERS)) { return null; } diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js index e7c0db20c..d3254588e 100644 --- a/graph/resolvers/user.js +++ b/graph/resolvers/user.js @@ -1,4 +1,5 @@ const KarmaService = require('../../services/karma'); +const {SEARCH_ACTIONS, SEARCH_OTHERS_COMMENTS, UPDATE_USER_ROLES} = require('../../perms/constants'); const User = { action_summaries({id}, _, {loaders: {Actions}}) { @@ -7,7 +8,7 @@ const User = { actions({id}, _, {user, loaders: {Actions}}) { // Only return the actions if the user is not an admin. - if (user && user.hasRoles('ADMIN')) { + if (user && user.can(SEARCH_ACTIONS)) { return Actions.getByID.load(id); } @@ -23,7 +24,7 @@ const User = { // If the user is not an admin, only return comment list for the owner of // the comments. - if (user && (user.hasRoles('ADMIN') || user.id === id)) { + if (user && (user.can(SEARCH_OTHERS_COMMENTS) || user.id === id)) { return Comments.getByQuery({author_id: id, sort: 'REVERSE_CHRONOLOGICAL'}); } @@ -56,7 +57,7 @@ const User = { roles({id, roles}, _, {user}) { // If the user is not an admin, only return the current user's roles. - if (user && (user.hasRoles('ADMIN') || user.id === id)) { + if (user && (user.can(UPDATE_USER_ROLES) || user.id === id)) { return roles; } diff --git a/models/user.js b/models/user.js index b02d2c3bc..27cecaef9 100644 --- a/models/user.js +++ b/models/user.js @@ -1,11 +1,14 @@ const mongoose = require('../services/mongoose'); const bcrypt = require('bcrypt'); const uuid = require('uuid'); +const intersection = require('lodash/intersection'); +const can = require('../perms'); // USER_ROLES is the array of roles that is permissible as a user role. const USER_ROLES = [ 'ADMIN', - 'MODERATOR' + 'MODERATOR', + 'STAFF' ]; // USER_STATUS is the list of statuses that are permitted for the user status. @@ -165,14 +168,10 @@ UserSchema.index({ }); /** - * Returns true if the user has all the roles specified. + * returns true if a commenter is staff */ -UserSchema.method('hasRoles', function(...roles) { - return roles.every((role) => { - - // TODO: remove toUpperCase() once we've migrated usage. - return this.roles.indexOf(role.toUpperCase()) >= 0; - }); +UserSchema.method('isStaff', function () { + return intersection(['ADMIN', 'MODERATOR', 'STAFF'], this.roles).length !== 0; }); /** @@ -194,49 +193,12 @@ UserSchema.method('verifyPassword', function(password) { }); }); -/** - * All the graph operations that are available for a user. - * @type {Array} - */ -const USER_GRAPH_OPERATIONS = [ - 'mutation:createComment', - 'mutation:createAction', - 'mutation:deleteAction', - 'mutation:editName', - 'mutation:setUserStatus', - 'mutation:suspendUser', - 'mutation:rejectUsername', - 'mutation:setCommentStatus', - 'mutation:addCommentTag', - 'mutation:removeCommentTag', - 'mutation:editComment' -]; - /** * Can returns true if the user is allowed to perform a specific graph * operation. */ UserSchema.method('can', function(...actions) { - if (actions.some((action) => USER_GRAPH_OPERATIONS.indexOf(action) === -1)) { - throw new Error(`invalid actions: ${actions}`); - } - - if (this.status === 'BANNED' || (this.suspension.until && this.suspension.until > new Date())) { - return false; - } - - const adminOnlyActions = ['mutation:setUserStatus', 'mutation:suspendUser', 'mutation:rejectUsername', 'mutation:setCommentStatus']; - if (actions.some((action) => adminOnlyActions.indexOf(action) > 0 && !this.hasRoles('ADMIN'))) { - return false; - } - - // {add,remove}CommentTag - requires admin and/or moderator role - const userCanModifyTags = (user) => ['ADMIN', 'MODERATOR'].some((r) => user.hasRoles(r)); - if (actions.some((a) => ['mutation:removeCommentTag', 'mutation:addCommentTag'].includes(a)) && !userCanModifyTags(this)) { - return false; - } - - return true; + return can(this, ...actions); }); // Create the User model. diff --git a/perms/constants.js b/perms/constants.js new file mode 100644 index 000000000..2b5b907ac --- /dev/null +++ b/perms/constants.js @@ -0,0 +1,25 @@ +module.exports = { + + // mutations + CREATE_COMMENT: 'CREATE_COMMENT', + CREATE_ACTION: 'CREATE_ACTION', + DELETE_ACTION: 'DELETE_ACTION', + EDIT_NAME: 'EDIT_NAME', + EDIT_COMMENT: 'EDIT_COMMENT', + REJECT_USERNAME: 'REJECT_USERNAME', + SET_USER_STATUS: 'SET_USER_STATUS', + SUSPEND_USER: 'SUSPEND_USER', + SET_COMMENT_STATUS: 'SET_COMMENT_STATUS', + ADD_COMMENT_TAG: 'ADD_COMMENT_TAG', + REMOVE_COMMENT_TAG: 'REMOVE_COMMENT_TAG', + UPDATE_USER_ROLES: 'UPDATE_USER_ROLES', + UPDATE_CONFIG: 'UPDATE_CONFIG', + + // queries + SEARCH_ASSETS: 'SEARCH_ASSETS', + SEARCH_OTHER_USERS: 'SEARCH_OTHER_USERS', + SEARCH_ACTIONS: 'SEARCH_ACTIONS', + SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: 'SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS', + SEARCH_OTHERS_COMMENTS: 'SEARCH_OTHERS_COMMENTS', + SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS' +}; diff --git a/perms/index.js b/perms/index.js new file mode 100644 index 000000000..f0e14c5fc --- /dev/null +++ b/perms/index.js @@ -0,0 +1,52 @@ +const constants = require('./constants'); +const root = require('./rootReducer'); +const queries = require('./queryReducer'); +const mutations = require('./mutationReducer'); + +const reducers = [ + root, + queries, + mutations +]; + +// this will make 'reducer' a key in this array. hm. +const allPermissions = Object.keys(constants); + +const findGrant = (user, perms) => { + + return perms.every((perm) => { + + for (let key in reducers) { + const reducer = reducers[key]; + const grant = reducer(user, perm); + + if (grant !== null && typeof grant !== 'undefined') { + return grant; + } + } + + return false; + }); +}; + +/** + * returns true, false, or null depending on whether the user has those permissions + * throws an error if you pass a permission that's not known to the system + * @param {User} user the user making the request for db operations + * @param {[type]} context [description] + * @param {String/Array} perms a string an array of strings which are the names of the permissions + * @return {Boolean} + */ +module.exports = (user, ...perms) => { + + // make sure all the passed permissions are not typos + const missingPerms = perms.filter((perm) => { + return allPermissions.indexOf(perm) === -1; + }); + + if (missingPerms.length > 0) { + throw new Error(`${missingPerms.join(' ')} are not valid permissions.`); + } + + return findGrant(user, perms); +}; diff --git a/perms/mutationReducer.js b/perms/mutationReducer.js new file mode 100644 index 000000000..53cece51f --- /dev/null +++ b/perms/mutationReducer.js @@ -0,0 +1,35 @@ +const {check} = require('./utils'); +const types = require('./constants'); + +module.exports = (user, perm) => { + switch (perm) { + case types.CREATE_COMMENT: + return true; + case types.CREATE_ACTION: + return true; + case types.DELETE_ACTION: + return true; + case types.EDIT_NAME: + return true; + case types.EDIT_COMMENT: + return true; + case types.UPDATE_USER_ROLES: + return check(user, ['ADMIN']); + case types.REJECT_USERNAME: + return check(user, ['ADMIN', 'MODERATOR']); + case types.SET_USER_STATUS: + return check(user, ['ADMIN', 'MODERATOR']); + case types.SUSPEND_USER: + return check(user, ['ADMIN', 'MODERATOR']); + case types.SET_COMMENT_STATUS: + return check(user, ['ADMIN', 'MODERATOR']); + case types.ADD_COMMENT_TAG: + return check(user, ['ADMIN', 'MODERATOR']); + case types.REMOVE_COMMENT_TAG: + return check(user, ['ADMIN', 'MODERATOR']); + case types.UPDATE_CONFIG: + return check(user, ['ADMIN', 'MODERATOR']); + default: + break; + } +}; diff --git a/perms/queryReducer.js b/perms/queryReducer.js new file mode 100644 index 000000000..0e5054788 --- /dev/null +++ b/perms/queryReducer.js @@ -0,0 +1,21 @@ +const {check} = require('./utils'); +const types = require('./constants'); + +module.exports = (user, perm) => { + switch (perm) { + case types.SEARCH_ASSETS: + return check(user, ['ADMIN', 'MODERATOR']); + case types.SEARCH_OTHER_USERS: + return check(user, ['ADMIN', 'MODERATOR']); + case types.SEARCH_ACTIONS: + return check(user, ['ADMIN', 'MODERATOR']); + case types.SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: + return check(user, ['ADMIN', 'MODERATOR']); + case types.SEARCH_OTHERS_COMMENTS: + return check(user, ['ADMIN', 'MODERATOR']); + case types.SEARCH_COMMENT_METRICS: + return check(user, ['ADMIN', 'MODERATOR']); + default: + break; + } +}; diff --git a/perms/rootReducer.js b/perms/rootReducer.js new file mode 100644 index 000000000..7fb665654 --- /dev/null +++ b/perms/rootReducer.js @@ -0,0 +1,10 @@ +module.exports = (user /* , perm*/) => { + + // this runs before everything + if ( + user.status === 'BANNED' || + (user.suspension.until && user.suspension.until > new Date()) + ) { + return false; + } +}; diff --git a/perms/utils.js b/perms/utils.js new file mode 100644 index 000000000..e72a49c53 --- /dev/null +++ b/perms/utils.js @@ -0,0 +1,8 @@ +const intersection = require('lodash/intersection'); +const check = (user, roles) => { + return intersection(roles, user.roles).length > 0; +}; + +module.exports = { + check +}; diff --git a/services/users.js b/services/users.js index 728569cd5..496db4528 100644 --- a/services/users.js +++ b/services/users.js @@ -389,13 +389,7 @@ module.exports = class UsersService { return Promise.reject(new Error(`role ${role} is not supported`)); } - return UserModel.update({ - id: id - }, { - $addToSet: { - roles: role - } - }); + return UserModel.update({id}, {$set: {roles: [role]}}); } /** diff --git a/test/server/graph/mutations/addCommentTag.js b/test/server/graph/mutations/addCommentTag.js index f456cda20..5d2a5b46c 100644 --- a/test/server/graph/mutations/addCommentTag.js +++ b/test/server/graph/mutations/addCommentTag.js @@ -44,6 +44,7 @@ describe('graph.mutations.addCommentTag', () => { Object.entries({ 'anonymous': undefined, 'regular commenter': new UserModel({}), + 'staff': new UserModel({roles: ['STAFF']}), 'banned moderator': new UserModel({roles: ['MODERATOR'], status: 'BANNED'}) }).forEach(([ userDescription, user ]) => { it(userDescription, async function () {