Skip to content

Commit

Permalink
feat(data-secrecy): Data Secrecy Settings UI (#75791)
Browse files Browse the repository at this point in the history
Here I add the settings for Data Secrecy in the frontend. There are two:
1. Org settings to disable and enable superuser access
2. Allow users to temporarily waive data secrecy so sentry employees and
help

## Updated Demo:
#75791 (comment)

<details>
<summary> Old Demo </summary>


https://github.com/user-attachments/assets/2e2cb5e1-d8b9-46a0-9b65-64228c99e03d



https://github.com/user-attachments/assets/a2541ccc-eae1-478a-bc99-216514e282fc

Rewatch Links for Demo:

https://sentry.rewatch.com/video/ispnhixepw165zl9-screen-recording-2024-08-07-at-1-28-31-pm

https://sentry.rewatch.com/video/2jucpfwxswxs0dv6-screen-recording-2024-08-07-at-2-45-14-pm

Update:
I moved the waive setting so that its under the original toggle
<img width="1062" alt="image"
src="https://github.com/user-attachments/assets/90d249b8-3cad-4298-aa64-13bd2b4bf0d1">
</details>

---------

Co-authored-by: Danny Lee <danny@dongwei.li>
  • Loading branch information
iamrajjoshi and leedongwei committed Sep 3, 2024
1 parent e73c3e9 commit 8151ca8
Show file tree
Hide file tree
Showing 6 changed files with 296 additions and 0 deletions.
1 change: 1 addition & 0 deletions static/app/types/organization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface Organization extends OrganizationSummary {
allowMemberInvite: boolean;
allowMemberProjectCreation: boolean;
allowSharedIssues: boolean;
allowSuperuserAccess: boolean;
attachmentsRole: string;
/** @deprecated use orgRoleList instead. */
availableRoles: {id: string; name: string}[];
Expand Down
106 changes: 106 additions & 0 deletions static/app/views/settings/components/dataSecrecy/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {initializeOrg} from 'sentry-test/initializeOrg';
import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary';

import DataSecrecy from 'sentry/views/settings/components/dataSecrecy';

jest.mock('sentry/actionCreators/indicator');

describe('DataSecrecy', function () {
const {organization} = initializeOrg({
organization: {features: ['data-secrecy']},
});

beforeEach(function () {
MockApiClient.clearMockResponses();
jest.clearAllMocks();
});

it('renders default state with no waiver', async function () {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/data-secrecy/`,
body: null,
});

render(<DataSecrecy />, {organization: organization});

await waitFor(() => {
expect(screen.getByText('Support Access')).toBeInTheDocument();
});

organization.allowSuperuserAccess = false;

await waitFor(() => {
expect(
screen.getByText(/sentry employees do not have access to your organization/i)
).toBeInTheDocument();
});
});

it('renders default state with waiver', async function () {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/data-secrecy/`,
body: null,
});

render(<DataSecrecy />, {organization: organization});

await waitFor(() => {
expect(screen.getByText('Support Access')).toBeInTheDocument();
});

organization.allowSuperuserAccess = true;

await waitFor(() => {
expect(
screen.getByText(/sentry employees do not have access to your organization/i)
).toBeInTheDocument();
});
});

it('renders no access state with waiver present', async function () {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/data-secrecy/`,
body: {
accessStart: '2022-08-29T01:05:00+00:00',
accessEnd: '2023-08-29T01:05:00+00:00',
},
});

render(<DataSecrecy />, {organization: organization});

await waitFor(() => {
expect(screen.getByText('Support Access')).toBeInTheDocument();
});

organization.allowSuperuserAccess = false;

// we should see no access message
await waitFor(() => {
expect(
screen.getByText(
/sentry employees will not have access to your organization unless granted permission/i
)
).toBeInTheDocument();
});
});

it('renders current waiver state', async function () {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/data-secrecy/`,
body: {
accessStart: '2023-08-29T01:05:00+00:00',
accessEnd: '2024-08-29T01:05:00+00:00',
},
});

organization.allowSuperuserAccess = false;
render(<DataSecrecy />, {organization: organization});

await waitFor(() => {
const accessMessage = screen.getByText(
/Sentry employees has access to your organization until/i
);
expect(accessMessage).toBeInTheDocument();
});
});
});
170 changes: 170 additions & 0 deletions static/app/views/settings/components/dataSecrecy/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import {useEffect, useState} from 'react';
import moment from 'moment-timezone';

