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(data-secrecy): Data Secrecy Settings UI #75791

Merged
merged 17 commits into from
Sep 3, 2024
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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we retry? I just don't see this often in our frontend code so wondering how this endpoint is different from others that needs retrying.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

most endpoints are able to use the default retry, but since the api will return a 404 if an org doesn't have a waiver currently, the default behavior of the api would be to keep refreshing then render a failure state. here we prevent this by not refreshing on 404s.

}
);

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,
iamrajjoshi marked this conversation as resolved.
Show resolved Hide resolved
}
);
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
Loading