diff --git a/keyserver/src/creators/role-creator.js b/keyserver/src/creators/role-creator.js --- a/keyserver/src/creators/role-creator.js +++ b/keyserver/src/creators/role-creator.js @@ -1,11 +1,25 @@ // @flow import { getRolePermissionBlobs } from 'lib/permissions/thread-permissions.js'; +import { + universalCommunityPermissions, + userSurfacedPermissionsSet, + configurableCommunityPermissions, + threadPermissions, +} from 'lib/types/thread-permission-types.js'; import type { ThreadType } from 'lib/types/thread-types-enum.js'; -import type { RoleInfo } from 'lib/types/thread-types.js'; +import { threadTypes } from 'lib/types/thread-types-enum.js'; +import type { + RoleInfo, + RoleModificationRequest, +} from 'lib/types/thread-types.js'; +import { ServerError } from 'lib/utils/errors.js'; import createIDs from './id-creator.js'; import { dbQuery, SQL } from '../database/database.js'; +import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; +import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js'; +import type { Viewer } from '../session/viewer.js'; type InitialRoles = { +default: RoleInfo, @@ -59,4 +73,71 @@ }; } -export { createInitialRolesForNewThread }; +async function modifyRole( + viewer: Viewer, + request: RoleModificationRequest, +): Promise { + const hasPermission = await checkThreadPermission( + viewer, + request.community, + threadPermissions.CHANGE_ROLE, + ); + if (!hasPermission) { + throw new ServerError('invalid_credentials'); + } + + const { community, name, permissions, action } = request; + + for (const permission of permissions) { + if (!userSurfacedPermissionsSet.has(permission)) { + throw new ServerError('invalid_parameters'); + } + } + + const [id] = await createIDs('roles', 1); + const time = Date.now(); + + const configuredPermissions = permissions + .map(permission => [...configurableCommunityPermissions[permission]]) + .flat(); + + const rolePermissions = [ + ...universalCommunityPermissions, + ...configuredPermissions, + ]; + + // For communities of the type `COMMUNITY_ANNOUNCEMENT_ROOT`, the ability for + // the role to be voiced needs to be configured (i.e. the parameters should + // include the user-facing permission VOICED_IN_ANNOUNCEMENT_CHANNELS). This + // means we do not give 'voiced' permissions by default to all new roles. As + // a result, if the thread type is `COMMUNITY_ROOT`, we want to ensure that + // the role has the voiced permission. + const { threadInfos } = await fetchThreadInfos(viewer, { + threadID: community, + }); + const threadInfo = threadInfos[community]; + + if (threadInfo.type === threadTypes.COMMUNITY_ROOT) { + rolePermissions.push(threadPermissions.VOICED); + } + + const permissionsBlob = JSON.stringify( + Object.fromEntries(rolePermissions.map(permission => [permission, true])), + ); + + const row = [id, community, name, permissionsBlob, time]; + + let query = SQL``; + if (action === 'create_role') { + query = SQL` + INSERT INTO roles (id, thread, name, permissions, creation_time) + VALUES (${row}) + `; + } else if (action === 'edit_role') { + throw new ServerError("unimplemented: can't edit roles yet"); + } + + await dbQuery(query); +} + +export { createInitialRolesForNewThread, modifyRole }; diff --git a/keyserver/src/endpoints.js b/keyserver/src/endpoints.js --- a/keyserver/src/endpoints.js +++ b/keyserver/src/endpoints.js @@ -59,6 +59,7 @@ threadFetchMediaResponder, threadJoinResponder, toggleMessagePinResponder, + roleModificationResponder, } from './responders/thread-responders.js'; import { userSubscriptionUpdateResponder, @@ -203,6 +204,10 @@ responder: logOutResponder, requiredPolicies: [], }, + modify_community_role: { + responder: roleModificationResponder, + requiredPolicies: baseLegalPolicies, + }, policy_acknowledgment: { responder: policyAcknowledgmentResponder, requiredPolicies: [], diff --git a/keyserver/src/responders/thread-responders.js b/keyserver/src/responders/thread-responders.js --- a/keyserver/src/responders/thread-responders.js +++ b/keyserver/src/responders/thread-responders.js @@ -8,6 +8,7 @@ rawMessageInfoValidator, messageTruncationStatusesValidator, } from 'lib/types/message-types.js'; +import { userSurfacedPermissionValidator } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type ThreadDeletionRequest, @@ -25,6 +26,7 @@ type ThreadFetchMediaRequest, type ToggleMessagePinRequest, type ToggleMessagePinResult, + type RoleModificationRequest, } from 'lib/types/thread-types.js'; import { serverUpdateInfoValidator } from 'lib/types/update-types.js'; import { userInfosValidator } from 'lib/types/user-types.js'; @@ -42,6 +44,7 @@ entryQueryInputValidator, verifyCalendarQueryThreadIDs, } from './entry-responders.js'; +import { modifyRole } from '../creators/role-creator.js'; import { createThread } from '../creators/thread-creator.js'; import { deleteThread } from '../deleters/thread-deleters.js'; import { fetchMediaForThread } from '../fetchers/upload-fetchers.js'; @@ -346,6 +349,25 @@ ); } +const roleModificationRequestInputValidator = tShape({ + community: tID, + name: t.String, + permissions: t.list(userSurfacedPermissionValidator), + action: t.enums.of(['create_role', 'edit_role']), +}); + +async function roleModificationResponder( + viewer: Viewer, + input: mixed, +): Promise { + const request = await validateInput( + viewer, + roleModificationRequestInputValidator, + input, + ); + await modifyRole(viewer, request); +} + export { threadDeletionResponder, roleUpdateResponder, @@ -357,4 +379,5 @@ threadFetchMediaResponder, newThreadRequestInputValidator, toggleMessagePinResponder, + roleModificationResponder, }; diff --git a/lib/actions/thread-actions.js b/lib/actions/thread-actions.js --- a/lib/actions/thread-actions.js +++ b/lib/actions/thread-actions.js @@ -14,6 +14,7 @@ ThreadFetchMediaResult, ToggleMessagePinRequest, ToggleMessagePinResult, + RoleModificationRequest, } from '../types/thread-types.js'; import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; import { values } from '../utils/objects.js'; @@ -193,6 +194,14 @@ }; }; +const modifyCommunityRole = + ( + callServerEndpoint: CallServerEndpoint, + ): ((request: RoleModificationRequest) => Promise) => + async request => { + await callServerEndpoint('modify_community_role', request); + }; + export { deleteThreadActionTypes, deleteThread, @@ -211,4 +220,5 @@ fetchThreadMedia, toggleMessagePinActionTypes, toggleMessagePin, + modifyCommunityRole, }; diff --git a/lib/types/endpoints.js b/lib/types/endpoints.js --- a/lib/types/endpoints.js +++ b/lib/types/endpoints.js @@ -71,6 +71,7 @@ GET_SESSION_PUBLIC_KEYS: 'get_session_public_keys', JOIN_THREAD: 'join_thread', LEAVE_THREAD: 'leave_thread', + MODIFY_COMMUNITY_ROLE: 'modify_community_role', REMOVE_MEMBERS: 'remove_members', REQUEST_ACCESS: 'request_access', RESTORE_ENTRY: 'restore_entry', diff --git a/lib/types/thread-permission-types.js b/lib/types/thread-permission-types.js --- a/lib/types/thread-permission-types.js +++ b/lib/types/thread-permission-types.js @@ -1,7 +1,7 @@ // @flow import invariant from 'invariant'; -import t, { type TDict, type TUnion } from 'tcomb'; +import t, { type TDict, type TUnion, type TEnums } from 'tcomb'; import { values } from '../utils/objects.js'; import { tBool, tShape, tID } from '../utils/validation-utils.js'; @@ -114,6 +114,11 @@ MANAGE_INVITE_LINKS: 'manage_invite_links', }); export type UserSurfacedPermission = $Values; +export const userSurfacedPermissionsSet: $ReadOnlySet = + new Set(values(userSurfacedPermissions)); +export const userSurfacedPermissionValidator: TEnums = t.enums.of( + values(userSurfacedPermissions), +); const editCalendarPermission = { title: 'Edit calendar', @@ -372,6 +377,37 @@ [userSurfacedPermissions.MANAGE_INVITE_LINKS]: manageInviteLinksPermissions, }); +export const universalCommunityPermissions: $ReadOnlyArray = [ + // know_of | descendant_open_know_of + threadPermissions.KNOW_OF, + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissionFilterPrefixes.OPEN + + threadPermissions.KNOW_OF, + + // descendant_open_voiced + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissionFilterPrefixes.OPEN + + threadPermissions.VOICED, + + // visible | descendant_open_visible + threadPermissions.VISIBLE, + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissionFilterPrefixes.OPEN + + threadPermissions.VISIBLE, + + // join_thread | child_open_join_thread | descendant_opentoplevel_join_thread + threadPermissions.JOIN_THREAD, + threadPermissionPropagationPrefixes.CHILD + + threadPermissionFilterPrefixes.OPEN + + threadPermissions.JOIN_THREAD, + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissionFilterPrefixes.OPEN_TOP_LEVEL + + threadPermissions.JOIN_THREAD, + + threadPermissions.CREATE_SIDEBARS, + threadPermissions.LEAVE_THREAD, +]; + export type ThreadPermissionInfo = | { +value: true, +source: string } | { +value: false, +source: null }; diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -24,6 +24,7 @@ threadPermissionsInfoValidator, type ThreadRolePermissionsBlob, threadRolePermissionsBlobValidator, + type UserSurfacedPermission, } from './thread-permission-types.js'; import { type ThreadType, threadTypeValidator } from './thread-types-enum.js'; import type { ClientUpdateInfo, ServerUpdateInfo } from './update-types.js'; @@ -396,6 +397,13 @@ +threadID: string, }; +export type RoleModificationRequest = { + +community: string, + +name: string, + +permissions: $ReadOnlyArray, + +action: 'create_role' | 'edit_role', +}; + // We can show a max of 3 sidebars inline underneath their parent in the chat // tab. If there are more, we show a button that opens a modal to see the rest export const maxReadSidebars = 3;