Skip to content

Commit

Permalink
GDPR: Right to be forgotten and delete one's own data (#323)
Browse files Browse the repository at this point in the history
  • Loading branch information
antoinejaussoin authored Oct 23, 2021
1 parent 678739e commit bc8a27c
Show file tree
Hide file tree
Showing 28 changed files with 995 additions and 40 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/alpha.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: 'Alpha Build'

on:
push:
branches: [v490/release2]
branches: [v491/gdpr]

jobs:
build:
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ This will run a demo version, which you can turn into a fully licenced version b

### Version 4.9.1 (unreleased)

- Add better GDPR compliance, with the right to be forgotten: allows a user to delete all of their data
- Add the ability for users to signal if they are done with their posts, to help the moderator
- ⏫ Upgrading dependencies

Expand Down
167 changes: 167 additions & 0 deletions backend/src/db/actions/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { DeleteAccountPayload } from '@retrospected/common';
import { EntityManager } from 'typeorm';
import { v4 } from 'uuid';
import { UserIdentityEntity, UserView } from '../entities';
import {
PostGroupRepository,
PostRepository,
SessionRepository,
VoteRepository,
} from '../repositories';
import { transaction } from './transaction';
import { registerAnonymousUser } from './users';

export async function deleteAccount(
user: UserView,
options: DeleteAccountPayload
): Promise<boolean> {
const anonymousAccount = await createAnonymousAccount();
if (!anonymousAccount) {
throw new Error('Could not create a anonymous account');
}

return await transaction(async (manager) => {
await deleteVisits(manager, options.deleteSessions, user, anonymousAccount);
await deleteVotes(manager, options.deleteVotes, user, anonymousAccount);
await deletePosts(manager, options.deletePosts, user, anonymousAccount);
await deleteSessions(
manager,
options.deleteSessions,
user,
anonymousAccount
);
await deleteUserAccount(manager, user);
return true;
});
}

async function deleteVisits(
manager: EntityManager,
hardDelete: boolean,
user: UserView,
anon: UserIdentityEntity
) {
if (hardDelete) {
await manager.query('delete from visitors where "usersId" = $1', [user.id]);
} else {
await manager.query('update visitors set usersId = $1 where usersId = $2', [
anon.user.id,
user.id,
]);
}
}

async function deleteVotes(
manager: EntityManager,
hardDelete: boolean,
user: UserView,
anon: UserIdentityEntity
) {
const repo = manager.getCustomRepository(VoteRepository);
if (hardDelete) {
await repo.delete({ user: { id: user.id } });
return true;
} else {
await repo.update({ user: { id: user.id } }, { user: anon.user });
return true;
}
}

async function deletePosts(
manager: EntityManager,
hardDelete: boolean,
user: UserView,
anon: UserIdentityEntity
) {
const repo = manager.getCustomRepository(PostRepository);
const groupRepo = manager.getCustomRepository(PostGroupRepository);
if (hardDelete) {
await manager.query(
`
delete from votes where "postId" in (select id from posts where "userId" = $1)
`,
[user.id]
);
await manager.query(
`
update posts set "groupId" = null where "groupId" in (select id from groups where "userId" = $1)
`,
[user.id]
);
await repo.delete({ user: { id: user.id } });
await groupRepo.delete({ user: { id: user.id } });
return true;
} else {
await repo.update({ user: { id: user.id } }, { user: anon.user });
await groupRepo.update({ user: { id: user.id } }, { user: anon.user });
return true;
}
}

async function deleteSessions(
manager: EntityManager,
hardDelete: boolean,
user: UserView,
anon: UserIdentityEntity
) {
const repo = manager.getCustomRepository(SessionRepository);
if (hardDelete) {
await manager.query(
`
delete from votes where "postId" in (select id from posts where "sessionId" in (select id from sessions where "createdById" = $1))
`,
[user.id]
);
await manager.query(
`
delete from posts where "sessionId" in (select id from sessions where "createdById" = $1)
`,
[user.id]
);
await manager.query(
`
delete from groups where "sessionId" in (select id from sessions where "createdById" = $1)
`,
[user.id]
);
await manager.query(
`
delete from columns where "sessionId" in (select id from sessions where "createdById" = $1)
`,
[user.id]
);
await repo.delete({ createdBy: { id: user.id } });
return true;
} else {
await repo.update({ createdBy: { id: user.id } }, { createdBy: anon.user });
return true;
}
}

async function deleteUserAccount(manager: EntityManager, user: UserView) {
await manager.query(
`
update users set "defaultTemplateId" = null where "defaultTemplateId" in (select id from templates where "createdById" = $1)
`,
[user.id]
);
await manager.query(
'delete from "templates-columns" where "templateId" in (select id from templates where "createdById" = $1)',
[user.id]
);
await manager.query('delete from templates where "createdById" = $1', [
user.id,
]);
await manager.query('delete from subscriptions where "ownerId" = $1', [
user.id,
]);
await manager.query('delete from users_identities where "userId" = $1', [
user.id,
]);
await manager.query('delete from users where id = $1', [user.id]);
}

async function createAnonymousAccount() {
const user = await registerAnonymousUser(`(deleted user)^${v4()}`, v4());
return user;
}
15 changes: 15 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
ResetChangePasswordPayload,
CreateSessionPayload,
SelfHostedCheckPayload,
DeleteAccountPayload,
} from '@retrospected/common';
import registerPasswordUser from './auth/register/register-user';
import { sendVerificationEmail, sendResetPassword } from './email/emailSender';
Expand All @@ -60,6 +61,7 @@ import { fetchLicence, validateLicence } from './db/actions/licences';
import { hasField } from './security/payload-checker';
import mung from 'express-mung';
import { QueryFailedError } from 'typeorm';
import { deleteAccount } from './db/actions/delete';

