From f3dde1874d9886052f05d7e3650ec98c0e971b3b Mon Sep 17 00:00:00 2001 From: masaaki Date: Thu, 26 Oct 2023 09:19:03 +0000 Subject: [PATCH 1/7] =?UTF-8?q?Merged=20PR=20512:=20ADB2C=E3=83=A6?= =?UTF-8?q?=E3=83=BC=E3=82=B6=E3=81=8C=E4=B8=80=E6=8B=AC=E3=81=A7=E5=89=8A?= =?UTF-8?q?=E9=99=A4=E3=81=A7=E3=81=8D=E3=81=AA=E3=81=84=E3=81=93=E3=81=A8?= =?UTF-8?q?=E3=81=AB=E5=AF=BE=E3=81=99=E3=82=8B=E5=AF=BE=E5=BF=9C=E5=84=AA?= =?UTF-8?q?=E5=85=88=E5=BA=A6=E3=81=AE=E6=95=B0=E5=80=A4=E6=A0=B9=E6=8B=A0?= =?UTF-8?q?=E3=81=A0=E3=81=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2831: ADB2Cユーザが一括で削除できないことに対する対応優先度の数値根拠だし](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2831) - 元PBI or タスクへのリンク(内容・目的などはそちらにあるはず) - 何をどう変更したか、追加したライブラリなど 削除処理の同期化、エラー出力の追加 - このPull Requestでの対象/対象外 - 影響範囲(他の機能にも影響があるか) ## レビューポイント - 特にレビューしてほしい箇所 - 軽微なものや自明なものは記載不要 - 修正範囲が大きい場合などに記載 - 全体的にや仕様を満たしているか等は本当に必要な時のみ記載 ## UIの変更 - Before/Afterのスクショなど - スクショ置き場 ## 動作確認状況 - ユニットテスト ## 補足 - 相談、参考資料などがあれば --- .../src/gateways/adb2c/adb2c.service.ts | 34 +++++++++++++++---- .../src/gateways/adb2c/utils/utils.ts | 10 ++++++ 2 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 dictation_server/src/gateways/adb2c/utils/utils.ts diff --git a/dictation_server/src/gateways/adb2c/adb2c.service.ts b/dictation_server/src/gateways/adb2c/adb2c.service.ts index e7dcce6..f38f5c5 100644 --- a/dictation_server/src/gateways/adb2c/adb2c.service.ts +++ b/dictation_server/src/gateways/adb2c/adb2c.service.ts @@ -6,8 +6,9 @@ import { ConfigService } from '@nestjs/config'; import axios from 'axios'; import { Aadb2cUser, B2cMetadata, JwkSignKey } from '../../common/token'; import { AdB2cResponse, AdB2cUser } from './types/types'; +import { isPromiseRejectedResult } from './utils/utils'; import { Context } from '../../common/log'; -import { ADB2C_SIGN_IN_TYPE } from '../../constants'; +import { ADB2C_SIGN_IN_TYPE, MANUAL_RECOVERY_REQUIRED } from '../../constants'; export type ConflictError = { reason: 'email'; @@ -267,12 +268,33 @@ export class AdB2cService { ); 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(), + // 複数ユーザーを一括削除する方法がないため、1人ずつで削除を行う(rate limitに大きな影響がないこと確認済) + const results = await Promise.allSettled( + externalIds.map( + async (x) => await this.graphClient.api(`users/${x}`).delete(), + ), ); + + // 失敗したプロミスを抽出 + const failedPromises = results.filter( + (result) => result.status === 'rejected', + ); + + // 失敗したプロミスのエラーをログに記録 + failedPromises.forEach((result, index) => { + const failedId = externalIds[index]; + if (isPromiseRejectedResult(result)) { + const error = result.reason.toString(); + + this.logger.error( + `${MANUAL_RECOVERY_REQUIRED}[${context.trackingId}] Failed to delete user ${failedId}: ${error}`, + ); + } else { + this.logger.error( + `${MANUAL_RECOVERY_REQUIRED}[${context.trackingId}] Failed to delete user ${failedId}`, + ); + } + }); } catch (e) { this.logger.error(`error=${e}`); throw e; diff --git a/dictation_server/src/gateways/adb2c/utils/utils.ts b/dictation_server/src/gateways/adb2c/utils/utils.ts new file mode 100644 index 0000000..1d3b4a9 --- /dev/null +++ b/dictation_server/src/gateways/adb2c/utils/utils.ts @@ -0,0 +1,10 @@ +export const isPromiseRejectedResult = ( + data: unknown, +): data is PromiseRejectedResult => { + return ( + data !== null && + typeof data === 'object' && + 'status' in data && + 'reason' in data + ); +}; From b314fe4b460568f0e21020dea11ac85b827978aa Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Thu, 26 Oct 2023 10:46:15 +0000 Subject: [PATCH 2/7] =?UTF-8?q?Merged=20PR=20513:=20=E6=AC=A1=E3=82=BF?= =?UTF-8?q?=E3=82=B9=E3=82=AF=E5=8F=96=E5=BE=97API=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2874: 次タスク取得API実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2874) - 次タスク取得APIとテストを実装しました。 ## レビューポイント - リポジトリからのタスク取得ロジックは適切か - テストケースは適切か ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 --- .../accounts/test/accounts.service.mock.ts | 1 - .../src/features/tasks/tasks.controller.ts | 36 +- .../src/features/tasks/tasks.service.spec.ts | 487 +++++++++++++++++- .../src/features/tasks/tasks.service.ts | 75 +++ .../src/features/tasks/test/utility.ts | 8 +- .../src/features/users/test/utility.ts | 14 + .../entity/checkout_permission.entity.ts | 5 +- .../tasks/tasks.repository.service.ts | 99 ++++ 8 files changed, 712 insertions(+), 13 deletions(-) diff --git a/dictation_server/src/features/accounts/test/accounts.service.mock.ts b/dictation_server/src/features/accounts/test/accounts.service.mock.ts index 0330884..8c5c68d 100644 --- a/dictation_server/src/features/accounts/test/accounts.service.mock.ts +++ b/dictation_server/src/features/accounts/test/accounts.service.mock.ts @@ -14,7 +14,6 @@ import { UserGroup } from '../../../repositories/user_groups/entity/user_group.e import { UserGroupsRepositoryService } from '../../../repositories/user_groups/user_groups.repository.service'; import { AdB2cUser } from '../../../gateways/adb2c/types/types'; import { LicensesRepositoryService } from '../../../repositories/licenses/licenses.repository.service'; -import { Context } from '../../../common/log'; import { BlobstorageService } from '../../../gateways/blobstorage/blobstorage.service'; import { Worktype } from '../../../repositories/worktypes/entity/worktype.entity'; import { WorktypesRepositoryService } from '../../../repositories/worktypes/worktypes.repository.service'; diff --git a/dictation_server/src/features/tasks/tasks.controller.ts b/dictation_server/src/features/tasks/tasks.controller.ts index 72bd402..aa06e6d 100644 --- a/dictation_server/src/features/tasks/tasks.controller.ts +++ b/dictation_server/src/features/tasks/tasks.controller.ts @@ -153,11 +153,41 @@ export class TasksController { '指定した文字起こしタスクの次のタスクに紐づく音声ファイルIDを取得します', }) @ApiBearerAuth() + @UseGuards( + RoleGuard.requireds({ + roles: [USER_ROLES.TYPIST], + }), + ) async getNextAudioFile( - @Headers() headers, - @Query() body: AudioNextRequest, + @Req() req: Request, + @Query() param: AudioNextRequest, ): Promise { - return { nextFileId: 1234 }; + const { endedFileId } = param; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; + const context = makeContext(userId); + + const nextFileId = await this.taskService.getNextTask( + context, + userId, + endedFileId, + ); + + return { nextFileId }; } @Post(':audioFileId/checkout') diff --git a/dictation_server/src/features/tasks/tasks.service.spec.ts b/dictation_server/src/features/tasks/tasks.service.spec.ts index bc61100..a01cebc 100644 --- a/dictation_server/src/features/tasks/tasks.service.spec.ts +++ b/dictation_server/src/features/tasks/tasks.service.spec.ts @@ -20,8 +20,14 @@ import { } from './test/utility'; import { Adb2cTooManyRequestsError } from '../../gateways/adb2c/adb2c.service'; import { makeContext } from '../../common/log'; -import { makeTestSimpleAccount, makeTestUser } from '../../common/test/utility'; -import { ADMIN_ROLES, USER_ROLES } from '../../constants'; +import { + makeTestAccount, + makeTestSimpleAccount, + makeTestUser, +} from '../../common/test/utility'; +import { ADMIN_ROLES, TASK_STATUS, USER_ROLES } from '../../constants'; +import { makeTestingModule } from '../../common/test/modules'; +import { createSortCriteria } from '../users/test/utility'; describe('TasksService', () => { it('タスク一覧を取得できる(admin)', async () => { @@ -2460,3 +2466,480 @@ describe('cancel', () => { ); }); }); + +describe('getNextTask', () => { + let source: DataSource | null = null; + beforeEach(async () => { + source = new DataSource({ + type: 'sqlite', + database: ':memory:', + logging: false, + entities: [__dirname + '/../../**/*.entity{.ts,.js}'], + synchronize: true, // trueにすると自動的にmigrationが行われるため注意 + }); + return source.initialize(); + }); + + afterEach(async () => { + if (!source) return; + await source.destroy(); + source = null; + }); + + it('次タスクを取得できる(JobNumber順)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const { id: authorUserId } = await makeTestUser(source, { + account_id: account.id, + author_id: 'AUTHOR_ID', + external_id: 'author-user-external-id', + role: USER_ROLES.AUTHOR, + }); + const { id: typistUserId, external_id: typistExternalId } = + await makeTestUser(source, { + account_id: account.id, + external_id: 'typist-user-external-id', + role: USER_ROLES.TYPIST, + }); + + await createSortCriteria(source, typistUserId, 'JOB_NUMBER', 'ASC'); + + const { taskId: taskId1 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000001', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId1, typistUserId); + + const { taskId: taskId3, audioFileId: audioFileId3 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000003', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId3, typistUserId); + + const { taskId: taskId2, audioFileId: audioFileId2 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000002', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId2, typistUserId); + + const service = module.get(TasksService); + const context = makeContext(admin.external_id); + + const nextAudioFileId = await service.getNextTask( + context, + typistExternalId, + audioFileId2, + ); + + // 実行結果が正しいか確認 + { + expect(nextAudioFileId).toEqual(audioFileId3); + } + }); + + it('次タスクを取得できる(JobNumber順+優先度)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const { id: authorUserId } = await makeTestUser(source, { + account_id: account.id, + author_id: 'AUTHOR_ID', + external_id: 'author-user-external-id', + role: USER_ROLES.AUTHOR, + }); + const { id: typistUserId, external_id: typistExternalId } = + await makeTestUser(source, { + account_id: account.id, + external_id: 'typist-user-external-id', + role: USER_ROLES.TYPIST, + }); + + await createSortCriteria(source, typistUserId, 'JOB_NUMBER', 'ASC'); + + const { taskId: taskId1, audioFileId: audioFileId1 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + '', + '00', + '00000001', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId1, typistUserId); + + const { taskId: taskId3, audioFileId: audioFileId3 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + '', + '00', + '00000003', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId3, typistUserId); + + const { taskId: taskId2 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000002', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId2, typistUserId); + + const service = module.get(TasksService); + const context = makeContext(admin.external_id); + + const nextAudioFileId = await service.getNextTask( + context, + typistExternalId, + audioFileId1, + ); + + // 実行結果が正しいか確認 + { + expect(nextAudioFileId).toEqual(audioFileId3); + } + }); + + it('次タスクを取得できる(JobNumber順、先頭)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const { id: authorUserId } = await makeTestUser(source, { + account_id: account.id, + author_id: 'AUTHOR_ID', + external_id: 'author-user-external-id', + role: USER_ROLES.AUTHOR, + }); + const { id: typistUserId, external_id: typistExternalId } = + await makeTestUser(source, { + account_id: account.id, + external_id: 'typist-user-external-id', + role: USER_ROLES.TYPIST, + }); + + await createSortCriteria(source, typistUserId, 'JOB_NUMBER', 'ASC'); + + const { taskId: taskId1, audioFileId: audioFileId1 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000001', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId1, typistUserId); + + const { taskId: taskId3, audioFileId: audioFileId3 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000003', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId3, typistUserId); + + const { taskId: taskId2 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000002', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId2, typistUserId); + + const service = module.get(TasksService); + const context = makeContext(admin.external_id); + + const nextAudioFileId = await service.getNextTask( + context, + typistExternalId, + audioFileId3, + ); + + // 実行結果が正しいか確認 + { + expect(nextAudioFileId).toEqual(audioFileId1); + } + }); + + it('次タスクを取得できる(Worktype順)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const { id: authorUserId } = await makeTestUser(source, { + account_id: account.id, + author_id: 'AUTHOR_ID', + external_id: 'author-user-external-id', + role: USER_ROLES.AUTHOR, + }); + const { id: typistUserId, external_id: typistExternalId } = + await makeTestUser(source, { + account_id: account.id, + external_id: 'typist-user-external-id', + role: USER_ROLES.TYPIST, + }); + + await createSortCriteria(source, typistUserId, 'WORK_TYPE', 'ASC'); + + const { taskId: taskId1 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + 'worktype1', + '01', + '00000001', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId1, typistUserId); + + const { taskId: taskId3, audioFileId: audioFileId3 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + 'worktype2', + '01', + '00000003', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId3, typistUserId); + + const { taskId: taskId2, audioFileId: audioFileId2 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + 'worktype3', + '01', + '00000002', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId2, typistUserId); + + const service = module.get(TasksService); + const context = makeContext(admin.external_id); + + const nextAudioFileId = await service.getNextTask( + context, + typistExternalId, + audioFileId3, + ); + + // 実行結果が正しいか確認 + { + expect(nextAudioFileId).toEqual(audioFileId2); + } + }); + + it('次タスクを取得できる(Status順)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const { id: authorUserId } = await makeTestUser(source, { + account_id: account.id, + author_id: 'AUTHOR_ID', + external_id: 'author-user-external-id', + role: USER_ROLES.AUTHOR, + }); + const { id: typistUserId, external_id: typistExternalId } = + await makeTestUser(source, { + account_id: account.id, + external_id: 'typist-user-external-id', + role: USER_ROLES.TYPIST, + }); + + await createSortCriteria(source, typistUserId, 'STATUS', 'ASC'); + + const { taskId: taskId1, audioFileId: audioFileId1 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + 'worktype1', + '01', + '00000001', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId1, typistUserId); + + const { taskId: taskId3 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + 'worktype2', + '01', + '00000003', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId3, typistUserId); + + const { taskId: taskId2, audioFileId: audioFileId2 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + 'worktype3', + '01', + '00000002', + TASK_STATUS.PENDING, + ); + await createCheckoutPermissions(source, taskId2, typistUserId); + + const service = module.get(TasksService); + const context = makeContext(admin.external_id); + + const nextAudioFileId = await service.getNextTask( + context, + typistExternalId, + audioFileId2, + ); + + // 実行結果が正しいか確認 + { + expect(nextAudioFileId).toEqual(audioFileId1); + } + }); + + it('次タスクが存在しない場合undefinedを返す(JobNumber順)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const { id: authorUserId } = await makeTestUser(source, { + account_id: account.id, + author_id: 'AUTHOR_ID', + external_id: 'author-user-external-id', + role: USER_ROLES.AUTHOR, + }); + const { id: typistUserId, external_id: typistExternalId } = + await makeTestUser(source, { + account_id: account.id, + external_id: 'typist-user-external-id', + role: USER_ROLES.TYPIST, + }); + + await createSortCriteria(source, typistUserId, 'JOB_NUMBER', 'ASC'); + + const { taskId: taskId1, audioFileId: audioFileId1 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + 'worktype1', + '01', + '00000001', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId1, typistUserId); + + const service = module.get(TasksService); + const context = makeContext(admin.external_id); + + const nextAudioFileId = await service.getNextTask( + context, + typistExternalId, + audioFileId1, + ); + + // 実行結果が正しいか確認 + { + expect(nextAudioFileId).toEqual(undefined); + } + }); + it('指定タスクが存在しない場合エラーを返す(JobNumber順)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const { id: authorUserId } = await makeTestUser(source, { + account_id: account.id, + author_id: 'AUTHOR_ID', + external_id: 'author-user-external-id', + role: USER_ROLES.AUTHOR, + }); + const { id: typistUserId, external_id: typistExternalId } = + await makeTestUser(source, { + account_id: account.id, + external_id: 'typist-user-external-id', + role: USER_ROLES.TYPIST, + }); + + await createSortCriteria(source, typistUserId, 'WORK_TYPE', 'ASC'); + + const { taskId: taskId1, audioFileId: audioFileId1 } = await createTask( + source, + account.id, + authorUserId, + 'MY_AUTHOR_ID', + 'worktype1', + '01', + '00000001', + TASK_STATUS.UPLOADED, + ); + await createCheckoutPermissions(source, taskId1, typistUserId); + + const service = module.get(TasksService); + const context = makeContext(admin.external_id); + + // 実行結果が正しいか確認 + try { + await service.getNextTask(context, typistExternalId, audioFileId1 + 1); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E010603')); + } else { + fail(); + } + } + }); +}); diff --git a/dictation_server/src/features/tasks/tasks.service.ts b/dictation_server/src/features/tasks/tasks.service.ts index 902dfc1..d69a608 100644 --- a/dictation_server/src/features/tasks/tasks.service.ts +++ b/dictation_server/src/features/tasks/tasks.service.ts @@ -157,6 +157,81 @@ export class TasksService { this.logger.log(`[OUT] [${context.trackingId}] ${this.getTasks.name}`); } } + + /** + * 完了したタスクの次のタスクを取得します + * @param context + * @param externalId + * @param fileId + * @returns next task + */ + async getNextTask( + context: Context, + externalId: string, + fileId: number, + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.getNextTask.name} | params: { externalId: ${externalId}, fileId: ${fileId} };`, + ); + + try { + const { account_id: accountId, id } = + await this.usersRepository.findUserByExternalId(externalId); + + // タスク一覧を取得する + const tasks = await this.taskRepository.getSortedTasks( + accountId, + id, + fileId, + ); + + // 指定タスクのインデックスを取得する + const targetTaskIndex = tasks.findIndex((x) => x.audio_file_id == fileId); + + // 指定したタスクが見つからない場合はエラーとする(リポジトリからは必ず取得できる想定) + if (targetTaskIndex === -1) { + throw new TasksNotFoundError(`task not found: ${fileId}`); + } + + // ソート順に並んだタスクについて、指定した完了済みタスクの次のタスクを取得する + let nextTaskIndex = targetTaskIndex + 1; + + // 次のタスクがない場合は先頭のタスクを返す + if (tasks.length - 1 < nextTaskIndex) { + nextTaskIndex = 0; + } + + const nextTask = tasks[nextTaskIndex]; + + // 先頭のタスクが指定した完了済みタスクの場合は次のタスクがないためundefinedを返す + return nextTask.audio_file_id === fileId + ? undefined + : nextTask.audio_file_id; + } catch (e) { + this.logger.error(`error=${e}`); + if (e instanceof Error) { + switch (e.constructor) { + case TasksNotFoundError: + throw new HttpException( + makeErrorResponse('E010603'), + HttpStatus.BAD_REQUEST, + ); + default: + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } finally { + this.logger.log(`[OUT] [${context.trackingId}] ${this.getNextTask.name}`); + } + } + /** * 指定した音声ファイルに紐づくタスクをcheckoutする * @param audioFileId diff --git a/dictation_server/src/features/tasks/test/utility.ts b/dictation_server/src/features/tasks/test/utility.ts index 8b8d1c4..65e629b 100644 --- a/dictation_server/src/features/tasks/test/utility.ts +++ b/dictation_server/src/features/tasks/test/utility.ts @@ -110,7 +110,7 @@ export const createTask = async ( jobNumber: string, status: string, typist_user_id?: number | undefined, -): Promise<{ taskId: number }> => { +): Promise<{ taskId: number; audioFileId: number }> => { const { identifiers: audioFileIdentifiers } = await datasource .getRepository(AudioFile) .insert({ @@ -144,7 +144,7 @@ export const createTask = async ( created_at: new Date(), }); const task = taskIdentifiers.pop() as Task; - return { taskId: task.id }; + return { taskId: task.id, audioFileId: audioFile.id }; }; /** * @@ -162,8 +162,8 @@ export const createCheckoutPermissions = async ( ): Promise => { await datasource.getRepository(CheckoutPermission).insert({ task_id: task_id, - user_id: user_id, - user_group_id: user_group_id, + user_id: user_id ?? null, + user_group_id: user_group_id ?? null, }); }; /** diff --git a/dictation_server/src/features/users/test/utility.ts b/dictation_server/src/features/users/test/utility.ts index ab4dfe2..58a7512 100644 --- a/dictation_server/src/features/users/test/utility.ts +++ b/dictation_server/src/features/users/test/utility.ts @@ -35,6 +35,7 @@ import { License } from '../../../repositories/licenses/entity/license.entity'; import { AdB2cMockValue, makeAdB2cServiceMock } from './users.service.mock'; import { AdB2cService } from '../../../gateways/adb2c/adb2c.service'; import { LICENSE_ALLOCATED_STATUS, LICENSE_TYPE } from '../../../constants'; +import { SortCriteria } from '../../../repositories/sort_criteria/entity/sort_criteria.entity'; export const getLicenses = async ( datasource: DataSource, @@ -164,3 +165,16 @@ export const makeTestingModuleWithAdb2c = async ( console.log(e); } }; + +export const createSortCriteria = async ( + datasource: DataSource, + userId: number, + parameter: string, + direction: string, +): Promise => { + await datasource.getRepository(SortCriteria).insert({ + user_id: userId, + parameter: parameter, + direction: direction, + }); +}; diff --git a/dictation_server/src/repositories/checkout_permissions/entity/checkout_permission.entity.ts b/dictation_server/src/repositories/checkout_permissions/entity/checkout_permission.entity.ts index f0b6608..3653872 100644 --- a/dictation_server/src/repositories/checkout_permissions/entity/checkout_permission.entity.ts +++ b/dictation_server/src/repositories/checkout_permissions/entity/checkout_permission.entity.ts @@ -7,7 +7,6 @@ import { Column, PrimaryGeneratedColumn, JoinColumn, - OneToOne, ManyToOne, } from 'typeorm'; @@ -25,11 +24,11 @@ export class CheckoutPermission { @Column({ nullable: true, type: 'bigint', transformer: bigintTransformer }) user_group_id: number | null; - @OneToOne(() => User, (user) => user.id) + @ManyToOne(() => User, (user) => user.id) @JoinColumn({ name: 'user_id' }) user: User | null; - @OneToOne(() => UserGroup, (group) => group.id) + @ManyToOne(() => UserGroup, (group) => group.id) @JoinColumn({ name: 'user_group_id' }) user_group: UserGroup | null; diff --git a/dictation_server/src/repositories/tasks/tasks.repository.service.ts b/dictation_server/src/repositories/tasks/tasks.repository.service.ts index b6edf11..63a813a 100644 --- a/dictation_server/src/repositories/tasks/tasks.repository.service.ts +++ b/dictation_server/src/repositories/tasks/tasks.repository.service.ts @@ -15,6 +15,8 @@ import { CheckoutPermission } from '../checkout_permissions/entity/checkout_perm import { SortDirection, TaskListSortableAttribute, + isSortDirection, + isTaskListSortableAttribute, } from '../../common/types/sort'; import { UserGroupMember } from '../user_groups/entity/user_group_member.entity'; import { Assignee } from '../../features/tasks/types/types'; @@ -32,6 +34,7 @@ import { } from './errors/types'; import { Roles } from '../../common/types/role'; import { TaskStatus, isTaskStatus } from '../../common/types/taskStatus'; +import { SortCriteria } from '../sort_criteria/entity/sort_criteria.entity'; @Injectable() export class TasksRepositoryService { @@ -853,6 +856,102 @@ export class TasksRepositoryService { return await checkoutPermissionRepo.save(checkoutPermissions); }); } + + /** + * 対象ユーザーのソート順でソートしたタスク一覧を取得します(指定タスクとユーザが着手可能なタスクの一覧を取得します) + * @param accountId + * @param userId + * @param audioFileId + * @returns sorted tasks + */ + async getSortedTasks( + accountId: number, + userId: number, + audioFileId: number, + ): Promise { + return await this.dataSource.transaction(async (entityManager) => { + const taskRepo = entityManager.getRepository(Task); + const sortRepo = entityManager.getRepository(SortCriteria); + + const sort = await sortRepo.findOne({ where: { user_id: userId } }); + + // 運用上はあり得ないが、プログラム上発生しうるのでエラーとして処理 + if (!sort) { + throw new Error(`sort criteria not found. userId: ${userId}`); + } + + const { direction, parameter } = sort; + //型チェック + if ( + !isTaskListSortableAttribute(parameter) || + !isSortDirection(direction) + ) { + throw new Error( + `The value stored in the DB is invalid. parameter: ${parameter}, direction: ${direction}`, + ); + } + + // 指定した音声ファイルIDのタスクを取得 + const targetTask = await taskRepo.findOne({ + where: { + account_id: accountId, + audio_file_id: audioFileId, + status: In([ + TASK_STATUS.PENDING, + TASK_STATUS.FINISHED, + TASK_STATUS.UPLOADED, + ]), + }, + }); + + if (!targetTask) { + throw new TasksNotFoundError( + `target task not found. audioFileId: ${audioFileId}`, + ); + } + + const groupMemberRepo = entityManager.getRepository(UserGroupMember); + // ユーザーの所属するすべてのグループを列挙 + const groups = await groupMemberRepo.find({ where: { user_id: userId } }); + // ユーザーの所属するすべてのグループIDを列挙 + const groupIds = groups.map((member) => member.user_group_id); + + const checkoutRepo = entityManager.getRepository(CheckoutPermission); + // ユーザーに対するチェックアウト権限、またはユーザーの所属するユーザーグループのチェックアウト権限を取得 + const related = await checkoutRepo.find({ + where: [ + // ユーザーがチェックアウト可能である + { user_id: userId }, + // ユーザーの所属するユーザーグループがチェックアウト可能である + { user_group_id: In(groupIds) }, + ], + }); + + // ユーザー本人、またはユーザーが所属するユーザーグループがチェックアウト可能なタスクIDの一覧を作成 + const relatedTaskIds = related.map((permission) => permission.task_id); + + const order = makeOrder(parameter, direction); + + // 引数の音声ファイルIDで指定したタスクとユーザが着手可能なタスクの一覧を取得 + const tasks = await taskRepo.find({ + where: [ + { + account_id: accountId, + id: targetTask.id, + }, + { + account_id: accountId, + status: In([TASK_STATUS.UPLOADED, TASK_STATUS.PENDING]), + // TypistまたはTypistが所属するユーザーグループが割り当て可能になっているTaskを取得 + id: In(relatedTaskIds), + }, + ], + order: order, + }); + + return tasks; + }); + } } // ソート用オブジェクトを生成する From e6da79140660ebf2daad3cbbe1ee92d1cf36a612 Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Mon, 30 Oct 2023 00:58:46 +0000 Subject: [PATCH 3/7] =?UTF-8?q?Merged=20PR=20530:=20API=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=EF=BC=88=E4=BB=A3=E8=A1=8C=E6=93=8D=E4=BD=9C=E7=94=A8=E3=83=88?= =?UTF-8?q?=E3=83=BC=E3=82=AF=E3=83=B3=E7=94=9F=E6=88=90API=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2905: API実装(代行操作用トークン生成API)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2905) - 代行操作用トークン生成APIとテストを実装しました。 ## レビューポイント - リポジトリの処理は適切か - アカウントの取得⇒管理者ユーザ取得としているためUsersリポジトリ配下に配置していますが構成として問題ないでしょうか。 - テストケースは適切か - アクセストークン生成は既存と別に代行操作用のメソッドを用意していますが想定とあっていますでしょうか。 ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 --- dictation_server/src/common/error/code.ts | 1 + dictation_server/src/common/error/message.ts | 1 + dictation_server/src/common/log/context.ts | 6 +- dictation_server/src/common/log/types.ts | 4 + dictation_server/src/common/token/types.ts | 8 + .../src/features/auth/auth.controller.ts | 27 +- .../src/features/auth/auth.service.spec.ts | 236 +++++++++++++++ .../src/features/auth/auth.service.ts | 281 +++++++++++++----- .../src/features/auth/errors/types.ts | 4 + .../src/features/auth/types/types.ts | 2 + .../src/repositories/users/errors/types.ts | 2 + .../users/users.repository.service.ts | 70 ++++- 12 files changed, 568 insertions(+), 74 deletions(-) create mode 100644 dictation_server/src/features/auth/errors/types.ts diff --git a/dictation_server/src/common/error/code.ts b/dictation_server/src/common/error/code.ts index 9b271e8..d7dec4b 100644 --- a/dictation_server/src/common/error/code.ts +++ b/dictation_server/src/common/error/code.ts @@ -38,6 +38,7 @@ export const ErrorCodes = [ 'E010401', // PONumber重複エラー 'E010501', // アカウント不在エラー 'E010502', // アカウント情報変更不可エラー + 'E010503', // 代行操作不許可エラー 'E010601', // タスク変更不可エラー(タスクが変更できる状態でない、またはタスクが存在しない) 'E010602', // タスク変更権限不足エラー 'E010603', // タスク不在エラー diff --git a/dictation_server/src/common/error/message.ts b/dictation_server/src/common/error/message.ts index 11b0c1d..38c5e98 100644 --- a/dictation_server/src/common/error/message.ts +++ b/dictation_server/src/common/error/message.ts @@ -27,6 +27,7 @@ export const errors: Errors = { E010401: 'This PoNumber already used Error', E010501: 'Account not Found Error.', E010502: 'Account information cannot be changed Error.', + E010503: 'Delegation not allowed Error.', E010601: 'Task is not Editable Error', E010602: 'No task edit permissions Error', E010603: 'Task not found Error.', diff --git a/dictation_server/src/common/log/context.ts b/dictation_server/src/common/log/context.ts index cd6079d..229cca8 100644 --- a/dictation_server/src/common/log/context.ts +++ b/dictation_server/src/common/log/context.ts @@ -1,7 +1,11 @@ import { Context } from './types'; -export const makeContext = (externalId: string): Context => { +export const makeContext = ( + externalId: string, + delegationId?: string, +): Context => { return { trackingId: externalId, + delegationId: delegationId, }; }; diff --git a/dictation_server/src/common/log/types.ts b/dictation_server/src/common/log/types.ts index da7e2ba..5c843db 100644 --- a/dictation_server/src/common/log/types.ts +++ b/dictation_server/src/common/log/types.ts @@ -3,4 +3,8 @@ export class Context { * APIの操作ユーザーを追跡するためのID */ trackingId: string; + /** + * APIの代行操作ユーザーを追跡するためのID + */ + delegationId?: string | undefined; } diff --git a/dictation_server/src/common/token/types.ts b/dictation_server/src/common/token/types.ts index 43650af..e1df6d9 100644 --- a/dictation_server/src/common/token/types.ts +++ b/dictation_server/src/common/token/types.ts @@ -1,4 +1,8 @@ export type RefreshToken = { + /** + * 外部認証サービスの識別子(代行者) + */ + delegateUserId?: string | undefined; /** * 外部認証サービスの識別子 */ @@ -14,6 +18,10 @@ export type RefreshToken = { }; export type AccessToken = { + /** + * 外部認証サービスの識別子(代行者) + */ + delegateUserId?: string | undefined; /** * 外部認証サービスの識別子 */ diff --git a/dictation_server/src/features/auth/auth.controller.ts b/dictation_server/src/features/auth/auth.controller.ts index aa82fc2..0418584 100644 --- a/dictation_server/src/features/auth/auth.controller.ts +++ b/dictation_server/src/features/auth/auth.controller.ts @@ -31,6 +31,8 @@ import { Request } from 'express'; import { AuthGuard } from '../../common/guards/auth/authguards'; import { RoleGuard } from '../../common/guards/role/roleguards'; import { ADMIN_ROLES, TIERS } from '../../constants'; +import jwt from 'jsonwebtoken'; +import { AccessToken } from '../../common/token'; @ApiTags('auth') @Controller('auth') @@ -183,18 +185,35 @@ export class AuthController { @Body() body: DelegationTokenRequest, ): Promise { const { delegatedAccountId } = body; - const refreshToken = retrieveAuthorizationToken(req); + const token = retrieveAuthorizationToken(req); - if (!refreshToken) { + if (!token) { throw new HttpException( makeErrorResponse('E000107'), HttpStatus.UNAUTHORIZED, ); } + const decodedAccessToken = jwt.decode(token, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; - const context = makeContext(uuidv4()); + const context = makeContext(userId); + const refreshToken = await this.authService.generateDelegationRefreshToken( + context, + userId, + delegatedAccountId, + ); + const accessToken = await this.authService.generateDelegationAccessToken( + context, + refreshToken, + ); - return { accessToken: '', refreshToken: '' }; + return { accessToken, refreshToken }; } @Post('delegation/access-token') diff --git a/dictation_server/src/features/auth/auth.service.spec.ts b/dictation_server/src/features/auth/auth.service.spec.ts index af11c93..bcb5324 100644 --- a/dictation_server/src/features/auth/auth.service.spec.ts +++ b/dictation_server/src/features/auth/auth.service.spec.ts @@ -13,6 +13,9 @@ import { makeTestAccount } from '../../common/test/utility'; import { AuthService } from './auth.service'; import { createTermInfo } from './test/utility'; import { v4 as uuidv4 } from 'uuid'; +import { TIERS, USER_ROLES } from '../../constants'; +import { decode, isVerifyError } from '../../common/jwt'; +import { RefreshToken, AccessToken } from '../../common/token'; describe('AuthService', () => { it('IDトークンの検証とペイロードの取得に成功する', async () => { @@ -276,6 +279,239 @@ describe('checkIsAcceptedLatestVersion', () => { }); }); +describe('generateDelegationRefreshToken', () => { + let source: DataSource | null = null; + beforeEach(async () => { + source = new DataSource({ + type: 'sqlite', + database: ':memory:', + logging: false, + entities: [__dirname + '/../../**/*.entity{.ts,.js}'], + synchronize: true, // trueにすると自動的にmigrationが行われるため注意 + }); + return source.initialize(); + }); + + afterEach(async () => { + if (!source) return; + await source.destroy(); + source = null; + }); + it('代行操作が許可されたパートナーの代行操作用リフレッシュトークンを取得できること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AuthService); + const { admin: parentAdmin, account: parentAccount } = + await makeTestAccount(source, { + tier: 4, + }); + const { admin: partnerAdmin, account: partnerAccount } = + await makeTestAccount( + source, + { + tier: 5, + parent_account_id: parentAccount.id, + delegation_permission: true, + }, + { role: USER_ROLES.NONE }, + ); + + const context = makeContext(parentAdmin.external_id); + + const delegationRefreshToken = await service.generateDelegationRefreshToken( + context, + parentAdmin.external_id, + partnerAccount.id, + ); + + // 取得できた代行操作用リフレッシュトークンをデコード + const decodeToken = decode(delegationRefreshToken); + if (isVerifyError(decodeToken)) { + fail(); + } + + expect(decodeToken.role).toBe('none admin'); + expect(decodeToken.tier).toBe(TIERS.TIER5); + expect(decodeToken.userId).toBe(partnerAdmin.external_id); + expect(decodeToken.delegateUserId).toBe(parentAdmin.external_id); + }); + it('代行操作が許可されていない場合、400エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AuthService); + const { admin: parentAdmin, account: parentAccount } = + await makeTestAccount(source, { + tier: 4, + }); + const { account: partnerAccount } = await makeTestAccount( + source, + { + tier: 5, + parent_account_id: parentAccount.id, + delegation_permission: false, + }, + { role: USER_ROLES.NONE }, + ); + + const context = makeContext(parentAdmin.external_id); + + try { + await service.generateDelegationRefreshToken( + context, + parentAdmin.external_id, + partnerAccount.id, + ); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E010503')); + } else { + fail(); + } + } + }); + + it('代行操作対象が存在しない場合、400エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AuthService); + const { admin: parentAdmin, account: parentAccount } = + await makeTestAccount(source, { + tier: 4, + }); + await makeTestAccount( + source, + { + tier: 5, + parent_account_id: parentAccount.id, + delegation_permission: false, + }, + { role: USER_ROLES.NONE }, + ); + + const context = makeContext(parentAdmin.external_id); + + try { + await service.generateDelegationRefreshToken( + context, + parentAdmin.external_id, + 9999, + ); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E010501')); + } else { + fail(); + } + } + }); +}); + +describe('generateDelegationAccessToken', () => { + let source: DataSource | null = null; + beforeEach(async () => { + source = new DataSource({ + type: 'sqlite', + database: ':memory:', + logging: false, + entities: [__dirname + '/../../**/*.entity{.ts,.js}'], + synchronize: true, // trueにすると自動的にmigrationが行われるため注意 + }); + return source.initialize(); + }); + + afterEach(async () => { + if (!source) return; + await source.destroy(); + source = null; + }); + it('代行操作用リフレッシュトークンから代行操作用アクセストークンを取得できること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AuthService); + const { admin: parentAdmin, account: parentAccount } = + await makeTestAccount(source, { + tier: 4, + }); + const { admin: partnerAdmin, account: partnerAccount } = + await makeTestAccount( + source, + { + tier: 5, + parent_account_id: parentAccount.id, + delegation_permission: true, + }, + { role: USER_ROLES.NONE }, + ); + + const context = makeContext(parentAdmin.external_id); + + const delegationRefreshToken = await service.generateDelegationRefreshToken( + context, + parentAdmin.external_id, + partnerAccount.id, + ); + + // 取得できた代行操作用リフレッシュトークンをデコード + const decodeRefreshToken = decode(delegationRefreshToken); + if (isVerifyError(decodeRefreshToken)) { + fail(); + } + + expect(decodeRefreshToken.role).toBe('none admin'); + expect(decodeRefreshToken.tier).toBe(TIERS.TIER5); + expect(decodeRefreshToken.userId).toBe(partnerAdmin.external_id); + expect(decodeRefreshToken.delegateUserId).toBe(parentAdmin.external_id); + + const delegationAccessToken = await service.generateDelegationAccessToken( + context, + delegationRefreshToken, + ); + + // 取得できた代行操作用アクセストークンをデコード + const decodeAccessToken = decode(delegationAccessToken); + if (isVerifyError(decodeAccessToken)) { + fail(); + } + + expect(decodeAccessToken.role).toBe('none admin'); + expect(decodeAccessToken.tier).toBe(TIERS.TIER5); + expect(decodeAccessToken.userId).toBe(partnerAdmin.external_id); + expect(decodeAccessToken.delegateUserId).toBe(parentAdmin.external_id); + }); + + it('代行操作用リフレッシュトークンの形式が不正な場合、エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AuthService); + const { admin: parentAdmin } = await makeTestAccount(source, { + tier: 4, + }); + + const context = makeContext(parentAdmin.external_id); + + try { + await service.generateDelegationAccessToken(context, 'invalid token'); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.UNAUTHORIZED); + expect(e.getResponse()).toEqual(makeErrorResponse('E000101')); + } else { + fail(); + } + } + }); +}); + const idTokenPayload = { exp: 9000000000, nbf: 1000000000, diff --git a/dictation_server/src/features/auth/auth.service.ts b/dictation_server/src/features/auth/auth.service.ts index 1454960..3e2a9cc 100644 --- a/dictation_server/src/features/auth/auth.service.ts +++ b/dictation_server/src/features/auth/auth.service.ts @@ -19,9 +19,14 @@ import { } from '../../common/token'; import { ADMIN_ROLES, TIERS, USER_ROLES } from '../../constants'; import { AdB2cService } from '../../gateways/adb2c/adb2c.service'; -import { User } from '../../repositories/users/entity/user.entity'; import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; import { Context } from '../../common/log'; +import { + AccountNotFoundError, + AdminUserNotFoundError, +} from '../../repositories/accounts/errors/types'; +import { DelegationNotAllowedError } from '../../repositories/users/errors/types'; +import { RoleUnexpectedError, TierUnexpectedError } from './errors/types'; @Injectable() export class AuthService { @@ -76,88 +81,77 @@ export class AuthService { `[IN] [${context.trackingId}] ${this.generateRefreshToken.name}`, ); - let user: User; // ユーザー情報とユーザーが属しているアカウント情報を取得 try { - user = await this.usersRepository.findUserByExternalId(idToken.sub); + const user = await this.usersRepository.findUserByExternalId(idToken.sub); if (!user.account) { throw new Error('Account information not found'); } + + // Tierのチェック + const minTier = 1; + const maxTier = 5; + const userTier = user.account.tier; + if (userTier < minTier || userTier > maxTier) { + throw new TierUnexpectedError( + `Tier from DB is unexpected value. tier=${user.account.tier}`, + ); + } + + // 要求された環境用トークンの寿命を決定 + const refreshTokenLifetime = + type === 'web' + ? this.refreshTokenLifetimeWeb + : this.refreshTokenLifetimeDefault; + const privateKey = getPrivateKey(this.configService); + + // ユーザーのロールを設定 + const role = this.getUserRole(user.role); + + const token = sign( + { + //ユーザーの属しているアカウントの管理者にユーザーが設定されていればadminをセットする + role: `${role} ${ + user.account.primary_admin_user_id === user.id || + user.account.secondary_admin_user_id === user.id + ? ADMIN_ROLES.ADMIN + : ADMIN_ROLES.STANDARD + }`, + tier: user.account.tier, + userId: idToken.sub, + }, + refreshTokenLifetime, + privateKey, + ); + + return token; } catch (e) { this.logger.error(`error=${e}`); - this.logger.log( - `[OUT] [${context.trackingId}] ${this.generateRefreshToken.name}`, - ); + if (e instanceof Error) { + switch (e.constructor) { + case TierUnexpectedError: + throw new HttpException( + makeErrorResponse('E010206'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + case RoleUnexpectedError: + throw new HttpException( + makeErrorResponse('E010205'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + default: + break; + } + } throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, ); - } - // Tierのチェック - const minTier = 1; - const maxTier = 5; - const userTier = user.account.tier; - if (userTier < minTier || userTier > maxTier) { - this.logger.error( - `Tier from DB is unexpected value. tier=${user.account.tier}`, - ); + } finally { this.logger.log( `[OUT] [${context.trackingId}] ${this.generateRefreshToken.name}`, ); - throw new HttpException( - makeErrorResponse('E010206'), - HttpStatus.INTERNAL_SERVER_ERROR, - ); } - // 要求された環境用トークンの寿命を決定 - const refreshTokenLifetime = - type === 'web' - ? this.refreshTokenLifetimeWeb - : this.refreshTokenLifetimeDefault; - const privateKey = getPrivateKey(this.configService); - - // ユーザーのロールを設定 - // 万一不正なRoleが登録されていた場合、そのままDBの値を使用すると不正なロールのリフレッシュトークンが発行されるため、 - // ロールの設定値はDBに保存したRoleの値を直接トークンに入れないように定数で設定する - // ※none/author/typist以外はロールに設定されない - let role = ''; - if (user.role === USER_ROLES.NONE) { - role = USER_ROLES.NONE; - } else if (user.role === USER_ROLES.AUTHOR) { - role = USER_ROLES.AUTHOR; - } else if (user.role === USER_ROLES.TYPIST) { - role = USER_ROLES.TYPIST; - } else { - this.logger.error(`Role from DB is unexpected value. role=${user.role}`); - this.logger.log( - `[OUT] [${context.trackingId}] ${this.generateRefreshToken.name}`, - ); - throw new HttpException( - makeErrorResponse('E010205'), - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - const token = sign( - { - //ユーザーの属しているアカウントの管理者にユーザーが設定されていればadminをセットする - role: `${role} ${ - user.account.primary_admin_user_id === user.id || - user.account.secondary_admin_user_id === user.id - ? ADMIN_ROLES.ADMIN - : ADMIN_ROLES.STANDARD - }`, - tier: user.account.tier, - userId: idToken.sub, - }, - refreshTokenLifetime, - privateKey, - ); - - this.logger.log( - `[OUT] [${context.trackingId}] ${this.generateRefreshToken.name}`, - ); - return token; } /** @@ -203,6 +197,137 @@ export class AuthService { ); return accessToken; } + + /** + * 代行操作用のリフレッシュトークンを生成します + * @param context + * @param delegateUserExternalId 代行操作者の外部認証サービスの識別子 + * @param originAccountId 代行操作対象アカウントのID + * @returns delegation refresh token + */ + async generateDelegationRefreshToken( + context: Context, + delegateUserExternalId: string, + originAccountId: number, + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.generateDelegationRefreshToken.name} | params: { ` + + `delegateUserExternalId: ${delegateUserExternalId}, ` + + `originAccountId: ${originAccountId}, };`, + ); + + // ユーザー情報とユーザーが属しているアカウント情報を取得 + try { + const user = await this.usersRepository.findUserByExternalId( + delegateUserExternalId, + ); + + // 代行操作対象アカウントの管理者ユーザーを取得 + const adminUser = await this.usersRepository.findDelegateUser( + user.account_id, + originAccountId, + ); + + // 要求された環境用トークンの寿命を決定 + const refreshTokenLifetime = this.refreshTokenLifetimeWeb; + const privateKey = getPrivateKey(this.configService); + + // ユーザーのロールを設定 + const role = this.getUserRole(adminUser.role); + + const token = sign( + { + role: `${role} ${ADMIN_ROLES.ADMIN}`, + tier: TIERS.TIER5, + userId: adminUser.external_id, + delegateUserId: delegateUserExternalId, + }, + refreshTokenLifetime, + privateKey, + ); + + return token; + } catch (e) { + this.logger.error(`error=${e}`); + if (e instanceof Error) { + switch (e.constructor) { + case AccountNotFoundError: + case AdminUserNotFoundError: + throw new HttpException( + makeErrorResponse('E010501'), + HttpStatus.BAD_REQUEST, + ); + case DelegationNotAllowedError: + throw new HttpException( + makeErrorResponse('E010503'), + HttpStatus.BAD_REQUEST, + ); + case RoleUnexpectedError: + throw new HttpException( + makeErrorResponse('E010205'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + default: + break; + } + } + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } finally { + this.logger.log( + `[OUT] [${context.trackingId}] ${this.generateDelegationRefreshToken.name}`, + ); + } + } + + /** + * 代行操作アクセストークンの更新 + * @param context + * @param refreshToken + * @returns delegation access token + */ + async generateDelegationAccessToken( + context: Context, + refreshToken: string, + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.generateDelegationAccessToken.name}`, + ); + + const privateKey = getPrivateKey(this.configService); + const pubkey = getPublicKey(this.configService); + + const token = verify(refreshToken, pubkey); + if (isVerifyError(token)) { + this.logger.error(`${token.reason} | ${token.message}`); + this.logger.log( + `[OUT] [${context.trackingId}] ${this.generateDelegationAccessToken.name}`, + ); + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + + const accessToken = sign( + { + role: token.role, + tier: token.tier, + userId: token.userId, + delegateUserId: token.delegateUserId, + }, + this.accessTokenlifetime, + privateKey, + ); + + this.logger.log( + `[OUT] [${context.trackingId}] ${this.generateDelegationAccessToken.name}`, + ); + return accessToken; + } + /** * Gets id token * @param token @@ -340,6 +465,26 @@ export class AuthService { HttpStatus.UNAUTHORIZED, ); }; + /** + * トークンに設定するユーザーのロールを取得 + */ + getUserRole = (role: string): string => { + // ユーザーのロールを設定 + // 万一不正なRoleが登録されていた場合、そのままDBの値を使用すると不正なロールのリフレッシュトークンが発行されるため、 + // ロールの設定値はDBに保存したRoleの値を直接トークンに入れないように定数で設定する + // ※none/author/typist以外はロールに設定されない + if (role === USER_ROLES.NONE) { + return USER_ROLES.NONE; + } else if (role === USER_ROLES.AUTHOR) { + return USER_ROLES.AUTHOR; + } else if (role === USER_ROLES.TYPIST) { + return USER_ROLES.TYPIST; + } else { + throw new RoleUnexpectedError( + `Role from DB is unexpected value. role=${role}`, + ); + } + }; /** * 同意済み利用規約バージョンが最新かチェック diff --git a/dictation_server/src/features/auth/errors/types.ts b/dictation_server/src/features/auth/errors/types.ts new file mode 100644 index 0000000..5c12d36 --- /dev/null +++ b/dictation_server/src/features/auth/errors/types.ts @@ -0,0 +1,4 @@ +// Role文字列想定外エラー +export class RoleUnexpectedError extends Error {} +// Tier範囲想定外エラー +export class TierUnexpectedError extends Error {} diff --git a/dictation_server/src/features/auth/types/types.ts b/dictation_server/src/features/auth/types/types.ts index 7528fa7..1be9570 100644 --- a/dictation_server/src/features/auth/types/types.ts +++ b/dictation_server/src/features/auth/types/types.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { IsInt } from 'class-validator'; export class TokenRequest { @ApiProperty() @@ -28,6 +29,7 @@ export type TermsCheckInfo = { export class DelegationTokenRequest { @ApiProperty({ description: '代行操作対象のアカウントID' }) + @IsInt() delegatedAccountId: number; } export class DelegationTokenResponse { diff --git a/dictation_server/src/repositories/users/errors/types.ts b/dictation_server/src/repositories/users/errors/types.ts index faee0b1..32c1af0 100644 --- a/dictation_server/src/repositories/users/errors/types.ts +++ b/dictation_server/src/repositories/users/errors/types.ts @@ -12,3 +12,5 @@ export class EncryptionPasswordNeedError extends Error {} export class TermInfoNotFoundError extends Error {} // 利用規約バージョンパラメータ不在エラー export class UpdateTermsVersionNotSetError extends Error {} +// 代行操作不許可エラー +export class DelegationNotAllowedError extends Error {} diff --git a/dictation_server/src/repositories/users/users.repository.service.ts b/dictation_server/src/repositories/users/users.repository.service.ts index f579c3e..7f92ae3 100644 --- a/dictation_server/src/repositories/users/users.repository.service.ts +++ b/dictation_server/src/repositories/users/users.repository.service.ts @@ -14,6 +14,7 @@ import { EncryptionPasswordNeedError, TermInfoNotFoundError, UpdateTermsVersionNotSetError, + DelegationNotAllowedError, } from './errors/types'; import { LICENSE_ALLOCATED_STATUS, @@ -27,7 +28,11 @@ import { License } from '../licenses/entity/license.entity'; import { NewTrialLicenseExpirationDate } from '../../features/licenses/types/types'; import { Term } from '../terms/entity/term.entity'; import { TermsCheckInfo } from '../../features/auth/types/types'; -import { AccountNotFoundError } from '../accounts/errors/types'; +import { + AccountNotFoundError, + AdminUserNotFoundError, +} from '../accounts/errors/types'; +import { Account } from '../accounts/entity/account.entity'; @Injectable() export class UsersRepositoryService { @@ -533,4 +538,67 @@ export class UsersRepositoryService { await userRepo.update({ id: user.id }, user); }); } + + /** + * 代行操作対象のユーザー情報を取得する + * @param delegateAccountId 代行操作者のアカウントID + * @param originAccountId 代行操作対象のアカウントID + * @returns delegate accounts + */ + async findDelegateUser( + delegateAccountId: number, + originAccountId: number, + ): Promise { + return await this.dataSource.transaction(async (entityManager) => { + const accountRepo = entityManager.getRepository(Account); + + // 代行操作対象のアカウントを取得 ※親アカウントが代行操作者のアカウントIDと一致すること + const account = await accountRepo.findOne({ + where: { + id: originAccountId, + parent_account_id: delegateAccountId, + tier: TIERS.TIER5, + }, + }); + + if (!account) { + throw new AccountNotFoundError( + `Account is not found. originAccountId: ${originAccountId}, delegateAccountId: ${delegateAccountId}`, + ); + } + + // 代行操作が許可されていない場合はエラー + if (!account.delegation_permission) { + throw new DelegationNotAllowedError( + `Delegation is not allowed. id: ${originAccountId}`, + ); + } + + const adminUserId = account.primary_admin_user_id; + + // 運用上、代行操作対象アカウントの管理者ユーザーがいないことはあり得ないが、プログラム上発生しうるのでエラーとして処理 + if (!adminUserId) { + throw new Error(`Admin user is not found. id: ${originAccountId}`); + } + + // 代行操作対象のアカウントの管理者ユーザーを取得 + const userRepo = entityManager.getRepository(User); + const primaryUser = await userRepo.findOne({ + where: { + account_id: originAccountId, + id: adminUserId, + }, + relations: { + account: true, + }, + }); + + // 運用上、代行操作対象アカウントの管理者ユーザーがいないことはあり得ないが、プログラム上発生しうるのでエラーとして処理 + if (!primaryUser) { + throw new Error(`Admin user is not found. id: ${originAccountId}`); + } + + return primaryUser; + }); + } } From f33af7a9cd451491b8a7c06c30caa5dd40119a42 Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Mon, 30 Oct 2023 07:06:31 +0000 Subject: [PATCH 4/7] =?UTF-8?q?Merged=20PR=20539:=20=E5=8B=95=E4=BD=9C?= =?UTF-8?q?=E7=A2=BA=E8=AA=8D=E4=B8=8D=E5=85=B7=E5=90=88=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2978: 動作確認不具合修正](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2978) - 次タスク取得時のパラメータをNumberで取得できるように修正 ## レビューポイント - 共有 ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 --- dictation_server/src/features/tasks/tasks.service.ts | 4 +++- dictation_server/src/features/tasks/types/types.ts | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/dictation_server/src/features/tasks/tasks.service.ts b/dictation_server/src/features/tasks/tasks.service.ts index d69a608..783ab23 100644 --- a/dictation_server/src/features/tasks/tasks.service.ts +++ b/dictation_server/src/features/tasks/tasks.service.ts @@ -186,7 +186,9 @@ export class TasksService { ); // 指定タスクのインデックスを取得する - const targetTaskIndex = tasks.findIndex((x) => x.audio_file_id == fileId); + const targetTaskIndex = tasks.findIndex( + (x) => x.audio_file_id === fileId, + ); // 指定したタスクが見つからない場合はエラーとする(リポジトリからは必ず取得できる想定) if (targetTaskIndex === -1) { diff --git a/dictation_server/src/features/tasks/types/types.ts b/dictation_server/src/features/tasks/types/types.ts index 4e07f73..80cff57 100644 --- a/dictation_server/src/features/tasks/types/types.ts +++ b/dictation_server/src/features/tasks/types/types.ts @@ -190,6 +190,8 @@ export class TasksResponse { } export class AudioNextRequest { @ApiProperty({ description: '文字起こし完了したタスクの音声ファイルID' }) + @Type(() => Number) + @IsInt() endedFileId: number; } From 01d92b24083823aed2647ab1ef04833b25531959 Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Tue, 31 Oct 2023 01:47:00 +0000 Subject: [PATCH 5/7] =?UTF-8?q?Merged=20PR=20537:=20API=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=EF=BC=88=E4=BB=A3=E8=A1=8C=E6=93=8D=E4=BD=9C=E7=94=A8=E3=83=88?= =?UTF-8?q?=E3=83=BC=E3=82=AF=E3=83=B3=E6=9B=B4=E6=96=B0API=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2906: API実装(代行操作用トークン更新API)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2906) - アクセストークン更新APIとテストを実装しました。 ## レビューポイント - リポジトリのアカウントチェックは適切か - テストケースは適切か ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 --- .../src/features/auth/auth.controller.ts | 20 +- .../src/features/auth/auth.service.spec.ts | 216 +++++++++++++++++- .../src/features/auth/auth.service.ts | 121 +++++++++- .../src/features/auth/errors/types.ts | 2 + .../src/features/auth/test/utility.ts | 20 ++ .../src/features/files/files.service.ts | 1 - .../users/users.repository.service.ts | 43 ++++ 7 files changed, 415 insertions(+), 8 deletions(-) diff --git a/dictation_server/src/features/auth/auth.controller.ts b/dictation_server/src/features/auth/auth.controller.ts index 0418584..a07b666 100644 --- a/dictation_server/src/features/auth/auth.controller.ts +++ b/dictation_server/src/features/auth/auth.controller.ts @@ -32,7 +32,7 @@ import { AuthGuard } from '../../common/guards/auth/authguards'; import { RoleGuard } from '../../common/guards/role/roleguards'; import { ADMIN_ROLES, TIERS } from '../../constants'; import jwt from 'jsonwebtoken'; -import { AccessToken } from '../../common/token'; +import { AccessToken, RefreshToken } from '../../common/token'; @ApiTags('auth') @Controller('auth') @@ -248,9 +248,23 @@ export class AuthController { HttpStatus.UNAUTHORIZED, ); } + const decodedRefreshToken = jwt.decode(refreshToken, { json: true }); + if (!decodedRefreshToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId, delegateUserId } = decodedRefreshToken as RefreshToken; - const context = makeContext(uuidv4()); + const context = makeContext(userId); + const accessToken = await this.authService.updateDelegationAccessToken( + context, + delegateUserId, + userId, + refreshToken, + ); - return { accessToken: '' }; + return { accessToken }; } } diff --git a/dictation_server/src/features/auth/auth.service.spec.ts b/dictation_server/src/features/auth/auth.service.spec.ts index bcb5324..1be5e40 100644 --- a/dictation_server/src/features/auth/auth.service.spec.ts +++ b/dictation_server/src/features/auth/auth.service.spec.ts @@ -9,9 +9,13 @@ import { import { DataSource } from 'typeorm'; import { makeContext } from '../../common/log'; import { makeTestingModule } from '../../common/test/modules'; -import { makeTestAccount } from '../../common/test/utility'; +import { getAccount, makeTestAccount } from '../../common/test/utility'; import { AuthService } from './auth.service'; -import { createTermInfo } from './test/utility'; +import { + createTermInfo, + deleteAccount, + updateAccountDelegationPermission, +} from './test/utility'; import { v4 as uuidv4 } from 'uuid'; import { TIERS, USER_ROLES } from '../../constants'; import { decode, isVerifyError } from '../../common/jwt'; @@ -512,6 +516,214 @@ describe('generateDelegationAccessToken', () => { }); }); +describe('updateDelegationAccessToken', () => { + let source: DataSource | null = null; + beforeEach(async () => { + source = new DataSource({ + type: 'sqlite', + database: ':memory:', + logging: false, + entities: [__dirname + '/../../**/*.entity{.ts,.js}'], + synchronize: true, // trueにすると自動的にmigrationが行われるため注意 + }); + return source.initialize(); + }); + + afterEach(async () => { + if (!source) return; + await source.destroy(); + source = null; + }); + + it('代行操作用リフレッシュトークンから代行操作用アクセストークンを更新できること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AuthService); + const { admin: parentAdmin, account: parentAccount } = + await makeTestAccount(source, { + tier: 4, + }); + const { admin: partnerAdmin, account: partnerAccount } = + await makeTestAccount( + source, + { + tier: 5, + parent_account_id: parentAccount.id, + delegation_permission: true, + }, + { role: USER_ROLES.NONE }, + ); + + const context = makeContext(parentAdmin.external_id); + + const delegationRefreshToken = await service.generateDelegationRefreshToken( + context, + parentAdmin.external_id, + partnerAccount.id, + ); + + // 取得できた代行操作用リフレッシュトークンをデコード + const decodeRefreshToken = decode(delegationRefreshToken); + if (isVerifyError(decodeRefreshToken)) { + fail(); + } + + expect(decodeRefreshToken.role).toBe('none admin'); + expect(decodeRefreshToken.tier).toBe(TIERS.TIER5); + expect(decodeRefreshToken.userId).toBe(partnerAdmin.external_id); + expect(decodeRefreshToken.delegateUserId).toBe(parentAdmin.external_id); + + const token = await service.updateDelegationAccessToken( + context, + decodeRefreshToken.delegateUserId, + decodeRefreshToken.userId, + delegationRefreshToken, + ); + + // 取得できた代行操作用リフレッシュトークンをデコード + const decodeAccessToken = decode(token); + if (isVerifyError(decodeAccessToken)) { + fail(); + } + + expect(decodeAccessToken.role).toBe('none admin'); + expect(decodeAccessToken.tier).toBe(TIERS.TIER5); + expect(decodeAccessToken.userId).toBe(partnerAdmin.external_id); + expect(decodeAccessToken.delegateUserId).toBe(parentAdmin.external_id); + }); + + it('代行操作対象アカウントの代行操作が許可されていない場合、エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AuthService); + const { admin: parentAdmin, account: parentAccount } = + await makeTestAccount(source, { + tier: 4, + }); + const { admin: partnerAdmin, account: partnerAccount } = + await makeTestAccount( + source, + { + tier: 5, + parent_account_id: parentAccount.id, + delegation_permission: true, + }, + { role: USER_ROLES.NONE }, + ); + + const context = makeContext(parentAdmin.external_id); + + const delegationRefreshToken = await service.generateDelegationRefreshToken( + context, + parentAdmin.external_id, + partnerAccount.id, + ); + + // 取得できた代行操作用リフレッシュトークンをデコード + const decodeRefreshToken = decode(delegationRefreshToken); + if (isVerifyError(decodeRefreshToken)) { + fail(); + } + + expect(decodeRefreshToken.role).toBe('none admin'); + expect(decodeRefreshToken.tier).toBe(TIERS.TIER5); + expect(decodeRefreshToken.userId).toBe(partnerAdmin.external_id); + expect(decodeRefreshToken.delegateUserId).toBe(parentAdmin.external_id); + + if (decodeRefreshToken.delegateUserId === undefined) { + fail(); + } + + // 代行操作対象アカウントの代行操作を許可しないように変更 + await updateAccountDelegationPermission(source, partnerAccount.id, false); + const account = await getAccount(source, partnerAccount.id); + + expect(account?.delegation_permission ?? true).toBeFalsy(); + + try { + await service.updateDelegationAccessToken( + context, + decodeRefreshToken.delegateUserId, + decodeRefreshToken.userId, + delegationRefreshToken, + ); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.UNAUTHORIZED); + expect(e.getResponse()).toEqual(makeErrorResponse('E010503')); + } else { + fail(); + } + } + }); + it('代行操作対象アカウントが存在しない場合、エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AuthService); + const { admin: parentAdmin, account: parentAccount } = + await makeTestAccount(source, { + tier: 4, + }); + const { admin: partnerAdmin, account: partnerAccount } = + await makeTestAccount( + source, + { + tier: 5, + parent_account_id: parentAccount.id, + delegation_permission: true, + }, + { role: USER_ROLES.NONE }, + ); + + const context = makeContext(parentAdmin.external_id); + + const delegationRefreshToken = await service.generateDelegationRefreshToken( + context, + parentAdmin.external_id, + partnerAccount.id, + ); + + // 取得できた代行操作用リフレッシュトークンをデコード + const decodeRefreshToken = decode(delegationRefreshToken); + if (isVerifyError(decodeRefreshToken)) { + fail(); + } + + expect(decodeRefreshToken.role).toBe('none admin'); + expect(decodeRefreshToken.tier).toBe(TIERS.TIER5); + expect(decodeRefreshToken.userId).toBe(partnerAdmin.external_id); + expect(decodeRefreshToken.delegateUserId).toBe(parentAdmin.external_id); + + if (decodeRefreshToken.delegateUserId === undefined) { + fail(); + } + + // 代行操作対象アカウントを削除 + deleteAccount(source, partnerAccount.id); + + try { + await service.updateDelegationAccessToken( + context, + decodeRefreshToken.delegateUserId, + partnerAdmin.external_id, + delegationRefreshToken, + ); + fail(); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.UNAUTHORIZED); + expect(e.getResponse()).toEqual(makeErrorResponse('E010501')); + } else { + fail(); + } + } + }); +}); + const idTokenPayload = { exp: 9000000000, nbf: 1000000000, diff --git a/dictation_server/src/features/auth/auth.service.ts b/dictation_server/src/features/auth/auth.service.ts index 3e2a9cc..23ba89b 100644 --- a/dictation_server/src/features/auth/auth.service.ts +++ b/dictation_server/src/features/auth/auth.service.ts @@ -25,8 +25,15 @@ import { AccountNotFoundError, AdminUserNotFoundError, } from '../../repositories/accounts/errors/types'; -import { DelegationNotAllowedError } from '../../repositories/users/errors/types'; -import { RoleUnexpectedError, TierUnexpectedError } from './errors/types'; +import { + DelegationNotAllowedError, + UserNotFoundError, +} from '../../repositories/users/errors/types'; +import { + InvalidTokenFormatError, + RoleUnexpectedError, + TierUnexpectedError, +} from './errors/types'; @Injectable() export class AuthService { @@ -328,6 +335,116 @@ export class AuthService { return accessToken; } + /** + * 代行操作用アクセストークンを更新する + * @param context + * @param delegateUserExternalId + * @param originUserExternalId + * @param refreshToken + * @returns delegation access token + */ + async updateDelegationAccessToken( + context: Context, + delegateUserExternalId: string | undefined, + originUserExternalId: string, + refreshToken: string, + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.updateDelegationAccessToken.name} | params: { ` + + `delegateUserExternalId: ${delegateUserExternalId}, ` + + `originUserExternalId: ${originUserExternalId}, };`, + ); + try { + if (!delegateUserExternalId) { + throw new UserNotFoundError('delegateUserExternalId is undefined'); + } + + const user = await this.usersRepository.findUserByExternalId( + delegateUserExternalId, + ); + + const privateKey = getPrivateKey(this.configService); + const pubkey = getPublicKey(this.configService); + + // トークンの検証 + const decodedToken = verify(refreshToken, pubkey); + if (isVerifyError(decodedToken)) { + throw new InvalidTokenFormatError( + `Invalid token format. ${decodedToken.reason} | ${decodedToken.message}`, + ); + } + + // トークンの生成には検証済みトークンの値を使用する + const { userId, delegateUserId, tier, role } = decodedToken; + + if (delegateUserId === undefined) { + throw new AdminUserNotFoundError('delegateUserId is undefined'); + } + + // 代行操作対象アカウントの管理者ユーザーが存在して、アカウントに対して代行操作権限があるか確認 + const delegationPermission = + await this.usersRepository.isAllowDelegationPermission( + user.account_id, + userId, + ); + + if (!delegationPermission) { + throw new DelegationNotAllowedError( + `Delegation is not allowed. delegateUserId=${delegateUserId}, userId=${userId}`, + ); + } + + const accessToken = sign( + { + role: role, + tier: tier, + userId: userId, + delegateUserId: delegateUserId, + }, + this.accessTokenlifetime, + privateKey, + ); + + return accessToken; + } catch (e) { + this.logger.error(`error=${e}`); + if (e instanceof HttpException) { + throw e; + } + if (e instanceof Error) { + switch (e.constructor) { + case InvalidTokenFormatError: + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + case UserNotFoundError: + case AccountNotFoundError: + case AdminUserNotFoundError: + throw new HttpException( + makeErrorResponse('E010501'), + HttpStatus.UNAUTHORIZED, + ); + case DelegationNotAllowedError: + throw new HttpException( + makeErrorResponse('E010503'), + HttpStatus.UNAUTHORIZED, + ); + default: + break; + } + } + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } finally { + this.logger.log( + `[OUT] [${context.trackingId}] ${this.updateDelegationAccessToken.name}`, + ); + } + } + /** * Gets id token * @param token diff --git a/dictation_server/src/features/auth/errors/types.ts b/dictation_server/src/features/auth/errors/types.ts index 5c12d36..969d13a 100644 --- a/dictation_server/src/features/auth/errors/types.ts +++ b/dictation_server/src/features/auth/errors/types.ts @@ -2,3 +2,5 @@ export class RoleUnexpectedError extends Error {} // Tier範囲想定外エラー export class TierUnexpectedError extends Error {} +// トークン形式不正エラー +export class InvalidTokenFormatError extends Error {} diff --git a/dictation_server/src/features/auth/test/utility.ts b/dictation_server/src/features/auth/test/utility.ts index c5c2c51..fbbc0e4 100644 --- a/dictation_server/src/features/auth/test/utility.ts +++ b/dictation_server/src/features/auth/test/utility.ts @@ -1,5 +1,7 @@ import { DataSource } from 'typeorm'; import { Term } from '../../../repositories/terms/entity/term.entity'; +import { Account } from '../../../repositories/accounts/entity/account.entity'; +import { User } from '../../../repositories/users/entity/user.entity'; export const createTermInfo = async ( datasource: DataSource, @@ -16,3 +18,21 @@ export const createTermInfo = async ( }); identifiers.pop() as Term; }; + +export const updateAccountDelegationPermission = async ( + dataSource: DataSource, + id: number, + delegationPermission: boolean, +): Promise => { + await dataSource + .getRepository(Account) + .update({ id: id }, { delegation_permission: delegationPermission }); +}; + +export const deleteAccount = async ( + dataSource: DataSource, + id: number, +): Promise => { + await dataSource.getRepository(User).delete({ account_id: id }); + await dataSource.getRepository(Account).delete({ id: id }); +}; diff --git a/dictation_server/src/features/files/files.service.ts b/dictation_server/src/features/files/files.service.ts index 96ac698..44dff8f 100644 --- a/dictation_server/src/features/files/files.service.ts +++ b/dictation_server/src/features/files/files.service.ts @@ -1,6 +1,5 @@ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { makeErrorResponse } from '../../common/error/makeErrorResponse'; -import { AccessToken } from '../../common/token'; import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; import { TasksRepositoryService } from '../../repositories/tasks/tasks.repository.service'; import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service'; diff --git a/dictation_server/src/repositories/users/users.repository.service.ts b/dictation_server/src/repositories/users/users.repository.service.ts index 7f92ae3..8a43aad 100644 --- a/dictation_server/src/repositories/users/users.repository.service.ts +++ b/dictation_server/src/repositories/users/users.repository.service.ts @@ -601,4 +601,47 @@ export class UsersRepositoryService { return primaryUser; }); } + + /** + * 代行操作対象のユーザーの所属するアカウントの代行操作が許可されているか + * @param delegateAccountId 代行操作者のアカウントID + * @param originAccountId 代行操作対象のアカウントID + * @returns delegate accounts + */ + async isAllowDelegationPermission( + delegateAccountId: number, + originUserExternalId: string, + ): Promise { + return await this.dataSource.transaction(async (entityManager) => { + const userRepo = entityManager.getRepository(User); + const primaryUser = await userRepo.findOne({ + where: { + external_id: originUserExternalId, + account: { + parent_account_id: delegateAccountId, + tier: TIERS.TIER5, + }, + }, + relations: { + account: true, + }, + }); + + if (!primaryUser) { + throw new AdminUserNotFoundError( + `Admin user is not found. externalId: ${originUserExternalId}`, + ); + } + + const originAccount = primaryUser.account; + + // 運用上、アカウントがいないことはあり得ないが、プログラム上発生しうるのでエラーとして処理 + if (!originAccount) { + throw new Error(`Account is Not Found. id: ${primaryUser.account_id}`); + } + + // 代行操作の許可の有無を返却 + return originAccount.delegation_permission; + }); + } } From 83add51148329e427dedc3cc6114e640c96c890a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=AF=E6=9C=AC=20=E9=96=8B?= Date: Tue, 31 Oct 2023 03:45:31 +0000 Subject: [PATCH 6/7] =?UTF-8?q?Merged=20PR=20538:=20Azure=20AD=20B2C?= =?UTF-8?q?=E3=81=AE=E7=B5=90=E6=9E=9C=E3=82=92CacheManager=E3=81=AB?= =?UTF-8?q?=E3=82=AD=E3=83=A3=E3=83=83=E3=82=B7=E3=83=A5=E3=81=99=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2967: Azure AD B2Cの結果をCacheManagerにキャッシュするよう修正](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2967) - キャッシュを扱うRedisServiceを追加 - AdB2cServiceでRedisServiceを使って結果をキャッシュする実装を追加 - ADB2Cの呼び出しと、キャッシュからの取得が行われた時にログを出す実装を追加 - Azure Monitorで呼び出しコストでアラート出したくなった時のための予防的追加 - 開発環境でローカルのredisを操作する用途のredis-cliをインストールする設定を追加&スクリプトを追加 - `getUser` と `getUsers` の返り値の方を統一 & 使用されなくなった方の型を削除 - AdB2Cの`ttl` に設定する用の値を環境変数に追加 - 今後実装予定のトークンのキャッシュとはTTLを別にしたかったため - 複数ユーザー削除処理内でのindex処理が不適切と思われる箇所があったので修正 ## レビューポイント - **Redisへのget/set/delが失敗した際に、エラーログだけ出して成功 or 取得対象なしと同様の動作をするように作成したが、問題なさそうか** - これは速度向上用のキャッシュが死んでいても業務は動くべきではないか、という考えによるもの - 通信できない=障害中であると想定されるので、失敗しても良いような気もするので相談 - **AdB2cService内でキャッシュを扱う箇所のコードの可読性に問題はないか** - 更にWrapしてキャッシュの具体的な動きを隠蔽することも考えたが、詳細なエラーの制御をしづらくなりそうだったので具体的な引数の変換等以上のことはしない形で実装 - AdB2cServiceが十分に末端の処理なので詳細な処理を生で書いていても認知負荷はそう変わらない可能性がある - **キャッシュする値の性質によってTTLを変えられる仕組みを前提に設計・実装したが、懸念点はないか** - **TTLに設定する値は妥当そうか** - **`Aadb2cUser` を削除したが問題ないか** - **`deleteUsers` 内のログ処理の変更は適切か** - to 岩田さん ## 動作確認状況 - ローカルで確認 - npm run testが通過することを確認 --- azure-pipelines-staging.yml | 4 + dictation_server/.devcontainer/Dockerfile | 5 + dictation_server/.env.local.example | 6 +- dictation_server/package-lock.json | 223 ++++++++++++------ dictation_server/package.json | 5 +- dictation_server/redis.sh | 3 + dictation_server/src/app.module.ts | 31 ++- .../src/common/cache/constants.ts | 1 + dictation_server/src/common/cache/index.ts | 19 ++ dictation_server/src/common/test/modules.ts | 2 + dictation_server/src/common/token/index.ts | 2 - dictation_server/src/common/token/types.ts | 5 - .../src/common/validators/env.validator.ts | 16 ++ .../features/users/test/users.service.mock.ts | 10 +- .../src/features/users/types/types.ts | 1 - .../src/gateways/adb2c/adb2c.module.ts | 3 +- .../src/gateways/adb2c/adb2c.service.ts | 106 +++++++-- .../src/gateways/redis/redis.module.ts | 8 + .../src/gateways/redis/redis.service.ts | 110 +++++++++ 19 files changed, 445 insertions(+), 115 deletions(-) create mode 100644 dictation_server/redis.sh create mode 100644 dictation_server/src/common/cache/constants.ts create mode 100644 dictation_server/src/common/cache/index.ts create mode 100644 dictation_server/src/gateways/redis/redis.module.ts create mode 100644 dictation_server/src/gateways/redis/redis.service.ts diff --git a/azure-pipelines-staging.yml b/azure-pipelines-staging.yml index faef499..9782b91 100644 --- a/azure-pipelines-staging.yml +++ b/azure-pipelines-staging.yml @@ -83,6 +83,10 @@ jobs: REFRESH_TOKEN_LIFETIME_WEB: 0 REFRESH_TOKEN_LIFETIME_DEFAULT: 0 ACCESS_TOKEN_LIFETIME_WEB: 0 + REDIS_HOST: xxxxxxxxxxxx + REDIS_PORT: 0 + REDIS_PASSWORD: xxxxxxxxxxxx + ADB2C_CACHE_TTL: 0 - task: Docker@0 displayName: build inputs: diff --git a/dictation_server/.devcontainer/Dockerfile b/dictation_server/.devcontainer/Dockerfile index be42795..cd636c4 100644 --- a/dictation_server/.devcontainer/Dockerfile +++ b/dictation_server/.devcontainer/Dockerfile @@ -17,6 +17,11 @@ RUN bash /tmp/library-scripts/common-debian.sh "${INSTALL_ZSH}" "${USERNAME}" "$ && apt-get install default-jre -y \ && apt-get clean -y && rm -rf /var/lib/apt/lists/* /tmp/library-scripts +# Install redis-cli +RUN curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg +RUN sudo apt-get update -y +RUN sudo apt-get install redis -y + # COPY --from=golang:1.18-buster /usr/local/go/ /usr/local/go/ ENV GO111MODULE=auto COPY library-scripts/go-debian.sh /tmp/library-scripts/ diff --git a/dictation_server/.env.local.example b/dictation_server/.env.local.example index 3aa12ac..c59d570 100644 --- a/dictation_server/.env.local.example +++ b/dictation_server/.env.local.example @@ -29,4 +29,8 @@ STORAGE_ACCOUNT_ENDPOINT_EU=https://AAAAAAAAAAAAA ACCESS_TOKEN_LIFETIME_WEB=7200000 REFRESH_TOKEN_LIFETIME_WEB=86400000 REFRESH_TOKEN_LIFETIME_DEFAULT=2592000000 -EMAIL_CONFIRM_LIFETIME=86400000 \ No newline at end of file +EMAIL_CONFIRM_LIFETIME=86400000 +REDIS_HOST=redis-cache +REDIS_PORT=6379 +REDIS_PASSWORD=omdsredispass +ADB2C_CACHE_TTL=86400 \ No newline at end of file diff --git a/dictation_server/package-lock.json b/dictation_server/package-lock.json index d9fffb4..da9ec43 100644 --- a/dictation_server/package-lock.json +++ b/dictation_server/package-lock.json @@ -26,7 +26,7 @@ "@types/jsonwebtoken": "^9.0.1", "@types/uuid": "^8.3.4", "axios": "^1.3.4", - "cache-manager": "^5.2.0", + "cache-manager": "^5.2.4", "cache-manager-redis-store": "^2.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", @@ -49,7 +49,8 @@ "@nestjs/schematics": "^8.0.0", "@nestjs/swagger": "^6.3.0", "@nestjs/testing": "^9.3.12", - "@types/cache-manager-redis-store": "^2.0.1", + "@types/cache-manager": "^4.0.4", + "@types/cache-manager-redis-store": "^2.0.3", "@types/cookie-parser": "^1.4.3", "@types/express": "^4.17.13", "@types/express-session": "^1.17.5", @@ -677,17 +678,89 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", - "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==", + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", "dev": true, "dependencies": { - "@babel/highlight": "^7.18.6" + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/code-frame/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/code-frame/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/compat-data": { "version": "7.21.0", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.21.0.tgz", @@ -737,12 +810,12 @@ } }, "node_modules/@babel/generator": { - "version": "7.21.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.3.tgz", - "integrity": "sha512-QS3iR1GYC/YGUnW7IdggFeN5c1poPUurnGttOV/bZgPGV+izC/D8HnD6DLwod0fsatNyVn1G3EVWMYIF0nHbeA==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", "dev": true, "dependencies": { - "@babel/types": "^7.21.3", + "@babel/types": "^7.23.0", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -794,34 +867,34 @@ } }, "node_modules/@babel/helper-environment-visitor": { - "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz", - "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-function-name": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz", - "integrity": "sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "dependencies": { - "@babel/template": "^7.20.7", - "@babel/types": "^7.21.0" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-hoist-variables": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", - "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", "dev": true, "dependencies": { - "@babel/types": "^7.18.6" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -880,30 +953,30 @@ } }, "node_modules/@babel/helper-split-export-declaration": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", - "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", "dev": true, "dependencies": { - "@babel/types": "^7.18.6" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.19.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz", - "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", + "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.19.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", - "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "dev": true, "engines": { "node": ">=6.9.0" @@ -933,13 +1006,13 @@ } }, "node_modules/@babel/highlight": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", - "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.18.6", - "chalk": "^2.0.0", + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", "js-tokens": "^4.0.0" }, "engines": { @@ -1018,9 +1091,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.21.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.3.tgz", - "integrity": "sha512-lobG0d7aOfQRXh8AyklEAgZGvA4FShxo6xQbUrrT/cNBPUdIDojlokwJsQyCC/eKia7ifqM0yP+2DRZ4WKw2RQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -1203,33 +1276,33 @@ } }, "node_modules/@babel/template": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz", - "integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.18.6", - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.21.3", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.21.3.tgz", - "integrity": "sha512-XLyopNeaTancVitYZe2MlUEvgKb6YVVPXzofHgqHijCImG33b/uTurMS488ht/Hbsb2XK3U2BnSTxKVNGV3nGQ==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.18.6", - "@babel/generator": "^7.21.3", - "@babel/helper-environment-visitor": "^7.18.9", - "@babel/helper-function-name": "^7.21.0", - "@babel/helper-hoist-variables": "^7.18.6", - "@babel/helper-split-export-declaration": "^7.18.6", - "@babel/parser": "^7.21.3", - "@babel/types": "^7.21.3", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -1247,13 +1320,13 @@ } }, "node_modules/@babel/types": { - "version": "7.21.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.21.3.tgz", - "integrity": "sha512-sBGdETxC+/M4o/zKC0sl6sjWv62WFR/uzxrJ6uYyMLZOUlPnwzw0tKgVHOXxaAd5l2g8pEDM5RZ495GPQI77kg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.19.4", - "@babel/helper-validator-identifier": "^7.19.1", + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, "engines": { @@ -2863,15 +2936,15 @@ } }, "node_modules/@types/cache-manager": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/cache-manager/-/cache-manager-4.0.2.tgz", - "integrity": "sha512-fT5FMdzsiSX0AbgnS5gDvHl2Nco0h5zYyjwDQy4yPC7Ww6DeGMVKPRqIZtg9HOXDV2kkc18SL1B0N8f0BecrCA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/cache-manager/-/cache-manager-4.0.4.tgz", + "integrity": "sha512-Kyk9uF54w5/JQWLDKr5378euWUPvebknZut6UpsKhO3R7vE5a5o71QxTR2uev1niBgVAoXAR+BCNMU1lipjxWQ==", "dev": true }, "node_modules/@types/cache-manager-redis-store": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/cache-manager-redis-store/-/cache-manager-redis-store-2.0.1.tgz", - "integrity": "sha512-8QuccvcPieh1xM/5kReE76SfdcIdEB0ePc+54ah/NBuK2eG+6O50SX4WKoJX81UxGdW3sh/WlDaDNqjnqxWNsA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/cache-manager-redis-store/-/cache-manager-redis-store-2.0.3.tgz", + "integrity": "sha512-6OpmRgz0KaTlh6zvqslxEKipCJWmTlI8HTtTzHkrYfPTpsISppaD2tRHaq6U+0jUCf1KvxMpm8RwCp2bnmFlZQ==", "dev": true, "dependencies": { "@types/cache-manager": "*", @@ -4264,12 +4337,12 @@ "optional": true }, "node_modules/cache-manager": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.2.3.tgz", - "integrity": "sha512-9OErI8fksFkxAMJ8Mco0aiZSdphyd90HcKiOMJQncSlU1yq/9lHHxrT8PDayxrmr9IIIZPOAEfXuGSD7g29uog==", + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.2.4.tgz", + "integrity": "sha512-gkuCjug16NdGvKm/sydxGVx17uffrSWcEe2xraBtwRCgdYcFxwJAla4OYpASAZT2yhSoxgDiWL9XH6IAChcZJA==", "dependencies": { "lodash.clonedeep": "^4.5.0", - "lru-cache": "^9.1.2" + "lru-cache": "^10.0.1" } }, "node_modules/cache-manager-redis-store": { @@ -4310,9 +4383,9 @@ } }, "node_modules/cache-manager/node_modules/lru-cache": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-9.1.2.tgz", - "integrity": "sha512-ERJq3FOzJTxBbFjZ7iDs+NiK4VI9Wz+RdrrAB8dio1oV+YvdPzUEE4QNiT2VD51DkIbCYRUUzCRkssXCHqSnKQ==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.1.tgz", + "integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==", "engines": { "node": "14 || >=16.14" } diff --git a/dictation_server/package.json b/dictation_server/package.json index 6523cee..415e65c 100644 --- a/dictation_server/package.json +++ b/dictation_server/package.json @@ -46,7 +46,7 @@ "@types/jsonwebtoken": "^9.0.1", "@types/uuid": "^8.3.4", "axios": "^1.3.4", - "cache-manager": "^5.2.0", + "cache-manager": "^5.2.4", "cache-manager-redis-store": "^2.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", @@ -69,7 +69,8 @@ "@nestjs/schematics": "^8.0.0", "@nestjs/swagger": "^6.3.0", "@nestjs/testing": "^9.3.12", - "@types/cache-manager-redis-store": "^2.0.1", + "@types/cache-manager": "^4.0.4", + "@types/cache-manager-redis-store": "^2.0.3", "@types/cookie-parser": "^1.4.3", "@types/express": "^4.17.13", "@types/express-session": "^1.17.5", diff --git a/dictation_server/redis.sh b/dictation_server/redis.sh new file mode 100644 index 0000000..91f36ca --- /dev/null +++ b/dictation_server/redis.sh @@ -0,0 +1,3 @@ +# source redis.sh で実行することでログインできる +source .env.local +redis-cli -h $REDIS_HOST -a $REDIS_PASSWORD \ No newline at end of file diff --git a/dictation_server/src/app.module.ts b/dictation_server/src/app.module.ts index f8c0664..667cde8 100644 --- a/dictation_server/src/app.module.ts +++ b/dictation_server/src/app.module.ts @@ -1,4 +1,4 @@ -import { MiddlewareConsumer, Module } from '@nestjs/common'; +import { CacheModule, MiddlewareConsumer, Module } from '@nestjs/common'; import { HealthController } from './health.controller'; import { ServeStaticModule } from '@nestjs/serve-static'; import { ConfigModule, ConfigService } from '@nestjs/config'; @@ -50,7 +50,8 @@ import { WorkflowsService } from './features/workflows/workflows.service'; import { validate } from './common/validators/env.validator'; import { WorkflowsRepositoryModule } from './repositories/workflows/workflows.repository.module'; import { TermsModule } from './features/terms/terms.module'; - +import { RedisModule } from './gateways/redis/redis.module'; +import * as redisStore from 'cache-manager-redis-store'; @Module({ imports: [ ServeStaticModule.forRootAsync({ @@ -103,6 +104,31 @@ import { TermsModule } from './features/terms/terms.module'; }), inject: [ConfigService], }), + CacheModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => { + const host = configService.getOrThrow('REDIS_HOST'); + const port = configService.getOrThrow('REDIS_PORT'); + const password = configService.getOrThrow('REDIS_PASSWORD'); + if (process.env.STAGE === 'local') { + return { + store: redisStore, + host: host, + port: port, + password: password, + }; + } + + return { + store: redisStore, + url: `rediss://${host}:${port}`, + password: password, + tls: {}, + }; + }, + inject: [ConfigService], + isGlobal: true, + }), NotificationModule, NotificationhubModule, BlobstorageModule, @@ -110,6 +136,7 @@ import { TermsModule } from './features/terms/terms.module'; SortCriteriaRepositoryModule, WorktypesRepositoryModule, TermsModule, + RedisModule, ], controllers: [ HealthController, diff --git a/dictation_server/src/common/cache/constants.ts b/dictation_server/src/common/cache/constants.ts new file mode 100644 index 0000000..da6c13e --- /dev/null +++ b/dictation_server/src/common/cache/constants.ts @@ -0,0 +1 @@ +export const ADB2C_PREFIX = "adb2c-external-id:" \ No newline at end of file diff --git a/dictation_server/src/common/cache/index.ts b/dictation_server/src/common/cache/index.ts new file mode 100644 index 0000000..067be25 --- /dev/null +++ b/dictation_server/src/common/cache/index.ts @@ -0,0 +1,19 @@ +import { ADB2C_PREFIX } from './constants'; + +/** + * ADB2Cのユーザー格納用のキーを生成する + * @param externalId 外部ユーザーID + * @returns キャッシュのキー + */ +export const makeADB2CKey = (externalId: string): string => { + return `${ADB2C_PREFIX}${externalId}`; +} + +/** + * ADB2Cのユーザー格納用のキーから外部ユーザーIDを取得する + * @param key キャッシュのキー + * @returns 外部ユーザーID + */ +export const restoreAdB2cID = (key: string): string => { + return key.replace(ADB2C_PREFIX, ''); +} \ No newline at end of file diff --git a/dictation_server/src/common/test/modules.ts b/dictation_server/src/common/test/modules.ts index aec0d61..9c3d578 100644 --- a/dictation_server/src/common/test/modules.ts +++ b/dictation_server/src/common/test/modules.ts @@ -37,6 +37,7 @@ import { WorkflowsModule } from '../../features/workflows/workflows.module'; import { TermsService } from '../../features/terms/terms.service'; import { TermsRepositoryModule } from '../../repositories/terms/terms.repository.module'; import { TermsModule } from '../../features/terms/terms.module'; +import { CacheModule } from '@nestjs/common'; export const makeTestingModule = async ( datasource: DataSource, @@ -76,6 +77,7 @@ export const makeTestingModule = async ( SortCriteriaRepositoryModule, WorktypesRepositoryModule, TermsRepositoryModule, + CacheModule.register({ isGlobal: true }), ], providers: [ AuthService, diff --git a/dictation_server/src/common/token/index.ts b/dictation_server/src/common/token/index.ts index 415676a..dd97ce3 100644 --- a/dictation_server/src/common/token/index.ts +++ b/dictation_server/src/common/token/index.ts @@ -4,7 +4,6 @@ import type { IDToken, JwkSignKey, RefreshToken, - Aadb2cUser, } from './types'; import { isIDToken } from './typeguard'; @@ -14,6 +13,5 @@ export type { IDToken, JwkSignKey, RefreshToken, - Aadb2cUser, }; export { isIDToken }; diff --git a/dictation_server/src/common/token/types.ts b/dictation_server/src/common/token/types.ts index e1df6d9..b602913 100644 --- a/dictation_server/src/common/token/types.ts +++ b/dictation_server/src/common/token/types.ts @@ -56,8 +56,3 @@ export type JwkSignKey = { e: string; n: string; }; - -export type Aadb2cUser = { - displayName: string; - mail: string; -}; diff --git a/dictation_server/src/common/validators/env.validator.ts b/dictation_server/src/common/validators/env.validator.ts index 836de47..8ce706f 100644 --- a/dictation_server/src/common/validators/env.validator.ts +++ b/dictation_server/src/common/validators/env.validator.ts @@ -156,6 +156,22 @@ export class EnvValidator { @IsNotEmpty() @IsNumber() EMAIL_CONFIRM_LIFETIME: number; + + @IsNotEmpty() + @IsString() + REDIS_HOST: string; + + @IsNotEmpty() + @IsNumber() + REDIS_PORT: number; + + @IsNotEmpty() + @IsString() + REDIS_PASSWORD: string; + + @IsNotEmpty() + @IsNumber() + ADB2C_CACHE_TTL: number; } export function validate(config: Record) { diff --git a/dictation_server/src/features/users/test/users.service.mock.ts b/dictation_server/src/features/users/test/users.service.mock.ts index b862547..0c44550 100644 --- a/dictation_server/src/features/users/test/users.service.mock.ts +++ b/dictation_server/src/features/users/test/users.service.mock.ts @@ -1,6 +1,6 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { Aadb2cUser, B2cMetadata, JwkSignKey } from '../../../common/token'; +import { B2cMetadata, JwkSignKey } from '../../../common/token'; import { AdB2cService, ConflictError, @@ -42,7 +42,7 @@ export type AdB2cMockValue = { getSignKeySets: JwkSignKey[] | Error; changePassword: { sub: string } | Error; createUser: string | ConflictError | Error; - getUser: Aadb2cUser | Error; + getUser: AdB2cUser | Error; getUsers: AdB2cUser[] | Error; }; @@ -184,10 +184,10 @@ export const makeAdB2cServiceMock = (value: AdB2cMockValue) => { getUser: getUser instanceof Error ? jest - .fn, []>() + .fn, []>() .mockRejectedValue(getUser) : jest - .fn, []>() + .fn, []>() .mockResolvedValue(getUser), getUsers: getUsers instanceof Error @@ -337,8 +337,8 @@ export const makeDefaultAdB2cMockValue = (): AdB2cMockValue => { }, createUser: '001', getUser: { + id: "xxxx-xxxxx-xxxxx-xxxx", displayName: 'Hanako Sato', - mail: 'hanako@sample.com', }, getUsers: AdB2cMockUsers, }; diff --git a/dictation_server/src/features/users/types/types.ts b/dictation_server/src/features/users/types/types.ts index b30b637..af38e72 100644 --- a/dictation_server/src/features/users/types/types.ts +++ b/dictation_server/src/features/users/types/types.ts @@ -10,7 +10,6 @@ import { IsPasswordvalid, } from '../../../common/validators/encryptionPassword.validator'; import { IsRoleAuthorDataValid } from '../../../common/validators/roleAuthor.validator'; -import { Aadb2cUser } from '../../../common/token'; export class ConfirmRequest { @ApiProperty() diff --git a/dictation_server/src/gateways/adb2c/adb2c.module.ts b/dictation_server/src/gateways/adb2c/adb2c.module.ts index 95d40fb..d16d471 100644 --- a/dictation_server/src/gateways/adb2c/adb2c.module.ts +++ b/dictation_server/src/gateways/adb2c/adb2c.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AdB2cService } from './adb2c.service'; +import { RedisModule } from '../redis/redis.module'; @Module({ - imports: [ConfigModule], + imports: [ConfigModule, RedisModule], exports: [AdB2cService], providers: [AdB2cService], }) diff --git a/dictation_server/src/gateways/adb2c/adb2c.service.ts b/dictation_server/src/gateways/adb2c/adb2c.service.ts index f38f5c5..6997ee9 100644 --- a/dictation_server/src/gateways/adb2c/adb2c.service.ts +++ b/dictation_server/src/gateways/adb2c/adb2c.service.ts @@ -1,14 +1,16 @@ import { ClientSecretCredential } from '@azure/identity'; import { Client } from '@microsoft/microsoft-graph-client'; import { TokenCredentialAuthenticationProvider } from '@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials'; -import { Injectable, Logger } from '@nestjs/common'; +import { CACHE_MANAGER, Inject, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; -import { Aadb2cUser, B2cMetadata, JwkSignKey } from '../../common/token'; +import { B2cMetadata, JwkSignKey } from '../../common/token'; import { AdB2cResponse, AdB2cUser } from './types/types'; import { isPromiseRejectedResult } from './utils/utils'; import { Context } from '../../common/log'; import { ADB2C_SIGN_IN_TYPE, MANUAL_RECOVERY_REQUIRED } from '../../constants'; +import { makeADB2CKey, restoreAdB2cID } from '../../common/cache'; +import { RedisService } from '../redis/redis.service'; export type ConflictError = { reason: 'email'; @@ -35,9 +37,14 @@ export class AdB2cService { this.configService.getOrThrow('TENANT_NAME'); private readonly flowName = this.configService.getOrThrow('SIGNIN_FLOW_NAME'); + private readonly ttl = + this.configService.getOrThrow('ADB2C_CACHE_TTL'); private graphClient: Client; - constructor(private readonly configService: ConfigService) { + constructor( + private readonly configService: ConfigService, + private readonly redisService: RedisService, + ) { // ADB2Cへの認証情報 const credential = new ClientSecretCredential( this.configService.getOrThrow('ADB2C_TENANT_ID'), @@ -176,11 +183,25 @@ export class AdB2cService { * @param externalId 外部ユーザーID * @returns ユーザ情報 */ - async getUser(externalId: string): Promise { + async getUser(externalId: string): Promise { this.logger.log(`[IN] ${this.getUser.name}`); try { - return await this.graphClient.api(`users/${externalId}`).get(); + const key = makeADB2CKey(externalId); + + // キャッシュ上に存在していれば、キャッシュから取得する + const cachedUser = await this.redisService.get(key); + if (cachedUser) { + this.logger.log(`[CACHE HIT] id: ${externalId}`); + return cachedUser; + } + + // キャッシュ上に存在していなければ、ADB2Cから取得してキャッシュに保存する + const user = await this.graphClient.api(`users/${externalId}`).get(); + await this.redisService.set(key, user, this.ttl); + this.logger.log(`[ADB2C GET] externalId: ${externalId}`); + + return user; } catch (e) { this.logger.error(e); throw e; @@ -203,12 +224,23 @@ export class AdB2cService { } | params: { externalIds:[${externalIds.join(',')}] };`, ); - /* - TODO [Task2002] 現状の実装だと1リクエストで最大15パラメータまでしか設定できないため、 - 別タスクでアカウント単位の検索用パラメータを用いて取得するように修正する。 - タスク 2002: B2Cからの名前取得をより低コストで行えるように修正する - */ - const chunkExternalIds = splitArrayInChunksOfFifteen(externalIds); + const keys = externalIds.map((externalId) => makeADB2CKey(externalId)); + const cache = await this.redisService.mget(keys); + + // キャッシュ上に存在していれば、キャッシュから取得する + const cachedUsers = cache.flatMap((x) => (x.value ? [x.value] : [])); + if (cachedUsers.length > 0) { + this.logger.log( + `[CACHE HIT] ids: ${cachedUsers.map((x) => x.id).join(',')}`, + ); + } + + // キャッシュ上に存在していなければ、ADB2Cから取得する + const queryExternalIds = cache + .filter((x) => x.value === null) + .map((x) => restoreAdB2cID(x.key)); + + const chunkExternalIds = splitArrayInChunksOfFifteen(queryExternalIds); try { const b2cUsers: AdB2cUser[] = []; @@ -221,9 +253,22 @@ export class AdB2cService { .get(); b2cUsers.push(...res.value); + + // 取得したユーザーをキャッシュに保存する + const users = res.value.map((user) => { + const key = makeADB2CKey(user.id); + return { + key: key, + value: user, + }; + }); + await this.redisService.mset(users, this.ttl); + this.logger.log( + `[ADB2C GET] externalIds: ${res.value?.map((x) => x.id).join(',')}`, + ); } - return b2cUsers; + return [...cachedUsers, ...b2cUsers]; } catch (e) { this.logger.error(e); const { statusCode } = e; @@ -249,6 +294,15 @@ export class AdB2cService { try { // https://learn.microsoft.com/en-us/graph/api/user-delete?view=graph-rest-1.0&tabs=javascript#example await this.graphClient.api(`users/${externalId}`).delete(); + this.logger.log(`[ADB2C DELETE] externalId: ${externalId}`); + + // キャッシュからも削除する + try { + await this.redisService.del(makeADB2CKey(externalId)); + } catch (e) { + // キャッシュからの削除に失敗しても、ADB2Cからの削除は成功しているため例外はスローしない + this.logger.error(`error=${e}`); + } } catch (e) { this.logger.error(`error=${e}`); throw e; @@ -270,18 +324,27 @@ export class AdB2cService { try { // 複数ユーザーを一括削除する方法がないため、1人ずつで削除を行う(rate limitに大きな影響がないこと確認済) const results = await Promise.allSettled( - externalIds.map( - async (x) => await this.graphClient.api(`users/${x}`).delete(), - ), - ); + externalIds.map(async (externalId) => { + await this.graphClient.api(`users/${externalId}`).delete(); + this.logger.log(`[ADB2C DELETE] externalId: ${externalId}`); - // 失敗したプロミスを抽出 - const failedPromises = results.filter( - (result) => result.status === 'rejected', + // キャッシュからも削除する + try { + await this.redisService.del(makeADB2CKey(externalId)); + } catch (e) { + // キャッシュからの削除に失敗しても、ADB2Cからの削除は成功しているため例外はスローしない + this.logger.error(`error=${e}`); + } + }), ); // 失敗したプロミスのエラーをログに記録 - failedPromises.forEach((result, index) => { + results.forEach((result, index) => { + // statusがrejectedでない場合は、エラーが発生していないためログに記録しない + if (result.status !== 'rejected') { + return; + } + const failedId = externalIds[index]; if (isPromiseRejectedResult(result)) { const error = result.reason.toString(); @@ -304,7 +367,8 @@ export class AdB2cService { } } -// TODO [Task2002] 文字列の配列を15要素ずつ区切る(この処理も別タスクで削除予定) +// ID指定で検索できる最大数は15のため、文字列の配列を15要素ずつ区切る。 +// PJ状況的にAzure AD B2C側のユーザーパラメータを増やして一括Queryできるようにする事が難しいので個別にQueryする。[2023/10/29] const splitArrayInChunksOfFifteen = (arr: string[]): string[][] => { const result: string[][] = []; const chunkSize = 15; // SDKの制限数 diff --git a/dictation_server/src/gateways/redis/redis.module.ts b/dictation_server/src/gateways/redis/redis.module.ts new file mode 100644 index 0000000..b019dec --- /dev/null +++ b/dictation_server/src/gateways/redis/redis.module.ts @@ -0,0 +1,8 @@ +import { CacheModule, Module } from '@nestjs/common'; +import { RedisService } from './redis.service'; +@Module({ + imports: [], + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/dictation_server/src/gateways/redis/redis.service.ts b/dictation_server/src/gateways/redis/redis.service.ts new file mode 100644 index 0000000..e932a19 --- /dev/null +++ b/dictation_server/src/gateways/redis/redis.service.ts @@ -0,0 +1,110 @@ +import { + CACHE_MANAGER, + Inject, + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import { Cache } from 'cache-manager'; + +@Injectable() +export class RedisService { + private readonly logger = new Logger(RedisService.name); + + constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {} + + /** + * キーに対応する値を設定する。 + * @param key キー + * @param value キーに対応する値 + * @param ttl 有効期限(秒) + */ + async set( + key: string, + value: unknown, + ttl?: number | undefined, + ): Promise { + try { + // cache-manager-redis-store がcache-managerのset形式と不一致な値の渡し方を採用しているため、 + // @types/cache-managerのset形式を使用すると、redisに値が保存されない。 + // そのため、{ttl : ttl} をany型として渡すことで、強引にcache-manager-redis-storeのsetを使用する。 + // https://www.npmjs.com/package/cache-manager + await this.cacheManager.set(key, value, { ttl: ttl } as any); + } catch (error) { + this.logger.error(error); + } + } + + /** + * 複数のキーとそのキーに対応する値を設定する。 + * @template T + * @param records キーとそのキーに対応する値のペアの配列 + * @param ttl 有効期限(秒) + */ + async mset( + records: { key: string; value: T }[], + ttl?: number | undefined, + ): Promise { + try { + // cache-manager-redis-store のmsetが壊れており、利用できないため、 + // 一つずつsetする。 + for await (const record of records) { + await this.set(record.key, record.value, ttl); + } + } catch (error) { + this.logger.error(error); + } + } + + /** + * キーに対応する値を取得する。 + * @template T + * @param key キー + * @returns キーに対応する値 + */ + async get(key: string): Promise { + try { + const value = await this.cacheManager.get(key); + return value; + } catch (error) { + this.logger.error(error); + return undefined; + } + } + + /** + * 複数のキーに対して、対応する値を取得する。 + * キーに対応する値がなかった場合、valueにはnullがセットされる。 + * @param keys キーの配列 + * @returns キーとそのキーに対応する値のペアの配列 + */ + async mget(keys: string[]): Promise<{ key: string; value: T | null }[]> { + if (keys.length === 0) return []; // mget操作は0件の時エラーとなるため、0件は特別扱いする + + try { + const records = await this.cacheManager.store.mget(...keys); + // getで取得した順序とKeysの順序は一致するはずなので、indexを利用してペアになるよう加工する + return records.map((record, index) => { + return { + key: keys[index], + value: record ? (record as T) : null, + }; + }); + } catch (error) { + this.logger.error(error); + return []; + } + } + + /** + * キーに対応する値を削除する。 + * @param key キー + */ + async del(key: string): Promise { + try { + await this.cacheManager.del(key); + } catch (error) { + this.logger.error(error); + } + } +} From 976271ab9267521f2f2c5324049156acbaae54d3 Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Tue, 31 Oct 2023 06:52:29 +0000 Subject: [PATCH 7/7] =?UTF-8?q?Merged=20PR=20543:=20=E3=83=87=E3=83=97?= =?UTF-8?q?=E3=83=AD=E3=82=A4=E3=83=91=E3=82=A4=E3=83=97=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=83=B3yaml=E3=82=92=E4=BF=AE=E6=AD=A3&=E3=83=AA=E3=83=9D?= =?UTF-8?q?=E3=82=B8=E3=83=88=E3=83=AA=E3=81=AE=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2995: デプロイパイプラインyamlを修正](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2995) - STGデプロイパイプラインの定義yamlについて以下の環境変数に0から具体的な値を設定しました。 - REFRESH_TOKEN_LIFETIME_WEB: 86400000 - REFRESH_TOKEN_LIFETIME_DEFAULT: 2592000000 - ACCESS_TOKEN_LIFETIME_WEB: 7200000 - ユーザーリポジトリの`findSameAccountUsers`についてentityManagerを使っていない箇所を使うように修正しました。 ## レビューポイント - 共有 ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 --- azure-pipelines-staging.yml | 6 +++--- .../src/repositories/users/users.repository.service.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/azure-pipelines-staging.yml b/azure-pipelines-staging.yml index 9782b91..a0e46a5 100644 --- a/azure-pipelines-staging.yml +++ b/azure-pipelines-staging.yml @@ -80,9 +80,9 @@ jobs: TENANT_NAME: xxxxxxxxxxxx SIGNIN_FLOW_NAME: xxxxxxxxxxxx STORAGE_TOKEN_EXPIRE_TIME: 0 - REFRESH_TOKEN_LIFETIME_WEB: 0 - REFRESH_TOKEN_LIFETIME_DEFAULT: 0 - ACCESS_TOKEN_LIFETIME_WEB: 0 + REFRESH_TOKEN_LIFETIME_WEB: 86400000 + REFRESH_TOKEN_LIFETIME_DEFAULT: 2592000000 + ACCESS_TOKEN_LIFETIME_WEB: 7200000 REDIS_HOST: xxxxxxxxxxxx REDIS_PORT: 0 REDIS_PASSWORD: xxxxxxxxxxxx diff --git a/dictation_server/src/repositories/users/users.repository.service.ts b/dictation_server/src/repositories/users/users.repository.service.ts index 8a43aad..2b5324b 100644 --- a/dictation_server/src/repositories/users/users.repository.service.ts +++ b/dictation_server/src/repositories/users/users.repository.service.ts @@ -349,7 +349,7 @@ export class UsersRepositoryService { throw new AccountNotFoundError('Account is Not Found.'); } - const dbUsers = await this.dataSource.getRepository(User).find({ + const dbUsers = await repo.find({ relations: { userGroupMembers: { userGroup: true,