import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
import BooleanField, {
type BooleanFieldProps,
} from 'sentry/components/forms/fields/booleanField';
import DateTimeField, {
type DateTimeFieldProps,
} from 'sentry/components/forms/fields/dateTimeField';
import Panel from 'sentry/components/panels/panel';
import PanelAlert from 'sentry/components/panels/panelAlert';
import PanelBody from 'sentry/components/panels/panelBody';
import PanelHeader from 'sentry/components/panels/panelHeader';
import {t, tct} from 'sentry/locale';
import {useApiQuery} from 'sentry/utils/queryClient';
import useApi from 'sentry/utils/useApi';
import useOrganization from 'sentry/utils/useOrganization';

type WaiverData = {
accessEnd: string | null;
accessStart: string | null;
};

export default function DataSecrecy() {
const api = useApi();
const organization = useOrganization();

const [allowAccess, setAllowAccess] = useState(organization.allowSuperuserAccess);
const [allowDate, setAllowDate] = useState<WaiverData>();
const [allowDateFormData, setAllowDateFormData] = useState<string>('');

const {data, refetch} = useApiQuery<WaiverData>(
[`/organizations/${organization.slug}/data-secrecy/`],
{
staleTime: 3000,
retry: (failureCount, error) => failureCount < 3 && error.status !== 404,
}
);

const hasValidTempAccess =
allowDate?.accessEnd && moment().toISOString() < allowDate.accessEnd;

useEffect(() => {
if (data?.accessEnd) {
setAllowDate(data);
// slice it to yyyy-MM-ddThh:mm format (be aware of the timezone)
const localDate = moment(data.accessEnd).local();
setAllowDateFormData(localDate.format('YYYY-MM-DDTHH:mm'));
}
}, [data]);

const updateAllowedAccess = async (value: boolean) => {
try {
await api.requestPromise(`/organizations/${organization.slug}/`, {
method: 'PUT',
data: {allowSuperuserAccess: value},
});
setAllowAccess(value);
addSuccessMessage(t('Successfully updated access.'));
} catch (error) {
addErrorMessage(t('Unable to save changes.'));
}
};

const updateTempAccessDate = async () => {
if (!allowDateFormData) {
try {
await api.requestPromise(`/organizations/${organization.slug}/data-secrecy/`, {
method: 'DELETE',
});
setAllowDate({accessStart: '', accessEnd: ''});
addSuccessMessage(t('Successfully removed temporary access window.'));
} catch (error) {
addErrorMessage(t('Unable to remove temporary access window.'));
}

return;
}

// maintain the standard format of storing the date in UTC
// even though the api should be able to handle the local time
const nextData: WaiverData = {
accessStart: moment().utc().toISOString(),
accessEnd: moment.tz(allowDateFormData, moment.tz.guess()).utc().toISOString(),
};

try {
await await api.requestPromise(
`/organizations/${organization.slug}/data-secrecy/`,
{
method: 'PUT',
data: nextData,
}
);
setAllowDate(nextData);
addSuccessMessage(t('Successfully updated temporary access window.'));
} catch (error) {
addErrorMessage(t('Unable to save changes.'));
setAllowDateFormData('');
}
// refetch to get the latest waiver data
refetch();
};

const allowAccessProps: BooleanFieldProps = {
name: 'allowSuperuserAccess',
label: t('Allow access to Sentry employees'),
help: t(
'Sentry employees will not have access to your organization unless granted permission'
),
'aria-label': t(
'Sentry employees will not have access to your data unless granted permission'
),
value: allowAccess,
disabled: !organization.access.includes('org:write'),
onBlur: updateAllowedAccess,
};

const allowTempAccessProps: DateTimeFieldProps = {
name: 'allowTemporarySuperuserAccess',
label: t('Allow temporary access to Sentry employees'),
help: t(
'Open a temporary time window for Sentry employees to access your organization'
),
disabled: allowAccess && !organization.access.includes('org:write'),
value: allowAccess ? '' : allowDateFormData,
onBlur: updateTempAccessDate,
onChange: v => {
// the picker doesn't like having a datetime string with seconds+ and a timezone,
// so we remove it -- we will add it back when we save the date
const formattedDate = v ? moment(v).format('YYYY-MM-DDTHH:mm') : '';
setAllowDateFormData(formattedDate);
},
};

return (
<Panel>
<PanelHeader>{t('Support Access')}</PanelHeader>
<PanelBody>
{!allowAccess && (
<PanelAlert>
{hasValidTempAccess
? tct(`Sentry employees has access to your organization until [date]`, {
date: formatDateTime(allowDate?.accessEnd as string),
})
: t('Sentry employees do not have access to your organization')}
</PanelAlert>
)}

<BooleanField {...(allowAccessProps as BooleanFieldProps)} />
<DateTimeField {...(allowTempAccessProps as DateTimeFieldProps)} />
</PanelBody>
</Panel>
);
}

