Merged PR 429: API実装(アカウント削除API:メイン処理)

## 概要
[Task2670: API実装(アカウント削除API:メイン処理)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2670)

アカウント削除APIを実装しました。
APIとしてはこれで実装完了ですが、DBに外部キー制約をつけていないので、現時点で削除できるものは以下のみです。
・アカウントテーブル
・ADB2Cのユーザー
・BLOBストレージ

## レビューポイント
内容が重めの処理なので全体的に見ていただけると嬉しいです。

## UIの変更
なし

## 動作確認状況
ローカルで以下の動作を確認
・RDBのアカウントが削除される
・ADB2Cのユーザーが削除される
・RDBのユーザーが退避テーブルに登録される
・BLOBストレージが削除される

## 補足
UTは別タスクに切り出しているので、本タスクでは実装していません。
This commit is contained in:
maruyama.t 2023-10-03 06:20:36 +00:00 committed by oura.a
parent d942dc73f1
commit 664e815ef9
8 changed files with 227 additions and 16 deletions

View File

@ -246,3 +246,9 @@ export const OPTION_ITEM_VALUE_TYPE = {
export const ADB2C_SIGN_IN_TYPE = {
EAMILADDRESS: 'emailAddress',
} as const;
/**
* MANUAL_RECOVERY_REQUIRED
* @const {string}
*/
export const MANUAL_RECOVERY_REQUIRED = '[MANUAL_RECOVERY_REQUIRED]';

View File

@ -1071,7 +1071,7 @@ export class AccountsController {
description: 'DBアクセスに失敗しログインできる状態で処理が終了した場合',
type: ErrorResponse,
})
@ApiOperation({ operationId: 'deleteAccount' })
@ApiOperation({ operationId: 'deleteAccountAndData' })
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(
@ -1079,7 +1079,7 @@ export class AccountsController {
roles: [ADMIN_ROLES.ADMIN],
}),
)
async deleteAccount(
async deleteAccountAndData(
@Req() req: Request,
@Body() body: DeleteAccountRequest,
): Promise<DeleteAccountResponse> {
@ -1088,12 +1088,7 @@ export class AccountsController {
const { userId } = jwt.decode(token, { json: true }) as AccessToken;
const context = makeContext(userId);
/* TODO
await this.accountService.deleteAccount(
context,
accountId
);
*/
await this.accountService.deleteAccountAndData(context, userId, accountId);
return;
}
}

View File

@ -15,6 +15,7 @@ import {
USER_ROLES,
ADB2C_SIGN_IN_TYPE,
OPTION_ITEM_VALUE_TYPE,
MANUAL_RECOVERY_REQUIRED,
} from '../../constants';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import {
@ -319,7 +320,7 @@ export class AccountsService {
} catch (error) {
this.logger.error(`error=${error}`);
this.logger.error(
`[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete externalUser: ${externalUserId}`,
`${MANUAL_RECOVERY_REQUIRED} [${context.trackingId}] Failed to delete externalUser: ${externalUserId}`,
);
}
}
@ -338,7 +339,7 @@ export class AccountsService {
} catch (error) {
this.logger.error(`error=${error}`);
this.logger.error(
`[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete account: ${accountId}, user: ${userId}`,
`${MANUAL_RECOVERY_REQUIRED} [${context.trackingId}] Failed to delete account: ${accountId}, user: ${userId}`,
);
}
}
@ -361,7 +362,7 @@ export class AccountsService {
);
} catch (error) {
this.logger.error(
`[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete container: ${accountId}, country: ${country}`,
`${MANUAL_RECOVERY_REQUIRED} [${context.trackingId}] Failed to delete container: ${accountId}, country: ${country}`,
);
}
}
@ -1684,4 +1685,99 @@ export class AccountsService {
);
}
}
/**
*
* @param context
* @param externalId
* @param accountId // 削除対象のアカウントID
*/
async deleteAccountAndData(
context: Context,
externalId: string,
accountId: number,
): Promise<void> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.deleteAccountAndData.name} | params: { ` +
`externalId: ${externalId}, ` +
`accountId: ${accountId}, };`,
);
let country: string;
let dbUsers: User[];
try {
// パラメータとトークンから取得したアカウントIDの突き合わせ
const { account_id: myAccountId } =
await this.usersRepository.findUserByExternalId(externalId);
if (myAccountId !== accountId) {
throw new HttpException(
makeErrorResponse('E000108'),
HttpStatus.UNAUTHORIZED,
);
}
// アカウント削除前に必要な情報を退避する
const targetAccount = await this.accountRepository.findAccountById(
accountId,
);
// 削除対象アカウントを削除する
dbUsers = await this.accountRepository.deleteAccountAndInsertArchives(
accountId,
);
this.logger.log(`[${context.trackingId}] delete account: ${accountId}`);
country = targetAccount.country;
} catch (e) {
// アカウントの削除に失敗した場合はエラーを返す
this.logger.log(`[${context.trackingId}] ${e}`);
this.logger.log(
`[OUT] [${context.trackingId}] ${this.deleteAccountAndData.name}`,
);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
// 削除対象アカウント内のADB2Cユーザーをすべて削除する
await this.adB2cService.deleteUsers(
dbUsers.map((x) => x.external_id),
context,
);
this.logger.log(
`[${
context.trackingId
}] delete ADB2C users: ${accountId}, users_id: ${dbUsers.map(
(x) => x.external_id,
)}`,
);
} catch (e) {
// ADB2Cユーザーの削除失敗時は、MANUAL_RECOVERY_REQUIREDを出して処理続行
this.logger.log(`[${context.trackingId}] ${e}`);
this.logger.log(
`${MANUAL_RECOVERY_REQUIRED} [${
context.trackingId
}] Failed to delete ADB2C users: ${accountId}, users_id: ${dbUsers.map(
(x) => x.external_id,
)}`,
);
}
try {
// blobstorageコンテナを削除する
await this.deleteBlobContainer(accountId, country, context);
this.logger.log(
`[${context.trackingId}] delete blob container: ${accountId}-${country}`,
);
} catch (e) {
// blobstorageコンテナを削除で失敗した場合は、MANUAL_RECOVERY_REQUIRED出して正常終了
this.logger.log(`[${context.trackingId}] ${e}`);
this.logger.log(
`${MANUAL_RECOVERY_REQUIRED}[${context.trackingId}] Failed to delete blob container: ${accountId}, country: ${country}`,
);
}
this.logger.log(
`[OUT] [${context.trackingId}] ${this.deleteAccountAndData.name}`,
);
}
}

View File

@ -35,6 +35,7 @@ import {
import {
ADB2C_SIGN_IN_TYPE,
LICENSE_EXPIRATION_THRESHOLD_DAYS,
MANUAL_RECOVERY_REQUIRED,
USER_LICENSE_STATUS,
USER_ROLES,
} from '../../constants';
@ -300,7 +301,7 @@ export class UsersService {
} catch (error) {
this.logger.error(`error=${error}`);
this.logger.error(
`[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete externalUser: ${externalUserId}`,
`${MANUAL_RECOVERY_REQUIRED} [${context.trackingId}] Failed to delete externalUser: ${externalUserId}`,
);
}
}
@ -313,7 +314,7 @@ export class UsersService {
} catch (error) {
this.logger.error(`error=${error}`);
this.logger.error(
`[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete user: ${userId}`,
`${MANUAL_RECOVERY_REQUIRED} [${context.trackingId}] Failed to delete user: ${userId}`,
);
}
}

View File

@ -254,6 +254,31 @@ export class AdB2cService {
this.logger.log(`[OUT] [${context.trackingId}] ${this.deleteUser.name}`);
}
}
/**
* Azure AD B2Cからユーザ情報を削除する
* @param externalIds ID
* @param context
*/
async deleteUsers(externalIds: string[], context: Context): Promise<void> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.deleteUsers.name} | params: { externalIds: ${externalIds} };`,
);
try {
// 複数ユーザーを一括削除する方法が不明なため、rate limitの懸念があるのを承知のうえ単一削除の繰り返しで実装
// TODO 一括削除する方法が判明したら修正する
// https://learn.microsoft.com/en-us/graph/api/user-delete?view=graph-rest-1.0&tabs=javascript#example
externalIds.map(
async (x) => await this.graphClient.api(`users/${x}`).delete(),
);
} catch (e) {
this.logger.error(`error=${e}`);
throw e;
} finally {
this.logger.log(`[OUT] [${context.trackingId}] ${this.deleteUsers.name}`);
}
}
}
// TODO [Task2002] 文字列の配列を15要素ずつ区切る(この処理も別タスクで削除予定)

