feat(recording): adds custom userdata recording permission (#21497)

* feat(recording): adds custom "bbb_record_permission" userdata

Adds two new userdata parameters:
 - bbb_record_permission: if true, bypass the moderator/viewer role permission and allows the user to start/stop the recording
 - bbb_record_permission_tooltip: a string to show in the tooltip when the user don't have permission, useful to explain why the user can't start recording

This enables integrations to have custom rules for specific users to have or not permission to record a meeting

* chore(docs): adds new recording userdata to docs

* refactor: clean unused code

* Update docs/docs/administration/customize.md

Co-authored-by: Anton Georgiev <antobinary@users.noreply.github.com>

---------

Co-authored-by: Anton Georgiev <antobinary@users.noreply.github.com>
This commit is contained in:
germanocaumo 2024-10-23 13:47:48 +00:00 committed by GitHub
parent d876c45784
commit bc324f480e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 62 additions and 26 deletions

View File

@ -9,6 +9,7 @@ import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait }
import org.bigbluebutton.core2.message.senders.MsgBuilder import org.bigbluebutton.core2.message.senders.MsgBuilder
import org.bigbluebutton.core.apps.voice.VoiceApp import org.bigbluebutton.core.apps.voice.VoiceApp
import org.bigbluebutton.core.db.{ MeetingRecordingDAO, NotificationDAO } import org.bigbluebutton.core.db.{ MeetingRecordingDAO, NotificationDAO }
import org.bigbluebutton.core.models.{ Users2x }
trait SetRecordingStatusCmdMsgHdlr extends RightsManagementTrait { trait SetRecordingStatusCmdMsgHdlr extends RightsManagementTrait {
this: UsersApp => this: UsersApp =>
@ -29,9 +30,19 @@ trait SetRecordingStatusCmdMsgHdlr extends RightsManagementTrait {
BbbCommonEnvCoreMsg(envelope, event) BbbCommonEnvCoreMsg(envelope, event)
} }
if (permissionFailed(PermissionCheck.MOD_LEVEL, PermissionCheck.VIEWER_LEVEL, liveMeeting.users2x, msg.header.userId)) { // Retrieve custom record permission from metadata
val customRecordPermission: Option[Boolean] = Users2x.findWithIntId(liveMeeting.users2x, msg.header.userId).flatMap { user =>
user.userMetadata.get("bbb_record_permission").map(_.toBoolean)
}
// Determine final permission using metadata or fallback
val hasPermission: Boolean = customRecordPermission.getOrElse {
!permissionFailed(PermissionCheck.MOD_LEVEL, PermissionCheck.VIEWER_LEVEL, liveMeeting.users2x, msg.header.userId)
}
if (!hasPermission) {
val meetingId = liveMeeting.props.meetingProp.intId val meetingId = liveMeeting.props.meetingProp.intId
val reason = "No permission to clear chat in meeting." val reason = "No permission to set recording status in meeting."
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, outGW, liveMeeting) PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, outGW, liveMeeting)
state state
} else { } else {

View File

@ -1,12 +1,11 @@
import { RedisMessage } from '../types'; import { RedisMessage } from '../types';
import {throwErrorIfInvalidInput, throwErrorIfNotModerator} from "../imports/validation"; import { throwErrorIfInvalidInput } from "../imports/validation";
export default function buildRedisMessage(sessionVariables: Record<string, unknown>, input: Record<string, unknown>): RedisMessage { export default function buildRedisMessage(sessionVariables: Record<string, unknown>, input: Record<string, unknown>): RedisMessage {
throwErrorIfNotModerator(sessionVariables);
throwErrorIfInvalidInput(input, throwErrorIfInvalidInput(input,
[ [
{name: 'recording', type: 'boolean', required: true}, { name: 'recording', type: 'boolean', required: true },
] ]
) )
const eventName = 'SetRecordingStatusCmdMsg'; const eventName = 'SetRecordingStatusCmdMsg';

View File

@ -6,6 +6,7 @@ import React, {
} from 'react'; } from 'react';
import useMeeting from '/imports/ui/core/hooks/useMeeting'; import useMeeting from '/imports/ui/core/hooks/useMeeting';
import deviceInfo, { isMobile } from '/imports/utils/deviceInfo'; import deviceInfo, { isMobile } from '/imports/utils/deviceInfo';
import { defineMessages, useIntl } from 'react-intl';
import { import {
GET_MEETING_RECORDING_DATA, GET_MEETING_RECORDING_DATA,
GET_MEETING_RECORDING_POLICIES, GET_MEETING_RECORDING_POLICIES,
@ -20,7 +21,6 @@ import humanizeSeconds from '/imports/utils/humanizeSeconds';
import { notify } from '/imports/ui/services/notification'; import { notify } from '/imports/ui/services/notification';
import Styled from './styles'; import Styled from './styles';
import { User } from '/imports/ui/Types/user'; import { User } from '/imports/ui/Types/user';
import { defineMessages, useIntl } from 'react-intl';
import useTimeSync from '/imports/ui/core/local-states/useTimeSync'; import useTimeSync from '/imports/ui/core/local-states/useTimeSync';
import RecordingNotify from './notify/component'; import RecordingNotify from './notify/component';
import RecordingContainer from '/imports/ui/components/recording/container'; import RecordingContainer from '/imports/ui/components/recording/container';
@ -28,6 +28,7 @@ import useDeduplicatedSubscription from '/imports/ui/core/hooks/useDeduplicatedS
import { getSettingsSingletonInstance } from '/imports/ui/services/settings'; import { getSettingsSingletonInstance } from '/imports/ui/services/settings';
import logger from '/imports/startup/client/logger'; import logger from '/imports/startup/client/logger';
import SvgIcon from '/imports/ui/components/common/icon-svg/component'; import SvgIcon from '/imports/ui/components/common/icon-svg/component';
import Service from './service';
const intlMessages = defineMessages({ const intlMessages = defineMessages({
notificationRecordingStart: { notificationRecordingStart: {
@ -233,7 +234,9 @@ const RecordingIndicator: React.FC<RecordingIndicatorProps> = ({
} }
const recordingButton = recording ? recordMeetingButtonWithTooltip : recordMeetingButton; const recordingButton = recording ? recordMeetingButtonWithTooltip : recordMeetingButton;
const showButton = isModerator && allowStartStopRecording; const showButton = Service.mayIRecord(isModerator, allowStartStopRecording);
const defaultRecordTooltip = intl.formatMessage(intlMessages.notificationRecordingStop);
const customRecordTooltip = Service.getCustomRecordTooltip(defaultRecordTooltip);
if (!record) return null; if (!record) return null;
return ( return (
<> <>
@ -249,18 +252,14 @@ const RecordingIndicator: React.FC<RecordingIndicatorProps> = ({
{showButton ? recordingButton : null} {showButton ? recordingButton : null}
{showButton ? null : ( {showButton ? null : (
<Tooltip <Tooltip
title={`${intl.formatMessage( title={recording
recording ? `${intl.formatMessage(intlMessages.notificationRecordingStart)}`
? intlMessages.notificationRecordingStart : customRecordTooltip}
: intlMessages.notificationRecordingStop,
)}`}
> >
<Styled.RecordingStatusViewOnly <Styled.RecordingStatusViewOnly
aria-label={`${intl.formatMessage( aria-label={recording
recording ? `${intl.formatMessage(intlMessages.notificationRecordingStart)}`
? intlMessages.notificationRecordingStart : customRecordTooltip}
: intlMessages.notificationRecordingStop,
)}`}
recording={recording} recording={recording}
> >
{recordingIndicatorIcon} {recordingIndicatorIcon}

View File

@ -0,0 +1,15 @@
import getFromUserSettings from '/imports/ui/services/users-settings';
const mayIRecord = (amIModerator: boolean, allowStartStopRecording: boolean) => {
const customRecord = getFromUserSettings('bbb_record_permission', undefined);
if (!allowStartStopRecording) return false;
if (customRecord !== undefined) {
return customRecord;
}
return amIModerator;
};
export default {
mayIRecord,
getCustomRecordTooltip: (value: string) => getFromUserSettings('bbb_record_permission_tooltip', value),
};

View File

@ -46,7 +46,6 @@ const intlMessages = defineMessages({
}); });
interface RecordingComponentProps { interface RecordingComponentProps {
amIModerator: boolean;
connected: boolean; connected: boolean;
isOpen: boolean; isOpen: boolean;
recordingStatus: boolean, recordingStatus: boolean,
@ -59,7 +58,6 @@ interface RecordingComponentProps {
const RecordingComponent: React.FC<RecordingComponentProps> = (props) => { const RecordingComponent: React.FC<RecordingComponentProps> = (props) => {
const { const {
amIModerator,
connected, connected,
isOpen, isOpen,
recordingStatus, recordingStatus,
@ -76,8 +74,6 @@ const RecordingComponent: React.FC<RecordingComponentProps> = (props) => {
let description; let description;
let cancelButtonLabel; let cancelButtonLabel;
if (!amIModerator) return null;
if (recordingStatus) { if (recordingStatus) {
description = intl.formatMessage(intlMessages.stopDescription); description = intl.formatMessage(intlMessages.stopDescription);
title = intl.formatMessage(intlMessages.stopTitle); title = intl.formatMessage(intlMessages.stopTitle);

View File

@ -2,10 +2,11 @@ import React from 'react';
import { useMutation, useReactiveVar } from '@apollo/client'; import { useMutation, useReactiveVar } from '@apollo/client';
import RecordingComponent from './component'; import RecordingComponent from './component';
import { SET_RECORDING_STATUS } from './mutations'; import { SET_RECORDING_STATUS } from './mutations';
import { GetRecordingResponse } from './queries'; import { GetRecordingResponse, GetRecordingPoliciesResponse } from './queries';
import useDeduplicatedSubscription from '/imports/ui/core/hooks/useDeduplicatedSubscription'; import useDeduplicatedSubscription from '/imports/ui/core/hooks/useDeduplicatedSubscription';
import ConnectionStatus from '/imports/ui/core/graphql/singletons/connectionStatus'; import ConnectionStatus from '/imports/ui/core/graphql/singletons/connectionStatus';
import { GET_MEETING_RECORDING_DATA } from '/imports/ui/components/nav-bar/nav-bar-graphql/recording-indicator/queries'; import { GET_MEETING_RECORDING_DATA, GET_MEETING_RECORDING_POLICIES } from '/imports/ui/components/nav-bar/nav-bar-graphql/recording-indicator/queries';
import Service from '/imports/ui/components/nav-bar/nav-bar-graphql/recording-indicator/service';
interface RecordingContainerProps { interface RecordingContainerProps {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
@ -24,9 +25,13 @@ const RecordingContainer: React.FC<RecordingContainerProps> = (props) => {
const { const {
data: recordingData, data: recordingData,
} = useDeduplicatedSubscription<GetRecordingResponse>(GET_MEETING_RECORDING_DATA); } = useDeduplicatedSubscription<GetRecordingResponse>(GET_MEETING_RECORDING_DATA);
const {
data: recordingPoliciesData,
} = useDeduplicatedSubscription<GetRecordingPoliciesResponse>(GET_MEETING_RECORDING_POLICIES);
const recording = recordingData?.meeting_recording[0]?.isRecording ?? false; const recording = recordingData?.meeting_recording[0]?.isRecording ?? false;
const time = recordingData?.meeting_recording[0]?.previousRecordedTimeInSeconds ?? 0; const time = recordingData?.meeting_recording[0]?.previousRecordedTimeInSeconds ?? 0;
const allowStartStopRecording = recordingPoliciesData?.meeting_recordingPolicies[0]?.allowStartStopRecording ?? false;
const toggleRecording = () => { const toggleRecording = () => {
setRecordingStatus({ setRecordingStatus({
@ -37,10 +42,13 @@ const RecordingContainer: React.FC<RecordingContainerProps> = (props) => {
setIsOpen(false); setIsOpen(false);
}; };
const mayIRecord = Service.mayIRecord(amIModerator, allowStartStopRecording);
if (!mayIRecord) return null;
return ( return (
<RecordingComponent <RecordingComponent
{...{ {...{
amIModerator,
connected, connected,
isOpen, isOpen,
onRequestClose, onRequestClose,

View File

@ -4,3 +4,9 @@ export interface GetRecordingResponse {
previousRecordedTimeInSeconds: number; previousRecordedTimeInSeconds: number;
}[] }[]
} }
export interface GetRecordingPoliciesResponse {
meeting_recordingPolicies: {
allowStartStopRecording: boolean;
}[]
}

View File

@ -1400,6 +1400,8 @@ Useful tools for development:
| `userdata-bbb_hide_presentation_on_join` | (Introduced in BigBlueButton 2.6) If set to `true` it will make the user enter the meeting with presentation minimized (Only for non-presenters), not permanent. | `userdata-bbb_hide_presentation_on_join` | (Introduced in BigBlueButton 2.6) If set to `true` it will make the user enter the meeting with presentation minimized (Only for non-presenters), not permanent.
| `userdata-bbb_direct_leave_button` | (Introduced in BigBlueButton 2.7) If set to `true` it will make a button to leave the meeting appear to the left of the Options menu. | `false` | | `userdata-bbb_direct_leave_button` | (Introduced in BigBlueButton 2.7) If set to `true` it will make a button to leave the meeting appear to the left of the Options menu. | `false` |
| `userdata-bbb_parent_room_moderator=` | (Introduced in BigBlueButton 3.0) Only used in breakouts: if set to `true`, user will have permission to kick other users inside the breakout | `false` | `userdata-bbb_parent_room_moderator=` | (Introduced in BigBlueButton 3.0) Only used in breakouts: if set to `true`, user will have permission to kick other users inside the breakout | `false`
| `userdata-bbb_record_permission=` | (Introduced in BigBlueButton 3.0) If set to `true`, the user will be able to control the recording start/stop. If set to `false`, the user will not be allowed to control the recording start/stop even if their role is moderator. Otherwise only moderators will have the control (default). | `null` |
| `userdata-bbb_record_permission_tooltip=` | (Introduced in BigBlueButton 3.0) If set, the tooltip of the recording indicator shown when the user don't have permission to record will be replaced by it's content. | `null` |
#### Branding parameters #### Branding parameters