const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
const options: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true,
timeZoneName: 'short',
};
return new Intl.DateTimeFormat('en-US', options).format(date);
};
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ describe('OrganizationSecurityAndPrivacy', function () {
method: 'GET',
body: {},
});

MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/data-secrecy/`,
method: 'GET',
body: null,
});
});

it('shows require2fa switch', async function () {
Expand Down
12 changes: 12 additions & 0 deletions static/app/views/settings/organizationSecurityAndPrivacy/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import JsonForm from 'sentry/components/forms/jsonForm';
import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
import organizationSecurityAndPrivacyGroups from 'sentry/data/forms/organizationSecurityAndPrivacyGroups';
import {t} from 'sentry/locale';
import ConfigStore from 'sentry/stores/configStore';
import type {AuthProvider} from 'sentry/types/auth';
import type {Organization} from 'sentry/types/organization';
import useApi from 'sentry/utils/useApi';
import useOrganization from 'sentry/utils/useOrganization';
import DataSecrecy from 'sentry/views/settings/components/dataSecrecy/index';
import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';

import {DataScrubbing} from '../components/dataScrubbing';
Expand Down Expand Up @@ -47,10 +49,16 @@ export default function OrganizationSecurityAndPrivacyContent() {
updateOrganization(data);
}

const {isSelfHosted} = ConfigStore.getState();
// only need data secrecy in saas
const showDataSecrecySettings =
organization.features.includes('data-secrecy') && !isSelfHosted;

return (
<Fragment>
<SentryDocumentTitle title={title} orgSlug={organization.slug} />
<SettingsPageHeader title={title} />

<Form
data-test-id="organization-settings-security-and-privacy"
apiMethod="PUT"
Expand All @@ -66,8 +74,12 @@ export default function OrganizationSecurityAndPrivacyContent() {
features={features}
forms={organizationSecurityAndPrivacyGroups}
disabled={!organization.access.includes('org:write')}
additionalFieldProps={{showDataSecrecySettings}}
/>
</Form>

{showDataSecrecySettings && <DataSecrecy />}

<DataScrubbing
additionalContext={t('These rules can be configured for each project.')}
endpoint={endpoint}
Expand Down
1 change: 1 addition & 0 deletions tests/js/fixtures/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function OrganizationFixture( params: Partial<Organization> = {}): Organi
allowJoinRequests: false,
allowMemberInvite: true,
allowMemberProjectCreation: false,
allowSuperuserAccess: false,
allowSharedIssues: false,
attachmentsRole: '',
availableRoles: [],
Expand Down

0 comments on commit 8151ca8

Please sign in to comment.