Merged PR 583: [ライセンスアラート改善]AzureAdB2Cアクセスの効率化

## 概要
[Task3023: [ライセンスアラート改善]AzureAdB2Cアクセスの効率化](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3023)

ADB2Cからユーザーを取得する際に、Redisによるキャッシュ保存・キャッシュからの取得を行う処理を実装しました。

## レビューポイント
処理の妥当性などを全体的にお願いします。

## UIの変更
なし

## 動作確認状況
ローカルで動作確認済み

## 補足
なし
This commit is contained in:
masaaki 2023-12-01 01:39:18 +00:00 committed by oura.a
parent 15fa10e265
commit fee99a0974
9 changed files with 242 additions and 95 deletions

View File

@ -13,11 +13,13 @@
"@sendgrid/mail": "^7.7.0",
"dotenv": "^16.0.3",
"mysql2": "^2.3.3",
"redis": "^3.1.2",
"typeorm": "^0.3.10"
},
"devDependencies": {
"@types/jest": "^27.5.0",
"@types/node": "18.x",
"@types/redis": "^2.8.13",
"azure-functions-core-tools": "^4.x",
"jest": "^28.0.3",
"rimraf": "^5.0.0",
@ -1989,6 +1991,15 @@
"integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==",
"dev": true
},
"node_modules/@types/redis": {
"version": "2.8.32",
"resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz",
"integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/stack-utils": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.2.tgz",
@ -5252,9 +5263,9 @@
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
},
"node_modules/lru-cache": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.1.tgz",
"integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz",
"integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==",
"dev": true,
"engines": {
"node": "14 || >=16.14"
@ -6371,8 +6382,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz",
"integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==",
"optional": true,
"peer": true,
"dependencies": {
"denque": "^1.5.0",
"redis-commands": "^1.7.0",
@ -6390,16 +6399,12 @@
"node_modules/redis-commands": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz",
"integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==",
"optional": true,
"peer": true
"integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ=="
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"optional": true,
"peer": true,
"engines": {
"node": ">=4"
}
@ -6408,8 +6413,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"optional": true,
"peer": true,
"dependencies": {
"redis-errors": "^1.0.0"
},
@ -6421,8 +6424,6 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz",
"integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10"
}

View File

@ -18,11 +18,13 @@
"@sendgrid/mail": "^7.7.0",
"dotenv": "^16.0.3",
"mysql2": "^2.3.3",
"redis": "^3.1.2",
"typeorm": "^0.3.10"
},
"devDependencies": {
"@types/jest": "^27.5.0",
"@types/node": "18.x",
"@types/redis": "^2.8.13",
"azure-functions-core-tools": "^4.x",
"jest": "^28.0.3",
"rimraf": "^5.0.0",

View File

@ -1,74 +0,0 @@
import { ClientSecretCredential } from "@azure/identity";
import { Client } from "@microsoft/microsoft-graph-client";
import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials";
import { AdB2cResponse, AdB2cUser } from "./types/types";
import { error } from "console";
export class Adb2cTooManyRequestsError extends Error {}
export class AdB2cService {
private graphClient: Client;
constructor() {
// ADB2Cへの認証情報
if (
!process.env.ADB2C_TENANT_ID ||
!process.env.ADB2C_CLIENT_ID ||
!process.env.ADB2C_CLIENT_SECRET
) {
throw error;
}
const credential = new ClientSecretCredential(
process.env.ADB2C_TENANT_ID,
process.env.ADB2C_CLIENT_ID,
process.env.ADB2C_CLIENT_SECRET
);
const authProvider = new TokenCredentialAuthenticationProvider(credential, {
scopes: ["https://graph.microsoft.com/.default"],
});
this.graphClient = Client.initWithMiddleware({ authProvider });
}
/**
* Azure AD B2Cからユーザ情報を取得する
* @param externalIds ID
* @returns
*/
async getUsers(externalIds: string[]): Promise<AdB2cUser[]> {
const chunkExternalIds = splitArrayInChunksOfFifteen(externalIds);
try {
const b2cUsers: AdB2cUser[] = [];
for (let index = 0; index < chunkExternalIds.length; index++) {
const element = chunkExternalIds[index];
const res: AdB2cResponse = await this.graphClient
.api(`users/`)
.select(["id", "displayName", "identities"])
.filter(`id in (${element.map((y) => `'${y}'`).join(",")})`)
.get();
b2cUsers.push(...res.value);
}
return b2cUsers;
} catch (e) {
const { statusCode } = e;
if (statusCode === 429) {
throw new Adb2cTooManyRequestsError();
}
throw e;
} finally {
}
}
}
const splitArrayInChunksOfFifteen = (arr: string[]): string[][] => {
const result: string[][] = [];
const chunkSize = 15; // SDKの制限数
for (let i = 0; i < arr.length; i += chunkSize) {
result.push(arr.slice(i, i + chunkSize));
}
return result;
};

View File

@ -0,0 +1,147 @@
import { ClientSecretCredential } from "@azure/identity";
import { Client } from "@microsoft/microsoft-graph-client";
import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials";
import { AdB2cResponse, AdB2cUser } from "./types/types";
import { error } from "console";
import { makeADB2CKey, restoreAdB2cID } from "../common/cache";
import { promisify } from "util";
import { createRedisClient } from "../redis/redis";
import { InvocationContext } from "@azure/functions";
export class Adb2cTooManyRequestsError extends Error {}
export class AdB2cService {
private graphClient: Client;
constructor() {
// ADB2Cへの認証情報
if (
!process.env.ADB2C_TENANT_ID ||
!process.env.ADB2C_CLIENT_ID ||
!process.env.ADB2C_CLIENT_SECRET ||
!process.env.ADB2C_CACHE_TTL
) {
throw error;
}
const credential = new ClientSecretCredential(
process.env.ADB2C_TENANT_ID,
process.env.ADB2C_CLIENT_ID,
process.env.ADB2C_CLIENT_SECRET
);
const authProvider = new TokenCredentialAuthenticationProvider(credential, {
scopes: ["https://graph.microsoft.com/.default"],
});
this.graphClient = Client.initWithMiddleware({ authProvider });
}
/**
* Azure AD B2Cからユーザ情報を取得する
* @param externalIds ID
* @returns
*/
async getUsers(
context: InvocationContext,
externalIds: string[]
): Promise<AdB2cUser[] | undefined> {
const redisClient = createRedisClient();
try {
const b2cUsers: AdB2cUser[] = [];
const keys = externalIds.map((externalId) => makeADB2CKey(externalId));
// 取得対象が0件だとmgetがエラーとなるので、1件以上のときに処理
if (keys.length !== 0) {
// redisからキャッシュされているユーザーを取得
let cacheUserObjects: {
key: string;
value: AdB2cUser;
}[] = [];
const mgetAsync = promisify(redisClient.mget).bind(redisClient);
try {
const values = await mgetAsync(...keys);
cacheUserObjects = values.map((value, index) => ({
key: keys[index],
value: value ? JSON.parse(value) : null,
}));
context.log("mget Result:", cacheUserObjects);
} catch (error) {
context.error("mget Error:", error);
}
// キャッシュ上に存在していれば、キャッシュから取得する
const cachedUsers = cacheUserObjects.flatMap((x) =>
x.value ? [x.value] : []
);
if (cachedUsers.length > 0) {
context.log(
`[CACHE HIT] ids: ${cachedUsers.map((x) => x.id).join(",")}`
);
}
// キャッシュ上に存在していなければ、ADB2Cから取得する
const queryExternalIds = cacheUserObjects
.filter((x) => x.value === null)
.map((x) => restoreAdB2cID(x.key));
const chunkExternalIds = splitArrayInChunksOfFifteen(queryExternalIds);
for (let index = 0; index < chunkExternalIds.length; index++) {
const element = chunkExternalIds[index];
const res: AdB2cResponse = await this.graphClient
.api(`users/`)
.select(["id", "displayName", "identities"])
.filter(`id in (${element.map((y) => `'${y}'`).join(",")})`)
.get();
b2cUsers.push(...res.value);
// 取得したユーザーをキャッシュに保存する
const users = res.value.map((user) => {
const key = makeADB2CKey(user.id);
return {
key: key,
value: user,
};
});
try {
const setexAsync = promisify(redisClient.setex).bind(redisClient);
const ttl = process.env.ADB2C_CACHE_TTL;
users.map(async (x) => {
await setexAsync(x.key, ttl, JSON.stringify(x.value));
context.log(
"setex Result:",
`key:${x.key},ttl:${ttl},value:${JSON.stringify(x.value)}`
);
});
} catch (error) {
context.error("setex Error:", error);
}
context.log(
`[ADB2C GET] externalIds: ${res.value?.map((x) => x.id).join(",")}`
);
}
return [...cachedUsers, ...b2cUsers];
} else {
return undefined;
}
} catch (e) {
const { statusCode } = e;
if (statusCode === 429) {
throw new Adb2cTooManyRequestsError();
}
throw e;
} finally {
redisClient.quit;
}
}
}
const splitArrayInChunksOfFifteen = (arr: string[]): string[][] => {
const result: string[][] = [];
const chunkSize = 15; // SDKの制限数
for (let i = 0; i < arr.length; i += chunkSize) {
result.push(arr.slice(i, i + chunkSize));
}
return result;
};

View File

@ -16,8 +16,8 @@ import {
} from "../common/types/types";
import { createMailContentOfLicenseShortage } from "../sendgrid/mailContents/U103ShortageAlert";
import { createMailContentOfLicenseExpiringSoon } from "../sendgrid/mailContents/U104ExpiringSoonAlert";
import { AdB2cService } from "../adb2c/adb2c.service";
import { SendGridService } from "../sendgrid/sendgrid.service";
import { AdB2cService } from "../adb2c/adb2c";
import { SendGridService } from "../sendgrid/sendgrid";
import { getMailFrom } from "../common/getEnv/getEnv";
export async function licenseAlertProcessing(
@ -156,8 +156,12 @@ export async function licenseAlertProcessing(
externalIds.push(x.secondaryAdminExternalId);
}
});
const adb2cUsers = await adb2c.getUsers(externalIds);
const adb2cUsers = await adb2c.getUsers(context, externalIds);
if (!adb2cUsers) {
context.log("Target user not found");
context.log("[OUT]licenseAlertProcessing");
return;
}
// ADB2Cから取得したメールアドレスをRDBから取得した情報にマージ
sendTargetAccounts.map((info) => {
const primaryAdminUser = adb2cUsers.find(

View File

@ -0,0 +1,29 @@
import { app, InvocationContext, Timer } from "@azure/functions";
import * as dotenv from "dotenv";
import { promisify } from "util";
import { createRedisClient } from "../redis/redis";
export async function redisTimerTest(
myTimer: Timer,
context: InvocationContext
): Promise<void> {
context.log("---Timer function processed request.");
dotenv.config({ path: ".env" });
dotenv.config({ path: ".env.local", override: true });
const redisClient = createRedisClient();
const setAsync = promisify(redisClient.set).bind(redisClient);
const getAsync = promisify(redisClient.get).bind(redisClient);
await setAsync("foo", "bar");
const value = await getAsync("foo");
context.log(`value=${value}`); // returns 'bar'
await redisClient.quit;
}
app.timer("redisTimerTest", {
schedule: "*/30 * * * * *",
handler: redisTimerTest,
});

View File

@ -0,0 +1,35 @@
import { createClient, RedisClient } from "redis";
import { error } from "console";
export const createRedisClient = (): RedisClient => {
if (
!process.env.REDIS_HOST ||
!process.env.REDIS_PORT ||
!process.env.REDIS_PASSWORD
) {
throw error("Required environment variables are not set.");
}
const host = process.env.REDIS_HOST;
const port = parseInt(process.env.REDIS_PORT, 10);
const password = process.env.REDIS_PASSWORD;
let client: RedisClient;
if (process.env.STAGE === "local") {
client = createClient({
host: host,
port: port,
password: password,
});
} else {
client = createClient({
url: `rediss://${host}:${port}`,
password: password,
tls: {},
});
}
client.on("error", (err) => console.log("Redis Client Error", err));
return client;
};

View File

@ -10,8 +10,8 @@ import {
} from "../common/types/types";
import { AdB2cUser } from "../adb2c/types/types";
import { ADB2C_SIGN_IN_TYPE } from "../constants";
import { SendGridService } from "../sendgrid/sendgrid.service";
import { AdB2cService } from "../adb2c/adb2c.service";
import { SendGridService } from "../sendgrid/sendgrid";
import { AdB2cService } from "../adb2c/adb2c";
import { InvocationContext } from "@azure/functions";
describe("licenseAlert", () => {
@ -209,7 +209,10 @@ export class AdB2cServiceMock {
* @param externalIds ID
* @returns
*/
async getUsers(externalIds: string[]): Promise<AdB2cUser[]> {
async getUsers(
context: InvocationContext,
externalIds: string[]
): Promise<AdB2cUser[]> {
const AdB2cMockUsers: AdB2cUser[] = [
{
id: "external_id1",