const realIpHeader = 'X-Forwarded-For';

Expand Down Expand Up @@ -304,6 +306,19 @@ db().then(() => {
}
});

app.delete('/api/me', csrfProtection, heavyLoadLimiter, async (req, res) => {
const user = await getUserViewFromRequest(req);
if (user) {
const result = await deleteAccount(
user,
req.body as DeleteAccountPayload
);
res.status(200).send(result);
} else {
res.status(401).send('Not logged in');
}
});

app.get('/api/quota', async (req, res) => {
const quota = await getUserQuota(req);
if (quota) {
Expand Down
6 changes: 6 additions & 0 deletions common/src/payloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,9 @@ export interface OAuthAvailabilities {
github: boolean;
okta: boolean;
}

export interface DeleteAccountPayload {
deleteSessions: boolean;
deletePosts: boolean;
deleteVotes: boolean;
}
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"isemail": "^3.2.0",
"lexorank": "^1.0.4",
"lodash": "^4.17.21",
"material-ui-confirm": "^3.0.2",
"md5": "^2.3.0",
"notistack": "^2.0.2",
"prop-types": "^15.7.2",
Expand Down
35 changes: 19 additions & 16 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Suspense } from 'react';
import { CodeSplitLoader } from './CodeSplitLoader';
import QuotaManager from './auth/QuotaManager';
import GlobalProvider from './global/GlobalProvider';
import { ConfirmProvider } from 'material-ui-confirm';

function App() {
return (
Expand All @@ -35,22 +36,24 @@ function App() {
}}
>
<ThemeProvider theme={theme}>
<BrowserRouter>
<GlobalProvider>
<AuthProvider>
<LanguageProvider>
<QuotaManager>
<Global styles={globalCss} />
<ErrorBoundary>
<Suspense fallback={<CodeSplitLoader />}>
<Layout />
</Suspense>
</ErrorBoundary>
</QuotaManager>
</LanguageProvider>
</AuthProvider>
</GlobalProvider>
</BrowserRouter>
<ConfirmProvider>
<BrowserRouter>
<GlobalProvider>
<AuthProvider>
<LanguageProvider>
<QuotaManager>
<Global styles={globalCss} />
<ErrorBoundary>
<Suspense fallback={<CodeSplitLoader />}>
<Layout />
</Suspense>
</ErrorBoundary>
</QuotaManager>
</LanguageProvider>
</AuthProvider>
</GlobalProvider>
</BrowserRouter>
</ConfirmProvider>
</ThemeProvider>
</SnackbarProvider>
</RecoilRoot>
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
FullUser,
Product,
BackendCapabilities,
DeleteAccountPayload,
} from '@retrospected/common';
import config from '../utils/getConfig';
import { v4 } from 'uuid';
Expand Down Expand Up @@ -206,6 +207,12 @@ export async function deleteSession(sessionId: string): Promise<boolean> {
return await fetchDelete(`/api/session/${sessionId}`);
}

export async function deleteAccount(
options: DeleteAccountPayload
): Promise<boolean> {
return await fetchDelete(`/api/me`, options);
}

export async function getGiphyUrl(giphyId: string): Promise<string | null> {
try {
const response = await fetch(
Expand Down
47 changes: 28 additions & 19 deletions frontend/src/auth/AccountMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import { logout } from '../api';
import UserContext from './Context';
import Avatar from '../components/Avatar';
import { useHistory } from 'react-router-dom';
import { Star } from '@mui/icons-material';
import { colors } from '@mui/material';
import { Logout, Star } from '@mui/icons-material';
import { colors, Divider, ListItemIcon, ListItemText } from '@mui/material';
import AccountCircle from '@mui/icons-material/AccountCircle';

const AccountMenu = () => {
const translations = useTranslation();
Expand Down Expand Up @@ -66,27 +67,35 @@ const AccountMenu = () => {
open={menuOpen}
onClose={closeMenu}
>
<MenuItem onClick={handleLogout}>
{translations.Header.logout}
</MenuItem>
{user && user.accountType !== 'anonymous' ? (
<MenuItem onClick={handleAccount}>
{translations.Header.account}
</MenuItem>
) : null}
{user && !user.pro && user.accountType !== 'anonymous' ? (
<MenuItem onClick={handleSubscribe}>
<Star
style={{
color: colors.yellow[700],
position: 'relative',
top: -2,
left: -5,
}}
/>{' '}
Go Pro!
<ListItemIcon>
<Star
style={{
color: colors.yellow[700],
position: 'relative',
top: -1,
}}
/>
</ListItemIcon>
<ListItemText>Go Pro!</ListItemText>
</MenuItem>
) : null}
{user && user.accountType !== 'anonymous' ? (
<MenuItem onClick={handleAccount}>
<ListItemIcon>
<AccountCircle />
</ListItemIcon>
<ListItemText>{translations.Header.account}</ListItemText>
</MenuItem>
) : null}
<Divider />
<MenuItem onClick={handleLogout}>
<ListItemIcon>
<Logout />
</ListItemIcon>
<ListItemText>{translations.Header.logout}</ListItemText>
</MenuItem>
</Menu>
) : null}
</div>
Expand Down
Loading

0 comments on commit bc8a27c

Please sign in to comment.