Skip to content

Commit

Permalink
[TECH] Ajouter une API de suppression de compte utilisateur (PIX-14910)
Browse files Browse the repository at this point in the history
  • Loading branch information
pix-service-auto-merge authored Nov 30, 2024
2 parents 6aa1507 + 04b5700 commit f525a20
Show file tree
Hide file tree
Showing 20 changed files with 673 additions and 363 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,14 @@ const rememberUserHasSeenLastDataProtectionPolicyInformation = async function (
return dependencies.userSerializer.serialize(updatedUser);
};

const selfDeleteUserAccount = async function (request, h) {
const authenticatedUserId = request.auth.credentials.userId;

await usecases.selfDeleteUserAccount({ userId: authenticatedUserId });

return h.response().code(204);
};

const sendVerificationCode = async function (
request,
h,
Expand Down Expand Up @@ -235,6 +243,7 @@ export const userController = {
getUserAuthenticationMethods,
rememberUserHasSeenLastDataProtectionPolicyInformation,
createUser,
selfDeleteUserAccount,
sendVerificationCode,
updatePassword,
updateUserEmailWithValidation,
Expand Down
12 changes: 12 additions & 0 deletions api/src/identity-access-management/application/user/user.route.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ export const userRoutes = [
tags: ['identity-access-management', 'api', 'user'],
},
},
{
method: 'DELETE',
path: '/api/users/me',
config: {
handler: (request, h) => userController.selfDeleteUserAccount(request, h),
notes: [
'- **Cette route est restreinte aux utilisateurs authentifiés**\n' +
'- Permet à l’utilisateur authentifié de supprimer son compte',
],
tags: ['identity-access-management', 'api', 'user'],
},
},
{
method: 'GET',
path: '/api/users/my-account',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import Joi from 'joi';
import { PIX_ADMIN } from '../../../authorization/domain/constants.js';
import { validateEntity } from '../../../shared/domain/validators/entity-validator.js';

const { ROLES } = PIX_ADMIN;
const USER_ROLE = 'USER';

// Todo rename on UserAnonymizedEventLoggingJob in order to erase Event logging context
export class UserAnonymizedEventLoggingJob {
constructor({ userId, updatedByUserId, client = 'PIX_ADMIN', role }) {
constructor({ userId, updatedByUserId, client, role }) {
this.userId = userId;
this.updatedByUserId = updatedByUserId;
this.client = client;
Expand All @@ -21,7 +21,7 @@ export class UserAnonymizedEventLoggingJob {
const USER_ANONYMIZED_SCHEMA = Joi.object({
userId: Joi.number().integer().required(),
updatedByUserId: Joi.number().integer().required(),
client: Joi.string().valid('PIX_ADMIN').required(),
client: Joi.string().valid('PIX_APP', 'PIX_ADMIN').required(),
occurredAt: Joi.date().required(),
role: Joi.string().valid(ROLES.SUPER_ADMIN, ROLES.SUPPORT).required(),
role: Joi.string().valid(USER_ROLE, PIX_ADMIN.ROLES.SUPER_ADMIN, PIX_ADMIN.ROLES.SUPPORT).required(),
});
Original file line number Diff line number Diff line change
@@ -1,105 +1,37 @@
import { config } from '../../../shared/config.js';
import { withTransaction } from '../../../shared/domain/DomainTransaction.js';
import { UserNotFoundError } from '../../../shared/domain/errors.js';
import { UserAnonymizedEventLoggingJob } from '../models/UserAnonymizedEventLoggingJob.js';

/**
* @param params
* @param{string} params.userId
* @param{string} params.updatedByUserId
* @param{boolean} params.preventAuditLogging
* @param{UserRepository} params.userRepository
* @param{AuthenticationMethodRepository} params.authenticationMethodRepository
* @param{MembershipRepository} params.membershipRepository
* @param{CertificationCenterMembershipRepository} params.certificationCenterMembershipRepository
* @param{OrganizationLearnerRepository} params.organizationLearnerRepository
* @param{RefreshTokenRepository} params.refreshTokenRepository
* @param{ResetPasswordDemandRepository} params.resetPasswordDemandRepository
* @param{UserLoginRepository} params.userLoginRepository
* @param{AdminMemberRepository} params.adminMemberRepository
* @param{UserAnonymizedEventLoggingJobRepository} params.userAnonymizedEventLoggingJobRepository
* @returns {Promise<null>}
*/
const anonymizeUser = async function ({
const anonymizeUser = withTransaction(async function ({
userId,
updatedByUserId,
preventAuditLogging = false,
userRepository,
authenticationMethodRepository,
membershipRepository,
certificationCenterMembershipRepository,
organizationLearnerRepository,
refreshTokenRepository,
resetPasswordDemandRepository,
userLoginRepository,
adminMemberRepository,
userAnonymizedEventLoggingJobRepository,
privacyUsersApiRepository,
}) {
const user = await userRepository.get(userId);

const anonymizedBy = await _getAdminUser({
adminUserId: updatedByUserId || user.hasBeenAnonymisedBy,
adminUserId: updatedByUserId,
adminMemberRepository,
});

await authenticationMethodRepository.removeAllAuthenticationMethodsByUserId({ userId });

await refreshTokenRepository.revokeAllByUserId({ userId });

if (user.email) {
await resetPasswordDemandRepository.removeAllByEmail(user.email);
}

await membershipRepository.disableMembershipsByUserId({ userId, updatedByUserId: anonymizedBy?.userId });

await certificationCenterMembershipRepository.disableMembershipsByUserId({
updatedByUserId: anonymizedBy?.userId,
userId,
});

await organizationLearnerRepository.dissociateAllStudentsByUserId({ userId });
const anonymizedByUserId = updatedByUserId;
const anonymizedByUserRole = anonymizedBy.role;
const client = 'PIX_ADMIN';

await _anonymizeUserLogin({ userId, userLoginRepository });

await _anonymizeUser({ user, anonymizedByUserId: anonymizedBy?.userId, userRepository });

if (anonymizedBy && !preventAuditLogging && config.auditLogger.isEnabled) {
await userAnonymizedEventLoggingJobRepository.performAsync(
new UserAnonymizedEventLoggingJob({
userId,
updatedByUserId: anonymizedBy.userId,
role: anonymizedBy.role,
}),
);
}
return null;
};
await privacyUsersApiRepository.anonymizeUser({ userId, anonymizedByUserId, anonymizedByUserRole, client });
});

async function _getAdminUser({ adminUserId, adminMemberRepository }) {
if (!adminUserId) return undefined;

const admin = await adminMemberRepository.get({ userId: adminUserId });
if (!admin) {
throw new UserNotFoundError(`Admin not found for id: ${adminUserId}`);
}
return admin;
}

async function _anonymizeUserLogin({ userId, userLoginRepository }) {
const userLogin = await userLoginRepository.findByUserId(userId);
if (!userLogin) return;

const anonymizedUserLogin = userLogin.anonymize();

await userLoginRepository.update(anonymizedUserLogin, { preventUpdatedAt: true });
}

async function _anonymizeUser({ user, anonymizedByUserId, userRepository }) {
const anonymizedUser = user.anonymize(anonymizedByUserId).mapToDatabaseDto();

await userRepository.updateUserDetailsForAdministration(
{ id: user.id, userAttributes: anonymizedUser },
{ preventUpdatedAt: true },
);
}

export { anonymizeUser };
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ForbiddenAccess } from '../../../shared/domain/errors.js';

/**
* @typedef {import('../../infrastructure/repositories/privacy-users-api.repository.js')} PrivacyUsersApiRepository
*/

/**
* @param{object} params
* @param{number} params.userId
* @param{PrivacyUsersApiRepository} privacyUsersApiRepository
* @returns {Promise<boolean>}
*/
export const selfDeleteUserAccount = async function ({ userId, privacyUsersApiRepository }) {
const canSelfDeleteAccount = await privacyUsersApiRepository.canSelfDeleteAccount({ userId });

if (!canSelfDeleteAccount) {
throw new ForbiddenAccess();
}

const anonymizedByUserId = userId;
const anonymizedByUserRole = 'USER';
const client = 'PIX_APP';
await privacyUsersApiRepository.anonymizeUser({ userId, anonymizedByUserId, anonymizedByUserRole, client });
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import * as privacyUsersApi from '../../../privacy/application/api/users-api.js';

const anonymizeUser = async ({
userId,
anonymizedByUserId,
anonymizedByUserRole,
client,
dependencies = { privacyUsersApi },
}) => {
return dependencies.privacyUsersApi.anonymizeUser({ userId, anonymizedByUserId, anonymizedByUserRole, client });
};

const canSelfDeleteAccount = async ({ userId, dependencies = { privacyUsersApi } }) => {
return dependencies.privacyUsersApi.canSelfDeleteAccount({ userId });
};

export { canSelfDeleteAccount };
export { anonymizeUser, canSelfDeleteAccount };
17 changes: 16 additions & 1 deletion api/src/privacy/application/api/users-api.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
import { usecases } from '../../domain/usecases/index.js';

/**
* Anonymizes everything related to the user.
*
* @param{string} params.userId
* @param{string} params.anonymizedByUserId
* @param{string} params.anonymizedByUserRole
* @param{string} params.client
* @returns {Promise<void>}
*/
const anonymizeUser = async ({ userId, anonymizedByUserId, anonymizedByUserRole, client }) => {
return usecases.anonymizeUser({ userId, anonymizedByUserId, anonymizedByUserRole, client });
};

/**
* Determines if a user can self-delete their account.
*
* @param {Object} params - The parameters for the function.
* @param {number} params.userId - The ID of the user.
* @returns {Promise<boolean>} - A promise that resolves to a boolean indicating if the user can self-delete their account.
*/
export const canSelfDeleteAccount = async ({ userId }) => {
const canSelfDeleteAccount = async ({ userId }) => {
return usecases.canSelfDeleteAccount({ userId });
};

export { anonymizeUser, canSelfDeleteAccount };
92 changes: 92 additions & 0 deletions api/src/privacy/domain/usecases/anonymize-user.usecase.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { UserAnonymizedEventLoggingJob } from '../../../identity-access-management/domain/models/UserAnonymizedEventLoggingJob.js';
import { config } from '../../../shared/config.js';
import { withTransaction } from '../../../shared/domain/DomainTransaction.js';

/**
* @param params
* @param{string} params.userId
* @param{string} params.anonymizedByUserId
* @param{string} params.anonymizedByUserRole
* @param{string} params.client
* @param{UserRepository} params.userRepository
* @param{AuthenticationMethodRepository} params.authenticationMethodRepository
* @param{MembershipRepository} params.membershipRepository
* @param{CertificationCenterMembershipRepository} params.certificationCenterMembershipRepository
* @param{OrganizationLearnerRepository} params.organizationLearnerRepository
* @param{RefreshTokenRepository} params.refreshTokenRepository
* @param{ResetPasswordDemandRepository} params.resetPasswordDemandRepository
* @param{UserLoginRepository} params.userLoginRepository
* @param{UserAnonymizedEventLoggingJobRepository} params.userAnonymizedEventLoggingJobRepository
* @returns {Promise<void>}
*/
const anonymizeUser = withTransaction(async function ({
userId,
anonymizedByUserId,
anonymizedByUserRole,
client,
userRepository,
authenticationMethodRepository,
membershipRepository,
certificationCenterMembershipRepository,
organizationLearnerRepository,
refreshTokenRepository,
resetPasswordDemandRepository,
userLoginRepository,
userAnonymizedEventLoggingJobRepository,
}) {
const user = await userRepository.get(userId);

await userRepository.get(anonymizedByUserId);

await authenticationMethodRepository.removeAllAuthenticationMethodsByUserId({ userId });

await refreshTokenRepository.revokeAllByUserId({ userId });

if (user.email) {
await resetPasswordDemandRepository.removeAllByEmail(user.email);
}

await membershipRepository.disableMembershipsByUserId({ userId, updatedByUserId: anonymizedByUserId });

await certificationCenterMembershipRepository.disableMembershipsByUserId({
updatedByUserId: anonymizedByUserId,
userId,
});

await organizationLearnerRepository.dissociateAllStudentsByUserId({ userId });

await _anonymizeUserLogin({ userId, userLoginRepository });

await _anonymizeUser({ user, anonymizedByUserId, userRepository });

if (config.auditLogger.isEnabled) {
await userAnonymizedEventLoggingJobRepository.performAsync(
new UserAnonymizedEventLoggingJob({
userId,
updatedByUserId: anonymizedByUserId,
client,
role: anonymizedByUserRole,
}),
);
}
});

async function _anonymizeUserLogin({ userId, userLoginRepository }) {
const userLogin = await userLoginRepository.findByUserId(userId);
if (!userLogin) return;

const anonymizedUserLogin = userLogin.anonymize();

await userLoginRepository.update(anonymizedUserLogin, { preventUpdatedAt: true });
}

async function _anonymizeUser({ user, anonymizedByUserId, userRepository }) {
const anonymizedUser = user.anonymize(anonymizedByUserId).mapToDatabaseDto();

await userRepository.updateUserDetailsForAdministration(
{ id: user.id, userAttributes: anonymizedUser },
{ preventUpdatedAt: true },
);
}

export { anonymizeUser };
30 changes: 25 additions & 5 deletions api/src/privacy/domain/usecases/index.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,44 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

import * as organizationLearnerRepository from '../../../../lib/infrastructure/repositories/organization-learner-repository.js';
import * as authenticationMethodRepository from '../../../identity-access-management/infrastructure/repositories/authentication-method.repository.js';
import { userAnonymizedEventLoggingJobRepository } from '../../../identity-access-management/infrastructure/repositories/jobs/user-anonymized-event-logging-job-repository.js';
import { refreshTokenRepository } from '../../../identity-access-management/infrastructure/repositories/refresh-token.repository.js';
import { resetPasswordDemandRepository } from '../../../identity-access-management/infrastructure/repositories/reset-password-demand.repository.js';
import * as userRepository from '../../../identity-access-management/infrastructure/repositories/user.repository.js';
import * as userLoginRepository from '../../../shared/infrastructure/repositories/user-login-repository.js';
import { injectDependencies } from '../../../shared/infrastructure/utils/dependency-injection.js';
import { importNamedExportsFromDirectory } from '../../../shared/infrastructure/utils/import-named-exports-from-directory.js';
import { certificationCenterMembershipRepository } from '../../../team/infrastructure/repositories/certification-center-membership.repository.js';
import * as membershipRepository from '../../../team/infrastructure/repositories/membership.repository.js';
import * as candidatesApiRepository from '../../infrastructure/repositories/candidates-api.repository.js';
import * as learnersApiRepository from '../../infrastructure/repositories/learners-api.repository.js';
import * as userTeamsApiRepository from '../../infrastructure/repositories/user-teams-api.repository.js';

const path = dirname(fileURLToPath(import.meta.url));

const usecasesWithoutInjectedDependencies = {
...(await importNamedExportsFromDirectory({ path: join(path, './'), ignoredFileNames: ['index.js'] })),
};

const dependencies = {
const repositories = {
authenticationMethodRepository,
candidatesApiRepository,
certificationCenterMembershipRepository,
learnersApiRepository,
membershipRepository,
organizationLearnerRepository,
refreshTokenRepository,
resetPasswordDemandRepository,
userAnonymizedEventLoggingJobRepository,
userLoginRepository,
userRepository,
userTeamsApiRepository,
};

const usecasesWithoutInjectedDependencies = {
...(await importNamedExportsFromDirectory({ path: join(path, './'), ignoredFileNames: ['index.js'] })),
};

const dependencies = Object.assign({}, repositories);

const usecases = injectDependencies(usecasesWithoutInjectedDependencies, dependencies);

export { usecases };
Loading

0 comments on commit f525a20

Please sign in to comment.