Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: apply restrictions to air gapped environments #33241

Merged
merged 41 commits into from
Oct 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
274aafa
feat: add support to decrypt the stats token
MarcosSpessatto Sep 10, 2024
dc4054b
feat: add class to manage air gapped restrictions based on stats token
MarcosSpessatto Sep 10, 2024
1f99cac
feat: setting to store remaining days of air gapped restrictions
MarcosSpessatto Sep 10, 2024
66ec736
feat: cron to check air gapped environments
MarcosSpessatto Sep 10, 2024
ea80b37
fix: fix types
MarcosSpessatto Sep 10, 2024
ffa67a1
fix: lint
MarcosSpessatto Sep 10, 2024
8b4d637
feat: block user message actions if airgapped restrictions must be ap…
MarcosSpessatto Sep 11, 2024
b632ff9
fix: test dir
MarcosSpessatto Sep 11, 2024
52179b5
feat: add rocket.cat warning within the warning period
MarcosSpessatto Sep 12, 2024
0a8f7d9
fix: make setting available on client side
MarcosSpessatto Sep 13, 2024
a068350
Create little-gifts-do.md
MarcosSpessatto Sep 13, 2024
b891617
wip
gabriellsh Sep 27, 2024
579426a
Update logic and tests
gabriellsh Sep 27, 2024
e8f799f
fix AirGappedRestriction logic
gabriellsh Sep 27, 2024
a2424af
undo
gabriellsh Sep 27, 2024
4e638a7
Fix airgappedRestrictionCheck tests
gabriellsh Sep 30, 2024
62523df
Fix airgappedRestrictionswrapper tests
gabriellsh Sep 30, 2024
449de1b
test fix
gabriellsh Sep 30, 2024
247c037
fix proxyquire for airGappedRestrictionsCheck.ts
gabriellsh Sep 30, 2024
17d9476
remove wrong import
gabriellsh Sep 30, 2024
af9f7e7
Move tests to test folder
gabriellsh Sep 30, 2024
9b9f323
fix changeset
gabriellsh Oct 2, 2024
b2e4d46
remove constant from restriction module
gabriellsh Oct 2, 2024
0d8c123
consolidate restriction logic inside module
gabriellsh Oct 2, 2024
60a3f46
fix: skip invalid test for now
gabriellsh Oct 2, 2024
71f3332
review
gabriellsh Oct 2, 2024
90f664b
Change get restricted notation
gabriellsh Oct 8, 2024
bd3e719
change cron location
gabriellsh Oct 9, 2024
b90ccd8
void promise
gabriellsh Oct 10, 2024
1375951
remove barrel export
gabriellsh Oct 10, 2024
e859eea
change flag default
gabriellsh Oct 14, 2024
2e9d7b7
change from module to license check
gabriellsh Oct 17, 2024
b656b13
add missing type
gabriellsh Oct 17, 2024
3d7ef8b
fix license listener
gabriellsh Oct 17, 2024
510b6b0
add tests to ensure setting and rocket.cat message
gabriellsh Oct 17, 2024
5ebc821
remove cached settings
gabriellsh Oct 18, 2024
5affa86
change test location
gabriellsh Oct 18, 2024
a4527e0
feat!: Airgapped composer restriction and warning banner (#33382)
gabriellsh Oct 18, 2024
aa2a1b4
Merge branch 'develop' into feat/airgapped-restrictions
gabriellsh Oct 18, 2024
5c97fc1
adjust name function
ggazzo Oct 18, 2024
879df1d
Merge branch 'develop' into feat/airgapped-restrictions
gabriellsh Oct 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading