From ee89b11b9efbdfb157f48d433e085ba67bc8a930 Mon Sep 17 00:00:00 2001 From: ZihanChen821 <130351655+ZihanChen821@users.noreply.github.com> Date: Mon, 28 Aug 2023 19:30:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=20(#782)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 改动 scow节点新增audit-server服务 1. 新增audit-server服务,供portal-web和mis-web调用记录日志和查看日志功能 2. 新增audit-db服务,供audit-server服务储存和查看操作日志 ![image](https://github.com/PKUHPC/SCOW/assets/130351655/bac72a8c-7506-4b0b-87e1-0e8e1f10485f) 数据库信息 databaseName: scow_audit tableName: operation_log table desc: ![image](https://github.com/PKUHPC/SCOW/assets/130351655/4462b6cc-e81b-4704-a76f-a6e3bd9f1ce7) 其中metaData里根据每种操作类型的不同,以json形式储存了不同的字段,供操作内容展示。 3. scow-cli增加以上服务部署,在/config/audit.yaml中配置是否部署此服务以及该服务的db和url ```yaml # 审计服务的url,默认不修改 url: audit-server:5000 # 审计系统数据库的信息 db: host: audit-db port: 3306 user: root dbName: scow-audit ``` 4. mis-web在用户空间,账户管理,租户管理,平台管理各增加操作日志页面,其中的展示逻辑如 4.1 用户空间展示的是所有operatorUserId 等于该用户的操作日志 4.2 账户管理展示的是所有保存了targetAccountName且与该账户名相等的操作日志 4.3 租户管理展示的是该租户下所有用户和operatorUserId相等的操作日志 4.4 平台管理展示的是所有操作日志 4.5 所有页面可筛选操作行为,操作时间,操作结果, 账户租户和平台的操作日志还能搜索操作者。 4.6 如果没有配置审计系统,则操作日志路由不展示。门户系统和管理系统将不会记录操作日志。 ![image](https://github.com/PKUHPC/SCOW/assets/130351655/67c45329-21ec-405d-91f4-3eb5290e9977) ![image](https://github.com/PKUHPC/SCOW/assets/130351655/77b238dd-933e-4a6c-a27f-121c9c0b093b) 关于操作行为的枚举以及对应的操作码和操作详情展示格式记录在该文档附录: https://jgf29kqp7z.feishu.cn/wiki/G30hwOvOJiJhWIk3mxIcen2snUe ps: 是否需要新增一篇blog 通知用户该新功能改动? --- .changeset/breezy-seals-tease.md | 5 + .changeset/cool-tomatoes-turn.md | 5 + .changeset/dirty-stingrays-unite.md | 12 + .github/workflows/test-build-publish.yaml | 2 +- apps/audit-server/CHANGELOG.md | 1 + apps/audit-server/README.md | 1 + apps/audit-server/config/audit.yaml | 8 + apps/audit-server/env/.env.dev | 2 + apps/audit-server/env/.env.test | 4 + apps/audit-server/jest.config.js | 35 ++ apps/audit-server/package.json | 54 +++ apps/audit-server/src/app.ts | 39 ++ apps/audit-server/src/config/audit.ts | 18 + apps/audit-server/src/config/env.ts | 27 ++ .../audit-server/src/entities/OperationLog.ts | 63 +++ apps/audit-server/src/entities/index.ts | 17 + apps/audit-server/src/index.ts | 44 ++ .../src/migrations/.snapshot-scow_audit.json | 85 ++++ .../src/migrations/Migration20230817054947.ts | 13 + apps/audit-server/src/mikro-orm.config.ts | 17 + apps/audit-server/src/plugins/index.ts | 34 ++ apps/audit-server/src/plugins/orm.ts | 70 +++ .../audit-server/src/services/operationLog.ts | 77 ++++ apps/audit-server/src/tasks/migrationUp.ts | 19 + apps/audit-server/src/utils/logger.ts | 21 + apps/audit-server/src/utils/operationLogs.ts | 68 +++ apps/audit-server/src/utils/orm.ts | 20 + apps/audit-server/tests/global.d.ts | 13 + .../tests/log/operationLogs.test.ts | 121 +++++ apps/audit-server/tests/utils/helpers.ts | 17 + apps/audit-server/tsconfig.build.json | 7 + apps/audit-server/tsconfig.json | 22 + apps/cli/assets/config/audit.yaml | 9 + apps/cli/assets/install.yaml | 16 + apps/cli/src/cmd/enterAuditDb.ts | 29 ++ apps/cli/src/compose/index.ts | 32 ++ apps/cli/src/config/install.ts | 14 +- apps/cli/src/index.ts | 4 + apps/cli/tests/compose.test.ts | 13 + apps/mis-web/config.js | 7 + apps/mis-web/config/audit.yaml | 8 + apps/mis-web/package.json | 1 + apps/mis-web/src/apis/api.mock.ts | 12 + apps/mis-web/src/apis/api.ts | 2 + .../src/components/OperationLogTable.tsx | 176 ++++++++ apps/mis-web/src/layouts/routes.tsx | 30 +- apps/mis-web/src/models/operationLog.ts | 315 +++++++++++++ .../accounts/[accountName]/operationLogs.tsx | 40 ++ .../mis-web/src/pages/admin/operationLogs.tsx | 36 ++ .../src/pages/api/admin/changePassword.ts | 22 +- .../src/pages/api/admin/finance/pay.ts | 33 +- .../src/pages/api/admin/importUsers.ts | 37 +- .../src/pages/api/admin/setPlatformRole.ts | 32 +- .../src/pages/api/admin/setTenantRole.ts | 36 +- .../src/pages/api/admin/unsetPlatformRole.ts | 31 +- .../src/pages/api/admin/unsetTenantRole.ts | 31 +- apps/mis-web/src/pages/api/auth/callback.ts | 14 +- apps/mis-web/src/pages/api/auth/logout.ts | 13 + apps/mis-web/src/pages/api/finance/pay.ts | 33 +- .../src/pages/api/job/addBillingItem.ts | 29 +- .../src/pages/api/job/changeJobTimeLimit.ts | 23 +- .../src/pages/api/job/queryJobTimeLimit.ts | 2 +- .../src/pages/api/log/getOperationLog.ts | 159 +++++++ .../accountWhitelist/dewhitelistAccount.ts | 23 +- .../accountWhitelist/whitelistAccount.ts | 22 +- .../src/pages/api/tenant/changePassword.ts | 22 +- apps/mis-web/src/pages/api/tenant/create.ts | 24 +- .../src/pages/api/tenant/createAccount.ts | 22 +- .../src/pages/api/users/addToAccount.ts | 33 +- .../src/pages/api/users/blockInAccount.ts | 26 +- apps/mis-web/src/pages/api/users/create.ts | 22 +- .../pages/api/users/jobChargeLimit/cancel.ts | 22 +- .../src/pages/api/users/jobChargeLimit/set.ts | 26 +- .../src/pages/api/users/removeFromAccount.ts | 26 +- .../mis-web/src/pages/api/users/setAsAdmin.ts | 24 +- .../src/pages/api/users/unblockInAccount.ts | 27 +- .../mis-web/src/pages/api/users/unsetAdmin.ts | 24 +- .../src/pages/tenant/operationLogs.tsx | 37 ++ apps/mis-web/src/pages/user/operationLogs.tsx | 32 ++ apps/mis-web/src/server/jobAccessible.ts | 34 +- apps/mis-web/src/server/operationLog.ts | 43 ++ apps/mis-web/src/utils/config.ts | 5 + apps/mis-web/src/utils/constants.ts | 2 + apps/mis-web/src/utils/server.ts | 5 +- apps/portal-web/config.js | 5 + apps/portal-web/config/audit.yaml | 8 + apps/portal-web/package.json | 1 + apps/portal-web/src/models/operationLog.ts | 72 +++ .../src/pages/api/app/createAppSession.ts | 24 +- .../portal-web/src/pages/api/auth/callback.ts | 10 +- apps/portal-web/src/pages/api/auth/logout.ts | 13 + .../src/pages/api/desktop/createDesktop.ts | 25 +- .../src/pages/api/desktop/killDesktop.ts | 21 +- apps/portal-web/src/pages/api/file/copy.ts | 22 +- .../src/pages/api/file/createFile.ts | 22 +- .../src/pages/api/file/deleteDir.ts | 26 +- .../src/pages/api/file/deleteFile.ts | 22 +- apps/portal-web/src/pages/api/file/mkdir.ts | 23 +- apps/portal-web/src/pages/api/file/move.ts | 22 +- apps/portal-web/src/pages/api/file/upload.ts | 23 +- .../portal-web/src/pages/api/job/cancelJob.ts | 20 +- .../src/pages/api/job/deleteJobTemplate.ts | 22 +- .../src/pages/api/job/renameJobTemplate.ts | 23 +- .../portal-web/src/pages/api/job/submitJob.ts | 41 +- apps/portal-web/src/pages/api/shell/index.ts | 22 +- apps/portal-web/src/server/operationLog.ts | 43 ++ apps/portal-web/src/utils/config.ts | 3 + apps/portal-web/src/utils/server.ts | 5 +- deploy/vagrant/README.md | 5 +- .../scow/scow-deployment/config/audit.yaml | 7 + .../vagrant/scow/scow-deployment/install.yaml | 6 +- dev/vagrant/README.md | 7 +- dev/vagrant/config/audit.yaml | 8 + dev/vagrant/pm2.config.js | 15 + docs/docs/deploy/config/audit/_category_.json | 8 + docs/docs/deploy/config/audit/intro.md | 44 ++ docs/docs/deploy/install/index.md | 1 + libs/config/src/audit.ts | 44 ++ libs/operation-log/CHANGELOG.md | 1 + libs/operation-log/jest.config.js | 26 ++ libs/operation-log/package.json | 30 ++ libs/operation-log/src/constant.ts | 25 ++ libs/operation-log/src/index.ts | 91 ++++ libs/operation-log/tsconfig.build.json | 6 + libs/operation-log/tsconfig.json | 13 + libs/protos/scow/buf.gen.yaml | 1 + pnpm-lock.yaml | 84 +++- protos/audit/operation_log.proto | 415 ++++++++++++++++++ scripts/copyDist.mjs | 2 +- 129 files changed, 3850 insertions(+), 188 deletions(-) create mode 100644 .changeset/breezy-seals-tease.md create mode 100644 .changeset/cool-tomatoes-turn.md create mode 100644 .changeset/dirty-stingrays-unite.md create mode 100644 apps/audit-server/CHANGELOG.md create mode 100644 apps/audit-server/README.md create mode 100644 apps/audit-server/config/audit.yaml create mode 100644 apps/audit-server/env/.env.dev create mode 100644 apps/audit-server/env/.env.test create mode 100644 apps/audit-server/jest.config.js create mode 100644 apps/audit-server/package.json create mode 100644 apps/audit-server/src/app.ts create mode 100644 apps/audit-server/src/config/audit.ts create mode 100644 apps/audit-server/src/config/env.ts create mode 100644 apps/audit-server/src/entities/OperationLog.ts create mode 100644 apps/audit-server/src/entities/index.ts create mode 100644 apps/audit-server/src/index.ts create mode 100644 apps/audit-server/src/migrations/.snapshot-scow_audit.json create mode 100644 apps/audit-server/src/migrations/Migration20230817054947.ts create mode 100644 apps/audit-server/src/mikro-orm.config.ts create mode 100644 apps/audit-server/src/plugins/index.ts create mode 100644 apps/audit-server/src/plugins/orm.ts create mode 100644 apps/audit-server/src/services/operationLog.ts create mode 100644 apps/audit-server/src/tasks/migrationUp.ts create mode 100644 apps/audit-server/src/utils/logger.ts create mode 100644 apps/audit-server/src/utils/operationLogs.ts create mode 100644 apps/audit-server/src/utils/orm.ts create mode 100644 apps/audit-server/tests/global.d.ts create mode 100644 apps/audit-server/tests/log/operationLogs.test.ts create mode 100644 apps/audit-server/tests/utils/helpers.ts create mode 100644 apps/audit-server/tsconfig.build.json create mode 100644 apps/audit-server/tsconfig.json create mode 100644 apps/cli/assets/config/audit.yaml create mode 100644 apps/cli/src/cmd/enterAuditDb.ts create mode 100644 apps/mis-web/config/audit.yaml create mode 100644 apps/mis-web/src/components/OperationLogTable.tsx create mode 100644 apps/mis-web/src/models/operationLog.ts create mode 100644 apps/mis-web/src/pages/accounts/[accountName]/operationLogs.tsx create mode 100644 apps/mis-web/src/pages/admin/operationLogs.tsx create mode 100644 apps/mis-web/src/pages/api/log/getOperationLog.ts create mode 100644 apps/mis-web/src/pages/tenant/operationLogs.tsx create mode 100644 apps/mis-web/src/pages/user/operationLogs.tsx create mode 100644 apps/mis-web/src/server/operationLog.ts create mode 100644 apps/portal-web/config/audit.yaml create mode 100644 apps/portal-web/src/models/operationLog.ts create mode 100644 apps/portal-web/src/server/operationLog.ts create mode 100644 deploy/vagrant/scow/scow-deployment/config/audit.yaml create mode 100644 dev/vagrant/config/audit.yaml create mode 100644 docs/docs/deploy/config/audit/_category_.json create mode 100644 docs/docs/deploy/config/audit/intro.md create mode 100644 libs/config/src/audit.ts create mode 100644 libs/operation-log/CHANGELOG.md create mode 100644 libs/operation-log/jest.config.js create mode 100644 libs/operation-log/package.json create mode 100644 libs/operation-log/src/constant.ts create mode 100644 libs/operation-log/src/index.ts create mode 100644 libs/operation-log/tsconfig.build.json create mode 100644 libs/operation-log/tsconfig.json create mode 100644 protos/audit/operation_log.proto diff --git a/.changeset/breezy-seals-tease.md b/.changeset/breezy-seals-tease.md new file mode 100644 index 0000000000..77527b4434 --- /dev/null +++ b/.changeset/breezy-seals-tease.md @@ -0,0 +1,5 @@ +--- +"@scow/grpc-api": minor +--- + +新增审计系统,增加 CreateOperationLog 和 GetOperationLogs 接口定义 diff --git a/.changeset/cool-tomatoes-turn.md b/.changeset/cool-tomatoes-turn.md new file mode 100644 index 0000000000..9068356278 --- /dev/null +++ b/.changeset/cool-tomatoes-turn.md @@ -0,0 +1,5 @@ +--- +"@scow/config": minor +--- + +新增审计系统配置文件 diff --git a/.changeset/dirty-stingrays-unite.md b/.changeset/dirty-stingrays-unite.md new file mode 100644 index 0000000000..80d3ce0fb5 --- /dev/null +++ b/.changeset/dirty-stingrays-unite.md @@ -0,0 +1,12 @@ +--- +"@scow/lib-operation-log": minor +"@scow/audit-server": minor +"@scow/protos": minor +"@scow/portal-web": minor +"@scow/demo-vagrant": minor +"@scow/mis-web": minor +"@scow/cli": minor +"@scow/docs": minor +--- + +新增审计系统服务,记录门户系统及管理系统操作日志及展示 diff --git a/.github/workflows/test-build-publish.yaml b/.github/workflows/test-build-publish.yaml index 25147ff893..0eaf64f0fd 100644 --- a/.github/workflows/test-build-publish.yaml +++ b/.github/workflows/test-build-publish.yaml @@ -72,7 +72,7 @@ jobs: - name: Upload test converage uses: codecov/codecov-action@v3 with: - files: ./libs/auth/coverage/lcov.info,./libs/ssh/coverage/lcov.info,./libs/libconfig/coverage/lcov.info,./libs/decimal/coverage/lcov.info,./libs/server/coverage/lcov.info,./apps/cli/coverage/lcov.info,./apps/auth/coverage/lcov.info,./apps/mis-server/coverage/lcov.info,./apps/portal-server/coverage/lcov.info,./apps/gateway/coverage/lcov.info + files: ./libs/auth/coverage/lcov.info,./libs/ssh/coverage/lcov.info,./libs/libconfig/coverage/lcov.info,./libs/decimal/coverage/lcov.info,./libs/server/coverage/lcov.info,./apps/cli/coverage/lcov.info,./apps/auth/coverage/lcov.info,./apps/mis-server/coverage/lcov.info,./apps/portal-server/coverage/lcov.info,./apps/gateway/coverage/lcov.info,./apps/audit-server/coverage/lcov.info - name: Create Release Pull Request or Publish id: changesets diff --git a/apps/audit-server/CHANGELOG.md b/apps/audit-server/CHANGELOG.md new file mode 100644 index 0000000000..b944a662d8 --- /dev/null +++ b/apps/audit-server/CHANGELOG.md @@ -0,0 +1 @@ +# @scow/audit-server \ No newline at end of file diff --git a/apps/audit-server/README.md b/apps/audit-server/README.md new file mode 100644 index 0000000000..7a22d33ba5 --- /dev/null +++ b/apps/audit-server/README.md @@ -0,0 +1 @@ +# 审计系统 diff --git a/apps/audit-server/config/audit.yaml b/apps/audit-server/config/audit.yaml new file mode 100644 index 0000000000..d8fb0b3975 --- /dev/null +++ b/apps/audit-server/config/audit.yaml @@ -0,0 +1,8 @@ +db: + host: localhost + port: 3306 + user: root + password: mysqlrootpassword + dbName: scow_audit + + diff --git a/apps/audit-server/env/.env.dev b/apps/audit-server/env/.env.dev new file mode 100644 index 0000000000..ceb53dfb94 --- /dev/null +++ b/apps/audit-server/env/.env.dev @@ -0,0 +1,2 @@ +DB_HOST=localhost +DB_NAME=scow_audit diff --git a/apps/audit-server/env/.env.test b/apps/audit-server/env/.env.test new file mode 100644 index 0000000000..c817b8e682 --- /dev/null +++ b/apps/audit-server/env/.env.test @@ -0,0 +1,4 @@ +LOG_LEVEL=error +PORT=0 +DB_HOST=localhost +DB_NAME=scow_audit_${JEST_WORKER_ID} diff --git a/apps/audit-server/jest.config.js b/apps/audit-server/jest.config.js new file mode 100644 index 0000000000..7a8b882391 --- /dev/null +++ b/apps/audit-server/jest.config.js @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +// jest.config.js +const { pathsToModuleNameMapper } = require("ts-jest"); +// In the following statement, replace `./tsconfig` with the path to your `tsconfig` file +// which contains the path mapping (ie the `compilerOptions.paths` option): +const { compilerOptions } = require("./tsconfig"); + +const dotenv = require("dotenv"); + +dotenv.config({ path: "env/.env.test" }); + +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + rootDir: ".", + preset: "ts-jest", + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: "/" }), + testMatch: [ + "/tests/**/*.test.ts?(x)", + ], + coverageDirectory: "coverage", + testTimeout: 30000, + coverageReporters: ["lcov"], + setupFilesAfterEnv: ["jest-extended/all"], +}; diff --git a/apps/audit-server/package.json b/apps/audit-server/package.json new file mode 100644 index 0000000000..03b0609773 --- /dev/null +++ b/apps/audit-server/package.json @@ -0,0 +1,54 @@ +{ + "name": "@scow/audit-server", + "version": "1.0.0", + "description": "", + "private": true, + "main": "build/index.js", + "scripts": { + "dev": "dotenv -e env/.env.dev -- node --watch -r ts-node/register -r tsconfig-paths/register src/index.ts", + "build": "rimraf build && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", + "serve": "node build/index.js", + "test": "jest", + "orm": "dotenv -e env/.env.dev -- npx mikro-orm" + }, + "files": [ + "scripts", + "build", + ".npmrc", + "!**/*.map" + ], + "keywords": [], + "author": "PKUHPC (https://github.com/PKUHPC)", + "license": "Mulan PSL v2", + "repository": "https://github.com/PKUHPC/SCOW", + "dependencies": { + "@ddadaal/tsgrpc-server": "0.19.4", + "@ddadaal/tsgrpc-common": "0.2.4", + "@ddadaal/tsgrpc-client": "0.17.6", + "@grpc/grpc-js": "1.8.21", + "@mikro-orm/cli": "5.7.14", + "@mikro-orm/core": "5.7.14", + "@mikro-orm/migrations": "5.7.14", + "@mikro-orm/mysql": "5.7.14", + "@scow/config": "workspace:*", + "@scow/lib-config": "workspace:*", + "@scow/lib-decimal": "workspace:*", + "@scow/utils": "workspace:*", + "@scow/protos": "workspace:*", + "pino": "8.15.0", + "pino-pretty": "10.2.0" + }, + "devDependencies": { + "@types/google-protobuf": "3.15.6" + }, + "mikro-orm": { + "useTsNode": true, + "configPaths": [ + "./src/mikro-orm.config.ts", + "./src/mikro-orm.config.js" + ] + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/apps/audit-server/src/app.ts b/apps/audit-server/src/app.ts new file mode 100644 index 0000000000..f4e795dca4 --- /dev/null +++ b/apps/audit-server/src/app.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { Server } from "@ddadaal/tsgrpc-server"; +import { omitConfigSpec } from "@scow/lib-config"; +import { readVersionFile } from "@scow/utils/build/version"; +import { config } from "src/config/env"; +import { plugins } from "src/plugins"; +import { operationLogServiceServer } from "src/services/operationLog"; +import { logger } from "src/utils/logger"; + +export async function createServer() { + + const server = new Server({ + host: config.HOST, + port: config.PORT, + + logger, + }); + + server.logger.info({ version: readVersionFile() }, "@scow/audit-server: "); + server.logger.info({ config: omitConfigSpec(config) }, "Loaded env config"); + + for (const plugin of plugins) { + await server.register(plugin); + } + await server.register(operationLogServiceServer); + + return server; +} diff --git a/apps/audit-server/src/config/audit.ts b/apps/audit-server/src/config/audit.ts new file mode 100644 index 0000000000..d58f4d0743 --- /dev/null +++ b/apps/audit-server/src/config/audit.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { getAuditConfig } from "@scow/config/build/audit"; +import { logger } from "src/utils/logger"; + +export const auditConfig = getAuditConfig(undefined, logger); + + diff --git a/apps/audit-server/src/config/env.ts b/apps/audit-server/src/config/env.ts new file mode 100644 index 0000000000..5815b9ace8 --- /dev/null +++ b/apps/audit-server/src/config/env.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { bool, envConfig, host, port, str } from "@scow/lib-config"; + +export const config = envConfig({ + HOST: host({ default: "0.0.0.0", desc: "监听地址" }), + PORT: port({ default: 5000, desc: "监听端口" }), + LOG_LEVEL: str({ + default: "info", + desc: "日志等级", + }), + LOG_PRETTY: bool({ desc: "以可读的方式输出log", default: false }), + + DB_NAME: str({ desc: "存放系统数据的数据库名,将会覆写配置文件。用于测试", default: undefined }), + DB_PASSWORD: str({ desc: "审计系统数据库密码,将会覆写配置文件", default: undefined }), +}); + diff --git a/apps/audit-server/src/entities/OperationLog.ts b/apps/audit-server/src/entities/OperationLog.ts new file mode 100644 index 0000000000..588478c929 --- /dev/null +++ b/apps/audit-server/src/entities/OperationLog.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { Entity, Enum, PrimaryKey, Property } from "@mikro-orm/core"; +import { CURRENT_TIMESTAMP, DATETIME_TYPE } from "src/utils/orm"; + +export enum OperationResult { + UNKNOWN = "UNKNOWN", + SUCCESS = "SUCCESS", + FAIL = "FAIL", +} + +@Entity() +export class OperationLog { + @PrimaryKey() + id!: number; + + @Property() + operatorUserId!: string; + + @Property() + operatorIp!: string; + + @Property({ columnType: DATETIME_TYPE, defaultRaw: CURRENT_TIMESTAMP }) + operationTime?: Date; + + @Enum({ items: () => OperationResult, comment: Object.values(OperationResult).join(", ") }) + operationResult: OperationResult; + + @Property({ type: "json", nullable: true }) + metaData?: { [key: string]: any; }; + + constructor(init: { + operationLogId?: number; + operatorUserId: string; + operatorIp: string; + operationTime?: Date; + operationResult: OperationResult; + metaData: { [key: string]: any }; + }) { + if (init.operationLogId) { + this.id = init.operationLogId; + } + this.operatorUserId = init.operatorUserId; + this.operatorIp = init.operatorIp; + if (init.operationTime) { + this.operationTime = init.operationTime; + } + this.operationResult = init.operationResult; + this.metaData = init.metaData; + } + +} + diff --git a/apps/audit-server/src/entities/index.ts b/apps/audit-server/src/entities/index.ts new file mode 100644 index 0000000000..5bcb7c4fd0 --- /dev/null +++ b/apps/audit-server/src/entities/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { OperationLog } from "src/entities/OperationLog"; + +export const entities = [ + OperationLog, +]; diff --git a/apps/audit-server/src/index.ts b/apps/audit-server/src/index.ts new file mode 100644 index 0000000000..73dcb19d5a --- /dev/null +++ b/apps/audit-server/src/index.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { createServer } from "src/app"; +import { migrationUp } from "src/tasks/migrationUp"; + +async function main() { + + const server = await createServer(); + + const args = process.argv.slice(1); + + // run tasks + if (args.length > 1) { + const [_scriptName, command] = args; + + const logger = server.logger.child({ task: command }); + + switch (command) { + + case "migrationUp": + await migrationUp(server.ext.orm); + break; + default: + logger.error("Unexpected task name %s", command); + process.exit(1); + } + + process.exit(0); + } + + await server.start(); +} + +main(); diff --git a/apps/audit-server/src/migrations/.snapshot-scow_audit.json b/apps/audit-server/src/migrations/.snapshot-scow_audit.json new file mode 100644 index 0000000000..c65518a64f --- /dev/null +++ b/apps/audit-server/src/migrations/.snapshot-scow_audit.json @@ -0,0 +1,85 @@ +{ + "namespaces": [], + "tables": [ + { + "columns": { + "id": { + "name": "id", + "type": "int", + "unsigned": true, + "autoincrement": true, + "primary": true, + "nullable": false, + "mappedType": "integer" + }, + "operator_user_id": { + "name": "operator_user_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "string" + }, + "operator_ip": { + "name": "operator_ip", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "string" + }, + "operation_time": { + "name": "operation_time", + "type": "DATETIME(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "default": "current_timestamp(0)", + "mappedType": "datetime" + }, + "operation_result": { + "name": "operation_result", + "type": "enum('UNKNOWN', 'SUCCESS', 'FAIL')", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "enumItems": [ + "UNKNOWN", + "SUCCESS", + "FAIL" + ], + "comment": "UNKNOWN, SUCCESS, FAIL", + "mappedType": "enum" + }, + "meta_data": { + "name": "meta_data", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + } + }, + "name": "operation_log", + "indexes": [ + { + "keyName": "PRIMARY", + "columnNames": [ + "id" + ], + "composite": false, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {} + } + ] +} diff --git a/apps/audit-server/src/migrations/Migration20230817054947.ts b/apps/audit-server/src/migrations/Migration20230817054947.ts new file mode 100644 index 0000000000..6f40af43af --- /dev/null +++ b/apps/audit-server/src/migrations/Migration20230817054947.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20230817054947 extends Migration { + + async up(): Promise { + this.addSql('create table `operation_log` (`id` int unsigned not null auto_increment primary key, `operator_user_id` varchar(255) not null, `operator_ip` varchar(255) not null, `operation_time` DATETIME(6) not null default current_timestamp(6), `operation_result` enum(\'UNKNOWN\', \'SUCCESS\', \'FAIL\') not null comment \'UNKNOWN, SUCCESS, FAIL\', `meta_data` json null) default character set utf8mb4 engine = InnoDB;'); + } + + async down(): Promise { + this.addSql('drop table if exists `operation_log`;'); + } + +} diff --git a/apps/audit-server/src/mikro-orm.config.ts b/apps/audit-server/src/mikro-orm.config.ts new file mode 100644 index 0000000000..b0c47b949c --- /dev/null +++ b/apps/audit-server/src/mikro-orm.config.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { ormConfigs } from "src/plugins/orm"; + +export default { + ...ormConfigs, +}; diff --git a/apps/audit-server/src/plugins/index.ts b/apps/audit-server/src/plugins/index.ts new file mode 100644 index 0000000000..4a7ded3d6c --- /dev/null +++ b/apps/audit-server/src/plugins/index.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +// Declares all plugins in this file +// In my yaarxiv project, there can be multiple interface augmentations separated in difference files +// But in this project, only one augmentation is resolved. +// Don't know why. + +import type { MikroORM } from "@mikro-orm/core"; +import type { MySqlDriver, SqlEntityManager } from "@mikro-orm/mysql"; +import { ormPlugin } from "src/plugins/orm"; + +declare module "@ddadaal/tsgrpc-server" { + interface Extensions { + orm: MikroORM; + } + + interface Request { + em: SqlEntityManager; + } +} + +export const plugins = [ + ormPlugin, +]; diff --git a/apps/audit-server/src/plugins/orm.ts b/apps/audit-server/src/plugins/orm.ts new file mode 100644 index 0000000000..40d4328847 --- /dev/null +++ b/apps/audit-server/src/plugins/orm.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { plugin } from "@ddadaal/tsgrpc-server"; +import { MikroORM, Options } from "@mikro-orm/core"; +import { MySqlDriver } from "@mikro-orm/mysql"; +import { join } from "path"; +import { auditConfig } from "src/config/audit"; +import { config } from "src/config/env"; + +import { entities } from "../entities"; + +const distPath = process.env.NODE_ENV === "production" ? "build" : "src"; + +export const ormConfigs = { + host: auditConfig.db.host, + port: auditConfig.db.port, + user: auditConfig.db.user, + dbName: config.DB_NAME ?? auditConfig.db.dbName, + password: config.DB_PASSWORD ?? auditConfig.db.password, + type: "mysql", + forceUndefined: true, + runMigrations: true, + migrations: { + path: join(distPath, "migrations"), + pattern: /^[\w-]+\d+\.(j|t)s$/, + }, + entities, + debug: auditConfig.db.debug, + seeder: { + path: join(distPath, "seenders"), + }, +} as Options; + +export const ormPlugin = plugin(async (server) => { + // create the database if not exists. + + const logger = server.logger.child({ plugin: "orm" }); + + const orm = await MikroORM.init({ + ...ormConfigs, + logger: (msg) => logger.info(msg), + }); + + const schemaGenerator = orm.getSchemaGenerator(); + await schemaGenerator.ensureDatabase(); + await orm.getMigrator().up(); + + server.addExtension("orm", orm); + + server.addRequestHook((req) => { + req.em = orm.em.fork(); + }); + + server.addCloseHook(async () => { + logger.info("Closing db connection."); + await orm.close(); + logger.info("db connection has been closed."); + }); + +}); diff --git a/apps/audit-server/src/services/operationLog.ts b/apps/audit-server/src/services/operationLog.ts new file mode 100644 index 0000000000..3f85a7d796 --- /dev/null +++ b/apps/audit-server/src/services/operationLog.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { ensureNotUndefined, plugin } from "@ddadaal/tsgrpc-server"; +import { QueryOrder } from "@mikro-orm/core"; +import { + OperationLogServiceServer, + OperationLogServiceService, + operationResultToJSON, +} from "@scow/protos/build/audit/operation_log"; +import { OperationLog, OperationResult } from "src/entities/OperationLog"; +import { filterOperationLogs, toGrpcOperationLog } from "src/utils/operationLogs"; +import { paginationProps } from "src/utils/orm"; + + +export const operationLogServiceServer = plugin((server) => { + + server.addService(OperationLogServiceService, { + + createOperationLog: async ({ request, em }) => { + const { + operatorUserId, + operatorIp, + operationResult, + operationEvent, + } = request; + + const metaData = operationEvent || {}; + const operationType = operationEvent?.$case; + const targetAccountName = (operationEvent && operationType) + ? operationEvent[operationType].accountName + : undefined; + + const dbOperationResult: OperationResult = OperationResult[operationResultToJSON(operationResult)]; + + const operationLog = new OperationLog({ + operatorUserId, + operatorIp, + operationResult: dbOperationResult, + metaData: { ...metaData, targetAccountName }, + }); + await em.persistAndFlush(operationLog); + return []; + }, + + getOperationLogs: async ({ request, em, logger }) => { + const { filter, page, pageSize } = ensureNotUndefined(request, ["filter", "page"]); + + const sqlFilter = await filterOperationLogs(filter); + + logger.info("getOperationLogs sqlFilter %s", JSON.stringify(sqlFilter)); + + const [operationLogs, count] = await em.findAndCount(OperationLog, sqlFilter, { + ...paginationProps(page, pageSize || 10), + orderBy: { operationTime: QueryOrder.DESC }, + }); + + const res = operationLogs.map(toGrpcOperationLog); + + return [{ + results: res, + totalCount: count, + }]; + }, + + }); + +}); diff --git a/apps/audit-server/src/tasks/migrationUp.ts b/apps/audit-server/src/tasks/migrationUp.ts new file mode 100644 index 0000000000..b1e9553830 --- /dev/null +++ b/apps/audit-server/src/tasks/migrationUp.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { MikroORM } from "@mikro-orm/core"; +import { MySqlDriver } from "@mikro-orm/mysql"; + +export async function migrationUp(orm: MikroORM) { + await orm.getSchemaGenerator().ensureDatabase(); + await orm.getMigrator().up(); +} diff --git a/apps/audit-server/src/utils/logger.ts b/apps/audit-server/src/utils/logger.ts new file mode 100644 index 0000000000..76075ea5f1 --- /dev/null +++ b/apps/audit-server/src/utils/logger.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import pino from "pino"; +import { config } from "src/config/env"; + +export const logger = pino({ + level: config.LOG_LEVEL, + ...config.LOG_PRETTY ? { + transport: { target: "pino-pretty" }, + } : {}, +}); diff --git a/apps/audit-server/src/utils/operationLogs.ts b/apps/audit-server/src/utils/operationLogs.ts new file mode 100644 index 0000000000..c392d4491a --- /dev/null +++ b/apps/audit-server/src/utils/operationLogs.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { FilterQuery } from "@mikro-orm/core"; +import { + OperationLog, + OperationLogFilter, + operationResultFromJSON, operationResultToJSON } from "@scow/protos/build/audit/operation_log"; +import { OperationLog as OperationLogEntity } from "src/entities/OperationLog"; + + +export async function filterOperationLogs( + { + operatorUserIds, + operationResult, + startTime, + endTime, + operationType, + operationTargetAccountName, + }: OperationLogFilter, +) { + + const sqlFilter: FilterQuery = { + ...(operatorUserIds.length > 0 ? { operatorUserId: { $in: operatorUserIds } } : {}), + $and: [ + ...(startTime ? [{ operationTime: { $gte: startTime } }] : []), + ...(endTime ? [{ operationTime: { $lte: endTime } }] : []), + ], + ...(operationType || operationTargetAccountName ? { + metaData: { + ...((operationType) ? { $case: operationType } : {}), + ...((operationTargetAccountName) ? { targetAccountName: operationTargetAccountName } : {}), + }, + } : {}), + ...(operationResult ? { operation_result: operationResultToJSON(operationResult) } : {}), + }; + return sqlFilter; +} + +export function toGrpcOperationLog(x: OperationLogEntity): OperationLog { + + const grpcOperationLog = { + operationLogId: x.id, + operatorUserId: x.operatorUserId, + operatorIp: x.operatorIp, + operationTime: x.operationTime?.toISOString(), + operationResult: operationResultFromJSON(x.operationResult), + }; + if (x.metaData && x.metaData.$case) { + // @ts-ignore + grpcOperationLog.operationEvent = { + $case: x.metaData.$case, + [x.metaData.$case]: x.metaData[x.metaData.$case], + }; + + } + + return grpcOperationLog; +} diff --git a/apps/audit-server/src/utils/orm.ts b/apps/audit-server/src/utils/orm.ts new file mode 100644 index 0000000000..ced9ee8b90 --- /dev/null +++ b/apps/audit-server/src/utils/orm.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +export const paginationProps = (page?: number, pageSize: number = 10) => ({ + offset: ((page ?? 1) - 1) * pageSize, + limit: pageSize, +}); + +export const DATETIME_TYPE = "DATETIME(6)"; + +export const CURRENT_TIMESTAMP = "CURRENT_TIMESTAMP(6)"; diff --git a/apps/audit-server/tests/global.d.ts b/apps/audit-server/tests/global.d.ts new file mode 100644 index 0000000000..e3fa4087f9 --- /dev/null +++ b/apps/audit-server/tests/global.d.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import "jest-extended"; diff --git a/apps/audit-server/tests/log/operationLogs.test.ts b/apps/audit-server/tests/log/operationLogs.test.ts new file mode 100644 index 0000000000..cd32adb631 --- /dev/null +++ b/apps/audit-server/tests/log/operationLogs.test.ts @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { asyncClientCall } from "@ddadaal/tsgrpc-client"; +import { Server } from "@ddadaal/tsgrpc-server"; +import { ChannelCredentials } from "@grpc/grpc-js"; +import { OperationLogServiceClient, operationResultFromJSON } from "@scow/protos/build/audit/operation_log"; +import { createServer } from "src/app"; +import { OperationLog, OperationResult } from "src/entities/OperationLog"; +import { dropDatabase } from "tests/utils/helpers"; + +let server: Server; +let client: OperationLogServiceClient; + +const operationLog = { + operatorUserId: "testUserId", + operatorIp: "127.0.0.1", + operationResult: OperationResult.SUCCESS, + operationEvent: { "$case": "submitJob" as const, submitJob: { accountName: "testAccount", jobId: 123 } }, +}; + + +beforeEach(async () => { + server = await createServer(); + await server.start(); + client = new OperationLogServiceClient(server.serverAddress, ChannelCredentials.createInsecure()); +}); + +afterEach(async () => { + await dropDatabase(server.ext.orm); + await server.close(); +}); + +it("create operation log", async () => { + + const em = server.ext.orm.em.fork(); + + await asyncClientCall(client, "createOperationLog", { + ...operationLog, + operationResult: operationResultFromJSON(operationLog.operationResult), + }); + + const operationLogs = await em.find(OperationLog, { operatorUserId: operationLog.operatorUserId }, { + orderBy: { operationTime: "DESC" }, + limit: 1, + }); + + expect(operationLogs[0].operatorUserId).toEqual(operationLog.operatorUserId); + expect(operationLogs[0].operatorIp).toEqual(operationLog.operatorIp); + expect(operationLogs[0].operationResult).toEqual(operationLog.operationResult); + expect(operationLogs[0].metaData?.$case).toEqual(operationLog.operationEvent.$case); + expect(operationLogs[0].metaData?.submitJob).toEqual(operationLog.operationEvent.submitJob); + expect(operationLogs[0].metaData?.targetAccountName).toEqual(operationLog.operationEvent.submitJob.accountName); +}); + +it("get operation logs", async () => { + + const operationLog1 = new OperationLog({ + operationLogId: 1, + operatorUserId: operationLog.operatorUserId, + operatorIp: operationLog.operatorIp, + operationResult: operationLog.operationResult, + operationTime: new Date("2023-08-14T10:45:02.000Z"), + metaData: operationLog.operationEvent, + }); + + const operationLog2 = new OperationLog({ + operationLogId: 2, + operatorUserId: operationLog.operatorUserId, + operatorIp: operationLog.operatorIp, + operationResult: operationLog.operationResult, + operationTime: new Date("2023-08-14T10:45:02.000Z"), + metaData: { + $case: "endJob", endJob: { + jobId:123, + }, + }, + }); + const em = server.ext.orm.em.fork(); + await em.persistAndFlush([operationLog1, operationLog2]); + + const resp = await asyncClientCall(client, "getOperationLogs", { + page: 1, + filter: { operatorUserIds: ["testUserId"]}, + }); + + expect(resp.totalCount).toBe(2); + + + expect(resp.results).toIncludeSameMembers([ + { + operationLogId: 1, + operatorUserId: operationLog.operatorUserId, + operatorIp: operationLog.operatorIp, + operationResult: operationResultFromJSON(operationLog.operationResult), + operationTime: "2023-08-14T10:45:02.000Z", + operationEvent: operationLog.operationEvent, + }, + { + operationLogId: 2, + operatorUserId: operationLog.operatorUserId, + operatorIp: operationLog.operatorIp, + operationResult: operationResultFromJSON(operationLog.operationResult), + operationTime: "2023-08-14T10:45:02.000Z", + operationEvent: { + $case: "endJob", endJob: { + jobId:123, + }, + }, + }, + ]); +}); diff --git a/apps/audit-server/tests/utils/helpers.ts b/apps/audit-server/tests/utils/helpers.ts new file mode 100644 index 0000000000..9be99a3a19 --- /dev/null +++ b/apps/audit-server/tests/utils/helpers.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { MikroORM } from "@mikro-orm/core"; + +export async function dropDatabase(orm: MikroORM) { + await orm.getSchemaGenerator().dropDatabase(orm.config.get("dbName")); +} diff --git a/apps/audit-server/tsconfig.build.json b/apps/audit-server/tsconfig.build.json new file mode 100644 index 0000000000..4b3715a411 --- /dev/null +++ b/apps/audit-server/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "migrations/**/*.ts" + ], +} diff --git a/apps/audit-server/tsconfig.json b/apps/audit-server/tsconfig.json new file mode 100644 index 0000000000..05d6a70fe0 --- /dev/null +++ b/apps/audit-server/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "build", + "baseUrl": ".", + "paths": { + "src/*": [ + "src/*" + ], + "tests/*": [ + "tests/*" + ] + } + }, + "include": [ + "src/**/*.ts", + "tests/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/apps/cli/assets/config/audit.yaml b/apps/cli/assets/config/audit.yaml new file mode 100644 index 0000000000..e185a6e3ca --- /dev/null +++ b/apps/cli/assets/config/audit.yaml @@ -0,0 +1,9 @@ +# 审计系统服务端url +url: audit-server:5000 + +# 审计系统数据库的信息 +db: + host: audit-db + port: 3306 + user: root + dbName: scow_audit diff --git a/apps/cli/assets/install.yaml b/apps/cli/assets/install.yaml index 861b238b6f..88f6e6cff5 100644 --- a/apps/cli/assets/install.yaml +++ b/apps/cli/assets/install.yaml @@ -76,6 +76,22 @@ # # environment: # # - DEBUG=log +# 审计系统相关配置。默认不部署 +# audit: +# # 审计系统服务url,默认audit-server:5000 +# # url: audit-server:5000 +# # mysql数据库的镜像地址。默认为mysql:8 +# # mysqlImage: "mysql:8" +# # 审计系统数据库的密码。如果部署审计系统,则必须配置 +# # 第一次启动审计系统时会使用此密码初始化审计系统数据库,之后如需修改需要手动在数据库中修改 +# # dbPassword: must!chang3this +# # 端口映射。如果不进行二次开发,请勿开放 +# # portMappings: +# # # 数据库的3306端口映射。不填写则不映射 +# # db: 127:0.0.1:3306 +# # # audit-server的5000端口映射到。不填写则不映射 +# # audit-server: 127.0.0.1:7573 + # 日志相关配置 # log: # # 日志级别。默认info diff --git a/apps/cli/src/cmd/enterAuditDb.ts b/apps/cli/src/cmd/enterAuditDb.ts new file mode 100644 index 0000000000..f9a26a9e30 --- /dev/null +++ b/apps/cli/src/cmd/enterAuditDb.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { runComposeCommand } from "src/compose/cmd"; +import { getInstallConfig } from "src/config/install"; + +interface Options { + configPath: string; +} + +export const enterAuditDb = async (options: Options) => { + + const config = getInstallConfig(options.configPath); + + if (!config.audit) { + throw new Error("audit is not deployed. db is not deployed"); + } + + runComposeCommand(config, ["exec", "audit-db", "mysql", "-uroot", `-p'${config.audit.dbPassword}'`]); +}; diff --git a/apps/cli/src/compose/index.ts b/apps/cli/src/compose/index.ts index 76bd9f9831..26b7e04fbd 100644 --- a/apps/cli/src/compose/index.ts +++ b/apps/cli/src/compose/index.ts @@ -207,6 +207,7 @@ export const createComposeSpec = (config: InstallConfigSchema) => { "NOVNC_CLIENT_URL": join(BASE_PATH, "/vnc"), "CLIENT_MAX_BODY_SIZE": config.gateway.uploadFileSizeLimit, "PUBLIC_PATH": join(BASE_PATH, publicPath), + "AUDIT_DEPLOYED": config.audit ? "true" : "false", }, ports: {}, volumes: { @@ -249,6 +250,7 @@ export const createComposeSpec = (config: InstallConfigSchema) => { "PORTAL_DEPLOYED": config.portal ? "true" : "false", "AUTH_EXTERNAL_URL": join(BASE_PATH, "/auth"), "PUBLIC_PATH": join(BASE_PATH, publicPath), + "AUDIT_DEPLOYED": config.audit ? "true" : "false", }, ports: {}, volumes: { @@ -270,5 +272,35 @@ export const createComposeSpec = (config: InstallConfigSchema) => { }); } + // AUDIT + if (config.audit) { + addService("audit-server", { + image: scowImage, + ports: config.audit.portMappings?.auditServer + ? { [config.audit.portMappings.auditServer]: 5000 } + : {}, + environment: { + "SCOW_LAUNCH_APP": "audit-server", + "DB_PASSWORD": config.audit.dbPassword, + ...serviceLogEnv, + }, + volumes: { + "./config": "/etc/scow", + }, + }); + + composeSpec.volumes["audit_db_data"] = {}; + + addService("audit-db", { + image: config.audit.mysqlImage, + volumes: { + "audit_db_data": "/var/lib/mysql", + }, + environment: { + "MYSQL_ROOT_PASSWORD": config.audit.dbPassword, + }, + ports: config.audit.portMappings?.db ? { [config.audit.portMappings?.db]: 3306 } : {}, + }); + } return composeSpec; }; diff --git a/apps/cli/src/config/install.ts b/apps/cli/src/config/install.ts index 770b541f5e..f215722737 100644 --- a/apps/cli/src/config/install.ts +++ b/apps/cli/src/config/install.ts @@ -100,7 +100,19 @@ export const InstallConfigSchema = Type.Object({ enabledPlugins: Type.Optional(Type.Array(Type.String(), { description: "启用的插件列表" })), pluginsDir: Type.String({ description: "插件目录", default: "./plugins" }), }, { default: {} }), -}); + + audit: Type.Optional(Type.Object({ + mysqlImage: Type.String({ description: "审计系统数据库镜像", default: "mysql:8" }), + dbPassword: Type.String({ description: "审计系统数据库密码", default: "must!chang3this" }), + + portMappings: Type.Optional(Type.Object({ + db: Type.Optional(Type.Union([Type.String(), Type.Integer()], { description: "数据库映射出来的端口" })), + auditServer: Type.Optional(Type.Union([Type.String(), Type.Integer()], { + description: "audit-server映射出来的端口", + })), + })), + })), +}, { description: "审计系统部署选项,如果不设置,则不部署审计系统" }); export type InstallConfigSchema = Static; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index ec701ca473..9e36def42f 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -18,6 +18,7 @@ import { join } from "path"; import { checkConfig } from "src/cmd/checkConfig"; import { runCompose } from "src/cmd/compose"; import { enterDb } from "src/cmd/db"; +import { enterAuditDb } from "src/cmd/enterAuditDb"; import { generateDockerComposeYml } from "src/cmd/generate"; import { init } from "src/cmd/init"; import { migrateFromScowDeployment } from "src/cmd/migrate"; @@ -128,6 +129,9 @@ yargs(hideBin(process.argv)) .command("db", "Enter mis db", (y) => y, (argv) => { enterDb(argv); }) + .command("audit-db", "Enter audit db", (y) => y, (argv) => { + enterAuditDb(argv); + }) .command("compose", "Run arbitrary compose commands", (y) => { return y.strict(false).parserConfiguration({ "unknown-options-as-args": true }); }, async (argv) => { diff --git a/apps/cli/tests/compose.test.ts b/apps/cli/tests/compose.test.ts index 71e2042d4a..1069b220a4 100644 --- a/apps/cli/tests/compose.test.ts +++ b/apps/cli/tests/compose.test.ts @@ -93,3 +93,16 @@ describe("sets custom auth environment", () => { .toInclude("CUSTOM_AUTH_KEY=CUSTOM_AUTH_VALUE"); }); }); + + +it("deploy audit", async () => { + const config = getInstallConfig(configPath); + config.audit = { dbPassword: "must!chang3this", mysqlImage: "" }; + config.portal = { basePath: "/", novncClientImage: "" }; + config.mis = { basePath: "/mis", dbPassword: "must!chang3this", mysqlImage: "" }; + + const composeConfig = createComposeSpec(config); + + expect(composeConfig.services["mis-web"].environment).toContain("AUDIT_DEPLOYED=true"); + expect(composeConfig.services["portal-web"].environment).toContain("AUDIT_DEPLOYED=true"); +}); diff --git a/apps/mis-web/config.js b/apps/mis-web/config.js index 6c0573d315..b5d937cc39 100644 --- a/apps/mis-web/config.js +++ b/apps/mis-web/config.js @@ -16,6 +16,7 @@ const { getMisConfig } = require("@scow/config/build/mis"); const { getCommonConfig } = require("@scow/config/build/common"); const { getClusterTextsConfig } = require("@scow/config/build/clusterTexts"); const { DEFAULT_PRIMARY_COLOR, getUiConfig } = require("@scow/config/build/ui"); +const { getAuditConfig } = require("@scow/config/build/audit"); const { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_BUILD, PHASE_PRODUCTION_SERVER } = require("next/constants"); const { join } = require("path"); const { getCapabilities } = require("@scow/lib-auth"); @@ -48,6 +49,8 @@ const specs = { PORTAL_URL: str({ desc: "如果部署了门户系统,门户系统的URL。如果和本系统域名相同,可以只写完整路径。将会覆盖配置文件。空字符串等价于未部署门户系统", default: "" }), PUBLIC_PATH: str({ desc: "SCOW公共文件的路径,需已包含SCOW的base path", default: "/public/" }), + + AUDIT_DEPLOYED: bool({ desc: "是否部署了审计系统", default: false }), }; const mockEnv = process.env.NEXT_PUBLIC_USE_MOCK === "1"; @@ -87,6 +90,7 @@ const buildRuntimeConfig = async (phase, basePath) => { const misConfig = getMisConfig(configBasePath, console); const commonConfig = getCommonConfig(configBasePath, console); + const auditConfig = getAuditConfig(configBasePath, console); const versionTag = readVersionFile()?.tag; @@ -102,6 +106,7 @@ const buildRuntimeConfig = async (phase, basePath) => { DEFAULT_PRIMARY_COLOR, SERVER_URL: config.SERVER_URL, SCOW_API_AUTH_TOKEN: commonConfig.scowApi?.auth?.token, + AUDIT_CONFIG: config.AUDIT_DEPLOYED ? auditConfig : undefined, }; /** @@ -140,6 +145,8 @@ const buildRuntimeConfig = async (phase, basePath) => { USER_LINKS: commonConfig.userLinks, VERSION_TAG: versionTag, + + AUDIT_DEPLOYED: config.AUDIT_DEPLOYED, }; if (!building) { diff --git a/apps/mis-web/config/audit.yaml b/apps/mis-web/config/audit.yaml new file mode 100644 index 0000000000..d8fb0b3975 --- /dev/null +++ b/apps/mis-web/config/audit.yaml @@ -0,0 +1,8 @@ +db: + host: localhost + port: 3306 + user: root + password: mysqlrootpassword + dbName: scow_audit + + diff --git a/apps/mis-web/package.json b/apps/mis-web/package.json index 147bfa3d1c..f5711dbdc7 100644 --- a/apps/mis-web/package.json +++ b/apps/mis-web/package.json @@ -35,6 +35,7 @@ "@scow/protos": "workspace:*", "@scow/lib-web": "workspace:*", "@scow/utils": "workspace:*", + "@scow/lib-operation-log": "workspace:*", "@sinclair/typebox": "0.31.1", "antd": "5.8.4", "dayjs": "1.11.9", diff --git a/apps/mis-web/src/apis/api.mock.ts b/apps/mis-web/src/apis/api.mock.ts index c53aa12337..e90e2014aa 100644 --- a/apps/mis-web/src/apis/api.mock.ts +++ b/apps/mis-web/src/apis/api.mock.ts @@ -17,6 +17,7 @@ import type { RunningJob } from "@scow/protos/build/common/job"; import type { Account } from "@scow/protos/build/server/account"; import type { AccountUserInfo, GetUserStatusResponse } from "@scow/protos/build/server/user"; import { api } from "src/apis/api"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { ClusterAccountInfo_ImportStatus, PlatformRole, TenantRole, UserInfo, UserRole, UserStatus } from "src/models/User"; import { DEFAULT_TENANT_NAME } from "src/utils/constants"; @@ -405,6 +406,17 @@ export const mockApi: MockApi = { }), createTenant: async () => ({ createdInAuth: true }), validateToken: async () => MOCK_USER_INFO, + + getOperationLog: async () => ({ results: [{ + operationLogId: 99, + operatorUserId: "testUser", + operatorIp: "localhost", + operationCode: "000000", + operationType: OperationType.login, + operationResult: OperationResult.SUCCESS, + operationTime: "2020-04-23T23:49:50.000Z", + operationDetail:"用户登录", + }], totalCount: 1 }), }; export const MOCK_USER_INFO = { diff --git a/apps/mis-web/src/apis/api.ts b/apps/mis-web/src/apis/api.ts index aa99f96982..209cb990df 100644 --- a/apps/mis-web/src/apis/api.ts +++ b/apps/mis-web/src/apis/api.ts @@ -58,6 +58,7 @@ import type { GetMissingDefaultPriceItemsSchema } from "src/pages/api/job/getMis import type { GetJobInfoSchema } from "src/pages/api/job/jobInfo"; import type { QueryJobTimeLimitSchema } from "src/pages/api/job/queryJobTimeLimit"; import type { GetRunningJobsSchema } from "src/pages/api/job/runningJobs"; +import type { GetOperationLogsSchema } from "src/pages/api/log/getOperationLog"; import type { ChangeEmailSchema } from "src/pages/api/profile/changeEmail"; import type { ChangePasswordSchema } from "src/pages/api/profile/changePassword"; import { CheckPasswordSchema } from "src/pages/api/profile/checkPassword"; @@ -150,4 +151,5 @@ export const api = { unblockUserInAccount: apiClient.fromTypeboxRoute("PUT", "/api/users/unblockInAccount"), unsetAdmin: apiClient.fromTypeboxRoute("PUT", "/api/users/unsetAdmin"), checkPassword: apiClient.fromTypeboxRoute("GET", "/api/profile/checkPassword"), + getOperationLog: apiClient.fromTypeboxRoute("GET", "/api/log/getOperationLog"), }; diff --git a/apps/mis-web/src/components/OperationLogTable.tsx b/apps/mis-web/src/components/OperationLogTable.tsx new file mode 100644 index 0000000000..78b2e3842a --- /dev/null +++ b/apps/mis-web/src/components/OperationLogTable.tsx @@ -0,0 +1,176 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { OperationType } from "@scow/lib-operation-log/build/index"; +import { defaultPresets, formatDateTime } from "@scow/lib-web/build/utils/datetime"; +import { Button, DatePicker, Form, Input, Select, Table } from "antd"; +import dayjs from "dayjs"; +import { useCallback, useState } from "react"; +import { useAsync } from "react-async"; +import { api } from "src/apis"; +import { FilterFormContainer } from "src/components/FilterFormContainer"; +import { + OperationLog, + OperationLogQueryType, + OperationResult, + OperationResultTexts, + OperationTypeTexts } from "src/models/operationLog"; +import { User } from "src/stores/UserStore"; + +interface FilterForm { + operatorUserId?: string; + operationType?: OperationType; + operationTime?: [dayjs.Dayjs, dayjs.Dayjs], + operationResult?: OperationResult; +} + +interface PageInfo { + page: number; + pageSize?: number; +} + +interface Props { + user: User; + queryType: OperationLogQueryType; + accountName?: string + tenantName?: string; +} + +const today = dayjs().endOf("day"); + +export const OperationLogTable: React.FC = ({ user, queryType, accountName, tenantName }) => { + + const [ query, setQuery ] = useState(() => { + return { + operatorUserId: undefined, + operationType: undefined, + operationTime: [today.clone().subtract(30, "day"), today], + operationResult: undefined, + }; + }); + + const [form] = Form.useForm(); + + const [pageInfo, setPageInfo] = useState({ page: 1, pageSize: 10 }); + + const getOperatorUserIds = () => { + if (queryType === OperationLogQueryType.USER) { + return [user.identityId]; + } + const operatorUserId = query.operatorUserId?.trim(); + return operatorUserId ? [operatorUserId] : []; + }; + + const promiseFn = useCallback(async () => { + return await api.getOperationLog({ query: { + type: queryType, + operatorUserIds: getOperatorUserIds().join(","), + operationType: query.operationType, + operationResult: query.operationResult, + startTime: query.operationTime?.[0].startOf("day").toISOString(), + endTime: query.operationTime?.[1].endOf("day").toISOString(), + operationTargetAccountName: accountName, + page: pageInfo.page, + pageSize: pageInfo.pageSize, + } }); + }, [query, pageInfo, queryType, accountName, tenantName]); + + const { data, isLoading } = useAsync({ promiseFn }); + + return ( +
+ + + form={form} + layout="inline" + initialValues={query} + onFinish={async () => { + const { operationType, operatorUserId, + operationResult, operationTime } = await form.validateFields(); + setQuery({ operationType, operatorUserId, operationResult, operationTime }); + setPageInfo({ page: 1, pageSize: pageInfo.pageSize }); + }} + > + + key !== OperationResult.UNKNOWN.toString()) + .map((key) => ({ value: key, label: OperationResultTexts[key] }))} + allowClear + style={{ width: 80 }} + /> + + {queryType !== OperationLogQueryType.USER && ( + + + + )} + + + + + + + + + setPageInfo({ page, pageSize }), + }} + > + dataIndex="operationLogId" title="ID" /> + dataIndex="operationCode" title="操作码" /> + + dataIndex="operationType" + title="操作行为" + render={(operationType) => OperationTypeTexts[operationType] } + /> + + dataIndex="operationDetail" + title="操作内容" + /> + + dataIndex="operationResult" + title="操作结果" + render={(operationResult) => OperationResultTexts[operationResult] } + /> + + dataIndex="operationTime" + title="操作时间" + render={formatDateTime} + /> + dataIndex="operatorUserId" title="操作员" /> + dataIndex="operatorIp" title="操作IP" /> +
+
+ ); +}; diff --git a/apps/mis-web/src/layouts/routes.tsx b/apps/mis-web/src/layouts/routes.tsx index 3b710799f5..63264d990d 100644 --- a/apps/mis-web/src/layouts/routes.tsx +++ b/apps/mis-web/src/layouts/routes.tsx @@ -116,7 +116,12 @@ export const platformAdminRoutes: (platformRoles: PlatformRole[]) => NavItemProp }, ], }, - + ...(publicConfig.AUDIT_DEPLOYED && platformRoles.includes(PlatformRole.PLATFORM_ADMIN) ? + [{ + Icon: BookOutlined, + text: "操作日志", + path: "/admin/operationLogs", + }] : []), ], }, ]; @@ -210,7 +215,7 @@ export const tenantRoutes: (tenantRoles: TenantRole[], token: string) => NavItem ], }, ] : []), - ...(tenantRoles.includes(TenantRole.TENANT_FINANCE) || + ...(tenantRoles.includes(TenantRole.TENANT_FINANCE) || tenantRoles.includes(TenantRole.TENANT_ADMIN) ? [ { Icon: MoneyCollectOutlined, @@ -236,6 +241,13 @@ export const tenantRoutes: (tenantRoles: TenantRole[], token: string) => NavItem ], }, ] : []), + ...(publicConfig.AUDIT_DEPLOYED && tenantRoles.includes(TenantRole.TENANT_ADMIN) ? [ + { + Icon: BookOutlined, + text: "操作日志", + path: "/tenant/operationLogs", + }, + ] : []), ], }, ]; @@ -269,6 +281,13 @@ export const userRoutes: (accounts: AccountAffiliation[]) => NavItemProps[] = (a text: "集群和分区信息", path: "/user/partitions", }, + ...(publicConfig.AUDIT_DEPLOYED + ? [{ + Icon: BookOutlined, + text: "操作日志", + path: "/user/operationLogs", + }] + : []), ], }, @@ -315,6 +334,13 @@ export const accountAdminRoutes: (adminAccounts: AccountAffiliation[]) => NavIte text: "消费记录", path: `/accounts/${x.accountName}/charges`, }, + ...(publicConfig.AUDIT_DEPLOYED + ? [{ + Icon: BookOutlined, + text: "操作日志", + path: `/accounts/${x.accountName}/operationLogs`, + }] + : []), ], })), diff --git a/apps/mis-web/src/models/operationLog.ts b/apps/mis-web/src/models/operationLog.ts new file mode 100644 index 0000000000..b7e9012484 --- /dev/null +++ b/apps/mis-web/src/models/operationLog.ts @@ -0,0 +1,315 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { OperationType as LibOperationType, OperationTypeEnum } from "@scow/lib-operation-log"; +import { OperationLog as OperationLogProto } from "@scow/protos/build/audit/operation_log"; +import { Static, Type } from "@sinclair/typebox"; +import { ValueOf } from "next/dist/shared/lib/constants"; +import { moneyToString } from "src/utils/money"; + +export const OperationResult = { + UNKNOWN: 0, + SUCCESS: 1, + FAIL: 2, +} as const; + +export type OperationResult = ValueOf + +export const OperationType: OperationTypeEnum = { + login: "login", + logout: "logout", + submitJob: "submitJob", + endJob: "endJob", + addJobTemplate: "addJobTemplate", + deleteJobTemplate: "deleteJobTemplate", + updateJobTemplate: "updateJobTemplate", + shellLogin: "shellLogin", + createDesktop: "createDesktop", + deleteDesktop: "deleteDesktop", + createApp: "createApp", + createFile: "createFile", + deleteFile: "deleteFile", + uploadFile: "uploadFile", + createDirectory: "createDirectory", + deleteDirectory: "deleteDirectory", + moveFileItem: "moveFileItem", + copyFileItem: "copyFileItem", + setJobTimeLimit: "setJobTimeLimit", + createUser: "createUser", + addUserToAccount: "addUserToAccount", + removeUserFromAccount: "removeUserFromAccount", + setAccountAdmin: "setAccountAdmin", + unsetAccountAdmin: "unsetAccountAdmin", + blockUser: "blockUser", + unblockUser: "unblockUser", + accountSetChargeLimit: "accountSetChargeLimit", + accountUnsetChargeLimit: "accountUnsetChargeLimit", + setTenantBilling: "setTenantBilling", + setTenantAdmin: "setTenantAdmin", + unsetTenantAdmin: "unsetTenantAdmin", + setTenantFinance: "setTenantFinance", + unsetTenantFinance: "unsetTenantFinance", + tenantChangePassword: "tenantChangePassword", + createAccount: "createAccount", + addAccountToWhitelist: "addAccountToWhitelist", + removeAccountFromWhitelist: "removeAccountFromWhitelist", + accountPay: "accountPay", + importUsers: "importUsers", + setPlatformAdmin: "setPlatformAdmin", + unsetPlatformAdmin: "unsetPlatformAdmin", + setPlatformFinance: "setPlatformFinance", + unsetPlatformFinance: "unsetPlatformFinance", + platformChangePassword: "platformChangePassword", + setPlatformBilling: "setPlatformBilling", + createTenant: "createTenant", + tenantPay: "tenantPay", +}; + +export const OperationLog = Type.Object({ + operationLogId: Type.Number(), + operatorUserId: Type.String(), + operatorIp: Type.String(), + operationCode: Type.String(), + operationType: Type.Enum(OperationType), + operationResult: Type.Enum(OperationResult), + operationTime: Type.Optional(Type.String()), + operationDetail: Type.String(), +}); +export type OperationLog = Static; + +export enum OperationLogQueryType { + USER = 0, + ACCOUNT = 1, + TENANT = 2, + PLATFORM = 3, +}; + +export const OperationResultTexts = { + [OperationResult.UNKNOWN]: "未知", + [OperationResult.SUCCESS]: "成功", + [OperationResult.FAIL]: "失败", +}; + +export const OperationTypeTexts: { [key in LibOperationType]: string } = { + login: "用户登录", + logout: "用户登出", + submitJob: "提交作业", + endJob: "结束作业", + addJobTemplate: "保存作业模板", + deleteJobTemplate: "删除作业模板", + updateJobTemplate: "更新作业模板", + shellLogin: "SHELL登录", + createDesktop: "新建桌面", + deleteDesktop: "删除桌面", + createApp: "创建应用", + createFile: "新建文件", + deleteFile: "删除文件", + uploadFile: "上传文件", + createDirectory: "新建文件夹", + deleteDirectory: "删除文件夹", + moveFileItem: "移动文件/文件夹", + copyFileItem: "复制文件/文件夹", + setJobTimeLimit: "设置作业时限", + createUser: "创建用户", + addUserToAccount: "添加用户至账户", + removeUserFromAccount: "从账户移出用户", + setAccountAdmin: "设置账户管理员", + unsetAccountAdmin: "取消账户管理员", + blockUser: "封锁用户", + unblockUser: "解封用户", + accountSetChargeLimit: "账户设置限额", + accountUnsetChargeLimit: "账户取消设置限额", + setTenantBilling: "设置作业租户计费", + setTenantAdmin: "设置租户管理员", + unsetTenantAdmin: "取消租户管理员", + setTenantFinance: "设置租户财务人员", + unsetTenantFinance: "取消租户财务人员", + tenantChangePassword: "租户重置用户密码", + createAccount: "创建账户", + addAccountToWhitelist: "添加白名单账户", + removeAccountFromWhitelist: "移出白名单", + accountPay: "账户充值", + importUsers: "导入用户", + setPlatformAdmin: "设置平台管理员", + unsetPlatformAdmin: "取消平台管理员", + setPlatformFinance: "设置平台财务人员", + unsetPlatformFinance: "取消平台财务人员", + platformChangePassword: "平台重置用户密码", + setPlatformBilling: "设置平台作业计费", + createTenant: "创建租户", + tenantPay: "租户充值", +}; + +export const OperationCodeMap: { [key in LibOperationType]: string } = { + login: "000001", + logout: "000002", + submitJob: "010101", + endJob: "010102", + addJobTemplate: "010103", + deleteJobTemplate: "010104", + updateJobTemplate: "010105", + shellLogin: "010201", + createDesktop: "010301", + deleteDesktop: "010302", + createApp: "010401", + createFile: "010501", + createDirectory: "010502", + uploadFile: "010503", + deleteFile: "010504", + deleteDirectory: "010505", + moveFileItem: "010506", + copyFileItem: "010507", + setJobTimeLimit: "010601", + createUser: "020201", + addUserToAccount: "020202", + removeUserFromAccount: "020203", + setAccountAdmin: "020204", + unsetAccountAdmin: "020205", + blockUser: "020206", + unblockUser: "020207", + accountSetChargeLimit: "020208", + accountUnsetChargeLimit: "020209", + setTenantBilling: "030101", + setTenantAdmin: "030202", + unsetTenantAdmin: "030203", + setTenantFinance: "030204", + unsetTenantFinance: "030205", + tenantChangePassword: "030206", + createAccount: "030301", + addAccountToWhitelist: "030302", + removeAccountFromWhitelist: "030303", + accountPay: "030304", + importUsers: "040101", + setPlatformAdmin: "040201", + unsetPlatformAdmin: "040202", + setPlatformFinance: "040203", + unsetPlatformFinance: "040204", + platformChangePassword: "040205", + setPlatformBilling: "040206", + createTenant: "040301", + tenantPay: "040302", +}; + +export const getOperationDetail = (operationLog: OperationLogProto) => { + + try { + const { operationEvent } = operationLog; + if (!operationEvent) { + return ""; + } + const logEvent = operationEvent.$case; + const logPayload = operationEvent[logEvent]; + switch (logEvent) { + case "login": + return "用户登录"; + case "logout": + return "用户退出登录"; + case "submitJob": + return `在账户${logPayload.accountName}下提交作业(ID: ${logPayload.jobId})`; + case "endJob": + return `结束作业(ID: ${logPayload.jobId})`; + case "addJobTemplate": + return `保存作业模板(模板名: ${logPayload.jobTemplateId})`; + case "deleteJobTemplate": + return `删除作业模板(模板名:${logPayload.jobTemplateId})`; + case "updateJobTemplate": + return `更新作业模板(旧模板名:${logPayload.jobTemplateId},新模板名:${logPayload.newJobTemplateId})`; + case "shellLogin": + return `登录${logPayload.clusterId}集群的${logPayload.loginNode}节点`; + case "createDesktop": + return `新建桌面(桌面名:${logPayload.desktopName}, 桌面类型: ${logPayload.wm})`; + case "deleteDesktop": + return `删除桌面(桌面ID: ${logPayload.loginNode}:${logPayload.desktopId})`; + case "createApp": + return `在账户${logPayload.accountName}下创建应用(ID: ${logPayload.jobId})`; + case "createFile": + return `新建文件:${logPayload.path}`; + case "deleteFile": + return `删除文件:${logPayload.path}`; + case "uploadFile": + return `上传文件:${logPayload.path}`; + case "createDirectory": + return `新建文件夹:${logPayload.path}`; + case "deleteDirectory": + return `删除文件夹:${logPayload.path}`; + case "moveFileItem": + return `移动文件/文件夹:${logPayload.fromPath}至${logPayload.toPath}`; + case "copyFileItem": + return `复制文件/文件夹:${logPayload.fromPath}至${logPayload.toPath}`; + case "setJobTimeLimit": + return `设置作业(ID: ${logPayload.jobId})时限 ${Math.abs(logPayload.limit_minutes)} 分钟`; + case "createUser": + return `创建用户${logPayload.userId}`; + case "addUserToAccount": + return `将用户${logPayload.userId}添加到账户${logPayload.accountName}中`; + case "removeUserFromAccount": + return `将用户${logPayload.userId}从账户${logPayload.accountName}中移除`; + case "setAccountAdmin": + return `设置用户${logPayload.userId}为账户${logPayload.accountName}的管理员`; + case "unsetAccountAdmin": + return `取消用户${logPayload.userId}为账户${logPayload.accountName}的管理员`; + case "blockUser": + return `在账户${logPayload.accountName}中封锁用户${logPayload.userId}`; + case "unblockUser": + return `在账户${logPayload.accountName}中解封用户${logPayload.userId}`; + case "accountSetChargeLimit": + return `在账户${logPayload.accountName}中设置用户${logPayload.userId}限额为${moneyToString(logPayload.limit)}元`; + case "accountUnsetChargeLimit": + return `在账户${logPayload.accountName}中取消用户${logPayload.userId}限额`; + case "setTenantBilling": + return `设置租户${logPayload.tenantName}的计费项${logPayload.path}价格为${moneyToString(logPayload.price)}元`; + case "setTenantAdmin": + return `设置用户${logPayload.userId}为租户${logPayload.tenantName}的管理员`; + case "unsetTenantAdmin": + return `取消用户${logPayload.userId}为租户${logPayload.tenantName}的管理员`; + case "setTenantFinance": + return `设置用户${logPayload.userId}为租户${logPayload.tenantName}的财务人员`; + case "unsetTenantFinance": + return `取消用户${logPayload.userId}为租户${logPayload.tenantName}的财务人员`; + case "tenantChangePassword": + return `重置用户${logPayload.userId}的登录密码`; + case "createAccount": + return `创建账户${logPayload.accountName}, 拥有者为${logPayload.accountOwner}`; + case "addAccountToWhitelist": + return `将账户${logPayload.accountName}添加到租户${logPayload.tenantName}的白名单中`; + case "removeAccountFromWhitelist": + return `将账户${logPayload.accountName}从租户${logPayload.tenantName}的白名单中移出`; + case "accountPay": + return `为账户${logPayload.accountName}充值${moneyToString(logPayload.amount)}元`; + case "importUsers": + return `给租户${logPayload.tenantName}导入用户, ${logPayload.importAccounts.map( + (account: { accountName: string; userIds: string[];}) => + (`在账户${account.accountName}下导入用户${account.userIds.join("、")}`), + ).join(", ")}`; + case "setPlatformAdmin": + return `设置用户${logPayload.userId}为平台管理员`; + case "unsetPlatformAdmin": + return `取消用户${logPayload.userId}为平台管理员`; + case "setPlatformFinance": + return `设置用户${logPayload.userId}为平台财务人员`; + case "unsetPlatformFinance": + return `取消用户${logPayload.userId}为平台财务人员`; + case "platformChangePassword": + return `重置用户${logPayload.userId}的登录密码`; + case "createTenant": + return `创建租户${logPayload.tenantName}, 租户管理员为: ${logPayload.tenantAdmin}`; + case "tenantPay": + return `为租户${logPayload.tenantName}充值${moneyToString(logPayload.amount)}`; + case "setPlatformBilling": + return `设置平台的计费项${logPayload.path}价格为${moneyToString(logPayload.price)}元`; + default: + return "-"; + } + } catch (e) { + return "-"; + } +}; diff --git a/apps/mis-web/src/pages/accounts/[accountName]/operationLogs.tsx b/apps/mis-web/src/pages/accounts/[accountName]/operationLogs.tsx new file mode 100644 index 0000000000..733d4ecf9f --- /dev/null +++ b/apps/mis-web/src/pages/accounts/[accountName]/operationLogs.tsx @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { NextPage } from "next"; +import { requireAuth } from "src/auth/requireAuth"; +import { OperationLogTable } from "src/components/OperationLogTable"; +import { PageTitle } from "src/components/PageTitle"; +import { OperationLogQueryType } from "src/models/operationLog"; +import { + checkQueryAccountNameIsAdmin, + useAccountPagesAccountName } from "src/pageComponents/accounts/checkQueryAccountNameIsAdmin"; +import { Head } from "src/utils/head"; + +export const OperationLogPage: NextPage = requireAuth( + (u) => u.accountAffiliations.length > 0, + checkQueryAccountNameIsAdmin)( + ({ userStore }) => { + const accountName = useAccountPagesAccountName(); + + const title = `账户${accountName}操作日志`; + return ( +
+ + + +
+ ); + + }); + +export default OperationLogPage; diff --git a/apps/mis-web/src/pages/admin/operationLogs.tsx b/apps/mis-web/src/pages/admin/operationLogs.tsx new file mode 100644 index 0000000000..664c1fafe7 --- /dev/null +++ b/apps/mis-web/src/pages/admin/operationLogs.tsx @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { NextPage } from "next"; +import { requireAuth } from "src/auth/requireAuth"; +import { OperationLogTable } from "src/components/OperationLogTable"; +import { PageTitle } from "src/components/PageTitle"; +import { OperationLogQueryType } from "src/models/operationLog"; +import { PlatformRole } from "src/models/User"; +import { Head } from "src/utils/head"; + +export const OperationLogPage: NextPage = requireAuth( + (u) => u.platformRoles.includes(PlatformRole.PLATFORM_ADMIN), +)( + ({ userStore }) => { + const title = "平台操作日志"; + return ( +
+ + + +
+ ); + + }); + +export default OperationLogPage; diff --git a/apps/mis-web/src/pages/api/admin/changePassword.ts b/apps/mis-web/src/pages/api/admin/changePassword.ts index a346a48627..99f4173b40 100644 --- a/apps/mis-web/src/pages/api/admin/changePassword.ts +++ b/apps/mis-web/src/pages/api/admin/changePassword.ts @@ -14,8 +14,11 @@ import { typeboxRoute, typeboxRouteSchema } from "@ddadaal/next-typed-api-routes import { changePassword as libChangePassword } from "@scow/lib-auth"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { PlatformRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { publicConfig, runtimeConfig } from "src/utils/config"; +import { parseIp } from "src/utils/server"; // 此API用于账户管理员修改其他任意用户的密码。 // 没有权限返回undefined @@ -61,8 +64,23 @@ export default /* #__PURE__*/typeboxRoute( const { identityId, newPassword } = req.body; + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.platformChangePassword, + operationTypePayload:{ + userId: identityId, + }, + }; + return await libChangePassword(runtimeConfig.AUTH_INTERNAL_URL, { identityId, newPassword }, console) - .then(() => ({ 204: null })) - .catch((e) => ({ [e.status]: null })); + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }) + .catch(async (e) => { + await callLog(logInfo, OperationResult.FAIL); + return { [e.status]: null }; + }); }); diff --git a/apps/mis-web/src/pages/api/admin/finance/pay.ts b/apps/mis-web/src/pages/api/admin/finance/pay.ts index 5c33d8ae23..32f1b21b3e 100644 --- a/apps/mis-web/src/pages/api/admin/finance/pay.ts +++ b/apps/mis-web/src/pages/api/admin/finance/pay.ts @@ -17,7 +17,9 @@ import { moneyToNumber, numberToMoney } from "@scow/lib-decimal"; import { ChargingServiceClient } from "@scow/protos/build/server/charging"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { PlatformRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { ensureNotUndefined } from "src/utils/checkNull"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; @@ -44,8 +46,8 @@ export const TenantFinancePaySchema = typeboxRouteSchema({ }, }); -const auth = authenticate((info) => - info.platformRoles.includes(PlatformRole.PLATFORM_FINANCE) || +const auth = authenticate((info) => + info.platformRoles.includes(PlatformRole.PLATFORM_FINANCE) || info.platformRoles.includes(PlatformRole.PLATFORM_ADMIN)); export default route(TenantFinancePaySchema, @@ -55,19 +57,32 @@ export default route(TenantFinancePaySchema, const client = getClient(ChargingServiceClient); + const { tenantName, comment, amount, type } = req.body; + + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.tenantPay, + operationTypePayload:{ + tenantName: info.tenant, + amount: numberToMoney(amount), + }, + }; return await asyncClientCall(client, "pay", { - tenantName: req.body.tenantName, - comment: req.body.comment ?? "", - amount: numberToMoney(req.body.amount), + tenantName: tenantName, + comment: comment ?? "", + amount: numberToMoney(amount), operatorId: info.identityId, ipAddress: parseIp(req) ?? "", - type: req.body.type, - }).then((reply) => { + type: type, + }).then(async (reply) => { const replyObj = ensureNotUndefined(reply, ["currentBalance"]); - + await callLog(logInfo, OperationResult.SUCCESS); return { 200: { balance: moneyToNumber(replyObj.currentBalance) } }; }).catch(handlegRPCError({ [Status.NOT_FOUND]: () => ({ 404: null }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }, ); diff --git a/apps/mis-web/src/pages/api/admin/importUsers.ts b/apps/mis-web/src/pages/api/admin/importUsers.ts index 7325f7a571..abaad53392 100644 --- a/apps/mis-web/src/pages/api/admin/importUsers.ts +++ b/apps/mis-web/src/pages/api/admin/importUsers.ts @@ -16,11 +16,14 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { AdminServiceClient } from "@scow/protos/build/server/admin"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { PlatformRole } from "src/models/User"; import { ImportUsersData } from "src/models/UserSchemaModel"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; +import { DEFAULT_INIT_USER_ID, DEFAULT_TENANT_NAME } from "src/utils/constants"; import { queryIfInitialized } from "src/utils/init"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const ImportUsersSchema = typeboxRouteSchema({ method: "POST", @@ -41,21 +44,43 @@ const auth = authenticate((info) => info.platformRoles.includes(PlatformRole.PLA export default typeboxRoute(ImportUsersSchema, async (req, res) => { + const { data, whitelist } = req.body; + + const logInfo = { + operatorUserId: DEFAULT_INIT_USER_ID, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.importUsers, + operationTypePayload: { + tenantName: DEFAULT_TENANT_NAME, + importAccounts: data.accounts.map((account) => ({ + accountName: account.accountName, + userIds: account.users.map((user) => user.userId), + })), + }, + }; + // if not initialized, every one can import users if (await queryIfInitialized()) { const info = await auth(req, res); - if (!info) { return; } + if (info) { + logInfo.operatorUserId = info.identityId; + } else { + return; + } } - const { data, whitelist } = req.body; - const client = getClient(AdminServiceClient); return await asyncClientCall(client, "importUsers", { data, whitelist, }) - .then(() => ({ 204: null })) + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }) .catch(handlegRPCError({ [Status.INVALID_ARGUMENT]: () => ({ 400: { code: "INVALID_DATA" } } as const), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/admin/setPlatformRole.ts b/apps/mis-web/src/pages/api/admin/setPlatformRole.ts index 14c27d858d..b1fe4d3965 100644 --- a/apps/mis-web/src/pages/api/admin/setPlatformRole.ts +++ b/apps/mis-web/src/pages/api/admin/setPlatformRole.ts @@ -16,10 +16,13 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { UserServiceClient } from "@scow/protos/build/server/user"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { PlatformRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; +import { DEFAULT_INIT_USER_ID } from "src/utils/constants"; import { queryIfInitialized } from "src/utils/init"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const SetPlatformRoleSchema = typeboxRouteSchema({ @@ -41,22 +44,43 @@ export const SetPlatformRoleSchema = typeboxRouteSchema({ export default typeboxRoute(SetPlatformRoleSchema, async (req, res) => { const { userId, roleType } = req.body; + const logInfo = { + operatorUserId: DEFAULT_INIT_USER_ID, + operatorIp: parseIp(req) ?? "", + operationTypeName: roleType === PlatformRole.PLATFORM_ADMIN + ? OperationType.setPlatformAdmin + : OperationType.setPlatformFinance, + operationTypePayload:{ + userId, + }, + }; + if (await queryIfInitialized()) { const auth = authenticate((u) => u.platformRoles.includes(PlatformRole.PLATFORM_ADMIN)); const info = await auth(req, res); - if (!info) { return; } + if (info) { + logInfo.operatorUserId = info.identityId; + } else { + return; + } } + const client = getClient(UserServiceClient); return await asyncClientCall(client, "setPlatformRole", { userId, roleType, }) - .then(() => ({ 200: { executed: true } })) + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 200: { executed: true } }; + }) .catch(handlegRPCError({ [Status.NOT_FOUND]: () => ({ 404: null }), [Status.FAILED_PRECONDITION]: () => ({ 200: { executed: false } }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/admin/setTenantRole.ts b/apps/mis-web/src/pages/api/admin/setTenantRole.ts index 1c2598d908..4bcec56d5b 100644 --- a/apps/mis-web/src/pages/api/admin/setTenantRole.ts +++ b/apps/mis-web/src/pages/api/admin/setTenantRole.ts @@ -16,10 +16,13 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { UserServiceClient } from "@scow/protos/build/server/user"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { TenantRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; +import { DEFAULT_INIT_USER_ID, DEFAULT_TENANT_NAME } from "src/utils/constants"; import { queryIfInitialized } from "src/utils/init"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const SetTenantRoleSchema = typeboxRouteSchema({ @@ -41,23 +44,44 @@ export const SetTenantRoleSchema = typeboxRouteSchema({ export default typeboxRoute(SetTenantRoleSchema, async (req, res) => { const { userId, roleType } = req.body; + const logInfo = { + operatorUserId: DEFAULT_INIT_USER_ID, + operatorIp: parseIp(req) ?? "", + operationTypeName: roleType === TenantRole.TENANT_ADMIN + ? OperationType.setTenantAdmin + : OperationType.setTenantFinance, + operationTypePayload:{ + tenantName: DEFAULT_TENANT_NAME, userId, + }, + }; + if (await queryIfInitialized()) { - const auth = authenticate((u) => - u.tenantRoles.includes(TenantRole.TENANT_ADMIN)); + const auth = authenticate((u) => u.tenantRoles.includes(TenantRole.TENANT_ADMIN)); const info = await auth(req, res); - if (!info) { return; } + if (info) { + logInfo.operatorUserId = info.identityId; + logInfo.operationTypePayload.tenantName = info.tenant; + } else { + return; + } } + const client = getClient(UserServiceClient); return await asyncClientCall(client, "setTenantRole", { userId, roleType, }) - .then(() => ({ 200: { executed: true } })) + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 200: { executed: true } }; + }) .catch(handlegRPCError({ [Status.NOT_FOUND]: () => ({ 404: null }), [Status.FAILED_PRECONDITION]: () => ({ 200: { executed: false } }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/admin/unsetPlatformRole.ts b/apps/mis-web/src/pages/api/admin/unsetPlatformRole.ts index 2f6d490852..44b0336fcb 100644 --- a/apps/mis-web/src/pages/api/admin/unsetPlatformRole.ts +++ b/apps/mis-web/src/pages/api/admin/unsetPlatformRole.ts @@ -16,10 +16,13 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { UserServiceClient } from "@scow/protos/build/server/user"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { PlatformRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; +import { DEFAULT_INIT_USER_ID } from "src/utils/constants"; import { queryIfInitialized } from "src/utils/init"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const UnsetPlatformRoleSchema = typeboxRouteSchema({ @@ -41,12 +44,27 @@ export const UnsetPlatformRoleSchema = typeboxRouteSchema({ export default typeboxRoute(UnsetPlatformRoleSchema, async (req, res) => { const { userId, roleType } = req.body; + const logInfo = { + operatorUserId: DEFAULT_INIT_USER_ID, + operatorIp: parseIp(req) ?? "", + operationTypeName: roleType === PlatformRole.PLATFORM_ADMIN + ? OperationType.unsetPlatformAdmin + : OperationType.unsetPlatformFinance, + operationTypePayload:{ + userId, + }, + }; + if (await queryIfInitialized()) { const auth = authenticate((u) => u.platformRoles.includes(PlatformRole.PLATFORM_ADMIN) && !(u.identityId === userId && roleType === PlatformRole.PLATFORM_ADMIN)); const info = await auth(req, res); - if (!info) { return; } + if (info) { + logInfo.operatorUserId = info.identityId; + } else { + return; + } } @@ -56,9 +74,14 @@ export default typeboxRoute(UnsetPlatformRoleSchema, async (req, res) => { userId, roleType, }) - .then(() => ({ 200: { executed: true } })) + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 200: { executed: true } }; + }) .catch(handlegRPCError({ [Status.NOT_FOUND]: () => ({ 404: null }), [Status.FAILED_PRECONDITION]: () => ({ 200: { executed: false } }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/admin/unsetTenantRole.ts b/apps/mis-web/src/pages/api/admin/unsetTenantRole.ts index beb789b0d1..14a4397dda 100644 --- a/apps/mis-web/src/pages/api/admin/unsetTenantRole.ts +++ b/apps/mis-web/src/pages/api/admin/unsetTenantRole.ts @@ -16,10 +16,13 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { UserServiceClient } from "@scow/protos/build/server/user"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { TenantRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; +import { DEFAULT_INIT_USER_ID, DEFAULT_TENANT_NAME } from "src/utils/constants"; import { queryIfInitialized } from "src/utils/init"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const UnsetTenantRoleSchema = typeboxRouteSchema({ @@ -41,24 +44,42 @@ export const UnsetTenantRoleSchema = typeboxRouteSchema({ export default typeboxRoute(UnsetTenantRoleSchema, async (req, res) => { const { userId, roleType } = req.body; + const logInfo = { + operatorUserId: DEFAULT_INIT_USER_ID, + operatorIp: parseIp(req) ?? "", + operationTypeName: roleType === TenantRole.TENANT_ADMIN + ? OperationType.unsetTenantAdmin + : OperationType.unsetTenantFinance, + operationTypePayload:{ + tenantName: DEFAULT_TENANT_NAME, userId, + }, + }; + if (await queryIfInitialized()) { const auth = authenticate((u) => u.tenantRoles.includes(TenantRole.TENANT_ADMIN) && !(u.identityId === userId && roleType === TenantRole.TENANT_ADMIN)); const info = await auth(req, res); - if (!info) { return; } + if (info) { + logInfo.operatorUserId = info.identityId; + logInfo.operationTypePayload.tenantName = info.tenant; + } else { return; } } - const client = getClient(UserServiceClient); return await asyncClientCall(client, "unsetTenantRole", { userId, roleType, }) - .then(() => ({ 200: { executed: true } })) + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 200: { executed: true } }; + }) .catch(handlegRPCError({ [Status.NOT_FOUND]: () => ({ 404: null }), [Status.FAILED_PRECONDITION]: () => ({ 200: { executed: false } }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/auth/callback.ts b/apps/mis-web/src/pages/api/auth/callback.ts index 81f0738a3b..2b100319b2 100644 --- a/apps/mis-web/src/pages/api/auth/callback.ts +++ b/apps/mis-web/src/pages/api/auth/callback.ts @@ -13,7 +13,10 @@ import { Type, typeboxRoute, typeboxRouteSchema } from "@ddadaal/next-typed-api-routes-runtime"; import { setTokenCookie } from "src/auth/cookie"; import { validateToken } from "src/auth/token"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { publicConfig } from "src/utils/config"; +import { parseIp } from "src/utils/server"; export const AuthCallbackSchema = typeboxRouteSchema({ method: "GET", @@ -34,13 +37,20 @@ export default typeboxRoute(AuthCallbackSchema, async (req, res) => { const { token } = req.query; - if (await validateToken(token)) { + const info = await validateToken(token); + + if (info) { // set token cache setTokenCookie({ res }, token); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.login, + }; + await callLog(logInfo, OperationResult.SUCCESS); res.redirect(publicConfig.BASE_PATH); } else { return { 403: null }; - } }); diff --git a/apps/mis-web/src/pages/api/auth/logout.ts b/apps/mis-web/src/pages/api/auth/logout.ts index c0fa112d73..c1db0fa23d 100644 --- a/apps/mis-web/src/pages/api/auth/logout.ts +++ b/apps/mis-web/src/pages/api/auth/logout.ts @@ -14,7 +14,11 @@ import { typeboxRoute, typeboxRouteSchema } from "@ddadaal/next-typed-api-routes import { deleteToken } from "@scow/lib-auth"; import { Type } from "@sinclair/typebox"; import { getTokenFromCookie } from "src/auth/cookie"; +import { validateToken } from "src/auth/token"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { runtimeConfig } from "src/utils/config"; +import { parseIp } from "src/utils/server"; export const LogoutSchema = typeboxRouteSchema({ method: "DELETE", @@ -30,6 +34,15 @@ export default typeboxRoute(LogoutSchema, async (req) => { const token = getTokenFromCookie({ req }); if (token) { + const info = await validateToken(token); + if (info) { + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.logout, + }; + await callLog(logInfo, OperationResult.SUCCESS); + } await deleteToken(token, runtimeConfig.AUTH_INTERNAL_URL); } return { 204: null }; diff --git a/apps/mis-web/src/pages/api/finance/pay.ts b/apps/mis-web/src/pages/api/finance/pay.ts index 647cff8afc..cf5a61830d 100644 --- a/apps/mis-web/src/pages/api/finance/pay.ts +++ b/apps/mis-web/src/pages/api/finance/pay.ts @@ -17,7 +17,9 @@ import { moneyToNumber, numberToMoney } from "@scow/lib-decimal"; import { ChargingServiceClient } from "@scow/protos/build/server/charging"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { TenantRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { ensureNotUndefined } from "src/utils/checkNull"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; @@ -43,7 +45,7 @@ export const FinancePaySchema = typeboxRouteSchema({ }, }); -const auth = authenticate((info) => info.tenantRoles.includes(TenantRole.TENANT_FINANCE) || +const auth = authenticate((info) => info.tenantRoles.includes(TenantRole.TENANT_FINANCE) || info.tenantRoles.includes(TenantRole.TENANT_ADMIN)); export default route(FinancePaySchema, @@ -52,22 +54,37 @@ export default route(FinancePaySchema, const info = await auth(req, res); if (!info) { return; } + const { accountName, comment, amount, type } = req.body; + + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.accountPay, + operationTypePayload:{ + tenantName: info.tenant, + accountName, + amount: numberToMoney(amount), + }, + }; + const client = getClient(ChargingServiceClient); return await asyncClientCall(client, "pay", { - accountName: req.body.accountName, + accountName: accountName, tenantName: info.tenant, - comment: req.body.comment ?? "", - amount: numberToMoney(req.body.amount), + comment: comment ?? "", + amount: numberToMoney(amount), operatorId: info.identityId, ipAddress: parseIp(req) ?? "", - type: req.body.type, - }).then((reply) => { + type: type, + }).then(async (reply) => { const replyObj = ensureNotUndefined(reply, ["currentBalance"]); - + await callLog(logInfo, OperationResult.SUCCESS); return { 200: { balance: moneyToNumber(replyObj.currentBalance) } }; }).catch(handlegRPCError({ [Status.NOT_FOUND]: () => ({ 404: null }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/job/addBillingItem.ts b/apps/mis-web/src/pages/api/job/addBillingItem.ts index c6ca635892..0d9395012d 100644 --- a/apps/mis-web/src/pages/api/job/addBillingItem.ts +++ b/apps/mis-web/src/pages/api/job/addBillingItem.ts @@ -17,12 +17,15 @@ import { JobServiceClient } from "@scow/protos/build/server/job"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; import { AmountStrategy } from "src/models/job"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { PlatformRole, TenantRole } from "src/models/User"; import { Money } from "src/models/UserSchemaModel"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { publicConfig } from "src/utils/config"; +import { DEFAULT_INIT_USER_ID } from "src/utils/constants"; import { queryIfInitialized } from "src/utils/init"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const AddBillingItemSchema = typeboxRouteSchema({ method: "POST", @@ -49,6 +52,15 @@ export const AddBillingItemSchema = typeboxRouteSchema({ export default /* #__PURE__*/typeboxRoute(AddBillingItemSchema, async (req, res) => { const { tenant, amount, itemId, path, price, description } = req.body; + const logInfo = { + operatorUserId: DEFAULT_INIT_USER_ID, + operatorIp: parseIp(req) ?? "", + operationTypeName: tenant ? OperationType.setTenantBilling : OperationType.setPlatformBilling, + operationTypePayload:{ + tenantName: tenant, path, amount, price, + }, + }; + if (await queryIfInitialized()) { // Platform admin can add to every tenant // only tenant admin can add to its own tenant @@ -58,7 +70,11 @@ export default /* #__PURE__*/typeboxRoute(AddBillingItemSchema, async (req, res) || (u.tenant === tenant && u.tenantRoles.includes(TenantRole.TENANT_ADMIN)), ); const info = await auth(req, res); - if (!info) { return; } + if (info) { + logInfo.operatorUserId = info.identityId; + } else { + return; + } } const customAmountStrategies = publicConfig.CUSTOM_AMOUNT_STRATEGIES?.map((i) => i.id) || []; @@ -71,9 +87,14 @@ export default /* #__PURE__*/typeboxRoute(AddBillingItemSchema, async (req, res) return await asyncClientCall(client, "addBillingItem", { tenantName: tenant, amountStrategy: amount, itemId, path, description, price, }) - .then(() => ({ 204: null })) + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }) .catch(handlegRPCError({ [status.ALREADY_EXISTS]: () => ({ 409: { code: "ITEM_ID_EXISTS" } } as const), [status.NOT_FOUND]: () => ({ 404: { code: "TENANT_NOT_FOUND" } } as const), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/job/changeJobTimeLimit.ts b/apps/mis-web/src/pages/api/job/changeJobTimeLimit.ts index 14b6f36cff..1bf36c6560 100644 --- a/apps/mis-web/src/pages/api/job/changeJobTimeLimit.ts +++ b/apps/mis-web/src/pages/api/job/changeJobTimeLimit.ts @@ -16,9 +16,11 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { JobServiceClient } from "@scow/protos/build/server/job"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { checkJobAccessible } from "src/server/jobAccessible"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export type ChangeMode = | "INCREASE" @@ -67,7 +69,7 @@ export default typeboxRoute(ChangeJobTimeLimitSchema, // check if the user can change the job time limit - const jobAccessible = await checkJobAccessible(jobId, cluster, info, limitMinutes); + const { job, jobAccessible } = await checkJobAccessible(jobId, cluster, info); if (jobAccessible === "NotAllowed") { return { 403: null }; @@ -82,13 +84,26 @@ export default typeboxRoute(ChangeJobTimeLimitSchema, }; } + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.setJobTimeLimit, + operationTypePayload:{ + jobId: +jobId, accountName: job.account, limitMinutes, + }, + }; return await asyncClientCall(client, "changeJobTimeLimit", { cluster, limitMinutes, jobId, }) - .then(() => ({ 204: null })) + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }) .catch(handlegRPCError({ [Status.NOT_FOUND]: () => ({ 404: null }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/job/queryJobTimeLimit.ts b/apps/mis-web/src/pages/api/job/queryJobTimeLimit.ts index 94442a3ec3..5397880b74 100644 --- a/apps/mis-web/src/pages/api/job/queryJobTimeLimit.ts +++ b/apps/mis-web/src/pages/api/job/queryJobTimeLimit.ts @@ -54,7 +54,7 @@ export default typeboxRoute(QueryJobTimeLimitSchema, const { cluster, jobId } = req.query; - const jobAccessible = await checkJobAccessible(jobId, cluster, info); + const { jobAccessible } = await checkJobAccessible(jobId, cluster, info); if (jobAccessible === "NotAllowed") { return { 403: null }; diff --git a/apps/mis-web/src/pages/api/log/getOperationLog.ts b/apps/mis-web/src/pages/api/log/getOperationLog.ts new file mode 100644 index 0000000000..265ef178f7 --- /dev/null +++ b/apps/mis-web/src/pages/api/log/getOperationLog.ts @@ -0,0 +1,159 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { typeboxRoute, typeboxRouteSchema } from "@ddadaal/next-typed-api-routes-runtime"; +import { asyncClientCall } from "@ddadaal/tsgrpc-client"; +import { createOperationLogClient } from "@scow/lib-operation-log/build/index"; +import { UserServiceClient } from "@scow/protos/build/server/user"; +import { Static, Type } from "@sinclair/typebox"; +import { authenticate } from "src/auth/server"; +import { getOperationDetail, OperationCodeMap, OperationLog, OperationLogQueryType, + OperationResult, OperationType } from "src/models/operationLog"; +import { PlatformRole, TenantRole, UserRole } from "src/models/User"; +import { getClient } from "src/utils/client"; +import { runtimeConfig } from "src/utils/config"; + + + +export const GetOperationLogFilter = Type.Object({ + + operatorUserIds: Type.String(), + + /** + * @format date-time + */ + startTime: Type.Optional(Type.String({ format: "date-time" })), + + /** + * @format date-time + */ + endTime: Type.Optional(Type.String({ format: "date-time" })), + + operationType: Type.Optional(Type.Enum(OperationType)), + operationResult: Type.Optional(Type.Enum(OperationResult)), + + operationTargetAccountName: Type.Optional(Type.String()), +}); + +export type GetOperationLogFilter = Static; + + +export const GetOperationLogsSchema = typeboxRouteSchema({ + + method: "GET", + + query: Type.Object({ + + type: Type.Enum(OperationLogQueryType), + + ...GetOperationLogFilter.properties, + /** + * @minimum 1 + * @type integer + */ + page: Type.Integer({ minimum: 1 }), + + /** + * @type integer + */ + pageSize: Type.Optional(Type.Integer()), + }), + + responses: { + 200: Type.Object({ + results: Type.Array(OperationLog), + totalCount: Type.Number(), + }), + + + 403: Type.Null(), + }, +}); + +export default typeboxRoute(GetOperationLogsSchema, async (req, res) => { + const auth = authenticate(() => true); + + const info = await auth(req, res); + + if (!info) { return; } + + const { + type, operatorUserIds, startTime, endTime, + operationType, operationResult, operationTargetAccountName, page, pageSize } = req.query; + + const filter = { + operatorUserIds: operatorUserIds ? operatorUserIds.split(",") : [], + startTime, endTime, operationType, + operationResult, operationTargetAccountName, + }; + // 用户请求 + if (type === OperationLogQueryType.USER) { + filter.operatorUserIds = [info.identityId]; + } + + if (type === OperationLogQueryType.ACCOUNT) { + if (!filter.operationTargetAccountName) { + return { 400: null }; + } + + // 确认用户是账户管理员或者拥有者 + if ( + !info.accountAffiliations + .find((au) => au.accountName === filter.operationTargetAccountName + && (au.role === UserRole.ADMIN || au.role === UserRole.OWNER)) + ) { + return { 403: null }; + } + }; + + if (type === OperationLogQueryType.TENANT) { + if (!info.tenantRoles.includes(TenantRole.TENANT_ADMIN)) { + return { 403: null }; + } + // 查看该租户下所有用户的操作日志 + const client = getClient(UserServiceClient); + const { users } = await asyncClientCall(client, "getUsers", { + tenantName: info.tenant, + }); + + // 搜索条件中的userId必须是属于该tenant的 + filter.operatorUserIds = filter.operatorUserIds.length === 0 + ? users.map((u) => u.userId) + : filter.operatorUserIds.filter((id) => users.find((u) => u.userId === id)); + }; + + if (type === OperationLogQueryType.PLATFORM) { + if (!info.platformRoles.includes(PlatformRole.PLATFORM_ADMIN)) { + return { 403: null }; + } + } + const { getLog } = createOperationLogClient(runtimeConfig.AUDIT_CONFIG, console); + const resp = await getLog({ filter, page, pageSize }); + + const { results, totalCount } = resp; + + const operationLogs = results.map((x) => { + return { + operationLogId: x.operationLogId, + operatorUserId: x.operatorUserId, + operatorIp: x.operatorIp, + operationResult: x.operationResult, + operationTime: x.operationTime, + operationType: x.operationEvent?.["$case"] || "unknown", + operationCode: x.operationEvent?.["$case"] ? OperationCodeMap[x.operationEvent?.["$case"]] : "000000", + operationDetail: getOperationDetail(x), + }; + }); + return { + 200: { results: operationLogs as OperationLog[], totalCount }, + }; +}); diff --git a/apps/mis-web/src/pages/api/tenant/accountWhitelist/dewhitelistAccount.ts b/apps/mis-web/src/pages/api/tenant/accountWhitelist/dewhitelistAccount.ts index 5b6c95ea51..f6e6e15ec5 100644 --- a/apps/mis-web/src/pages/api/tenant/accountWhitelist/dewhitelistAccount.ts +++ b/apps/mis-web/src/pages/api/tenant/accountWhitelist/dewhitelistAccount.ts @@ -16,10 +16,12 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { AccountServiceClient } from "@scow/protos/build/server/account"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { TenantRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const DewhitelistAccountSchema = typeboxRouteSchema({ method: "DELETE", @@ -44,14 +46,29 @@ export default route(DewhitelistAccountSchema, const { accountName } = req.query; + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.removeAccountFromWhitelist, + operationTypePayload:{ + tenantName: info.tenant, accountName, + }, + }; + + const client = getClient(AccountServiceClient); return await asyncClientCall(client, "dewhitelistAccount", { tenantName: info.tenant, accountName, }) - .then(() => ({ 204: null })) + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }) .catch(handlegRPCError({ [Status.NOT_FOUND]: () => ({ 404: null }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/tenant/accountWhitelist/whitelistAccount.ts b/apps/mis-web/src/pages/api/tenant/accountWhitelist/whitelistAccount.ts index 91ec91f800..2f6295c01b 100644 --- a/apps/mis-web/src/pages/api/tenant/accountWhitelist/whitelistAccount.ts +++ b/apps/mis-web/src/pages/api/tenant/accountWhitelist/whitelistAccount.ts @@ -16,10 +16,12 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { AccountServiceClient } from "@scow/protos/build/server/account"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { TenantRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const WhitelistAccountSchema = typeboxRouteSchema({ method: "PUT", @@ -47,6 +49,15 @@ export default route(WhitelistAccountSchema, const { accountName, comment } = req.body; + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.addAccountToWhitelist, + operationTypePayload:{ + tenantName: info.tenant, accountName, + }, + }; + const client = getClient(AccountServiceClient); return await asyncClientCall(client, "whitelistAccount", { @@ -55,8 +66,13 @@ export default route(WhitelistAccountSchema, operatorId: info.identityId, comment, }) - .then(() => ({ 204: null })) + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }) .catch(handlegRPCError({ [Status.NOT_FOUND]: () => ({ 404: null }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/tenant/changePassword.ts b/apps/mis-web/src/pages/api/tenant/changePassword.ts index bd8a9d5cc6..7b36b3558b 100644 --- a/apps/mis-web/src/pages/api/tenant/changePassword.ts +++ b/apps/mis-web/src/pages/api/tenant/changePassword.ts @@ -16,9 +16,12 @@ import { changePassword as libChangePassword } from "@scow/lib-auth"; import { GetUserInfoResponse, UserServiceClient } from "@scow/protos/build/server/user"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { TenantRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { publicConfig, runtimeConfig } from "src/utils/config"; +import { parseIp } from "src/utils/server"; // 此API用于租户管理员修改自己租户的用户密码 // 没有权限返回undefined @@ -74,7 +77,22 @@ export default /* #__PURE__*/typeboxRoute( return; } + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.tenantChangePassword, + operationTypePayload:{ + tenantName: info.tenant, userId: identityId, + }, + }; + return await libChangePassword(runtimeConfig.AUTH_INTERNAL_URL, { identityId, newPassword }, console) - .then(() => ({ 204: null })) - .catch((e) => ({ [e.status]: null })); + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }) + .catch(async (e) => { + await callLog(logInfo, OperationResult.FAIL); + return { [e.status]: null }; + }); }); diff --git a/apps/mis-web/src/pages/api/tenant/create.ts b/apps/mis-web/src/pages/api/tenant/create.ts index e0a7a57831..d8768dae7b 100644 --- a/apps/mis-web/src/pages/api/tenant/create.ts +++ b/apps/mis-web/src/pages/api/tenant/create.ts @@ -16,11 +16,13 @@ import { status } from "@grpc/grpc-js"; import { TenantServiceClient } from "@scow/protos/build/server/tenant"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { PlatformRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { publicConfig } from "src/utils/config"; import { getUserIdRule } from "src/utils/createUser"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const CreateTenantSchema = typeboxRouteSchema({ method: "POST", @@ -82,9 +84,18 @@ export default /* #__PURE__*/typeboxRoute(CreateTenantSchema, async (req, res) = return { 400: { code: "PASSWORD_NOT_VALID" as const, message: publicConfig.PASSWORD_PATTERN_MESSAGE } }; } + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.createTenant, + operationTypePayload:{ + tenantName, tenantAdmin: userId, + }, + }; + + // create tenant on server const client = getClient(TenantServiceClient); - return await asyncClientCall(client, "createTenant", { tenantName: tenantName, userId: userId, @@ -92,7 +103,10 @@ export default /* #__PURE__*/typeboxRoute(CreateTenantSchema, async (req, res) = userEmail: userEmail, userPassword: userPassword, }) - .then((res) => ({ 200: { createdInAuth: res.createdInAuth } })) + .then(async (res) => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 200: { createdInAuth: res.createdInAuth } }; + }) .catch(handlegRPCError({ [status.ALREADY_EXISTS]: (e) => { return { @@ -104,5 +118,7 @@ export default /* #__PURE__*/typeboxRoute(CreateTenantSchema, async (req, res) = : { code: "USER_ALREADY_EXISTS" as const, message: `User with userId ${userId} already exists` }, }; }, - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/tenant/createAccount.ts b/apps/mis-web/src/pages/api/tenant/createAccount.ts index 8a014816fb..e488aa92e8 100644 --- a/apps/mis-web/src/pages/api/tenant/createAccount.ts +++ b/apps/mis-web/src/pages/api/tenant/createAccount.ts @@ -16,12 +16,14 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { AccountServiceClient } from "@scow/protos/build/server/account"; import { Static, Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { TenantRole } from "src/models/User"; import { checkNameMatch } from "src/server/checkIdNameMatch"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { publicConfig } from "src/utils/config"; import { route } from "src/utils/route"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; // Cannot use CreateAccountResponse from protos export const CreateAccountResponse = Type.Object({}); @@ -86,14 +88,28 @@ export default route(CreateAccountSchema, return { 400: { code: "ID_NAME_NOT_MATCH" as const } }; } + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.createAccount, + operationTypePayload:{ + tenantName: info.tenant, accountName, accountOwner: ownerId, + }, + }; + const client = getClient(AccountServiceClient); return await asyncClientCall(client, "createAccount", { accountName, ownerId, comment, tenantName: info.tenant, }) - .then((x) => ({ 200: x })) + .then(async (x) => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 200: x }; + }) .catch(handlegRPCError({ [Status.ALREADY_EXISTS]: () => ({ 409: null }), [Status.NOT_FOUND]: () => ({ 404: null }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/users/addToAccount.ts b/apps/mis-web/src/pages/api/users/addToAccount.ts index 7128c3b661..790fe054cc 100644 --- a/apps/mis-web/src/pages/api/users/addToAccount.ts +++ b/apps/mis-web/src/pages/api/users/addToAccount.ts @@ -16,10 +16,12 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { UserServiceClient } from "@scow/protos/build/server/user"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { PlatformRole, TenantRole, UserRole } from "src/models/User"; import { checkNameMatch } from "src/server/checkIdNameMatch"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const AddUserToAccountSchema = typeboxRouteSchema({ method: "POST", @@ -87,15 +89,27 @@ export default /* #__PURE__*/typeboxRoute(AddUserToAccountSchema, async (req, re // call ua service to add user const client = getClient(UserServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.addUserToAccount, + operationTypePayload:{ + accountName, userId: identityId, + }, + }; + return await asyncClientCall(client, "addUserToAccount", { tenantName: info.tenant, accountName, userId: identityId, - }).then(() => ({ 204: null })) + }).then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }) .catch(handlegRPCError({ [Status.ALREADY_EXISTS]: () => ({ 409: { code: "ACCOUNT_OR_USER_ERROR" as const, message:"用户已经存在于此账户中!" } }), [Status.INTERNAL]: (e) => { return ({ 409: { code: "ACCOUNT_OR_USER_ERROR" as const, message: e.details } }); }, - [Status.NOT_FOUND]: (e) => { + [Status.NOT_FOUND]: (e) => { if (e.details === "USER_OR_TENANT_NOT_FOUND") { @@ -103,14 +117,15 @@ export default /* #__PURE__*/typeboxRoute(AddUserToAccountSchema, async (req, re * 后端接口addUserToAccount返回USER_OR_TENANT_NOT_FOUND * 说明操作者的租户下的不存在要添加的这个用户 * 该用户存不存在于scow系统中在上面的checkNameMatch函数中已通过检查 - * */ + * */ return { 404: { code: "USER_ALREADY_EXIST_IN_OTHER_TENANT" as const } }; } else { - - return { 404: { code: "ACCOUNT_OR_TENANT_NOT_FOUND" as const } }; + + return { 404: { code: "ACCOUNT_OR_TENANT_NOT_FOUND" as const } }; } - } - , - })); + }, + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/users/blockInAccount.ts b/apps/mis-web/src/pages/api/users/blockInAccount.ts index 4507740ce4..66df313865 100644 --- a/apps/mis-web/src/pages/api/users/blockInAccount.ts +++ b/apps/mis-web/src/pages/api/users/blockInAccount.ts @@ -16,10 +16,12 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { UserServiceClient } from "@scow/protos/build/server/user"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { PlatformRole, TenantRole, UserRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const BlockUserInAccountSchema = typeboxRouteSchema({ method: "PUT", @@ -45,10 +47,10 @@ export default /* #__PURE__*/route(BlockUserInAccountSchema, async (req, res) => const acccountBelonged = u.accountAffiliations.find((x) => x.accountName === accountName); return u.platformRoles.includes(PlatformRole.PLATFORM_ADMIN) || - (acccountBelonged && acccountBelonged.role !== UserRole.USER) || + (acccountBelonged && acccountBelonged.role !== UserRole.USER) || u.tenantRoles.includes(TenantRole.TENANT_ADMIN); }); - + const info = await auth(req, res); if (!info) { return; } @@ -56,14 +58,28 @@ export default /* #__PURE__*/route(BlockUserInAccountSchema, async (req, res) => const client = getClient(UserServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.blockUser, + operationTypePayload:{ + accountName, userId: identityId, + }, + }; + return await asyncClientCall(client, "blockUserInAccount", { tenantName: info.tenant, accountName, userId: identityId, }) - .then(() => ({ 200: { executed: true } })) + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 200: { executed: true } }; + }) .catch(handlegRPCError({ [Status.NOT_FOUND]: () => ({ 404: null }), [Status.FAILED_PRECONDITION]: () => ({ 200: { executed: false } }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/users/create.ts b/apps/mis-web/src/pages/api/users/create.ts index 3772991b35..d951085f4e 100644 --- a/apps/mis-web/src/pages/api/users/create.ts +++ b/apps/mis-web/src/pages/api/users/create.ts @@ -16,11 +16,13 @@ import { status } from "@grpc/grpc-js"; import { UserServiceClient } from "@scow/protos/build/server/user"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { PlatformRole, TenantRole, UserRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { publicConfig } from "src/utils/config"; import { getUserIdRule, useBuiltinCreateUser } from "src/utils/createUser"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const CreateUserSchema = typeboxRouteSchema({ method: "POST", @@ -89,6 +91,15 @@ export default /* #__PURE__*/typeboxRoute(CreateUserSchema, async (req, res) => return { 400: { code: "PASSWORD_NOT_VALID" as const, message: publicConfig.PASSWORD_PATTERN_MESSAGE } }; } + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.createUser, + operationTypePayload:{ + userId: identityId, + }, + }; + // create user on server const client = getClient(UserServiceClient); @@ -99,8 +110,13 @@ export default /* #__PURE__*/typeboxRoute(CreateUserSchema, async (req, res) => password, tenantName: info.tenant, }) - .then((res) => ({ 200: { createdInAuth: res.createdInAuth } })) + .then(async (res) => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 200: { createdInAuth: res.createdInAuth } }; + }) .catch(handlegRPCError({ [status.ALREADY_EXISTS]: () => ({ 409: null }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/users/jobChargeLimit/cancel.ts b/apps/mis-web/src/pages/api/users/jobChargeLimit/cancel.ts index 0db743b18f..8ba09a30fe 100644 --- a/apps/mis-web/src/pages/api/users/jobChargeLimit/cancel.ts +++ b/apps/mis-web/src/pages/api/users/jobChargeLimit/cancel.ts @@ -16,9 +16,11 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { JobChargeLimitServiceClient } from "@scow/protos/build/server/job_charge_limit"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { TenantRole, UserRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const CancelJobChargeLimitSchema = typeboxRouteSchema({ method: "DELETE", @@ -53,13 +55,27 @@ export default typeboxRoute(CancelJobChargeLimitSchema, async (req, res) => { const client = getClient(JobChargeLimitServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.accountUnsetChargeLimit, + operationTypePayload:{ + accountName, userId, + }, + }; + return await asyncClientCall(client, "cancelJobChargeLimit", { tenantName: info.tenant, accountName, userId, unblock, }) - .then(() => ({ 204: null })) + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }) .catch(handlegRPCError({ [Status.NOT_FOUND]: () => ({ 404: null }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/users/jobChargeLimit/set.ts b/apps/mis-web/src/pages/api/users/jobChargeLimit/set.ts index ff6598a06f..efe5a309ee 100644 --- a/apps/mis-web/src/pages/api/users/jobChargeLimit/set.ts +++ b/apps/mis-web/src/pages/api/users/jobChargeLimit/set.ts @@ -17,9 +17,11 @@ import { numberToMoney } from "@scow/lib-decimal"; import { JobChargeLimitServiceClient } from "@scow/protos/build/server/job_charge_limit"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { TenantRole, UserRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const SetJobChargeLimitSchema = typeboxRouteSchema({ method: "PUT", @@ -44,22 +46,36 @@ export default typeboxRoute(SetJobChargeLimitSchema, async (req, res) => { const auth = authenticate((u) => { const acccountBelonged = u.accountAffiliations.find((x) => x.accountName === accountName); - return (acccountBelonged && acccountBelonged.role !== UserRole.USER) || + return (acccountBelonged && acccountBelonged.role !== UserRole.USER) || u.tenantRoles.includes(TenantRole.TENANT_ADMIN); }); - + const info = await auth(req, res); if (!info) { return; } const client = getClient(JobChargeLimitServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.accountSetChargeLimit, + operationTypePayload:{ + accountName, userId, limit: numberToMoney(limit), + }, + }; + return await asyncClientCall(client, "setJobChargeLimit", { tenantName: info.tenant, accountName, userId, limit: numberToMoney(limit), }) - .then(() => ({ 204: null })) + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }) .catch(handlegRPCError({ [Status.NOT_FOUND]: () => ({ 404: null }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/users/removeFromAccount.ts b/apps/mis-web/src/pages/api/users/removeFromAccount.ts index 6be747a723..2acca0cfc9 100644 --- a/apps/mis-web/src/pages/api/users/removeFromAccount.ts +++ b/apps/mis-web/src/pages/api/users/removeFromAccount.ts @@ -16,10 +16,12 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { UserServiceClient } from "@scow/protos/build/server/user"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { PlatformRole, TenantRole, UserRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const RemoveUserFromAccountSchema = typeboxRouteSchema({ method: "DELETE", @@ -47,10 +49,10 @@ export default /* #__PURE__*/route(RemoveUserFromAccountSchema, async (req, res) const acccountBelonged = u.accountAffiliations.find((x) => x.accountName === accountName); return u.platformRoles.includes(PlatformRole.PLATFORM_ADMIN) || - (acccountBelonged && acccountBelonged.role !== UserRole.USER) || + (acccountBelonged && acccountBelonged.role !== UserRole.USER) || u.tenantRoles.includes(TenantRole.TENANT_ADMIN); }); - + const info = await auth(req, res); if (!info) { return; } @@ -58,14 +60,28 @@ export default /* #__PURE__*/route(RemoveUserFromAccountSchema, async (req, res) // call ua service to add user const client = getClient(UserServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.removeUserFromAccount, + operationTypePayload:{ + accountName, userId: identityId, + }, + }; + return await asyncClientCall(client, "removeUserFromAccount", { tenantName: info.tenant, accountName, userId: identityId, }) - .then(() => ({ 204: null })) + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }) .catch(handlegRPCError({ [Status.NOT_FOUND]: () => ({ 404: null }), [Status.OUT_OF_RANGE]: () => ({ 406: null }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/users/setAsAdmin.ts b/apps/mis-web/src/pages/api/users/setAsAdmin.ts index c31a2ecf53..99bdb86f1c 100644 --- a/apps/mis-web/src/pages/api/users/setAsAdmin.ts +++ b/apps/mis-web/src/pages/api/users/setAsAdmin.ts @@ -16,9 +16,11 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { UserServiceClient } from "@scow/protos/build/server/user"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { PlatformRole, TenantRole, UserRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const SetAdminSchema = typeboxRouteSchema({ method: "PUT", @@ -43,7 +45,7 @@ export default /* #__PURE__*/typeboxRoute(SetAdminSchema, async (req, res) => { const acccountBelonged = u.accountAffiliations.find((x) => x.accountName === accountName); return u.platformRoles.includes(PlatformRole.PLATFORM_ADMIN) || - (acccountBelonged && acccountBelonged.role !== UserRole.USER) || + (acccountBelonged && acccountBelonged.role !== UserRole.USER) || u.tenantRoles.includes(TenantRole.TENANT_ADMIN); }); @@ -53,14 +55,28 @@ export default /* #__PURE__*/typeboxRoute(SetAdminSchema, async (req, res) => { const client = getClient(UserServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.setAccountAdmin, + operationTypePayload:{ + accountName, userId: identityId, + }, + }; + return await asyncClientCall(client, "setAsAdmin", { tenantName: info.tenant, accountName, userId: identityId, }) - .then(() => ({ 200: { executed: true } })) + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 200: { executed: true } }; + }) .catch(handlegRPCError({ [Status.NOT_FOUND]: () => ({ 404: null }), [Status.FAILED_PRECONDITION]: () => ({ 200: { executed: false } }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/users/unblockInAccount.ts b/apps/mis-web/src/pages/api/users/unblockInAccount.ts index 7f08380237..44e7196387 100644 --- a/apps/mis-web/src/pages/api/users/unblockInAccount.ts +++ b/apps/mis-web/src/pages/api/users/unblockInAccount.ts @@ -16,10 +16,12 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { UserServiceClient } from "@scow/protos/build/server/user"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { PlatformRole, TenantRole, UserRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const UnblockUserInAccountSchema = typeboxRouteSchema({ method: "PUT", @@ -45,24 +47,39 @@ export default /* #__PURE__*/route(UnblockUserInAccountSchema, async (req, res) const acccountBelonged = u.accountAffiliations.find((x) => x.accountName === accountName); return u.platformRoles.includes(PlatformRole.PLATFORM_ADMIN) || - (acccountBelonged && acccountBelonged.role !== UserRole.USER) || + (acccountBelonged && acccountBelonged.role !== UserRole.USER) || u.tenantRoles.includes(TenantRole.TENANT_ADMIN); }); - + const info = await auth(req, res); if (!info) { return; } const client = getClient(UserServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.unblockUser, + operationTypePayload:{ + accountName, userId: identityId, + }, + }; + + return await asyncClientCall(client, "unblockUserInAccount", { tenantName: info.tenant, accountName, userId: identityId, }) - .then(() => ({ 200: { executed: true } })) + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 200: { executed: true } }; + }) .catch(handlegRPCError({ [Status.NOT_FOUND]: () => ({ 404: null }), [Status.FAILED_PRECONDITION]: () => ({ 200: { executed: false } }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/api/users/unsetAdmin.ts b/apps/mis-web/src/pages/api/users/unsetAdmin.ts index c88cb78a0b..b8f0eb76e9 100644 --- a/apps/mis-web/src/pages/api/users/unsetAdmin.ts +++ b/apps/mis-web/src/pages/api/users/unsetAdmin.ts @@ -16,9 +16,11 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { UserServiceClient } from "@scow/protos/build/server/user"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; import { PlatformRole, TenantRole, UserRole } from "src/models/User"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const UnsetAdminSchema = typeboxRouteSchema({ method: "PUT", @@ -43,7 +45,7 @@ export default typeboxRoute(UnsetAdminSchema, async (req, res) => { const acccountBelonged = u.accountAffiliations.find((x) => x.accountName === accountName); return u.platformRoles.includes(PlatformRole.PLATFORM_ADMIN) || - (acccountBelonged && acccountBelonged.role !== UserRole.USER) || + (acccountBelonged && acccountBelonged.role !== UserRole.USER) || u.tenantRoles.includes(TenantRole.TENANT_ADMIN); }); @@ -53,14 +55,28 @@ export default typeboxRoute(UnsetAdminSchema, async (req, res) => { const client = getClient(UserServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.unsetAccountAdmin, + operationTypePayload:{ + accountName, userId: identityId, + }, + }; + return await asyncClientCall(client, "unsetAdmin", { tenantName: info.tenant, accountName, userId: identityId, }) - .then(() => ({ 200: { executed: true } })) + .then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 200: { executed: true } }; + }) .catch(handlegRPCError({ [Status.NOT_FOUND]: () => ({ 404: null }), [Status.FAILED_PRECONDITION]: () => ({ 200: { executed: false } }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/mis-web/src/pages/tenant/operationLogs.tsx b/apps/mis-web/src/pages/tenant/operationLogs.tsx new file mode 100644 index 0000000000..f42d1e873f --- /dev/null +++ b/apps/mis-web/src/pages/tenant/operationLogs.tsx @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { NextPage } from "next"; +import { requireAuth } from "src/auth/requireAuth"; +import { OperationLogTable } from "src/components/OperationLogTable"; +import { PageTitle } from "src/components/PageTitle"; +import { OperationLogQueryType } from "src/models/operationLog"; +import { TenantRole } from "src/models/User"; +import { Head } from "src/utils/head"; + +export const OperationLogPage: NextPage = requireAuth( + (u) => u.tenantRoles.includes(TenantRole.TENANT_ADMIN), +)( + ({ userStore }) => { + const tenant = userStore.user.tenant; + const title = `租户${tenant}操作日志`; + return ( +
+ + + +
+ ); + + }); + +export default OperationLogPage; diff --git a/apps/mis-web/src/pages/user/operationLogs.tsx b/apps/mis-web/src/pages/user/operationLogs.tsx new file mode 100644 index 0000000000..9d5dc94cd6 --- /dev/null +++ b/apps/mis-web/src/pages/user/operationLogs.tsx @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { NextPage } from "next"; +import { requireAuth } from "src/auth/requireAuth"; +import { OperationLogTable } from "src/components/OperationLogTable"; +import { PageTitle } from "src/components/PageTitle"; +import { OperationLogQueryType } from "src/models/operationLog"; +import { Head } from "src/utils/head"; + +export const OperationLogPage: NextPage = requireAuth(() => true)( + ({ userStore }) => { + return ( +
+ + + +
+ ); + + }); + +export default OperationLogPage; diff --git a/apps/mis-web/src/server/jobAccessible.ts b/apps/mis-web/src/server/jobAccessible.ts index 37c85826ed..451a4f5e33 100644 --- a/apps/mis-web/src/server/jobAccessible.ts +++ b/apps/mis-web/src/server/jobAccessible.ts @@ -12,12 +12,17 @@ import { asyncClientCall } from "@ddadaal/tsgrpc-client"; import { parseTime } from "@scow/lib-web/build/utils/datetime"; +import { RunningJob } from "@scow/protos/build/common/job"; import { AccountServiceClient } from "@scow/protos/build/server/account"; import { JobServiceClient } from "@scow/protos/build/server/job"; import { PlatformRole, TenantRole, UserInfo, UserRole } from "src/models/User"; import { getClient } from "src/utils/client"; -type Result = "OK" | "NotFound" | "NotAllowed" | "LimitNotValid"; +type JobAccessible = "OK" | "NotFound" | "NotAllowed" | "LimitNotValid"; + +type Result = { + job: RunningJob, jobAccessible: JobAccessible +} export async function checkJobAccessible( jobId: string, cluster: string, info: UserInfo, limitMinutes?: number, @@ -25,6 +30,7 @@ export async function checkJobAccessible( const client = getClient(JobServiceClient); + const result: Result = {} as Result; const reply = await asyncClientCall(client, "getRunningJobs", { cluster, @@ -32,28 +38,35 @@ export async function checkJobAccessible( }); if (reply.jobs.length === 0) { - return "NotFound"; + result.jobAccessible = "NotFound"; + result.job = {} as RunningJob; + return result; } const job = reply.jobs[0]; + result.job = job; // 如果设置的作业时限比该作业运行时间小, 则该作业不可以修改作业时限 if (!!limitMinutes && (limitMinutes) * 60 * 1000 < parseTime(job.runningTime)) { - return "LimitNotValid"; + result.jobAccessible = "LimitNotValid"; + return result; } + if (info.platformRoles.includes(PlatformRole.PLATFORM_ADMIN)) { - return "OK"; + result.jobAccessible = "OK"; + return result; } - // 用户发起了这个作业 if (job.user === info.identityId) { - return "OK"; + result.jobAccessible = "OK"; + return result; } // 用户是这个作业的账户的管理员或者拥有者 if (info.accountAffiliations.some((x) => x.accountName === job.account && x.role !== UserRole.USER)) { - return "OK"; + result.jobAccessible = "OK"; + return result; } // 如果用户是租户的管理员,而且这个作业的账户属于这个租户 @@ -63,10 +76,11 @@ export async function checkJobAccessible( tenantName: info.tenant, }); - return results.length !== 0 ? "OK" : "NotAllowed"; + results.length !== 0 ? result.jobAccessible = "OK" : result.jobAccessible = "NotAllowed"; + return result; } - return "NotAllowed"; - + result.jobAccessible = "NotAllowed"; + return result; } diff --git a/apps/mis-web/src/server/operationLog.ts b/apps/mis-web/src/server/operationLog.ts new file mode 100644 index 0000000000..daa2f4b8a1 --- /dev/null +++ b/apps/mis-web/src/server/operationLog.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { createOperationLogClient, + LogCallParams, OperationEvent, OperationResult } from "@scow/lib-operation-log/build/index"; +import { runtimeConfig } from "src/utils/config"; + +interface PartialLogCallParams + extends Omit, "operationResult" | "logger"> {} + +export const callLog = async ( + { + operatorUserId, + operatorIp, + operationTypeName, + operationTypePayload, + }: PartialLogCallParams, + operationResult: OperationResult, +) => { + + const { callLog } = createOperationLogClient(runtimeConfig.AUDIT_CONFIG, console); + + await callLog( + { + operatorUserId, + operatorIp, + operationTypeName, + operationTypePayload, + operationResult, + logger: console, + }, + ); +}; + diff --git a/apps/mis-web/src/utils/config.ts b/apps/mis-web/src/utils/config.ts index 2269ae3776..30ea7e9748 100644 --- a/apps/mis-web/src/utils/config.ts +++ b/apps/mis-web/src/utils/config.ts @@ -10,6 +10,7 @@ * See the Mulan PSL v2 for more details. */ +import { AuditConfigSchema } from "@scow/config/build/audit"; import type { ClusterConfigSchema } from "@scow/config/build/cluster"; import type { ClusterTextsConfigSchema } from "@scow/config/build/clusterTexts"; import type { MisConfigSchema } from "@scow/config/build/mis"; @@ -30,6 +31,8 @@ export interface ServerRuntimeConfig { CLUSTER_TEXTS_CONFIG: ClusterTextsConfigSchema; SCOW_API_AUTH_TOKEN?: string; + + AUDIT_CONFIG: AuditConfigSchema | undefined; } export interface PublicRuntimeConfig { @@ -59,6 +62,8 @@ export interface PublicRuntimeConfig { USER_LINKS?: UserLink[]; VERSION_TAG: string | undefined; + + AUDIT_DEPLOYED: boolean; } export const runtimeConfig: ServerRuntimeConfig = getConfig().serverRuntimeConfig; diff --git a/apps/mis-web/src/utils/constants.ts b/apps/mis-web/src/utils/constants.ts index 2126471cd3..72ec8d744b 100644 --- a/apps/mis-web/src/utils/constants.ts +++ b/apps/mis-web/src/utils/constants.ts @@ -13,3 +13,5 @@ export const DEFAULT_TENANT_NAME = "default"; export const UNKNOWN_PRICE_ITEM = "UNKNOWN"; + +export const DEFAULT_INIT_USER_ID = "init_user"; diff --git a/apps/mis-web/src/utils/server.ts b/apps/mis-web/src/utils/server.ts index 0fc5b32a12..0274be5611 100644 --- a/apps/mis-web/src/utils/server.ts +++ b/apps/mis-web/src/utils/server.ts @@ -18,8 +18,10 @@ type ValueOf = T[keyof T]; export const handlegRPCError = unknown>>>( handlers: THandlers, + logHandle?: () => Promise, // @ts-ignore -) => (e: ServiceError): ReturnType> => { +) => async (e: ServiceError): ReturnType> => { + await logHandle?.(); const handler = handlers[e.code]; if (handler) { // @ts-ignore @@ -28,7 +30,6 @@ export const handlegRPCError = { let forwardedFor = req.headers["x-forwarded-for"]; diff --git a/apps/portal-web/config.js b/apps/portal-web/config.js index f0357b9f12..be8d69b391 100644 --- a/apps/portal-web/config.js +++ b/apps/portal-web/config.js @@ -24,6 +24,7 @@ const { DEFAULT_PRIMARY_COLOR, getUiConfig } = require("@scow/config/build/ui"); const { getPortalConfig } = require("@scow/config/build/portal"); const { getClusterConfigs, getLoginNode, getSortedClusters } = require("@scow/config/build/cluster"); const { getCommonConfig } = require("@scow/config/build/common"); +const { getAuditConfig } = require("@scow/config/build/audit"); /** * Get auth capabilities @@ -85,6 +86,8 @@ const specs = { CLIENT_MAX_BODY_SIZE: str({ desc: "限制整个系统上传(请求)文件的大小,可接受的格式为nginx的client_max_body_size可接受的值", default: "1G" }), PUBLIC_PATH: str({ desc: "SCOW公共文件的路径,需已包含SCOW的base path", default: "/public/" }), + + AUDIT_DEPLOYED: bool({ desc: "是否部署了审计系统", default: false }), }; const mockEnv = process.env.NEXT_PUBLIC_USE_MOCK === "1"; @@ -125,6 +128,7 @@ const buildRuntimeConfig = async (phase, basePath) => { const uiConfig = getUiConfig(configPath, console); const portalConfig = getPortalConfig(configPath, console); const commonConfig = getCommonConfig(configPath, console); + const auditConfig = getAuditConfig(configPath, console); const versionTag = readVersionFile()?.tag; @@ -148,6 +152,7 @@ const buildRuntimeConfig = async (phase, basePath) => { SUBMIT_JOB_WORKING_DIR: portalConfig.submitJobDefaultPwd, SCOW_API_AUTH_TOKEN: commonConfig.scowApi?.auth?.token, SUBMIT_JOB_PROMPT_TEXT:portalConfig.submitJobPromptText, + AUDIT_CONFIG: config.AUDIT_DEPLOYED ? auditConfig : undefined, }; // query auth capabilities to set optional auth features diff --git a/apps/portal-web/config/audit.yaml b/apps/portal-web/config/audit.yaml new file mode 100644 index 0000000000..d8fb0b3975 --- /dev/null +++ b/apps/portal-web/config/audit.yaml @@ -0,0 +1,8 @@ +db: + host: localhost + port: 3306 + user: root + password: mysqlrootpassword + dbName: scow_audit + + diff --git a/apps/portal-web/package.json b/apps/portal-web/package.json index a998787ca3..3b2ea36eb5 100644 --- a/apps/portal-web/package.json +++ b/apps/portal-web/package.json @@ -42,6 +42,7 @@ "@scow/lib-web": "workspace:*", "@scow/protos": "workspace:*", "@scow/utils": "workspace:*", + "@scow/lib-operation-log": "workspace:*", "@scow/rich-error-model": "workspace:*", "@sinclair/typebox": "0.31.1", "@ant-design/cssinjs": "1.16.2", diff --git a/apps/portal-web/src/models/operationLog.ts b/apps/portal-web/src/models/operationLog.ts new file mode 100644 index 0000000000..a046a6f18e --- /dev/null +++ b/apps/portal-web/src/models/operationLog.ts @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { OperationTypeEnum } from "@scow/lib-operation-log/build/index"; +import { ValueOf } from "next/dist/shared/lib/constants"; + +export const OperationResult = { + UNKNOWN: 0, + SUCCESS: 1, + FAIL: 2, +} as const; + +export type OperationResult = ValueOf + +export const OperationType: OperationTypeEnum = { + login: "login", + logout: "logout", + submitJob: "submitJob", + endJob: "endJob", + addJobTemplate: "addJobTemplate", + deleteJobTemplate: "deleteJobTemplate", + updateJobTemplate: "updateJobTemplate", + shellLogin: "shellLogin", + createDesktop: "createDesktop", + deleteDesktop: "deleteDesktop", + createApp: "createApp", + createFile: "createFile", + deleteFile: "deleteFile", + uploadFile: "uploadFile", + createDirectory: "createDirectory", + deleteDirectory: "deleteDirectory", + moveFileItem: "moveFileItem", + copyFileItem: "copyFileItem", + setJobTimeLimit: "setJobTimeLimit", + createUser: "createUser", + addUserToAccount: "addUserToAccount", + removeUserFromAccount: "removeUserFromAccount", + setAccountAdmin: "setAccountAdmin", + unsetAccountAdmin: "unsetAccountAdmin", + blockUser: "blockUser", + unblockUser: "unblockUser", + accountSetChargeLimit: "accountSetChargeLimit", + accountUnsetChargeLimit: "accountUnsetChargeLimit", + setTenantBilling: "setTenantBilling", + setTenantAdmin: "setTenantAdmin", + unsetTenantAdmin: "unsetTenantAdmin", + setTenantFinance: "setTenantFinance", + unsetTenantFinance: "unsetTenantFinance", + tenantChangePassword: "tenantChangePassword", + createAccount: "createAccount", + addAccountToWhitelist: "addAccountToWhitelist", + removeAccountFromWhitelist: "removeAccountFromWhitelist", + accountPay: "accountPay", + importUsers: "importUsers", + setPlatformAdmin: "setPlatformAdmin", + unsetPlatformAdmin: "unsetPlatformAdmin", + setPlatformFinance: "setPlatformFinance", + unsetPlatformFinance: "unsetPlatformFinance", + platformChangePassword: "platformChangePassword", + setPlatformBilling: "setPlatformBilling", + createTenant: "createTenant", + tenantPay: "tenantPay", +}; diff --git a/apps/portal-web/src/pages/api/app/createAppSession.ts b/apps/portal-web/src/pages/api/app/createAppSession.ts index 2b3f405722..63068dc3d0 100644 --- a/apps/portal-web/src/pages/api/app/createAppSession.ts +++ b/apps/portal-web/src/pages/api/app/createAppSession.ts @@ -18,9 +18,12 @@ import { parseErrorDetails } from "@scow/rich-error-model"; import { Type } from "@sinclair/typebox"; import { join } from "path"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { publicConfig } from "src/utils/config"; import { route } from "src/utils/route"; +import { parseIp } from "src/utils/server"; export const CreateAppSessionSchema = typeboxRouteSchema({ method: "POST", @@ -82,6 +85,15 @@ export default /* #__PURE__*/route(CreateAppSessionSchema, async (req, res) => { const proxyBasePath = join(publicConfig.BASE_PATH, "/api/proxy", cluster); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.createApp, + operationTypePayload:{ + accountName: account, + }, + }; + return await asyncUnaryCall(client, "createAppSession", { appId, appJobName, @@ -97,9 +109,17 @@ export default /* #__PURE__*/route(CreateAppSessionSchema, async (req, res) => { qos, proxyBasePath, customAttributes, - }).then((reply) => { + }).then(async (reply) => { + await callLog({ + ...logInfo, + operationTypePayload: { ... logInfo.operationTypePayload, jobId: reply.jobId }, + }, OperationResult.SUCCESS); return { 200: { jobId: reply.jobId, sessionId: reply.sessionId } }; - }).catch((e) => { + }).catch(async (e) => { + await callLog({ + ...logInfo, + operationTypePayload: { ... logInfo.operationTypePayload, jobId: -1 }, + }, OperationResult.FAIL); const ex = e as ServiceError; const errors = parseErrorDetails(ex.metadata); if (errors[0] && errors[0].$type === "google.rpc.ErrorInfo") { diff --git a/apps/portal-web/src/pages/api/auth/callback.ts b/apps/portal-web/src/pages/api/auth/callback.ts index 18eb0b12fc..508ba98630 100644 --- a/apps/portal-web/src/pages/api/auth/callback.ts +++ b/apps/portal-web/src/pages/api/auth/callback.ts @@ -14,8 +14,11 @@ import { typeboxRouteSchema } from "@ddadaal/next-typed-api-routes-runtime"; import { Type } from "@sinclair/typebox"; import { setTokenCookie } from "src/auth/cookie"; import { validateToken } from "src/auth/token"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { publicConfig } from "src/utils/config"; import { route } from "src/utils/route"; +import { parseIp } from "src/utils/server"; export const AuthCallbackSchema = typeboxRouteSchema({ method: "GET", @@ -41,7 +44,12 @@ export default route(AuthCallbackSchema, async (req, res) => { if (info) { // set token cache setTokenCookie({ res }, token); - + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.login, + }; + await callLog(logInfo, OperationResult.SUCCESS); res.redirect(publicConfig.BASE_PATH); } else { return { 403: null }; diff --git a/apps/portal-web/src/pages/api/auth/logout.ts b/apps/portal-web/src/pages/api/auth/logout.ts index 771a594b25..f6a07f6324 100644 --- a/apps/portal-web/src/pages/api/auth/logout.ts +++ b/apps/portal-web/src/pages/api/auth/logout.ts @@ -14,8 +14,12 @@ import { typeboxRouteSchema } from "@ddadaal/next-typed-api-routes-runtime"; import { deleteToken } from "@scow/lib-auth"; import { Type } from "@sinclair/typebox"; import { getTokenFromCookie } from "src/auth/cookie"; +import { validateToken } from "src/auth/token"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { runtimeConfig } from "src/utils/config"; import { route } from "src/utils/route"; +import { parseIp } from "src/utils/server"; export const LogoutSchema = typeboxRouteSchema({ method: "DELETE", @@ -31,6 +35,15 @@ export default route(LogoutSchema, async (req) => { const token = getTokenFromCookie({ req }); if (token) { + const info = await validateToken(token); + if (info) { + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.logout, + }; + await callLog(logInfo, OperationResult.SUCCESS); + } await deleteToken(token, runtimeConfig.AUTH_INTERNAL_URL); } return { 204: null }; diff --git a/apps/portal-web/src/pages/api/desktop/createDesktop.ts b/apps/portal-web/src/pages/api/desktop/createDesktop.ts index a092e9d301..875f8b1dea 100644 --- a/apps/portal-web/src/pages/api/desktop/createDesktop.ts +++ b/apps/portal-web/src/pages/api/desktop/createDesktop.ts @@ -16,9 +16,11 @@ import { status } from "@grpc/grpc-js"; import { DesktopServiceClient } from "@scow/protos/build/portal/desktop"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { getLoginDesktopEnabled } from "src/utils/config"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const CreateDesktopSchema = typeboxRouteSchema({ method: "POST", @@ -71,17 +73,28 @@ export default /* #__PURE__*/typeboxRoute(CreateDesktopSchema, async (req, res) const client = getClient(DesktopServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.createDesktop, + operationTypePayload:{ + desktopName, wm, + }, + }; + return await asyncUnaryCall(client, "createDesktop", { cluster, loginNode, userId: info.identityId, wm, desktopName, }).then( - async ({ host, password, port }) => ({ - 200: { host, password, port }, - }), + async ({ host, password, port }) => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 200: { host, password, port } }; + }, handlegRPCError({ [status.NOT_FOUND]: () => ({ 400: { code: "INVALID_CLUSTER" as const } }), [status.INVALID_ARGUMENT]: () => ({ 400: { code: "INVALID_WM" as const } }), [status.RESOURCE_EXHAUSTED]: () => ({ 409: { code: "TOO_MANY_DESKTOPS" as const } }), - })); - + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/portal-web/src/pages/api/desktop/killDesktop.ts b/apps/portal-web/src/pages/api/desktop/killDesktop.ts index a87f0303c4..ecf023100b 100644 --- a/apps/portal-web/src/pages/api/desktop/killDesktop.ts +++ b/apps/portal-web/src/pages/api/desktop/killDesktop.ts @@ -15,8 +15,11 @@ import { asyncUnaryCall } from "@ddadaal/tsgrpc-client"; import { DesktopServiceClient } from "@scow/protos/build/portal/desktop"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { getLoginDesktopEnabled } from "src/utils/config"; +import { parseIp } from "src/utils/server"; export const KillDesktopSchema = typeboxRouteSchema({ method: "POST", @@ -53,8 +56,24 @@ export default /* #__PURE__*/typeboxRoute(KillDesktopSchema, async (req, res) => const client = getClient(DesktopServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.deleteDesktop, + operationTypePayload:{ + desktopId: displayId, + loginNode: loginNode, + }, + }; + return await asyncUnaryCall(client, "killDesktop", { cluster, loginNode, displayId, userId: info.identityId, - }).then(() => ({ 204: null })); + }).then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }).catch(async (e) => { + await callLog(logInfo, OperationResult.FAIL); + throw e; + }); }); diff --git a/apps/portal-web/src/pages/api/file/copy.ts b/apps/portal-web/src/pages/api/file/copy.ts index f314918970..63d113be36 100644 --- a/apps/portal-web/src/pages/api/file/copy.ts +++ b/apps/portal-web/src/pages/api/file/copy.ts @@ -15,9 +15,11 @@ import { asyncUnaryCall } from "@ddadaal/tsgrpc-client"; import { status } from "@grpc/grpc-js"; import { FileServiceClient } from "@scow/protos/build/portal/file"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const CopyFileItemSchema = typeboxRouteSchema({ method: "PATCH", @@ -50,10 +52,24 @@ export default route(CopyFileItemSchema, async (req, res) => { const client = getClient(FileServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.copyFileItem, + operationTypePayload:{ + clusterId: cluster, fromPath, toPath, + }, + }; + return asyncUnaryCall(client, "copy", { cluster, fromPath, toPath, userId: info.identityId, - }).then(() => ({ 204: null }), handlegRPCError({ + }).then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }, handlegRPCError({ [status.INTERNAL]: (e) => ({ 415: { code: "CP_CMD_FAILED" as const, error: e.details } }), [status.NOT_FOUND]: () => ({ 400: { code: "INVALID_CLUSTER" as const } }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/portal-web/src/pages/api/file/createFile.ts b/apps/portal-web/src/pages/api/file/createFile.ts index cf371cae7a..4b0265e1e0 100644 --- a/apps/portal-web/src/pages/api/file/createFile.ts +++ b/apps/portal-web/src/pages/api/file/createFile.ts @@ -16,9 +16,11 @@ import { status } from "@grpc/grpc-js"; import { FileServiceClient } from "@scow/protos/build/portal/file"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const CreateFileSchema = typeboxRouteSchema({ method: "POST", @@ -47,11 +49,25 @@ export default route(CreateFileSchema, async (req, res) => { const client = getClient(FileServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.createFile, + operationTypePayload:{ + clusterId: cluster, path, + }, + }; + return asyncUnaryCall(client, "createFile", { cluster, path, userId: info.identityId, - }).then(() => ({ 204: null }), handlegRPCError({ + }).then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }, handlegRPCError({ [status.NOT_FOUND]: () => ({ 400: { code: "INVALID_CLUSTER" as const } }), [status.ALREADY_EXISTS]: () => ({ 409: { code: "ALREADY_EXISTS" as const } }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/portal-web/src/pages/api/file/deleteDir.ts b/apps/portal-web/src/pages/api/file/deleteDir.ts index a97089c4b7..c115efaca6 100644 --- a/apps/portal-web/src/pages/api/file/deleteDir.ts +++ b/apps/portal-web/src/pages/api/file/deleteDir.ts @@ -16,9 +16,11 @@ import { status } from "@grpc/grpc-js"; import { FileServiceClient } from "@scow/protos/build/portal/file"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const DeleteDirSchema = typeboxRouteSchema({ method: "DELETE", @@ -38,8 +40,6 @@ const auth = authenticate(() => true); export default route(DeleteDirSchema, async (req, res) => { - - const info = await auth(req, res); if (!info) { return; } @@ -48,12 +48,24 @@ export default route(DeleteDirSchema, async (req, res) => { const client = getClient(FileServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.deleteDirectory, + operationTypePayload:{ + clusterId: cluster, path, + }, + }; + return asyncUnaryCall(client, "deleteDirectory", { cluster, path, userId: info.identityId, - }).then(() => ({ 204: null }), handlegRPCError({ + }).then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }, handlegRPCError({ [status.NOT_FOUND]: () => ({ 400: { code: "INVALID_CLUSTER" as const } }), - })); - - + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/portal-web/src/pages/api/file/deleteFile.ts b/apps/portal-web/src/pages/api/file/deleteFile.ts index 720e016432..f92f344c54 100644 --- a/apps/portal-web/src/pages/api/file/deleteFile.ts +++ b/apps/portal-web/src/pages/api/file/deleteFile.ts @@ -15,9 +15,11 @@ import { asyncUnaryCall } from "@ddadaal/tsgrpc-client"; import { status } from "@grpc/grpc-js"; import { FileServiceClient } from "@scow/protos/build/portal/file"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const DeleteFileSchema = typeboxRouteSchema({ @@ -46,10 +48,24 @@ export default route(DeleteFileSchema, async (req, res) => { const client = getClient(FileServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.deleteFile, + operationTypePayload:{ + clusterId: cluster, path, + }, + }; + return asyncUnaryCall(client, "deleteFile", { cluster, path, userId: info.identityId, - }).then(() => ({ 204: null }), handlegRPCError({ + }).then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }, handlegRPCError({ [status.NOT_FOUND]: () => ({ 400: { code: "INVALID_CLUSTER" as const } }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/portal-web/src/pages/api/file/mkdir.ts b/apps/portal-web/src/pages/api/file/mkdir.ts index 68549da857..e01af4c096 100644 --- a/apps/portal-web/src/pages/api/file/mkdir.ts +++ b/apps/portal-web/src/pages/api/file/mkdir.ts @@ -16,9 +16,11 @@ import { status } from "@grpc/grpc-js"; import { FileServiceClient } from "@scow/protos/build/portal/file"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const MkdirSchema = typeboxRouteSchema({ @@ -48,12 +50,25 @@ export default route(MkdirSchema, async (req, res) => { const client = getClient(FileServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.createDirectory, + operationTypePayload:{ + clusterId: cluster, path, + }, + }; + return asyncUnaryCall(client, "makeDirectory", { cluster, path, userId: info.identityId, - }).then(() => ({ 204: null }), handlegRPCError({ + }).then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }, handlegRPCError({ [status.NOT_FOUND]: () => ({ 400: { code: "INVALID_CLUSTER" as const } }), [status.ALREADY_EXISTS]: () => ({ 409: { code: "ALREADY_EXISTS" as const } }), - })); - + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/portal-web/src/pages/api/file/move.ts b/apps/portal-web/src/pages/api/file/move.ts index 03350f0dee..0c9c1785c8 100644 --- a/apps/portal-web/src/pages/api/file/move.ts +++ b/apps/portal-web/src/pages/api/file/move.ts @@ -15,9 +15,11 @@ import { asyncUnaryCall } from "@ddadaal/tsgrpc-client"; import { status } from "@grpc/grpc-js"; import { FileServiceClient } from "@scow/protos/build/portal/file"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const MoveFileItemSchema = typeboxRouteSchema({ method: "PATCH", @@ -47,11 +49,25 @@ export default route(MoveFileItemSchema, async (req, res) => { const client = getClient(FileServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.moveFileItem, + operationTypePayload:{ + clusterId: cluster, fromPath, toPath, + }, + }; + return asyncUnaryCall(client, "move", { cluster, fromPath, toPath, userId: info.identityId, - }).then(() => ({ 204: null }), handlegRPCError({ + }).then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }, handlegRPCError({ [status.INTERNAL]: (e) => ({ 415: { code: "RENAME_FAILED" as const, error: e.details } }), [status.NOT_FOUND]: () => ({ 400: { code: "INVALID_CLUSTER" as const } }), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/portal-web/src/pages/api/file/upload.ts b/apps/portal-web/src/pages/api/file/upload.ts index 0029844a00..912b56ceae 100644 --- a/apps/portal-web/src/pages/api/file/upload.ts +++ b/apps/portal-web/src/pages/api/file/upload.ts @@ -17,9 +17,12 @@ import { Type } from "@sinclair/typebox"; import busboy, { BusboyEvents } from "busboy"; import { once } from "events"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { pipeline } from "src/utils/pipeline"; import { route } from "src/utils/route"; +import { parseIp } from "src/utils/server"; import { pipeline as pipelineStream } from "stream/promises"; export const UploadFileSchema = typeboxRouteSchema({ @@ -50,9 +53,19 @@ export default route(UploadFileSchema, async (req, res) => { const client = getClient(FileServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.uploadFile, + operationTypePayload:{ + clusterId: cluster, path, + }, + }; + pipelineStream(req, bb); - const [_name, file] = (await once(bb, "file").catch((e) => { + const [_name, file] = (await once(bb, "file").catch(async (e) => { + await callLog(logInfo, OperationResult.FAIL); throw new Error("Error when waiting for file upload", { cause: e }); })) as Parameters; @@ -63,11 +76,15 @@ export default route(UploadFileSchema, async (req, res) => { file, (chunk) => ({ message: { $case: "chunk" as const, chunk } }), stream, - ).catch((e) => { + ).catch(async (e) => { + await callLog(logInfo, OperationResult.FAIL); throw new Error("Error when writing stream", { cause: e }); }); - }).then(() => ({ 204: null })).finally(() => { + }).then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }).finally(() => { bb.end(); }); }); diff --git a/apps/portal-web/src/pages/api/job/cancelJob.ts b/apps/portal-web/src/pages/api/job/cancelJob.ts index 5ece7d4112..50dc7a240c 100644 --- a/apps/portal-web/src/pages/api/job/cancelJob.ts +++ b/apps/portal-web/src/pages/api/job/cancelJob.ts @@ -16,9 +16,11 @@ import { status } from "@grpc/grpc-js"; import { JobServiceClient } from "@scow/protos/build/portal/job"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const CancelJobSchema = typeboxRouteSchema({ method: "DELETE", @@ -46,9 +48,21 @@ export default /* #__PURE__*/route(CancelJobSchema, async (req, res) => { const client = getClient(JobServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.endJob, + operationTypePayload: { jobId }, + }; + return asyncUnaryCall(client, "cancelJob", { jobId, userId: info.identityId, cluster, - }).then(() => ({ 204: null }), handlegRPCError({ + }).then(async () => { + await callLog(logInfo, OperationResult.SUCCESS); + return { 204: null }; + }, handlegRPCError({ [status.NOT_FOUND]: () => ({ 404: { code: "JOB_NOT_FOUND" } } as const), - })); + }, + async () => await callLog(logInfo, OperationResult.FAIL), + )); }); diff --git a/apps/portal-web/src/pages/api/job/deleteJobTemplate.ts b/apps/portal-web/src/pages/api/job/deleteJobTemplate.ts index 0141e8e97e..4436fe6b3e 100644 --- a/apps/portal-web/src/pages/api/job/deleteJobTemplate.ts +++ b/apps/portal-web/src/pages/api/job/deleteJobTemplate.ts @@ -16,9 +16,11 @@ import { status } from "@grpc/grpc-js"; import { JobServiceClient } from "@scow/protos/build/portal/job"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const DeleteJobTemplateSchema = typeboxRouteSchema({ method: "DELETE", @@ -46,9 +48,23 @@ export default /* #__PURE__*/route(DeleteJobTemplateSchema, async (req, res) => const client = getClient(JobServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.deleteJobTemplate, + operationTypePayload:{ + jobTemplateId: templateId, + }, + }; + return asyncUnaryCall(client, "deleteJobTemplate", { templateId, userId: info.identityId, cluster, - }).then(() => ({ 204: null }), handlegRPCError({ + }).then(async () => { + await callLog({ ...logInfo }, OperationResult.SUCCESS); + return { 204: null }; + }, handlegRPCError({ [status.NOT_FOUND]: () => ({ 404: { code: "TEMPLATE_NOT_FOUND" } } as const), - })); + }, + async () => await callLog({ ...logInfo }, OperationResult.FAIL), + )); }); diff --git a/apps/portal-web/src/pages/api/job/renameJobTemplate.ts b/apps/portal-web/src/pages/api/job/renameJobTemplate.ts index d051f53dfd..acccc97b32 100644 --- a/apps/portal-web/src/pages/api/job/renameJobTemplate.ts +++ b/apps/portal-web/src/pages/api/job/renameJobTemplate.ts @@ -16,9 +16,11 @@ import { status } from "@grpc/grpc-js"; import { JobServiceClient } from "@scow/protos/build/portal/job"; import { Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const RenameJobTemplateSchema = typeboxRouteSchema({ method: "POST", @@ -47,9 +49,24 @@ export default /* #__PURE__*/route(RenameJobTemplateSchema, async (req, res) => const client = getClient(JobServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.updateJobTemplate, + operationTypePayload:{ + jobTemplateId: templateId, + newJobTemplateId: jobName, + }, + }; + return asyncUnaryCall(client, "renameJobTemplate", { templateId, userId: info.identityId, cluster, jobName, - }).then(() => ({ 204: null }), handlegRPCError({ + }).then(async () => { + await callLog({ ...logInfo }, OperationResult.SUCCESS); + return { 204: null }; + }, handlegRPCError({ [status.NOT_FOUND]: () => ({ 404: { code: "TEMPLATE_NOT_FOUND" } } as const), - })); + }, + async () => await callLog({ ...logInfo }, OperationResult.FAIL), + )); }); diff --git a/apps/portal-web/src/pages/api/job/submitJob.ts b/apps/portal-web/src/pages/api/job/submitJob.ts index 85578218aa..cdc322b745 100644 --- a/apps/portal-web/src/pages/api/job/submitJob.ts +++ b/apps/portal-web/src/pages/api/job/submitJob.ts @@ -16,9 +16,11 @@ import { status } from "@grpc/grpc-js"; import { JobServiceClient } from "@scow/protos/build/portal/job"; import { Static, Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { route } from "src/utils/route"; -import { handlegRPCError } from "src/utils/server"; +import { handlegRPCError, parseIp } from "src/utils/server"; export const SubmitJobInfo = Type.Object({ cluster: Type.String(), @@ -75,6 +77,14 @@ export default route(SubmitJobSchema, async (req, res) => { const client = getClient(JobServiceClient); + const logInfo = { + operatorUserId: info.identityId, + operatorIp: parseIp(req) ?? "", + operationTypePayload:{ + accountName: account, + }, + }; + return await asyncUnaryCall(client, "submitJob", { cluster, userId: info.identityId, jobName, @@ -93,8 +103,33 @@ export default route(SubmitJobSchema, async (req, res) => { errorOutput, saveAsTemplate: save, }) - .then(({ jobId }) => ({ 201: { jobId } } as const)) + .then(async ({ jobId }) => { + await callLog( + { ...logInfo, + operationTypeName: OperationType.submitJob, + operationTypePayload: { ... logInfo.operationTypePayload, jobId } }, + OperationResult.SUCCESS, + ); + if (save) { + await callLog( + { + ...logInfo, + operationTypeName: OperationType.addJobTemplate, + operationTypePayload: { ... logInfo.operationTypePayload, jobTemplateId: `${jobName}-${jobId}` }, + }, + OperationResult.SUCCESS, + ); + } + return { 201: { jobId } } as const; + }) .catch(handlegRPCError({ [status.INTERNAL]: (err) => ({ 500: { code: "SCHEDULER_FAILED", message: err.details } } as const), - })); + }, + async () => await callLog( + { ...logInfo, + operationTypeName: OperationType.submitJob, + operationTypePayload: { ... logInfo.operationTypePayload, jobId: -1 }, + }, + OperationResult.FAIL, + ))); }); diff --git a/apps/portal-web/src/pages/api/shell/index.ts b/apps/portal-web/src/pages/api/shell/index.ts index c0511b38af..542f016132 100644 --- a/apps/portal-web/src/pages/api/shell/index.ts +++ b/apps/portal-web/src/pages/api/shell/index.ts @@ -18,8 +18,11 @@ import { normalizePathnameWithQuery } from "@scow/utils"; import { NextApiRequest, NextApiResponse } from "next"; import { join } from "path"; import { checkCookie } from "src/auth/server"; +import { OperationResult, OperationType } from "src/models/operationLog"; +import { callLog } from "src/server/operationLog"; import { getClient } from "src/utils/client"; import { publicConfig, runtimeConfig } from "src/utils/config"; +import { parseIp } from "src/utils/server"; import { parse } from "url"; import { WebSocket, WebSocketServer } from "ws"; @@ -125,6 +128,15 @@ wss.on("connection", async (ws: AliveCheckedWebSocket, req) => { log("Connected to shell"); + await callLog({ + operatorUserId: user.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.shellLogin, + operationTypePayload: { + clusterId: cluster, loginNode: loginNode.address, + }, + }, OperationResult.SUCCESS); + const send = (data: ShellOutputData) => { ws.send(JSON.stringify(data)); }; @@ -146,8 +158,16 @@ wss.on("connection", async (ws: AliveCheckedWebSocket, req) => { } }); - ws.on("error", (err) => { + ws.on("error", async (err) => { log("Error occurred from client. Disconnect.", err); + await callLog({ + operatorUserId: user.identityId, + operatorIp: parseIp(req) ?? "", + operationTypeName: OperationType.shellLogin, + operationTypePayload: { + clusterId: cluster, loginNode: loginNode.address, + }, + }, OperationResult.FAIL); stream.write({ message: { $case: "disconnect", disconnect: {} } }); stream.end(); }); diff --git a/apps/portal-web/src/server/operationLog.ts b/apps/portal-web/src/server/operationLog.ts new file mode 100644 index 0000000000..daa2f4b8a1 --- /dev/null +++ b/apps/portal-web/src/server/operationLog.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { createOperationLogClient, + LogCallParams, OperationEvent, OperationResult } from "@scow/lib-operation-log/build/index"; +import { runtimeConfig } from "src/utils/config"; + +interface PartialLogCallParams + extends Omit, "operationResult" | "logger"> {} + +export const callLog = async ( + { + operatorUserId, + operatorIp, + operationTypeName, + operationTypePayload, + }: PartialLogCallParams, + operationResult: OperationResult, +) => { + + const { callLog } = createOperationLogClient(runtimeConfig.AUDIT_CONFIG, console); + + await callLog( + { + operatorUserId, + operatorIp, + operationTypeName, + operationTypePayload, + operationResult, + logger: console, + }, + ); +}; + diff --git a/apps/portal-web/src/utils/config.ts b/apps/portal-web/src/utils/config.ts index ed30d68967..13f4be21b2 100644 --- a/apps/portal-web/src/utils/config.ts +++ b/apps/portal-web/src/utils/config.ts @@ -10,6 +10,7 @@ * See the Mulan PSL v2 for more details. */ +import { AuditConfigSchema } from "@scow/config/build/audit"; import type { ClusterConfigSchema } from "@scow/config/build/cluster"; import type { PortalConfigSchema } from "@scow/config/build/portal"; import type { UiConfigSchema } from "@scow/config/build/ui"; @@ -47,6 +48,8 @@ export interface ServerRuntimeConfig { SUBMIT_JOB_WORKING_DIR: string; SCOW_API_AUTH_TOKEN?: string; + + AUDIT_CONFIG: AuditConfigSchema | undefined; } export interface PublicRuntimeConfig { diff --git a/apps/portal-web/src/utils/server.ts b/apps/portal-web/src/utils/server.ts index 0fc5b32a12..8d2a9c6eb0 100644 --- a/apps/portal-web/src/utils/server.ts +++ b/apps/portal-web/src/utils/server.ts @@ -12,14 +12,17 @@ import { ServiceError } from "@grpc/grpc-js"; import { Status } from "@grpc/grpc-js/build/src/constants"; +import { IncomingMessage } from "http"; import { NextApiRequest } from "next"; type ValueOf = T[keyof T]; export const handlegRPCError = unknown>>>( handlers: THandlers, + logHandle?: () => void, // @ts-ignore ) => (e: ServiceError): ReturnType> => { + logHandle?.(); const handler = handlers[e.code]; if (handler) { // @ts-ignore @@ -29,7 +32,7 @@ export const handlegRPCError = { +export const parseIp = (req: NextApiRequest | IncomingMessage): string | undefined => { let forwardedFor = req.headers["x-forwarded-for"]; diff --git a/deploy/vagrant/README.md b/deploy/vagrant/README.md index faf04e8a4b..7f08884e39 100644 --- a/deploy/vagrant/README.md +++ b/deploy/vagrant/README.md @@ -10,7 +10,7 @@ | 节点名称/角色 | 主要服务 | 私网IP | 配置 | | :-----------: | :----------------------------------------------------------: | :------------: | :--: | -| scow | scow:portal、mis、auth、gateway | 192.168.88.100 | 4C4G | +| scow | scow:portal、mis、auth、gateway,audit | 192.168.88.100 | 4C4G | | slurm | slurmdbd、slurmctld、slurmd、mariadb、nfs-server、slapd、sssd | 192.168.88.101 | 2C2G | | login | slurmd、sssd、nfs、Xfce、KDE、MATE、cinnamon | 192.168.88.102 | 2C2G | | cn01 | slurmd、sssd、nfs、Xfce、KDE、MATE、cinnamon | 192.168.88.103 | 2C2G | @@ -43,6 +43,9 @@ vagrant ssh scow # 进入scow部署目录 /root/scow/scow-deployment +# 更新cli +./cli update --branch master + # 拉取最新镜像 ./cli compose pull diff --git a/deploy/vagrant/scow/scow-deployment/config/audit.yaml b/deploy/vagrant/scow/scow-deployment/config/audit.yaml new file mode 100644 index 0000000000..064f304771 --- /dev/null +++ b/deploy/vagrant/scow/scow-deployment/config/audit.yaml @@ -0,0 +1,7 @@ +db: + host: audit-db + port: 3306 + user: root + dbName: scow_audit + + diff --git a/deploy/vagrant/scow/scow-deployment/install.yaml b/deploy/vagrant/scow/scow-deployment/install.yaml index 84e4554074..e6a78f0037 100644 --- a/deploy/vagrant/scow/scow-deployment/install.yaml +++ b/deploy/vagrant/scow/scow-deployment/install.yaml @@ -1,6 +1,5 @@ port: 80 basePath: / -#image: ghcr.io/pkuhpc/scow/scow image: mirrors.pku.edu.cn/pkuhpc/scow/scow imageTag: master portal: @@ -13,4 +12,7 @@ log: fluentd: logDir: /var/log/fluentd auth: - portMappings: {} \ No newline at end of file + portMappings: {} +audit: + dbPassword: must!chang3this + portMappings: {} diff --git a/dev/vagrant/README.md b/dev/vagrant/README.md index 9f919eff26..ec9be534a0 100644 --- a/dev/vagrant/README.md +++ b/dev/vagrant/README.md @@ -17,7 +17,7 @@ ssh-copy-id root@192.168.88.102 ssh-copy-id root@192.168.88.103 ``` -3. 打开redis和数据库的端口映射,使得本机可以访问这两个服务 +3. 打开redis和两个数据库的端口映射,使得本机可以访问这三个服务 给vagrant集群的`install.yaml`中增加以下部分,并运行`./cli compose up -d`重启服务。 @@ -30,6 +30,10 @@ mis: auth: portMappings: redis: 6379 + +audit: + portMappings: + db: 3306 ``` 4. 在仓库根目录下,运行`npx pm2 start dev/vagrant/pm2.config.js`启动各个服务 @@ -42,6 +46,7 @@ auth: | http://localhost:5002 | 门户后端 | gRPC | | http://localhost:5003 | 管理前端(无/mis前缀) | HTTP | | http://localhost:5004 | 管理后端 | gRPC | +| http://localhost:5005 | 审计系统 | gRPC | | http://localhost:3890 | 一个phpLDAPadmin,可用于管理LDAP | HTTP | 使用[pm2](https://pm2.keymetrics.io/)在本地启动多个开发用进程,可直接像`pnpm dev`一样,在本地修改文件后,对应系统自动更新。 diff --git a/dev/vagrant/config/audit.yaml b/dev/vagrant/config/audit.yaml new file mode 100644 index 0000000000..951953e578 --- /dev/null +++ b/dev/vagrant/config/audit.yaml @@ -0,0 +1,8 @@ +db: + host: 192.168.88.100 + port: 3306 + user: root + password: must!chang3this + dbName: scow_audit + +url: localhost:5005 diff --git a/dev/vagrant/pm2.config.js b/dev/vagrant/pm2.config.js index 806d438e07..acd5c226fa 100644 --- a/dev/vagrant/pm2.config.js +++ b/dev/vagrant/pm2.config.js @@ -54,6 +54,7 @@ module.exports = { NEXT_PUBLIC_USE_MOCK: 0, SERVER_URL: "localhost:5002", MIS_DEPLOYED: 1, + AUDIT_DEPLOYED: 1, MIS_URL: "localhost:5003", NOVNC_CLIENT_URL: "http://localhost:6080", ...SCOW_CONFIG_PATH_ENV, @@ -85,6 +86,7 @@ module.exports = { NEXT_PUBLIC_USE_MOCK: 0, SERVER_URL: "localhost:5004", PORTAL_DEPLOYED: 1, + AUDIT_DEPLOYED: 1, PORTAL_URL: "localhost:5001", ...SCOW_CONFIG_PATH_ENV, }, @@ -102,6 +104,19 @@ module.exports = { ...SCOW_CONFIG_PATH_ENV, }, }, + { + name: "audit-server", + script: "src/index.ts", + cwd: "./apps/audit-server", + watch: "./apps/audit-server", + interpreter, + interpreter_args, + env: { + PORT: "5005", + ...PRODUCTION_ENV, + ...SCOW_CONFIG_PATH_ENV, + }, + }, { name: "dev:libs", cwd: ".", diff --git a/docs/docs/deploy/config/audit/_category_.json b/docs/docs/deploy/config/audit/_category_.json new file mode 100644 index 0000000000..e0a0a3cb37 --- /dev/null +++ b/docs/docs/deploy/config/audit/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "审计系统", + "position": 5, + "link": { + "type": "generated-index", + "description": "关于审计系统的部署和配置" + } +} diff --git a/docs/docs/deploy/config/audit/intro.md b/docs/docs/deploy/config/audit/intro.md new file mode 100644 index 0000000000..181afe3072 --- /dev/null +++ b/docs/docs/deploy/config/audit/intro.md @@ -0,0 +1,44 @@ +--- +sidebar_position: 3 +title: 配置审计系统 +--- + +# 配置审计系统 + +本节介绍如何配置审计系统。 + +## 修改安装配置文件 + +修改安装配置文件 + +```yaml title="install.yaml" +# 确保审计系统会部署 +audit: + + # dbPassword为审计系统数据库密码 + # 在系统第一次启动前可自由设置,使用此密码可以以root身份登录数据库 + # 一旦数据库启动后即不可修改 + # 必须长于8个字符,并同时包括字母、数字和符号 + dbPassword: "must!chang3this" +``` + +## 编写后端服务配置 + +在`config/audit.yaml`文件中,根据备注修改所需要的配置 + +```yaml title="config/audit.yaml" + +# 审计服务的url,默认不修改 +url: audit-server:5000 +# 审计系统数据库的信息。可以不修改 +db: + host: audit-db + port: 3306 + user: root + password: mysqlrootpassword + dbName: scow_audit +``` + +## 启动服务 + +运行`./cli compose up -d`启动审计服务。 diff --git a/docs/docs/deploy/install/index.md b/docs/docs/deploy/install/index.md index da87422560..c39584a08e 100644 --- a/docs/docs/deploy/install/index.md +++ b/docs/docs/deploy/install/index.md @@ -89,6 +89,7 @@ chmod +x scow-cli 2. [配置认证系统](../config/auth/intro.md) 3. (可选)[配置门户系统](../config/portal/intro.md) 4. (可选)[配置管理系统](../config/mis/intro.md) +5. (可选)[配置审计系统](../config/audit/intro.md) 部署完成后,运行以下命令启动系统。 diff --git a/libs/config/src/audit.ts b/libs/config/src/audit.ts new file mode 100644 index 0000000000..620f8f266c --- /dev/null +++ b/libs/config/src/audit.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { GetConfigFn, getConfigFromFile } from "@scow/lib-config"; +import { Static, Type } from "@sinclair/typebox"; +import { DEFAULT_CONFIG_BASE_PATH } from "src/constants"; + + +export const AuditConfigSchema = Type.Object({ + + url: Type.String({ description: "Audit Server的URL, 默认为audit-server:5000", default: "audit-server:5000" }), + + db: Type.Object({ + host: Type.String({ description: "数据库地址" }), + port: Type.Integer({ description: "数据库端口" }), + user: Type.String({ description: "数据库用户名" }), + password: Type.Optional(Type.String({ description: "数据库密码" })), + dbName: Type.String({ description: "数据库数据库名" }), + debug: Type.Boolean({ description: "打开ORM的debug模式", default: false }), + }), + + +}); + +const AUDIT_CONFIG_NAME = "audit"; + +export type AuditConfigSchema = Static; + +export const getAuditConfig: GetConfigFn = (baseConfigPath) => { + const config = + getConfigFromFile(AuditConfigSchema, AUDIT_CONFIG_NAME, baseConfigPath ?? DEFAULT_CONFIG_BASE_PATH); + + return config; + +}; diff --git a/libs/operation-log/CHANGELOG.md b/libs/operation-log/CHANGELOG.md new file mode 100644 index 0000000000..077c8f52e3 --- /dev/null +++ b/libs/operation-log/CHANGELOG.md @@ -0,0 +1 @@ +# @scow/lib-operation-log diff --git a/libs/operation-log/jest.config.js b/libs/operation-log/jest.config.js new file mode 100644 index 0000000000..4b6b4b5a9e --- /dev/null +++ b/libs/operation-log/jest.config.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +// In the following statement, replace `./tsconfig` with the path to your `tsconfig` file +// which contains the path mapping (ie the `compilerOptions.paths` option): + +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + rootDir: ".", + preset: "ts-jest", + testMatch: [ + "/tests/**/*.test.ts?(x)", + ], + coverageDirectory: "coverage", + testTimeout: 30000, + coverageReporters: ["lcov"], +}; diff --git a/libs/operation-log/package.json b/libs/operation-log/package.json new file mode 100644 index 0000000000..d71be850b8 --- /dev/null +++ b/libs/operation-log/package.json @@ -0,0 +1,30 @@ +{ + "name": "@scow/lib-operation-log", + "version": "1.0.0", + "description": "", + "private": true, + "main": "build/index.js", + "scripts": { + "dev": "tsc -p tsconfig.build.json && (concurrently \"tsc -p tsconfig.build.json -w\" \"tsc-alias -p tsconfig.build.json -w\")", + "build": "rimraf build && tsc -p tsconfig.build.json" + }, + "files": [ + "build", + "!**/*.map" + ], + "author": "PKUHPC (https://github.com/PKUHPC)", + "license": "Mulan PSL v2", + "repository": "https://github.com/PKUHPC/SCOW", + "dependencies": { + "@ddadaal/tsgrpc-client": "0.17.6", + "@grpc/grpc-js": "1.8.21", + "@scow/protos": "workspace:*" + }, + "devDependencies": { + "@scow/config": "workspace:*", + "ts-log": "2.2.5" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/libs/operation-log/src/constant.ts b/libs/operation-log/src/constant.ts new file mode 100644 index 0000000000..6f40bcf213 --- /dev/null +++ b/libs/operation-log/src/constant.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { OperationLog } from "@scow/protos/build/audit/operation_log"; + +export enum OperationResult { + UNKNOWN = 0, + SUCCESS = 1, + FAIL = 2, +}; + +type ExtractCases = T extends { $case: infer U } ? U : never; + +export type OperationType = ExtractCases; + +export type OperationTypeEnum = { [K in OperationType]: K }; diff --git a/libs/operation-log/src/index.ts b/libs/operation-log/src/index.ts new file mode 100644 index 0000000000..a26eedad32 --- /dev/null +++ b/libs/operation-log/src/index.ts @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { asyncUnaryCall } from "@ddadaal/tsgrpc-client"; +import { ChannelCredentials } from "@grpc/grpc-js"; +import { AuditConfigSchema } from "@scow/config/build/audit"; +import { + CreateOperationLogRequest, + GetOperationLogsRequest, + GetOperationLogsResponse, + OperationLogServiceClient, +} from "@scow/protos/build/audit/operation_log"; +import { Logger } from "ts-log"; + +import { OperationResult } from "./constant"; + +export type OperationEvent = Exclude; + +export interface LogCallParams { + operatorUserId: string; + operatorIp: string; + operationTypeName: TName; + // @ts-ignore + operationTypePayload?: (OperationEvent & { $case: TName })[TName]; + operationResult: OperationResult; + logger: Logger; +} + +export const createOperationLogClient = ( + config: AuditConfigSchema | undefined, + logger: Logger | Console, +) => { + const client = config && config.url + ? new OperationLogServiceClient(config.url, ChannelCredentials.createInsecure()) + : undefined; + + if (!config || !client) { + logger.debug("Operation Log Server disabled"); + } + + return { + getLog: async (request: GetOperationLogsRequest): Promise => { + + if (!client) { + logger.debug("Attempt to get Log with %o", request); + return { results: [], totalCount: 0 }; + } + + return await asyncUnaryCall(client, "getOperationLogs", request); + }, + callLog: async ({ + operatorUserId, + operatorIp, + operationTypeName, + operationTypePayload, + operationResult, + logger, + }: LogCallParams) => { + + if (!client) { + logger.debug("Attempt to call Log %s with %o", operationTypeName, operationTypePayload); + return; + } + + return await asyncUnaryCall(client, "createOperationLog", { + operatorUserId, + operatorIp, + operationResult, + // @ts-ignore + operationEvent: { $case: operationTypeName, [operationTypeName]: { ...operationTypePayload } }, + }).then( + () => { logger.debug("Operation Log call completed"); }, + (e) => { + logger.error(e, "Error when calling Operation Log"); + }, + ); + }, + }; +}; + + +export * from "./constant"; diff --git a/libs/operation-log/tsconfig.build.json b/libs/operation-log/tsconfig.build.json new file mode 100644 index 0000000000..3fa3d46ca4 --- /dev/null +++ b/libs/operation-log/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + ], +} diff --git a/libs/operation-log/tsconfig.json b/libs/operation-log/tsconfig.json new file mode 100644 index 0000000000..25c769b715 --- /dev/null +++ b/libs/operation-log/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "build" + }, + "include": [ + "src/**/*.ts", + "tests/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/libs/protos/scow/buf.gen.yaml b/libs/protos/scow/buf.gen.yaml index 28ba6b954c..f8abd9b18d 100644 --- a/libs/protos/scow/buf.gen.yaml +++ b/libs/protos/scow/buf.gen.yaml @@ -7,6 +7,7 @@ plugins: opt: - unrecognizedEnum=false - useDate=string + - useExactTypes=false - oneof=unions - esModuleInterop=true - useOptionals=messages diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89d705f901..0c17e7365a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3,7 +3,6 @@ lockfileVersion: '6.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false - importers: .: @@ -117,6 +116,58 @@ importers: specifier: 5.1.6 version: 5.1.6 + apps/audit-server: + dependencies: + '@ddadaal/tsgrpc-client': + specifier: 0.17.6 + version: 0.17.6(@grpc/grpc-js@1.8.21) + '@ddadaal/tsgrpc-common': + specifier: 0.2.4 + version: 0.2.4 + '@ddadaal/tsgrpc-server': + specifier: 0.19.4 + version: 0.19.4(@grpc/grpc-js@1.8.21) + '@grpc/grpc-js': + specifier: 1.8.21 + version: 1.8.21 + '@mikro-orm/cli': + specifier: 5.7.14 + version: 5.7.14(@mikro-orm/mariadb@5.7.14)(@mikro-orm/migrations@5.7.14)(@mikro-orm/mysql@5.7.14)(@mikro-orm/seeder@5.7.14) + '@mikro-orm/core': + specifier: 5.7.14 + version: 5.7.14(@mikro-orm/mariadb@5.7.14)(@mikro-orm/migrations@5.7.14)(@mikro-orm/mysql@5.7.14)(@mikro-orm/seeder@5.7.14) + '@mikro-orm/migrations': + specifier: 5.7.14 + version: 5.7.14(@mikro-orm/core@5.7.14) + '@mikro-orm/mysql': + specifier: 5.7.14 + version: 5.7.14(@mikro-orm/core@5.7.14)(@mikro-orm/migrations@5.7.14)(@mikro-orm/seeder@5.7.14) + '@scow/config': + specifier: workspace:* + version: link:../../libs/config + '@scow/lib-config': + specifier: workspace:* + version: link:../../libs/libconfig + '@scow/lib-decimal': + specifier: workspace:* + version: link:../../libs/decimal + '@scow/protos': + specifier: workspace:* + version: link:../../libs/protos/scow + '@scow/utils': + specifier: workspace:* + version: link:../../libs/utils + pino: + specifier: 8.15.0 + version: 8.15.0 + pino-pretty: + specifier: 10.2.0 + version: 10.2.0 + devDependencies: + '@types/google-protobuf': + specifier: 3.15.6 + version: 3.15.6 + apps/auth: dependencies: '@fastify/error': @@ -408,6 +459,9 @@ importers: '@scow/lib-decimal': specifier: workspace:* version: link:../../libs/decimal + '@scow/lib-operation-log': + specifier: workspace:* + version: link:../../libs/operation-log '@scow/lib-web': specifier: workspace:* version: link:../../libs/web @@ -644,6 +698,9 @@ importers: '@scow/lib-decimal': specifier: workspace:* version: link:../../libs/decimal + '@scow/lib-operation-log': + specifier: workspace:* + version: link:../../libs/operation-log '@scow/lib-ssh': specifier: workspace:* version: link:../../libs/ssh @@ -942,6 +999,25 @@ importers: specifier: 2.2.5 version: 2.2.5 + libs/operation-log: + dependencies: + '@ddadaal/tsgrpc-client': + specifier: 0.17.6 + version: 0.17.6(@grpc/grpc-js@1.8.21) + '@grpc/grpc-js': + specifier: 1.8.21 + version: 1.8.21 + '@scow/protos': + specifier: workspace:* + version: link:../protos/scow + devDependencies: + '@scow/config': + specifier: workspace:* + version: link:../config + ts-log: + specifier: 2.2.5 + version: 2.2.5 + libs/protos/scheduler-adapter: dependencies: '@grpc/grpc-js': @@ -5238,7 +5314,7 @@ packages: engines: {node: ^8.13.0 || >=10.10.0} dependencies: '@grpc/proto-loader': 0.7.3 - '@types/node': 18.17.3 + '@types/node': 18.17.6 /@grpc/proto-loader@0.7.3: resolution: {integrity: sha512-5dAvoZwna2Py3Ef96Ux9jIkp3iZ62TUsV00p3wVBPNX5K178UbNi8Q7gQVqwXT1Yq9RejIGG9G2IPEo93T6RcA==} @@ -7330,6 +7406,7 @@ packages: /@types/node@18.17.3: resolution: {integrity: sha512-2x8HWtFk0S99zqVQABU9wTpr8wPoaDHZUcAkoTKH+nL7kPv3WUI9cRi/Kk5Mz4xdqXSqTkKP7IWNoQQYCnDsTA==} + dev: true /@types/node@18.17.6: resolution: {integrity: sha512-fGmT/P7z7ecA6bv/ia5DlaWCH4YeZvAQMNpUhrJjtAhOhZfoxS1VLUgU2pdk63efSjQaOJWdXMuAJsws+8I6dg==} @@ -9413,7 +9490,7 @@ packages: peerDependencies: webpack: ^5.1.0 dependencies: - fast-glob: 3.3.0 + fast-glob: 3.3.1 glob-parent: 6.0.2 globby: 13.2.2 normalize-path: 3.0.0 @@ -10959,7 +11036,6 @@ packages: glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.5 - dev: true /fast-json-patch@3.1.1: resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==} diff --git a/protos/audit/operation_log.proto b/protos/audit/operation_log.proto new file mode 100644 index 0000000000..7fbb612787 --- /dev/null +++ b/protos/audit/operation_log.proto @@ -0,0 +1,415 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. +*/ +syntax = "proto3"; + +package scow.audit; + +import "google/protobuf/timestamp.proto"; +import "common/money.proto"; + + +enum OperationResult { + UNKNOWN = 0; + SUCCESS = 1; + FAIL = 2; +} + +message Login { + +} +message Logout { + +} + +message SubmitJob { + string account_name = 1; + uint32 job_id = 2; +} + +message EndJob { + uint32 job_id = 1; +} + +message AddJobTemplate { + string job_template_id = 1; +} + +message DeleteJobTemplate { + string job_template_id = 1; +} + +message UpdateJobTemplate { + string job_template_id = 1; + string new_job_template_id = 2; +} + +message ShellLogin { + string cluster_id = 1; + string login_node = 2; +} + +message CreateDesktop { + string desktop_name = 1; + string wm = 2; +} + +message DeleteDesktop { + uint32 desktop_id = 1; + string login_node = 2; +} + +message CreateApp { + string account_name = 1; + uint32 job_id = 2; +} + +message CreateFile { + string cluster_id = 1; + string path = 2; +} + +message DeleteFile { + string cluster_id = 1; + string path = 2; +} + +message UploadFile { + string cluster_id = 1; + string path = 2; +} + +message CreateDirectory { + string cluster_id = 1; + string path = 2; +} + +message DeleteDirectory { + string cluster_id = 1; + string path = 2; +} + + +message MoveFileItem { + string cluster_id = 1; + string from_path = 2; + string to_path = 3; +} + + +message CopyFileItem { + string cluster_id = 1; + string from_path = 2; + string to_path = 3; +} + +message SetJobTimeLimit { + string account_name = 1; + uint32 job_id = 2; + int64 limit_minutes = 3; +} + +message CreateUser { + string user_id = 1; +} + +message AddUserToAccount { + string account_name = 1; + string user_id = 2; +} + +message RemoveUserFromAccount { + string account_name = 1; + string user_id = 2; +} + +message SetAccountAdmin { + string account_name = 1; + string user_id = 2; +} + +message UnsetAccountAdmin { + string account_name = 1; + string user_id = 2; +} + +message BlockUser { + string account_name = 1; + string user_id = 2; +} + +message UnblockUser { + string account_name = 1; + string user_id = 2; +} + +message AccountSetChargeLimit { + string account_name = 1; + string user_id = 2; + common.Money limit = 3; +} + +message AccountUnsetChargeLimit { + string account_name = 1; + string user_id = 2; +} + +message SetTenantBilling { + string tenant_name = 1; + // 集群_分区_QOS + string path = 2; + // 计量方式 + string amount = 3; + common.Money price = 4; +} + +message SetTenantAdmin { + string tenant_name = 1; + string user_id = 2; +} + +message UnsetTenantAdmin { + string tenant_name = 1; + string user_id = 2; +} + +message SetTenantFinance { + string tenant_name = 1; + string user_id = 2; +} + +message UnsetTenantFinance { + string tenant_name = 1; + string user_id = 2; +} + +message TenantChangePassword { + string tenant_name = 1; + string user_id = 2; +} + +message CreateAccount { + string tenant_name = 1; + string account_name = 2; + string account_owner = 3; +} + +message AddAccountToWhitelist { + string tenant_name = 1; + string account_name = 2; +} + +message RemoveAccountFromWhitelist { + string tenant_name = 1; + string account_name = 2; +} + +message AccountPay { + string tenant_name = 1; + string account_name = 2; + common.Money amount = 3; +} + +message ImportUsers { + string tenant_name = 1; + message ImportAccount { + string account_name = 1; + repeated string user_ids = 2; + } + repeated ImportAccount import_accounts = 2; +} + +message SetPlatformAdmin { + string user_id = 1; +} + +message UnsetPlatformAdmin { + string user_id = 1; +} + +message SetPlatformFinance { + string user_id = 1; +} + +message UnsetPlatformFinance { + string user_id = 1; +} + +message PlatformChangePassword { + string user_id = 2; +} + +message SetPlatformBilling { + // 集群_分区_QOS + string path = 1; + // 计量方式 + string amount = 2; + common.Money price = 3; +} + +message CreateTenant { + string tenant_name = 1; + string tenant_admin = 2; +} + +message TenantPay { + string tenant_name = 1; + common.Money amount = 2; +} + + +message CreateOperationLogRequest { + string operator_user_id = 1; + string operator_ip = 2; + OperationResult operation_result = 3; + oneof operation_event { + Login login = 4; + Logout logout = 5; + SubmitJob submit_job = 6; + EndJob end_job = 7; + AddJobTemplate add_job_template = 8; + DeleteJobTemplate delete_job_template = 9; + UpdateJobTemplate update_job_template = 10; + ShellLogin shell_login = 11; + CreateDesktop create_desktop = 12; + DeleteDesktop delete_desktop = 13; + CreateApp create_app = 14; + CreateFile create_file = 15; + DeleteFile delete_file = 16; + UploadFile upload_file = 17; + CreateDirectory create_directory = 18; + DeleteDirectory delete_directory = 19; + MoveFileItem move_file_item = 20; + CopyFileItem copy_file_item = 21; + SetJobTimeLimit set_job_time_limit = 22; + CreateUser create_user = 23; + AddUserToAccount add_user_to_account = 24; + RemoveUserFromAccount remove_user_from_account = 25; + SetAccountAdmin set_account_admin = 26; + UnsetAccountAdmin unset_account_admin = 27; + BlockUser block_user = 28; + UnblockUser unblock_user = 29; + AccountSetChargeLimit account_set_charge_limit = 30; + AccountUnsetChargeLimit account_unset_charge_limit = 31; + SetTenantBilling set_tenant_billing = 32; + SetTenantAdmin set_tenant_admin = 33; + UnsetTenantAdmin unset_tenant_admin = 34; + SetTenantFinance set_tenant_finance = 35; + UnsetTenantFinance unset_tenant_finance = 36; + TenantChangePassword tenant_change_password = 37; + CreateAccount create_account = 38; + AddAccountToWhitelist add_account_to_whitelist = 39; + RemoveAccountFromWhitelist remove_account_from_whitelist = 40; + AccountPay account_pay = 41; + ImportUsers import_users = 42; + SetPlatformAdmin set_platform_admin = 43; + UnsetPlatformAdmin unset_platform_admin = 44; + SetPlatformFinance set_platform_finance = 45; + UnsetPlatformFinance unset_platform_finance = 46; + PlatformChangePassword platform_change_password = 47; + SetPlatformBilling set_platform_billing = 48; + CreateTenant create_tenant = 49; + TenantPay tenant_pay = 50; + } +} + + +message OperationLog { + uint64 operation_log_id = 1; + string operator_user_id = 2; + string operator_ip = 3; + google.protobuf.Timestamp operation_time = 4; + OperationResult operation_result = 5; + oneof operation_event { + Login login = 6; + Logout logout = 7; + SubmitJob submit_job = 8; + EndJob end_job = 9; + AddJobTemplate add_job_template = 10; + DeleteJobTemplate delete_job_template = 11; + UpdateJobTemplate update_job_template = 12; + ShellLogin shell_login = 13; + CreateDesktop create_desktop = 14; + DeleteDesktop delete_desktop = 15; + CreateApp create_app = 16; + CreateFile create_file = 17; + DeleteFile delete_file = 18; + UploadFile upload_file = 19; + CreateDirectory create_directory = 20; + DeleteDirectory delete_directory = 21; + MoveFileItem move_file_item = 22; + CopyFileItem copy_file_item = 23; + SetJobTimeLimit set_job_time_limit = 24; + CreateUser create_user = 25; + AddUserToAccount add_user_to_account = 26; + RemoveUserFromAccount remove_user_from_account = 27; + SetAccountAdmin set_account_admin = 28; + UnsetAccountAdmin unset_account_admin = 29; + BlockUser block_user = 30; + UnblockUser unblock_user = 31; + AccountSetChargeLimit account_set_charge_limit = 32; + AccountUnsetChargeLimit account_unset_charge_limit = 33; + SetTenantBilling set_tenant_billing = 34; + SetTenantAdmin set_tenant_admin = 35; + UnsetTenantAdmin unset_tenant_admin = 36; + SetTenantFinance set_tenant_finance = 37; + UnsetTenantFinance unset_tenant_finance = 38; + TenantChangePassword tenant_change_password = 39; + CreateAccount create_account = 40; + AddAccountToWhitelist add_account_to_whitelist = 41; + RemoveAccountFromWhitelist remove_account_from_whitelist = 42; + AccountPay account_pay = 43; + ImportUsers import_users = 44; + SetPlatformAdmin set_platform_admin = 45; + UnsetPlatformAdmin unset_platform_admin = 46; + SetPlatformFinance set_platform_finance = 47; + UnsetPlatformFinance unset_platform_finance = 48; + PlatformChangePassword platform_change_password = 49; + SetPlatformBilling set_platform_billing = 50; + CreateTenant create_tenant = 51; + TenantPay tenant_pay = 52; + } +} + +message CreateOperationLogResponse { +} + +message OperationLogFilter { + // if length === 0, get from all operators + repeated string operator_user_ids = 1; + // 如果存在,则表示筛选特定账户的操作日志 + optional string operation_target_account_name = 2; + + // 筛选项如果为空,则返回所有 + optional string operation_type = 3; + optional google.protobuf.Timestamp start_time = 4; + optional google.protobuf.Timestamp end_time = 5; + optional OperationResult operation_result = 6; +} + +message GetOperationLogsRequest { + OperationLogFilter filter = 1; + uint32 page = 2; + // if undefined or 0, page_size is 10 + optional uint64 page_size = 3; +} + +message GetOperationLogsResponse { + repeated OperationLog results = 1; + uint32 total_count = 2; +} + + +service OperationLogService { + rpc CreateOperationLog(CreateOperationLogRequest) returns (CreateOperationLogResponse); + rpc GetOperationLogs(GetOperationLogsRequest) returns (GetOperationLogsResponse); + +} diff --git a/scripts/copyDist.mjs b/scripts/copyDist.mjs index 7d855e8952..b7d31e453c 100644 --- a/scripts/copyDist.mjs +++ b/scripts/copyDist.mjs @@ -42,7 +42,7 @@ if (!lockFile) { throw new Error("No lockfile found"); } let appDirs = process.argv.slice(2); if (appDirs.length === 0) { - appDirs = ["portal-web", "portal-server", "auth", "mis-web", "mis-server", "gateway"] + appDirs = ["portal-web", "portal-server", "auth", "mis-web", "mis-server", "gateway", "audit-server"] .map((x) => join(APPS_BASE_PATH, x)); }