Skip to content

Commit

Permalink
feat!: apply restrictions to air gapped environments (#33241)
Browse files Browse the repository at this point in the history
Co-authored-by: gabriellsh <[email protected]>
Co-authored-by: Guilherme Gazzo <[email protected]>
  • Loading branch information
3 people authored Oct 19, 2024
1 parent 634a6a5 commit f33c07e
Show file tree
Hide file tree
Showing 43 changed files with 971 additions and 62 deletions.
7 changes: 7 additions & 0 deletions .changeset/little-gifts-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@rocket.chat/meteor": major
"@rocket.chat/i18n": major
"@rocket.chat/license": major
---

Adds restrictions to air-gapped environments without a license
29 changes: 18 additions & 11 deletions apps/meteor/app/api/server/v1/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { deleteMessageValidatingPermission } from '../../../lib/server/functions
import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage';
import { executeSendMessage } from '../../../lib/server/methods/sendMessage';
import { executeUpdateMessage } from '../../../lib/server/methods/updateMessage';
import { applyAirGappedRestrictionsValidation } from '../../../license/server/airGappedRestrictionsWrapper';
import { OEmbed } from '../../../oembed/server/server';
import { executeSetReaction } from '../../../reactions/server/setReaction';
import { settings } from '../../../settings/server';
Expand Down Expand Up @@ -160,7 +161,7 @@ API.v1.addRoute(
{ authRequired: true },
{
async post() {
const messageReturn = (await processWebhookMessage(this.bodyParams, this.user))[0];
const messageReturn = (await applyAirGappedRestrictionsValidation(() => processWebhookMessage(this.bodyParams, this.user)))[0];

if (!messageReturn) {
return API.v1.failure('unknown-error');
Expand Down Expand Up @@ -218,7 +219,9 @@ API.v1.addRoute(
throw new Error("Cannot send system messages using 'chat.sendMessage'");
}

const sent = await executeSendMessage(this.userId, this.bodyParams.message as Pick<IMessage, 'rid'>, this.bodyParams.previewUrls);
const sent = await applyAirGappedRestrictionsValidation(() =>
executeSendMessage(this.userId, this.bodyParams.message as Pick<IMessage, 'rid'>, this.bodyParams.previewUrls),
);
const [message] = await normalizeMessagesForUser([sent], this.userId);

return API.v1.success({
Expand Down Expand Up @@ -318,16 +321,20 @@ API.v1.addRoute(
return API.v1.failure('The room id provided does not match where the message is from.');
}

const msgFromBody = this.bodyParams.text;

// Permission checks are already done in the updateMessage method, so no need to duplicate them
await executeUpdateMessage(
this.userId,
{
_id: msg._id,
msg: this.bodyParams.text,
rid: msg.rid,
customFields: this.bodyParams.customFields as Record<string, any> | undefined,
},
this.bodyParams.previewUrls,
await applyAirGappedRestrictionsValidation(() =>
executeUpdateMessage(
this.userId,
{
_id: msg._id,
msg: msgFromBody,
rid: msg.rid,
customFields: this.bodyParams.customFields as Record<string, any> | undefined,
},
this.bodyParams.previewUrls,
),
);

const updatedMessage = await Messages.findOneById(msg._id);
Expand Down
31 changes: 17 additions & 14 deletions apps/meteor/app/api/server/v1/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { createDiscussion } from '../../../discussion/server/methods/createDiscu
import { FileUpload } from '../../../file-upload/server';
import { sendFileMessage } from '../../../file-upload/server/methods/sendFileMessage';
import { leaveRoomMethod } from '../../../lib/server/methods/leaveRoom';
import { applyAirGappedRestrictionsValidation } from '../../../license/server/airGappedRestrictionsWrapper';
import { settings } from '../../../settings/server';
import { API } from '../api';
import { composeRoomWithLastMessage } from '../helpers/composeRoomWithLastMessage';
Expand Down Expand Up @@ -199,7 +200,9 @@ API.v1.addRoute(

delete fields.description;

await sendFileMessage(this.userId, { roomId: this.urlParams.rid, file: uploadedFile, msgData: fields });
await applyAirGappedRestrictionsValidation(() =>
sendFileMessage(this.userId, { roomId: this.urlParams.rid, file: uploadedFile, msgData: fields }),
);

const message = await Messages.getMessageByFileIdAndUsername(uploadedFile._id, this.userId);

Expand Down Expand Up @@ -299,10 +302,8 @@ API.v1.addRoute(
file.description = this.bodyParams.description;
delete this.bodyParams.description;

await sendFileMessage(
this.userId,
{ roomId: this.urlParams.rid, file, msgData: this.bodyParams },
{ parseAttachmentsForE2EE: false },
await applyAirGappedRestrictionsValidation(() =>
sendFileMessage(this.userId, { roomId: this.urlParams.rid, file, msgData: this.bodyParams }, { parseAttachmentsForE2EE: false }),
);

await Uploads.confirmTemporaryFile(this.urlParams.fileId, this.userId);
Expand Down Expand Up @@ -479,15 +480,17 @@ API.v1.addRoute(
return API.v1.failure('Body parameter "encrypted" must be a boolean when included.');
}

const discussion = await createDiscussion(this.userId, {
prid,
pmid,
t_name,
reply,
users: users?.filter(isTruthy) || [],
encrypted,
topic,
});
const discussion = await applyAirGappedRestrictionsValidation(() =>
createDiscussion(this.userId, {
prid,
pmid,
t_name,
reply,
users: users?.filter(isTruthy) || [],
encrypted,
topic,
}),
);

return API.v1.success({ discussion });
},
Expand Down
5 changes: 3 additions & 2 deletions apps/meteor/app/lib/server/methods/sendMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { i18n } from '../../../../server/lib/i18n';
import { SystemLogger } from '../../../../server/lib/logger/system';
import { canSendMessageAsync } from '../../../authorization/server/functions/canSendMessage';
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
import { applyAirGappedRestrictionsValidation } from '../../../license/server/airGappedRestrictionsWrapper';
import { metrics } from '../../../metrics/server';
import { settings } from '../../../settings/server';
import { MessageTypes } from '../../../ui-utils/server';
Expand Down Expand Up @@ -136,9 +137,9 @@ Meteor.methods<ServerMethods>({
}

try {
return await executeSendMessage(uid, message, previewUrls);
return await applyAirGappedRestrictionsValidation(() => executeSendMessage(uid, message, previewUrls));
} catch (error: any) {
if ((error.error || error.message) === 'error-not-allowed') {
if (['error-not-allowed', 'restricted-workspace'].includes(error.error || error.message)) {
throw new Meteor.Error(error.error || error.message, error.reason, {
method: 'sendMessage',
});
Expand Down
3 changes: 2 additions & 1 deletion apps/meteor/app/lib/server/methods/updateMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import moment from 'moment';

import { canSendMessageAsync } from '../../../authorization/server/functions/canSendMessage';
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
import { applyAirGappedRestrictionsValidation } from '../../../license/server/airGappedRestrictionsWrapper';
import { settings } from '../../../settings/server';
import { updateMessage } from '../functions/updateMessage';

Expand Down Expand Up @@ -115,6 +116,6 @@ Meteor.methods<ServerMethods>({
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateMessage' });
}

return executeUpdateMessage(uid, message, previewUrls);
return applyAirGappedRestrictionsValidation(() => executeUpdateMessage(uid, message, previewUrls));
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { makeFunction } from '@rocket.chat/patch-injection';

export const applyAirGappedRestrictionsValidation = makeFunction(async <T>(fn: () => Promise<T>): Promise<T> => {
return fn();
});
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { cronJobs } from '@rocket.chat/cron';
import type { Logger } from '@rocket.chat/logger';
import { Statistics } from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { Meteor } from 'meteor/meteor';

import { getWorkspaceAccessToken } from '../../app/cloud/server';
import { statistics } from '../../app/statistics/server';
import { statistics } from '..';
import { getWorkspaceAccessToken } from '../../../cloud/server';

async function generateStatistics(logger: Logger): Promise<void> {
export async function sendUsageReport(logger: Logger): Promise<string | undefined> {
const cronStatistics = await statistics.save();

try {
Expand All @@ -27,20 +26,10 @@ async function generateStatistics(logger: Logger): Promise<void> {

if (statsToken != null) {
await Statistics.updateOne({ _id: cronStatistics._id }, { $set: { statsToken } });
return statsToken;
}
} catch (error) {
/* error*/
logger.warn('Failed to send usage report');
}
}

export async function statsCron(logger: Logger): Promise<void> {
const name = 'Generate and save statistics';
await generateStatistics(logger);

const now = new Date();

await cronJobs.add(name, `12 ${now.getHours()} * * *`, async () => {
await generateStatistics(logger);
});
}
52 changes: 52 additions & 0 deletions apps/meteor/client/hooks/useAirGappedRestriction.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook } from '@testing-library/react';

import { useAirGappedRestriction } from './useAirGappedRestriction';

// [restricted, warning, remainingDays]
describe('useAirGappedRestriction hook', () => {
it('should return [false, false, -1] if setting value is not a number', () => {
const { result } = renderHook(() => useAirGappedRestriction(), {
legacyRoot: true,
wrapper: mockAppRoot().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', -1).build(),
});

expect(result.current).toEqual([false, false, -1]);
});

it('should return [false, false, -1] if user has a license (remaining days is a negative value)', () => {
const { result } = renderHook(() => useAirGappedRestriction(), {
legacyRoot: true,
wrapper: mockAppRoot().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', -1).build(),
});

expect(result.current).toEqual([false, false, -1]);
});

it('should return [false, false, 8] if not on warning or restriction phase', () => {
const { result } = renderHook(() => useAirGappedRestriction(), {
legacyRoot: true,
wrapper: mockAppRoot().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 8).build(),
});

expect(result.current).toEqual([false, false, 8]);
});

it('should return [true, false, 7] if on warning phase', () => {
const { result } = renderHook(() => useAirGappedRestriction(), {
legacyRoot: true,
wrapper: mockAppRoot().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 7).build(),
});

expect(result.current).toEqual([false, true, 7]);
});

it('should return [true, false, 0] if on restriction phase', () => {
const { result } = renderHook(() => useAirGappedRestriction(), {
legacyRoot: true,
wrapper: mockAppRoot().withSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', 0).build(),
});

expect(result.current).toEqual([true, false, 0]);
});
});
19 changes: 19 additions & 0 deletions apps/meteor/client/hooks/useAirGappedRestriction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useSetting } from '@rocket.chat/ui-contexts';

export const useAirGappedRestriction = (): [isRestrictionPhase: boolean, isWarningPhase: boolean, remainingDays: number] => {
const airGappedRestrictionRemainingDays = useSetting('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days');

if (typeof airGappedRestrictionRemainingDays !== 'number') {
return [false, false, -1];
}

// If this value is negative, the user has a license with valid module
if (airGappedRestrictionRemainingDays < 0) {
return [false, false, airGappedRestrictionRemainingDays];
}

const isRestrictionPhase = airGappedRestrictionRemainingDays === 0;
const isWarningPhase = !isRestrictionPhase && airGappedRestrictionRemainingDays <= 7;

return [isRestrictionPhase, isWarningPhase, airGappedRestrictionRemainingDays];
};
10 changes: 4 additions & 6 deletions apps/meteor/client/sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import { useSessionStorage } from '@rocket.chat/fuselage-hooks';
import { useLayout, useSetting, useUserPreference } from '@rocket.chat/ui-contexts';
import { useLayout, useUserPreference } from '@rocket.chat/ui-contexts';
import React, { memo } from 'react';

import { useOmnichannelEnabled } from '../hooks/omnichannel/useOmnichannelEnabled';
import SidebarRoomList from './RoomList';
import SidebarFooter from './footer';
import SidebarHeader from './header';
import BannerSection from './sections/BannerSection';
import OmnichannelSection from './sections/OmnichannelSection';
import StatusDisabledSection from './sections/StatusDisabledSection';

// TODO unit test airgappedbanner
const Sidebar = () => {
const showOmnichannel = useOmnichannelEnabled();

const sidebarViewMode = useUserPreference('sidebarViewMode');
const sidebarHideAvatar = !useUserPreference('sidebarDisplayAvatar');
const { sidebar } = useLayout();
const [bannerDismissed, setBannerDismissed] = useSessionStorage('presence_cap_notifier', false);
const presenceDisabled = useSetting<boolean>('Presence_broadcast_disabled');

const sidebarLink = css`
a {
Expand All @@ -41,7 +39,7 @@ const Sidebar = () => {
data-qa-opened={sidebar.isCollapsed ? 'false' : 'true'}
>
<SidebarHeader />
{presenceDisabled && !bannerDismissed && <StatusDisabledSection onDismiss={() => setBannerDismissed(true)} />}
<BannerSection />
{showOmnichannel && <OmnichannelSection />}
<SidebarRoomList />
<SidebarFooter />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { SidebarBanner } from '@rocket.chat/fuselage';
import { ExternalLink } from '@rocket.chat/ui-client';
import React from 'react';
import { useTranslation } from 'react-i18next';

import AirGappedRestrictionWarning from './AirGappedRestrictionWarning';

const AirGappedRestrictionSection = ({ isRestricted, remainingDays }: { isRestricted: boolean; remainingDays: number }) => {
const { t } = useTranslation();

return (
<SidebarBanner
text={<AirGappedRestrictionWarning isRestricted={isRestricted} remainingDays={remainingDays} />}
description={<ExternalLink to='https://go.rocket.chat/i/airgapped-restriction'>{t('Learn_more')}</ExternalLink>}
/>
);
};

export default AirGappedRestrictionSection;
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Box } from '@rocket.chat/fuselage';
import React from 'react';
import { Trans } from 'react-i18next';

const AirGappedRestrictionWarning = ({ isRestricted, remainingDays }: { isRestricted: boolean; remainingDays: number }) => {
if (isRestricted) {
return (
<Trans i18nKey='Airgapped_workspace_restriction'>
This air-gapped workspace is in read-only mode.{' '}
<Box fontScale='p2' is='span'>
Connect it to internet or upgraded to a premium plan to restore full functionality.
</Box>
</Trans>
);
}

return (
<Trans i18nKey='Airgapped_workspace_warning2' values={{ remainingDays }}>
This air-gapped workspace will enter read-only mode in <>{{ remainingDays }}</> days.{' '}
<Box fontScale='p2' is='span'>
Connect it to internet or upgrade to a premium plan to prevent this.
</Box>
</Trans>
);
};

export default AirGappedRestrictionWarning;
Loading

0 comments on commit f33c07e

Please sign in to comment.