[CORL-1134] Site Moderators (#2995)

* feat: added server support for site moderators

* feat: added unscoped property to @auth directive

* feat: added frontend support for site moderator feature

* feat: added site moderator support to stream

* fix: linting

* fix: updated snapshots
This commit is contained in:
Wyatt Johnson
2020-07-13 18:19:22 +00:00
committed by GitHub
parent 00e074d49d
commit 7b2cdbad49
125 changed files with 2663 additions and 972 deletions
@@ -1,14 +1,11 @@
// import createDOMPurify from "dompurify";
// import { JSDOM } from "jsdom";
import { AppOptions } from "coral-server/app";
import { calculateTotalPublishedCommentCount } from "coral-server/models/comment";
import { translate } from "coral-server/services/i18n";
import { find } from "coral-server/services/stories";
import { RequestHandler } from "coral-server/types/express";
const NUMBER_CLASSNAME = "coral-count-number";
const TEXT_CLASSNAME = "coral-count-text";
const NUMBER_CLASS_NAME = "coral-count-number";
const TEXT_CLASS_NAME = "coral-count-text";
export type CountOptions = Pick<AppOptions, "mongo" | "tenantCache" | "i18n">;
@@ -36,18 +33,18 @@ export const countHandler = ({
let html = "";
if (req.query.notext === "true") {
// We only need the count without the text.
html = `<span class="${NUMBER_CLASSNAME}">${count}</span>`;
html = `<span class="${NUMBER_CLASS_NAME}">${count}</span>`;
} else {
// Use translated string.
const bundle = i18n.getBundle(tenant.locale);
html = translate(
bundle,
`<span class="${NUMBER_CLASSNAME}">${count}</span> <span class="${TEXT_CLASSNAME}">Comments</span>`,
`<span class="${NUMBER_CLASS_NAME}">${count}</span> <span class="${TEXT_CLASS_NAME}">Comments</span>`,
"comment-count",
{
number: count,
numberClass: NUMBER_CLASSNAME,
textClass: TEXT_CLASSNAME,
numberClass: NUMBER_CLASS_NAME,
textClass: TEXT_CLASS_NAME,
}
);
}
+11 -2
View File
@@ -414,12 +414,21 @@ export class UserForbiddenError extends CoralError {
operation: string,
userID?: string,
permit?: GQLUSER_AUTH_CONDITIONS[],
conditions?: GQLUSER_AUTH_CONDITIONS[]
conditions?: GQLUSER_AUTH_CONDITIONS[],
unscoped?: boolean
) {
super({
code: ERROR_CODES.USER_NOT_ENTITLED,
context: {
pvt: { reason, userID, resource, operation, conditions, permit },
pvt: {
reason,
userID,
resource,
operation,
conditions,
permit,
unscoped,
},
},
status: 403,
});
+23 -3
View File
@@ -12,6 +12,7 @@ import {
consolidateUserSuspensionStatus,
User,
} from "coral-server/models/user";
import { canModerateUnscoped } from "coral-server/models/user/helpers";
import {
GQLUSER_AUTH_CONDITIONS,
@@ -27,6 +28,7 @@ export interface AuthDirectiveArgs {
roles?: GQLUSER_ROLE[];
userIDField?: string;
permit?: GQLUSER_AUTH_CONDITIONS[];
unscoped?: boolean;
}
function calculateAuthConditions(
@@ -68,7 +70,7 @@ const auth: DirectiveResolverFn<
> = (
next,
src,
{ roles, userIDField, permit }: AuthDirectiveArgs,
{ roles, userIDField, permit, unscoped = false }: AuthDirectiveArgs,
{ user, now },
info
) => {
@@ -115,7 +117,22 @@ const auth: DirectiveResolverFn<
info.operation.operation,
user.id,
permit,
conditions
conditions,
unscoped
);
}
// If the unscoped check is enabled, then ensure that the user can moderate
// unscoped.
if (unscoped && !canModerateUnscoped(user)) {
throw new UserForbiddenError(
"user cannot access unscoped resource as they are scoped",
calculateLocationKey(info),
info.operation.operation,
user.id,
permit,
conditions,
unscoped
);
}
@@ -141,7 +158,10 @@ const auth: DirectiveResolverFn<
"user does not have permission to access the resource",
calculateLocationKey(info),
info.operation.operation,
user ? user.id : undefined
user ? user.id : undefined,
permit,
undefined,
unscoped
);
};
+24 -6
View File
@@ -1,14 +1,24 @@
import GraphContext from "coral-server/graph/context";
import { hasFeatureFlag } from "coral-server/models/tenant";
import { approveComment, rejectComment } from "coral-server/stacks";
import {
GQLApproveCommentInput,
GQLFEATURE_FLAG,
GQLRejectCommentInput,
} from "../schema/__generated__/types";
import { validateUserModerationScopes } from "./helpers";
export const Actions = (ctx: GraphContext) => ({
approveComment: (input: GQLApproveCommentInput) =>
approveComment(
approveComment: async (input: GQLApproveCommentInput) => {
// Validate that this user is allowed to moderate this comment if the
// feature flag is enabled.
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
await validateUserModerationScopes(ctx, ctx.user!, input);
}
return approveComment(
ctx.mongo,
ctx.redis,
ctx.broker,
@@ -17,9 +27,16 @@ export const Actions = (ctx: GraphContext) => ({
input.commentRevisionID,
ctx.user!.id,
ctx.now
),
rejectComment: (input: GQLRejectCommentInput) =>
rejectComment(
);
},
rejectComment: async (input: GQLRejectCommentInput) => {
// Validate that this user is allowed to moderate this comment if the
// feature flag is enabled.
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
await validateUserModerationScopes(ctx, ctx.user!, input);
}
return rejectComment(
ctx.mongo,
ctx.redis,
ctx.broker,
@@ -28,5 +45,6 @@ export const Actions = (ctx: GraphContext) => ({
input.commentRevisionID,
ctx.user!.id,
ctx.now
),
);
},
});
+43 -26
View File
@@ -2,6 +2,7 @@ import { ERROR_CODES } from "coral-common/errors";
import { ADDITIONAL_DETAILS_MAX_LENGTH } from "coral-common/helpers/validate";
import GraphContext from "coral-server/graph/context";
import { mapFieldsetToErrorCodes } from "coral-server/graph/errors";
import { hasFeatureFlag } from "coral-server/models/tenant";
import { addTag, removeTag } from "coral-server/services/comments";
import {
createDontAgree,
@@ -25,6 +26,7 @@ import {
GQLCreateCommentReactionInput,
GQLCreateCommentReplyInput,
GQLEditCommentInput,
GQLFEATURE_FLAG,
GQLFeatureCommentInput,
GQLRemoveCommentDontAgreeInput,
GQLRemoveCommentReactionInput,
@@ -32,6 +34,7 @@ import {
GQLUnfeatureCommentInput,
} from "coral-server/graph/schema/__generated__/types";
import { validateUserModerationScopes } from "./helpers";
import { validateMaximumLength, WithoutMutationID } from "./util";
export const Comments = (ctx: GraphContext) => ({
@@ -155,11 +158,17 @@ export const Comments = (ctx: GraphContext) => ({
},
ctx.now
),
feature: ({
feature: async ({
commentID,
commentRevisionID,
}: WithoutMutationID<GQLFeatureCommentInput>) =>
addTag(
}: WithoutMutationID<GQLFeatureCommentInput>) => {
// Validate that this user is allowed to moderate this comment if the
// feature flag is enabled.
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
await validateUserModerationScopes(ctx, ctx.user!, { commentID });
}
const comment = await addTag(
ctx.mongo,
ctx.tenant,
commentID,
@@ -167,28 +176,36 @@ export const Comments = (ctx: GraphContext) => ({
ctx.user!,
GQLTAG.FEATURED,
ctx.now
)
.then((comment) =>
comment.status !== GQLCOMMENT_STATUS.APPROVED
? approveComment(
ctx.mongo,
ctx.redis,
ctx.broker,
ctx.tenant,
commentID,
commentRevisionID,
ctx.user!.id,
ctx.now
)
: comment
)
.then((comment) => {
// Publish that the comment was featured.
void publishCommentFeatured(ctx.broker, comment);
);
// Return it to the next step.
return comment;
}),
unfeature: ({ commentID }: WithoutMutationID<GQLUnfeatureCommentInput>) =>
removeTag(ctx.mongo, ctx.tenant, commentID, GQLTAG.FEATURED),
if (comment.status !== GQLCOMMENT_STATUS.APPROVED) {
await approveComment(
ctx.mongo,
ctx.redis,
ctx.broker,
ctx.tenant,
commentID,
commentRevisionID,
ctx.user!.id,
ctx.now
);
}
// Publish that the comment was featured.
await publishCommentFeatured(ctx.broker, comment);
// Return it to the next step.
return comment;
},
unfeature: async ({
commentID,
}: WithoutMutationID<GQLUnfeatureCommentInput>) => {
// Validate that this user is allowed to moderate this comment if the
// feature flag is enabled.
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
await validateUserModerationScopes(ctx, ctx.user!, { commentID });
}
return removeTag(ctx.mongo, ctx.tenant, commentID, GQLTAG.FEATURED);
},
});
@@ -15,7 +15,6 @@ import {
enableExternalModerationPhase,
enableFeatureFlag,
enableWebhookEndpoint,
regenerateSSOKey,
rotateExternalModerationPhaseSigningSecret,
rotateSSOSigningSecret,
rotateWebhookEndpointSigningSecret,
@@ -62,9 +61,6 @@ export const Settings = ({
input: WithoutMutationID<GQLUpdateSettingsInput>
): Promise<Tenant | null> =>
update(mongo, redis, tenantCache, config, tenant, input.settings),
// DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0.
regenerateSSOKey: (): Promise<Tenant | null> =>
regenerateSSOKey(mongo, redis, tenantCache, tenant, now),
rotateSSOSigningSecret: ({ inactiveIn }: GQLRotateSSOSigningSecretInput) =>
rotateSSOSigningSecret(mongo, redis, tenantCache, tenant, inactiveIn, now),
deleteSSOSigningSecret: ({ kid }: GQLDeleteSSOSigningSecretInput) =>
+75 -12
View File
@@ -4,6 +4,7 @@ import { ERROR_CODES } from "coral-common/errors";
import GraphContext from "coral-server/graph/context";
import { mapFieldsetToErrorCodes } from "coral-server/graph/errors";
import { Story } from "coral-server/models/story";
import { hasFeatureFlag } from "coral-server/models/tenant";
import {
addStoryExpert,
close,
@@ -22,6 +23,7 @@ import {
GQLAddStoryExpertInput,
GQLCloseStoryInput,
GQLCreateStoryInput,
GQLFEATURE_FLAG,
GQLMergeStoriesInput,
GQLOpenStoryInput,
GQLRemoveStoryExpertInput,
@@ -32,6 +34,8 @@ import {
GQLUpdateStorySettingsInput,
} from "coral-server/graph/schema/__generated__/types";
import { validateUserModerationScopes } from "./helpers";
export const Stories = (ctx: GraphContext) => ({
create: async (input: GQLCreateStoryInput): Promise<Readonly<Story> | null> =>
mapFieldsetToErrorCodes(
@@ -64,22 +68,81 @@ export const Stories = (ctx: GraphContext) => ({
),
updateSettings: async (
input: GQLUpdateStorySettingsInput
): Promise<Readonly<Story> | null> =>
updateSettings(ctx.mongo, ctx.tenant, input.id, input.settings, ctx.now),
close: (input: GQLCloseStoryInput): Promise<Readonly<Story> | null> =>
close(ctx.mongo, ctx.tenant, input.id, ctx.now),
open: (input: GQLOpenStoryInput): Promise<Readonly<Story> | null> =>
open(ctx.mongo, ctx.tenant, input.id, ctx.now),
): Promise<Readonly<Story> | null> => {
// Validate that this user is allowed to edit this story if the feature
// flag is enabled.
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
await validateUserModerationScopes(ctx, ctx.user!, { storyID: input.id });
}
return updateSettings(
ctx.mongo,
ctx.tenant,
input.id,
input.settings,
ctx.now
);
},
close: async (input: GQLCloseStoryInput): Promise<Readonly<Story> | null> => {
// Validate that this user is allowed to close this story if the feature
// flag is enabled.
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
await validateUserModerationScopes(ctx, ctx.user!, { storyID: input.id });
}
return close(ctx.mongo, ctx.tenant, input.id, ctx.now);
},
open: async (input: GQLOpenStoryInput): Promise<Readonly<Story> | null> => {
// Validate that this user is allowed to open this story if the feature
// flag is enabled.
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
await validateUserModerationScopes(ctx, ctx.user!, { storyID: input.id });
}
return open(ctx.mongo, ctx.tenant, input.id, ctx.now);
},
merge: async (input: GQLMergeStoriesInput): Promise<Readonly<Story> | null> =>
merge(ctx.mongo, ctx.tenant, input.destinationID, input.sourceIDs),
remove: async (input: GQLRemoveStoryInput): Promise<Readonly<Story> | null> =>
remove(ctx.mongo, ctx.tenant, input.id, input.includeComments),
scrape: async (input: GQLScrapeStoryInput): Promise<Readonly<Story> | null> =>
scrape(ctx.mongo, ctx.config, ctx.tenant.id, input.id),
updateStoryMode: async (input: GQLUpdateStoryModeInput) =>
updateStoryMode(ctx.mongo, ctx.tenant, input.storyID, input.mode),
addStoryExpert: async (input: GQLAddStoryExpertInput) =>
addStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID),
removeStoryExpert: async (input: GQLRemoveStoryExpertInput) =>
removeStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID),
updateStoryMode: async (input: GQLUpdateStoryModeInput) => {
// Validate that this user is allowed to update the story mode if the
// feature flag is enabled.
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
await validateUserModerationScopes(ctx, ctx.user!, {
storyID: input.storyID,
});
}
return updateStoryMode(ctx.mongo, ctx.tenant, input.storyID, input.mode);
},
addStoryExpert: async (input: GQLAddStoryExpertInput) => {
// Validate that this user is allowed to add a story expert if the
// feature flag is enabled.
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
await validateUserModerationScopes(ctx, ctx.user!, {
storyID: input.storyID,
});
}
return addStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID);
},
removeStoryExpert: async (input: GQLRemoveStoryExpertInput) => {
// Validate that this user is allowed to remove a story expert if the
// feature flag is enabled.
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
await validateUserModerationScopes(ctx, ctx.user!, {
storyID: input.storyID,
});
}
return removeStoryExpert(
ctx.mongo,
ctx.tenant,
input.storyID,
input.userID
);
},
});
+21 -5
View File
@@ -25,6 +25,7 @@ import {
updateAvatar,
updateEmail,
updateEmailByID,
updateModerationScopes,
updateNotificationSettings,
updatePassword,
updateRole,
@@ -61,10 +62,11 @@ import {
GQLUpdatePasswordInput,
GQLUpdateUserAvatarInput,
GQLUpdateUserEmailInput,
GQLUpdateUserModerationScopesInput,
GQLUpdateUsernameInput,
GQLUpdateUserRoleInput,
GQLUpdateUserUsernameInput,
} from "../schema/__generated__/types";
} from "coral-server/graph/schema/__generated__/types";
import { WithoutMutationID } from "./util";
@@ -208,6 +210,16 @@ export const Users = (ctx: GraphContext) => ({
updateAvatar(ctx.mongo, ctx.tenant, input.userID, input.avatar),
updateUserRole: async (input: GQLUpdateUserRoleInput) =>
updateRole(ctx.mongo, ctx.tenant, ctx.user!, input.userID, input.role),
updateUserModerationScopes: async (
input: GQLUpdateUserModerationScopesInput
) =>
updateModerationScopes(
ctx.mongo,
ctx.tenant,
ctx.user!,
input.userID,
input.moderationScopes
),
createModeratorNote: async (input: GQLCreateModeratorNoteInput) =>
addModeratorNote(
ctx.mongo,
@@ -225,16 +237,20 @@ export const Users = (ctx: GraphContext) => ({
input.id,
ctx.user!
),
ban: async (input: GQLBanUserInput) =>
ban: async ({
userID,
message,
rejectExistingComments = false,
}: GQLBanUserInput) =>
ban(
ctx.mongo,
ctx.mailerQueue,
ctx.rejectorQueue,
ctx.tenant,
ctx.user!,
input.userID,
input.message,
input.rejectExistingComments || false,
userID,
message,
rejectExistingComments,
ctx.now
),
premodUser: async (input: GQLPremodUserInput) =>
+107
View File
@@ -0,0 +1,107 @@
import {
CommentNotFoundError,
StoryNotFoundError,
UserForbiddenError,
} from "coral-server/errors";
import { User } from "coral-server/models/user";
import {
canModerate,
canModerateUnscoped,
ModerationScopeResource,
} from "coral-server/models/user/helpers";
import GraphContext from "../context";
interface CommentResourceModerationScope {
commentID: string;
}
function isCommentResourceModerationScope(
scope: ResourceModerationScopes
): scope is CommentResourceModerationScope {
if ((scope as CommentResourceModerationScope).commentID) {
return true;
}
return false;
}
interface StoryResourceModerationScope {
storyID: string;
}
function isStoryResourceModerationScope(
scope: ResourceModerationScopes
): scope is StoryResourceModerationScope {
if ((scope as StoryResourceModerationScope).storyID) {
return true;
}
return false;
}
interface SiteResourceModerationScope {
siteID: string;
}
type ResourceModerationScopes =
| StoryResourceModerationScope
| CommentResourceModerationScope
| SiteResourceModerationScope;
/**
* validateUserModerationScopes will validate if the user has access to
* moderating the resource indicated by the resource scopes.
*
* @param ctx the graph context for this request
* @param user the user being evaluated
* @param scope scope keys for the documents referencing moderation scopes
*/
export async function validateUserModerationScopes(
ctx: GraphContext,
user: Pick<User, "id" | "role" | "moderationScopes">,
scope: ResourceModerationScopes
) {
// If the user has no restrictions on them, exit now.
if (canModerateUnscoped(user)) {
return;
}
let resource: ModerationScopeResource;
if (isCommentResourceModerationScope(scope)) {
// Because the user has siteID restrictions on their moderator capabilities,
// we have to check the siteID of the comment before we make a decision.
const comment = await ctx.loaders.Comments.comment.load(scope.commentID);
if (!comment) {
throw new CommentNotFoundError(scope.commentID);
}
resource = comment;
} else if (isStoryResourceModerationScope(scope)) {
// Because the user has siteID restrictions on their moderator capabilities,
// we have to check the siteID of the story before we make a decision.
const story = await ctx.loaders.Stories.story.load(scope.storyID);
if (!story) {
throw new StoryNotFoundError(scope.storyID);
}
resource = story;
} else {
resource = { siteID: scope.siteID };
}
// Check to see if this user is allowed to moderate this comment.
if (canModerate(user, resource)) {
return;
}
// The user had the right role, but had a siteID restriction that prevented
// them from moderating this comment.
throw new UserForbiddenError(
"user does not have permission to moderate this comment",
"comment",
"mutation",
user.id
);
}
@@ -16,11 +16,17 @@ import {
} from "coral-server/models/comment/helpers";
import { createConnection } from "coral-server/models/helpers";
import { getURLWithCommentID } from "coral-server/models/story";
import { hasFeatureFlag } from "coral-server/models/tenant";
import {
canModerate,
hasModeratorRole,
} from "coral-server/models/user/helpers";
import { getCommentEditableUntilDate } from "coral-server/services/comments";
import {
GQLComment,
GQLCommentTypeResolver,
GQLFEATURE_FLAG,
} from "coral-server/graph/schema/__generated__/types";
import GraphContext from "../context";
@@ -53,6 +59,19 @@ export const Comment: GQLCommentTypeResolver<comment.Comment> = {
c.revisions.length > 0
? { revision: getLatestRevision(c), comment: c }
: null,
canModerate: (c, input, ctx) => {
if (!ctx.user) {
return false;
}
// If the feature flag for site moderators is not turned on return based on
// the users role.
if (!hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
return hasModeratorRole(ctx.user);
}
return canModerate(ctx.user, { siteID: c.siteID });
},
deleted: ({ deletedAt }) => !!deletedAt,
revisionHistory: (c) =>
c.revisions.map((revision) => ({ revision, comment: c })),
+4 -5
View File
@@ -75,11 +75,6 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
comment: await ctx.mutators.Comments.unfeature(input),
clientMutationId,
}),
// DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0.
regenerateSSOKey: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.regenerateSSOKey(),
clientMutationId: input.clientMutationId,
}),
rotateSSOSigningSecret: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.rotateSSOSigningSecret(input),
clientMutationId: input.clientMutationId,
@@ -184,6 +179,10 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
user: await ctx.mutators.Users.updateUserRole(input),
clientMutationId: input.clientMutationId,
}),
updateUserModerationScopes: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.updateUserModerationScopes(input),
clientMutationId: input.clientMutationId,
}),
banUser: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.ban(input),
clientMutationId: input.clientMutationId,
+27
View File
@@ -0,0 +1,27 @@
import * as site from "coral-server/models/site";
import { hasFeatureFlag } from "coral-server/models/tenant";
import {
canModerate,
hasModeratorRole,
} from "coral-server/models/user/helpers";
import {
GQLFEATURE_FLAG,
GQLSiteTypeResolver,
} from "coral-server/graph/schema/__generated__/types";
export const Site: GQLSiteTypeResolver<site.Site> = {
canModerate: ({ id }, args, ctx) => {
if (!ctx.user) {
return false;
}
// If the feature flag for site moderators is not turned on return based on
// the users role.
if (!hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
return hasModeratorRole(ctx.user);
}
return canModerate(ctx.user, { siteID: id });
},
};
+20
View File
@@ -2,8 +2,14 @@ import { defaultsDeep } from "lodash";
import { decodeActionCounts } from "coral-server/models/action/comment";
import * as story from "coral-server/models/story";
import { hasFeatureFlag } from "coral-server/models/tenant";
import {
canModerate,
hasModeratorRole,
} from "coral-server/models/user/helpers";
import {
GQLFEATURE_FLAG,
GQLSTORY_STATUS,
GQLStoryTypeResolver,
GQLTAG,
@@ -21,6 +27,20 @@ export const Story: GQLStoryTypeResolver<story.Story> = {
story.isStoryClosed(ctx.tenant, s, ctx.now)
? GQLSTORY_STATUS.CLOSED
: GQLSTORY_STATUS.OPEN,
canModerate: (s, input, ctx) => {
if (!ctx.user) {
return false;
}
// If the feature flag for site moderators is not turned on return based on
// the users role.
if (!hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
return hasModeratorRole(ctx.user);
}
// We know the user is provided because this edge is authenticated.
return canModerate(ctx.user, { siteID: s.siteID });
},
isClosed: (s, input, ctx) => story.isStoryClosed(ctx.tenant, s, ctx.now),
closedAt: (s, input, ctx) => story.getStoryClosedAt(ctx.tenant, s) || null,
commentActionCounts: (s) => decodeActionCounts(s.commentCounts.action),
+24 -5
View File
@@ -1,13 +1,17 @@
import { GraphQLResolveInfo } from "graphql";
import GraphContext from "coral-server/graph/context";
import {
GQLUser,
GQLUserTypeResolver,
} from "coral-server/graph/schema/__generated__/types";
import { hasFeatureFlag } from "coral-server/models/tenant";
import * as user from "coral-server/models/user";
import { roleIsStaff } from "coral-server/models/user/helpers";
import {
GQLFEATURE_FLAG,
GQLUser,
GQLUSER_ROLE,
GQLUserTypeResolver,
} from "coral-server/graph/schema/__generated__/types";
import { RecentCommentHistoryInput } from "./RecentCommentHistory";
import { UserStatusInput } from "./UserStatus";
import { getRequestedFields } from "./util";
@@ -45,9 +49,24 @@ export const User: GQLUserTypeResolver<user.User> = {
...status,
userID: id,
}),
moderationScopes: ({ role, moderationScopes }, input, ctx) => {
// If the feature flag for site moderators is not turned on return null
// always.
if (!hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
return null;
}
// Moderation scopes only apply to users that have the moderator role.
if (role !== GQLUSER_ROLE.MODERATOR) {
return null;
}
// For all other users return null for moderation scopes.
return moderationScopes;
},
ignoredUsers: ({ ignoredUsers }, input, ctx, info) =>
maybeLoadOnlyIgnoredUserID(ctx, info, ignoredUsers),
ignoreable: ({ role }) => !roleIsStaff(role),
recentCommentHistory: ({ id }): RecentCommentHistoryInput => ({ userID: id }),
profiles: ({ profiles }) => (profiles ? profiles : []),
profiles: ({ profiles = [] }) => profiles,
};
@@ -0,0 +1,15 @@
import * as user from "coral-server/models/user";
import { GQLUserModerationScopesTypeResolver } from "coral-server/graph/schema/__generated__/types";
import { isModerationScoped } from "coral-server/models/user/helpers";
export const UserModerationScopes: GQLUserModerationScopesTypeResolver<user.UserModerationScopes> = {
scoped: (moderationScopes) => isModerationScoped(moderationScopes),
sites: ({ siteIDs }, args, ctx) => {
if (siteIDs) {
return ctx.loaders.Sites.site.loadMany(siteIDs);
}
return null;
},
};
+4
View File
@@ -42,6 +42,7 @@ import { RecentCommentHistory } from "./RecentCommentHistory";
import { RejectCommentPayload } from "./RejectCommentPayload";
import { Settings } from "./Settings";
import { SigningSecret } from "./SigningSecret";
import { Site } from "./Site";
import { SlackConfiguration } from "./SlackConfiguration";
import { SSOAuthIntegration } from "./SSOAuthIntegration";
import { Story } from "./Story";
@@ -51,6 +52,7 @@ import { SuspensionStatus } from "./SuspensionStatus";
import { SuspensionStatusHistory } from "./SuspensionStatusHistory";
import { Tag } from "./Tag";
import { User } from "./User";
import { UserModerationScopes } from "./UserModerationScopes";
import { UsernameHistory } from "./UsernameHistory";
import { UsernameStatus } from "./UsernameStatus";
import { UserStatus } from "./UserStatus";
@@ -95,6 +97,7 @@ const Resolvers: GQLResolver = {
RejectCommentPayload,
SSOAuthIntegration,
SigningSecret,
Site,
Story,
StorySettings,
Subscription,
@@ -103,6 +106,7 @@ const Resolvers: GQLResolver = {
Tag,
Time,
User,
UserModerationScopes,
Queue,
Queues,
UsernameHistory,
+111 -41
View File
@@ -46,10 +46,13 @@ is used without options, it simply requires a logged in user. `permit` can be
used to allow specific `USER_AUTH_CONDITIONS` that normally (if present) would
deny access to any edge associated with the `@auth` directive. If a User has
only some of the conditions listed, they will pass, but if they have at least
one more that isn't in the list, the request will be denied.
one more that isn't in the list, the request will be denied. If `unscoped` is
provided and is `true`, it will require that the user does not have any
moderation scopes applied to their account (such as a site moderator).
"""
directive @auth(
roles: [USER_ROLE!]
unscoped: Boolean
userIDField: String
permit: [USER_AUTH_CONDITIONS!]
) on FIELD_DEFINITION
@@ -387,6 +390,12 @@ enum FEATURE_FLAG {
DEFAULT_QA_STORY_MODE will set the story mode to QA by default.
"""
DEFAULT_QA_STORY_MODE
"""
SITE_MODERATOR will enable the enhanced Site Moderator role by assigning a new
modeartion scope to a user.
"""
SITE_MODERATOR
}
# The moderation mode of the site.
@@ -1510,6 +1519,12 @@ type Site {
"""
allowedOrigins: [String!]!
"""
canModerate when true indicates that the current user can moderate comments
left on this Site.
"""
canModerate: Boolean!
"""
createdAt is when the site was created.
"""
@@ -2122,6 +2137,24 @@ type UserNotificationSettings {
digestFrequency: DIGEST_FREQUENCY!
}
"""
UserModerationScopes describes the scopes for moderation. These only apply when
the user has a MODERATOR role.
"""
type UserModerationScopes {
"""
scoped returns true when the moderator has a scoped moderation role, and
cannot moderate organization wide.
"""
scoped: Boolean!
"""
sites (when not null) list the sites that the user is only allowed to moderate
on. When null, it means that the user is allowed to moderate on all sites.
"""
sites: [Site!]
}
"""
User is someone that leaves Comments, and logs in.
"""
@@ -2318,6 +2351,13 @@ type User {
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
moderationScopes describes the scopes for moderation. These only apply when
the user has a MODERATOR role.
"""
moderationScopes: UserModerationScopes
@auth(userIDField: "id", roles: [ADMIN, MODERATOR])
"""
ssoURL is the url for managing sso account
"""
@@ -2748,6 +2788,11 @@ type Comment {
"""
deleted: Boolean
"""
canModerate returns true if the current user can moderate this Comment.
"""
canModerate: Boolean!
"""
site is the Site referenced by the Story for this Comment.
"""
@@ -3082,6 +3127,12 @@ type Story {
"""
lastCommentedAt: Time @auth(roles: [ADMIN])
"""
canModerate returns true if the current user can moderate comments on this
Story.
"""
canModerate: Boolean!
"""
site is the site associated with the story
"""
@@ -4167,7 +4218,6 @@ input NewCommentersConfigurationInput {
approvedCommentsThreshold: Int
}
"""
RTEConfigurationInput specifies the configuration for the rte.
"""
@@ -4313,7 +4363,6 @@ input SettingsInput {
rte: RTEConfigurationInput
}
"""
UpdateSettingsInput provides the input for the updateSettings Mutation.
"""
@@ -4521,29 +4570,6 @@ type CreateCommentFlagPayload {
clientMutationId: String!
}
##################
## regenerateSSOKey
##################
input RegenerateSSOKeyInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type RegenerateSSOKeyPayload {
"""
settings is the Settings that the SSO secret was regenerated on.
"""
settings: Settings
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## createStory
##################
@@ -6038,13 +6064,59 @@ type UpdateUserAvatarPayload {
clientMutationId: String!
}
##################
# updateUserModerationScopes
##################
"""
ModerationScopesInput describes the different scopes that a given moderator
could be assigned.
"""
input ModerationScopesInput {
"""
siteIDs is an array of ID's that should be the list of sites that a moderator
is limited to. If none are passed, it will remove the scoping for this User.
"""
siteIDs: [String!]
}
input UpdateUserModerationScopesInput {
"""
userID is the ID of the User that should have their role updated.
"""
userID: ID!
"""
moderationScopes defines the scopes that a given moderator should be limited
to when
"""
moderationScopes: ModerationScopesInput!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type UpdateUserModerationScopesPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# updateUserRole
##################
input UpdateUserRoleInput {
"""
userID is the ID of the User that should have their avatar updated.
userID is the ID of the User that should have their role updated.
"""
userID: ID!
@@ -6739,16 +6811,6 @@ type Mutation {
updateSettings(input: UpdateSettingsInput!): UpdateSettingsPayload!
@auth(roles: [ADMIN])
"""
regenerateSSOKey will regenerate the SSO secret used to sign secrets. This will
invalidate any existing user sessions.
DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0.
"""
regenerateSSOKey(input: RegenerateSSOKeyInput!): RegenerateSSOKeyPayload!
@auth(roles: [ADMIN])
@deprecated(reason: "deprecated in favour of `rotateSSOSigningSecret`")
"""
rotateSSOSigningSecret can be used to rotate a given active SigningSecret.
"""
@@ -7010,17 +7072,26 @@ type Mutation {
updateUserRole(input: UpdateUserRoleInput!): UpdateUserRolePayload!
@auth(roles: [ADMIN])
"""
updateUserModerationScopes will update the moderation scopes for a given user.
This is used to limit the scopes for which a given moderator can moderate if
they also have the moderator role.
"""
updateUserModerationScopes(
input: UpdateUserModerationScopesInput!
): UpdateUserModerationScopesPayload! @auth(roles: [ADMIN])
"""
banUser will ban a specific User from interacting with Comments.
"""
banUser(input: BanUserInput!): BanUserPayload!
@auth(roles: [ADMIN, MODERATOR])
@auth(roles: [ADMIN, MODERATOR], unscoped: true)
"""
removeUserBan will remove an active ban from a User if they have one.
"""
removeUserBan(input: RemoveUserBanInput!): RemoveUserBanPayload!
@auth(roles: [ADMIN, MODERATOR])
@auth(roles: [ADMIN, MODERATOR], unscoped: true)
"""
suspendUser will suspend a specific User from interacting with Comments.
@@ -7230,8 +7301,7 @@ type Mutation {
"""
testSMTP sends a test email.
"""
testSMTP(input: TestSMTPInput!): TestSMTPPayload!
@auth(roles: [ADMIN, MODERATOR])
testSMTP(input: TestSMTPInput!): TestSMTPPayload! @auth(roles: [ADMIN])
}
##################
+2
View File
@@ -1,5 +1,7 @@
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
export const MODERATOR_ROLES = [GQLUSER_ROLE.ADMIN, GQLUSER_ROLE.MODERATOR];
export const STAFF_ROLES = [
GQLUSER_ROLE.ADMIN,
GQLUSER_ROLE.MODERATOR,
+94 -2
View File
@@ -4,8 +4,14 @@ import { SSOUserProfile } from "coral-server/app/middleware/passport/strategies/
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
import { STAFF_ROLES } from "./constants";
import { LocalProfile, Profile, SSOProfile, User } from "./user";
import { MODERATOR_ROLES, STAFF_ROLES } from "./constants";
import {
LocalProfile,
Profile,
SSOProfile,
User,
UserModerationScopes,
} from "./user";
export function roleIsStaff(role: GQLUSER_ROLE) {
if (STAFF_ROLES.includes(role)) {
@@ -19,6 +25,92 @@ export function hasStaffRole(user: Pick<User, "role">) {
return roleIsStaff(user.role);
}
function roleIsModerator(role: GQLUSER_ROLE) {
if (MODERATOR_ROLES.includes(role)) {
return true;
}
return false;
}
export function hasModeratorRole(user: Pick<User, "role">) {
return roleIsModerator(user.role);
}
export function isModerationScoped(moderationScopes?: UserModerationScopes) {
return (
!!moderationScopes &&
!!moderationScopes.siteIDs &&
moderationScopes.siteIDs.length > 0
);
}
/**
* canModerateUnscoped will check if a given user is unscoped (without any
* restrictions) on their moderation capacity.
*
* @param user the user being checked for moderation scopes
*/
export function canModerateUnscoped(
user: Pick<User, "role" | "moderationScopes">
) {
// You can't possibly be a global moderator if you don't at least have a
// moderator compatible role.
if (!hasModeratorRole(user)) {
return false;
}
// If you specifically have a moderator role, then if there is a siteID
// restricting which sites you can moderate on, then you are not a global
// moderator.
if (
user.role === GQLUSER_ROLE.MODERATOR &&
isModerationScoped(user.moderationScopes)
) {
return false;
}
return true;
}
export interface ModerationScopeResource {
siteID?: string;
}
/**
* canModerate checks against the moderation scopes to determine if the current
* user can moderate the given scope.
*
* @param user the user to check moderation scopes on
* @param scopes the scopes to check against
*/
export function canModerate(
user: Pick<User, "role" | "moderationScopes">,
{ siteID }: ModerationScopeResource
) {
// You can't possibly moderate if you don't at least have a moderator
// compatible role.
if (!hasModeratorRole(user)) {
return false;
}
// If the user is a moderator, then if the user is scoped to only moderate
// specific sites, then ensure they can moderate _this_ siteID.
if (
user.role === GQLUSER_ROLE.MODERATOR &&
user.moderationScopes &&
siteID &&
user.moderationScopes.siteIDs &&
!user.moderationScopes.siteIDs.includes(siteID)
) {
return false;
}
// The moderator does not have any scopes that prevent them from moderating
// this comment.
return true;
}
export function getUserProfile(
user: Pick<User, "profiles">,
type: Profile["type"]
+36
View File
@@ -363,6 +363,14 @@ export interface UserCommentCounts {
status: CommentStatusCounts;
}
export interface UserModerationScopes {
/**
* siteIDs is the array of site ID's that this User can moderate. If not
* provided the user can moderate all sites.
*/
siteIDs?: string[];
}
/**
* User is someone that leaves Comments, and logs in.
*/
@@ -432,6 +440,12 @@ export interface User extends TenantResource {
*/
role: GQLUSER_ROLE;
/**
* moderationScopes describes the scopes for moderation. These only apply when
* the user has a MODERATOR role.
*/
moderationScopes?: UserModerationScopes;
/**
* notifications stores the notification settings for the given User.
*/
@@ -743,6 +757,28 @@ export async function updateUserRole(
return result.value;
}
export async function updateUserModerationScopes(
mongo: Db,
tenantID: string,
id: string,
moderationScopes: UserModerationScopes
) {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
{ $set: { moderationScopes } },
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
throw new UserNotFoundError(id);
}
return result.value;
}
export async function verifyUserPassword(
user: Pick<User, "profiles">,
password: string,
-24
View File
@@ -12,30 +12,6 @@ import {
import { TenantCache } from "./cache";
/**
* regenerateSSOKey will regenerate the Single Sign-On key for the specified
* Tenant and notify all other Tenant's connected that the Tenant was updated.
*
* DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0.
*/
export async function regenerateSSOKey(
mongo: Db,
redis: Redis,
cache: TenantCache,
tenant: Tenant,
now: Date
) {
// Regeneration is the same as rotating but with a specific 30 day window.
return rotateSSOSigningSecret(
mongo,
redis,
cache,
tenant,
30 * 24 * 60 * 60,
now
);
}
export async function rotateSSOSigningSecret(
mongo: Db,
redis: Redis,
+43 -1
View File
@@ -14,6 +14,7 @@ import {
DuplicateUserError,
EmailAlreadySetError,
EmailNotSetError,
InternalError,
InvalidCredentialsError,
LocalProfileAlreadySetError,
LocalProfileNotSetError,
@@ -29,7 +30,12 @@ import {
} from "coral-server/errors";
import logger from "coral-server/logger";
import { Comment, retrieveComment } from "coral-server/models/comment";
import { linkUsersAvailable, Tenant } from "coral-server/models/tenant";
import { retrieveManySites } from "coral-server/models/site";
import {
hasFeatureFlag,
linkUsersAvailable,
Tenant,
} from "coral-server/models/tenant";
import {
banUser,
clearDeletionDate,
@@ -61,11 +67,13 @@ import {
suspendUser,
updateUserAvatar,
updateUserEmail,
updateUserModerationScopes,
updateUserNotificationSettings,
updateUserPassword,
updateUserRole,
updateUserUsername,
User,
UserModerationScopes,
verifyUserPassword,
} from "coral-server/models/user";
import {
@@ -80,6 +88,7 @@ import { sendConfirmationEmail } from "coral-server/services/users/auth";
import {
GQLAuthIntegrations,
GQLFEATURE_FLAG,
GQLUSER_ROLE,
} from "coral-server/graph/schema/__generated__/types";
@@ -656,6 +665,39 @@ export async function updateRole(
return updateUserRole(mongo, tenant.id, userID, role);
}
export async function updateModerationScopes(
mongo: Db,
tenant: Tenant,
user: Pick<User, "id">,
userID: string,
moderationScopes: UserModerationScopes
) {
if (!hasFeatureFlag(tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
throw new InternalError("feature flag not enabled", {
flag: GQLFEATURE_FLAG.SITE_MODERATOR,
});
}
if (user.id === userID) {
throw new Error("cannot update your own moderation scopes");
}
// Verify that the scopes referenced exist.
if (moderationScopes.siteIDs) {
const sites = await retrieveManySites(
mongo,
tenant.id,
moderationScopes.siteIDs
);
if (sites.some((site) => site === null)) {
throw new Error("site specified does not exist");
}
}
return updateUserModerationScopes(mongo, tenant.id, userID, moderationScopes);
}
/**
* enabledAuthenticationIntegrations returns enabled auth integrations for a tenant
*