View File

@ -10,7 +10,7 @@ import {
UpdateResult,
EntityManager,
} from 'typeorm';
import { User } from '../users/entity/user.entity';
import { User, UserArchive } from '../users/entity/user.entity';
import { Account } from './entity/account.entity';
import { License, LicenseOrder } from '../licenses/entity/license.entity';
import { SortCriteria } from '../sort_criteria/entity/sort_criteria.entity';
@ -902,4 +902,34 @@ export class AccountsRepositoryService {
);
});
}
/**
*
* @param accountId
* @returns users
*/
async deleteAccountAndInsertArchives(accountId: number): Promise<User[]> {
return await this.dataSource.transaction(async (entityManager) => {
// 削除対象のユーザーを退避テーブルに退避
const users = await this.dataSource.getRepository(User).find({
where: {
account_id: accountId,
},
});
const userArchiveRepo = entityManager.getRepository(UserArchive);
await userArchiveRepo
.createQueryBuilder()
.insert()
.into(UserArchive)
.values(users)
.execute();
// アカウントを削除
// アカウントを削除することで、外部キー制約がで紐づいている関連テーブルのデータも削除される
const accountRepo = entityManager.getRepository(Account);
await accountRepo.delete({ id: accountId });
return users;
});
}
}

View File

@ -9,6 +9,7 @@ import {
JoinColumn,
OneToOne,
OneToMany,
PrimaryColumn,
} from 'typeorm';
import { License } from '../../licenses/entity/license.entity';
import { UserGroupMember } from '../../user_groups/entity/user_group_member.entity';
@ -80,6 +81,63 @@ export class User {
userGroupMembers?: UserGroupMember[];
}
@Entity({ name: 'users_archive' })
export class UserArchive {
@PrimaryColumn()
id: number;
@Column()
external_id: string;
@Column()
account_id: number;
@Column()
role: string;
@Column({ nullable: true })
author_id?: string;
@Column({ nullable: true })
accepted_terms_version?: string;
@Column()
email_verified: boolean;
@Column()
auto_renew: boolean;
@Column()
license_alert: boolean;
@Column()
notification: boolean;
@Column()
encryption: boolean;
@Column()
prompt: boolean;
@Column({ nullable: true })
deleted_at?: Date;
@Column({ nullable: true })
created_by: string;
@Column()
created_at: Date;
@Column({ nullable: true })
updated_by?: string;
@Column()
updated_at: Date;
@CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定
archived_at: Date;
}
export type newUser = Omit<
User,
| 'id'

View File

@ -1,10 +1,10 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entity/user.entity';
import { User, UserArchive } from './entity/user.entity';
import { UsersRepositoryService } from './users.repository.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
imports: [TypeOrmModule.forFeature([User, UserArchive])],
providers: [UsersRepositoryService],
exports: [UsersRepositoryService],
})