mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 05:29:26 +08:00
feat: add audit manager (#5601)
* feat: 部分代码 * feat: auditmanager * feat: remove plugin and update Audit test case * fix: build error * feat: update audit value * fix: some action like create remove can not work correctly * fix: metadata data * feat: update some dataSource * fix: filterKeys error * style: update code format * feat: add getMetaData test & add audit log in specific module * fix: bug * refactor: registerAction and getAction * refactor: registerAction and getAction * fix: log * fix: fix middleware * chore: add getResourceUk * chore: use response code as status * chore: log error * chore: get role from response header * chore: auth signIn getUserInfo * chore: add X-Forwarded-For * chore: adjust IP acquisition and add log user updateprofile * fix: getAction bug * fix: get ip from header * chore: register uiSchemas actions * chore: register uiSchemas:insertAdjacent * chore: record source and target * chore: record auth:changePassword * chore: add getSourceAndTarget * chore: auditManager tests * fix: delete submodule * chore: delete debug port * fix: module not found * chore: save path and swap the values of source and target --------- Co-authored-by: yujian.sun <yujian.sun@dmall.com> Co-authored-by: sunyujian <565974029@qq.com> Co-authored-by: xilesun <2013xile@gmail.com>
This commit is contained in:
parent
ac7098d0c5
commit
3d1e856d55
@ -34,6 +34,7 @@ server {
|
||||
location ^~ /api/ {
|
||||
proxy_pass http://127.0.0.1:13000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
|
@ -59,6 +59,7 @@ server {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_connect_timeout 600;
|
||||
proxy_send_timeout 600;
|
||||
|
@ -2,9 +2,7 @@
|
||||
"version": "1.5.0-alpha.5",
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"npmClientArgs": [
|
||||
"--ignore-engines"
|
||||
],
|
||||
"npmClientArgs": ["--ignore-engines"],
|
||||
"command": {
|
||||
"version": {
|
||||
"forcePublish": true,
|
||||
|
@ -70,6 +70,7 @@ server {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
add_header Cache-Control 'no-cache, no-store';
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
347
packages/core/server/src/__tests__/audit-manager.test.ts
Normal file
347
packages/core/server/src/__tests__/audit-manager.test.ts
Normal file
@ -0,0 +1,347 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
import { MockServer, mockServer } from '@nocobase/test';
|
||||
import { SourceAndTarget, UserInfo } from '../audit-manager';
|
||||
|
||||
async function createApp(options: any = {}) {
|
||||
const app = mockServer({
|
||||
...options,
|
||||
// 还会有些其他参数配置
|
||||
});
|
||||
// 这里可以补充一些需要特殊处理的逻辑,比如导入测试需要的数据表
|
||||
return app;
|
||||
}
|
||||
|
||||
async function startApp() {
|
||||
const app = createApp();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('audit manager', () => {
|
||||
let app: MockServer;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await startApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// 运行测试后,清空数据库
|
||||
await app.destroy();
|
||||
// 只停止不清空数据库
|
||||
await app.stop();
|
||||
});
|
||||
|
||||
it('register action', () => {
|
||||
app.auditManager.registerAction('create');
|
||||
expect(app.auditManager.resources).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"__default__" => Map {
|
||||
"create" => {
|
||||
"name": "create",
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('register actions with getMetaData and getUserInfo and getresourceUk', () => {
|
||||
const getMetaData = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve({
|
||||
request: {
|
||||
params: {
|
||||
testData: 'testParamData',
|
||||
},
|
||||
body: {
|
||||
testBody: 'testBodyData',
|
||||
},
|
||||
},
|
||||
response: {
|
||||
body: {
|
||||
testBody: 'testBody',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
const getUserInfo = () => {
|
||||
return new Promise<UserInfo>((resolve, reject) => {
|
||||
resolve({
|
||||
userId: '1',
|
||||
});
|
||||
});
|
||||
};
|
||||
const getSourceAndTarget = () => {
|
||||
return new Promise<SourceAndTarget>((resolve, reject) => {
|
||||
resolve({
|
||||
sourceCollection: 'users',
|
||||
sourceRecordUK: '1',
|
||||
targetCollection: 'roles',
|
||||
targetRecordUK: '2',
|
||||
});
|
||||
});
|
||||
};
|
||||
app.auditManager.registerAction({ name: 'create', getMetaData, getUserInfo, getSourceAndTarget });
|
||||
expect(app.auditManager.resources).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"__default__" => Map {
|
||||
"create" => {
|
||||
"getMetaData": [Function],
|
||||
"getSourceAndTarget": [Function],
|
||||
"getUserInfo": [Function],
|
||||
"name": "create",
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('register actions', () => {
|
||||
app.auditManager.registerActions(['create', 'update', 'user:create', 'user:*', 'user:destory', 'role:*']);
|
||||
expect(app.auditManager.resources).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"__default__" => Map {
|
||||
"create" => {
|
||||
"name": "create",
|
||||
},
|
||||
"update" => {
|
||||
"name": "update",
|
||||
},
|
||||
},
|
||||
"user" => Map {
|
||||
"create" => {
|
||||
"name": "user:create",
|
||||
},
|
||||
"__default__" => {
|
||||
"name": "user:*",
|
||||
},
|
||||
"destory" => {
|
||||
"name": "user:destory",
|
||||
},
|
||||
},
|
||||
"role" => Map {
|
||||
"__default__" => {
|
||||
"name": "role:*",
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('register actions with getMetaData', async () => {
|
||||
const getMetaData = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve({
|
||||
request: {
|
||||
params: {
|
||||
testData: 'testParamData',
|
||||
},
|
||||
body: {
|
||||
testBody: 'testBodyData',
|
||||
},
|
||||
},
|
||||
response: {
|
||||
body: {
|
||||
testBody: 'testBody',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
app.auditManager.registerActions(['pm:enable', { name: 'pm:enable', getMetaData }, 'pm:add']);
|
||||
expect(app.auditManager.resources).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"pm" => Map {
|
||||
"enable" => {
|
||||
"getMetaData": [Function],
|
||||
"name": "pm:enable",
|
||||
},
|
||||
"add" => {
|
||||
"name": "pm:add",
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('register actions with getUserInfo', async () => {
|
||||
const getUserInfo = () => {
|
||||
return new Promise<UserInfo>((resolve, reject) => {
|
||||
resolve({
|
||||
userId: '1',
|
||||
roleName: 'test',
|
||||
});
|
||||
});
|
||||
};
|
||||
app.auditManager.registerActions(['pm:enable', { name: 'pm:enable', getUserInfo }, 'pm:add']);
|
||||
expect(app.auditManager.resources).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"pm" => Map {
|
||||
"enable" => {
|
||||
"getUserInfo": [Function],
|
||||
"name": "pm:enable",
|
||||
},
|
||||
"add" => {
|
||||
"name": "pm:add",
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('register actions with getSourceAndTarget', async () => {
|
||||
const getSourceAndTarget = () => {
|
||||
return new Promise<SourceAndTarget>((resolve, reject) => {
|
||||
resolve({
|
||||
sourceCollection: 'users',
|
||||
sourceRecordUK: '1',
|
||||
targetCollection: 'roles',
|
||||
targetRecordUK: '2',
|
||||
});
|
||||
});
|
||||
};
|
||||
app.auditManager.registerActions(['pm:enable', { name: 'pm:enable', getSourceAndTarget }, 'pm:add']);
|
||||
expect(app.auditManager.resources).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"pm" => Map {
|
||||
"enable" => {
|
||||
"getSourceAndTarget": [Function],
|
||||
"name": "pm:enable",
|
||||
},
|
||||
"add" => {
|
||||
"name": "pm:add",
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('getAction', () => {
|
||||
app.auditManager.registerActions(['create', 'update', 'user:create', 'user:*', 'user:destory', 'role:*']);
|
||||
expect(app.auditManager.resources).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"__default__" => Map {
|
||||
"create" => {
|
||||
"name": "create",
|
||||
},
|
||||
"update" => {
|
||||
"name": "update",
|
||||
},
|
||||
},
|
||||
"user" => Map {
|
||||
"create" => {
|
||||
"name": "user:create",
|
||||
},
|
||||
"__default__" => {
|
||||
"name": "user:*",
|
||||
},
|
||||
"destory" => {
|
||||
"name": "user:destory",
|
||||
},
|
||||
},
|
||||
"role" => Map {
|
||||
"__default__" => {
|
||||
"name": "role:*",
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
||||
const action = app.auditManager.getAction('update');
|
||||
expect(action).toMatchInlineSnapshot(`
|
||||
{
|
||||
"name": "update",
|
||||
}
|
||||
`);
|
||||
|
||||
const action2 = app.auditManager.getAction('user:update');
|
||||
expect(action2).toEqual(null);
|
||||
|
||||
const action3 = app.auditManager.getAction('update', 'app');
|
||||
expect(action3).toMatchInlineSnapshot(`
|
||||
{
|
||||
"name": "update",
|
||||
}
|
||||
`);
|
||||
|
||||
const action4 = app.auditManager.getAction('update', 'role');
|
||||
expect(action4).toMatchInlineSnapshot(`
|
||||
{
|
||||
"name": "role:*",
|
||||
}
|
||||
`);
|
||||
|
||||
const action5 = app.auditManager.getAction('update', 'user');
|
||||
expect(action5).toMatchInlineSnapshot(`
|
||||
{
|
||||
"name": "user:*",
|
||||
}
|
||||
`);
|
||||
|
||||
const action6 = app.auditManager.getAction('update', 'department');
|
||||
expect(action6).toMatchInlineSnapshot(`
|
||||
{
|
||||
"name": "update",
|
||||
}
|
||||
`);
|
||||
|
||||
const action7 = app.auditManager.getAction('destory');
|
||||
expect(action7).toEqual(null);
|
||||
});
|
||||
|
||||
it('getAction priority', () => {
|
||||
app.auditManager.registerActions(['list']);
|
||||
const action8 = app.auditManager.getAction('list', 'department');
|
||||
expect(action8).toMatchInlineSnapshot(`
|
||||
{
|
||||
"name": "list",
|
||||
}
|
||||
`);
|
||||
|
||||
app.auditManager.registerActions(['department:*']);
|
||||
const action9 = app.auditManager.getAction('list', 'department');
|
||||
expect(action9).toMatchInlineSnapshot(`
|
||||
{
|
||||
"name": "department:*",
|
||||
}
|
||||
`);
|
||||
|
||||
app.auditManager.registerActions(['department:list']);
|
||||
const action10 = app.auditManager.getAction('list', 'department');
|
||||
expect(action10).toMatchInlineSnapshot(`
|
||||
{
|
||||
"name": "department:list",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('getAction default', () => {
|
||||
app.auditManager.registerActions(['users:updateProfile', 'update']);
|
||||
expect(app.auditManager.resources).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"users" => Map {
|
||||
"updateProfile" => {
|
||||
"name": "users:updateProfile",
|
||||
},
|
||||
},
|
||||
"__default__" => Map {
|
||||
"update" => {
|
||||
"name": "update",
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
const action = app.auditManager.getAction('update', 'users');
|
||||
expect(action).toMatchInlineSnapshot(`
|
||||
{
|
||||
"name": "update",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
@ -73,6 +73,7 @@ import { createPubSubManager, PubSubManager, PubSubManagerOptions } from './pub-
|
||||
import { SyncMessageManager } from './sync-message-manager';
|
||||
|
||||
import packageJson from '../package.json';
|
||||
import { AuditManager } from './audit-manager';
|
||||
|
||||
export type PluginType = string | typeof Plugin;
|
||||
export type PluginConfiguration = PluginType | [PluginType, any];
|
||||
@ -124,7 +125,9 @@ export interface ApplicationOptions {
|
||||
pmSock?: string;
|
||||
name?: string;
|
||||
authManager?: AuthManagerOptions;
|
||||
auditManager?: AuditManager;
|
||||
lockManager?: LockManagerOptions;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
@ -382,6 +385,11 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
return this._authManager;
|
||||
}
|
||||
|
||||
protected _auditManager: AuditManager;
|
||||
get auditManager() {
|
||||
return this._auditManager;
|
||||
}
|
||||
|
||||
protected _locales: Locale;
|
||||
|
||||
/**
|
||||
@ -1207,6 +1215,8 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
...(this.options.authManager || {}),
|
||||
});
|
||||
|
||||
this._auditManager = new AuditManager();
|
||||
|
||||
this.resourceManager.define({
|
||||
name: 'auth',
|
||||
actions: authActions,
|
||||
|
346
packages/core/server/src/audit-manager/index.ts
Normal file
346
packages/core/server/src/audit-manager/index.ts
Normal file
@ -0,0 +1,346 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Context } from '@nocobase/actions';
|
||||
import { head } from 'lodash';
|
||||
import Stream from 'stream';
|
||||
|
||||
function isStream(obj) {
|
||||
return (
|
||||
obj instanceof Stream.Readable ||
|
||||
obj instanceof Stream.Writable ||
|
||||
obj instanceof Stream.Duplex ||
|
||||
obj instanceof Stream.Transform
|
||||
);
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
uuid: string;
|
||||
dataSource: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
sourceCollection?: string;
|
||||
sourceRecordUK?: string;
|
||||
targetCollection?: string;
|
||||
targetRecordUK?: string;
|
||||
userId: string;
|
||||
roleName: string;
|
||||
ip: string;
|
||||
ua: string;
|
||||
status: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
userId?: string;
|
||||
roleName?: string;
|
||||
}
|
||||
|
||||
export interface SourceAndTarget {
|
||||
sourceCollection?: string;
|
||||
sourceRecordUK?: string;
|
||||
targetCollection?: string;
|
||||
targetRecordUK?: string;
|
||||
}
|
||||
|
||||
export interface AuditLogger {
|
||||
log(auditLog: AuditLog): Promise<void>;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| string
|
||||
| {
|
||||
name: string;
|
||||
getMetaData?: (ctx: Context) => Promise<Record<string, any>>;
|
||||
getUserInfo?: (ctx: Context) => Promise<UserInfo>;
|
||||
getSourceAndTarget?: (ctx: Context) => Promise<SourceAndTarget>;
|
||||
};
|
||||
|
||||
export class AuditManager {
|
||||
logger: AuditLogger;
|
||||
resources: Map<string, Map<string, Action>>;
|
||||
|
||||
constructor() {
|
||||
this.resources = new Map();
|
||||
}
|
||||
|
||||
public setLogger(logger: AuditLogger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
/**
|
||||
* 注册需要参与审计的资源和操作,支持几种写法
|
||||
*
|
||||
* 对所有资源生效;
|
||||
* registerActions(['create'])
|
||||
*
|
||||
* 对某个资源的所有操作生效 resource:*
|
||||
* registerActions(['app:*'])
|
||||
*
|
||||
* 对某个资源的某个操作生效 resouce:action
|
||||
* registerAction(['pm:update'])
|
||||
*
|
||||
* 支持传getMetaData方法
|
||||
*
|
||||
* registerActions([
|
||||
* 'create',
|
||||
* { name: 'auth:signIn', getMetaData}
|
||||
* ])
|
||||
*
|
||||
* 支持传getUserInfo方法
|
||||
*
|
||||
* registerActions([
|
||||
* 'create',
|
||||
* { name: 'auth:signIn', getUserInfo }
|
||||
* ])
|
||||
*
|
||||
* 当注册的接口有重叠时,颗粒度细的注册方法优先级更高
|
||||
*
|
||||
* Action1: registerActions(['create']);
|
||||
*
|
||||
* Action2: registerAction([{ name: 'user:*', getMetaData }]);
|
||||
*
|
||||
* Action3: registerAction([{ name: 'user:create', getMetaData }]);
|
||||
*
|
||||
* 对于user:create接口,以上优先级顺序是 Action3 > Action2 > Action1
|
||||
*
|
||||
* @param actions 操作列表
|
||||
*/
|
||||
registerActions(actions: Action[]) {
|
||||
actions.forEach((action) => {
|
||||
this.registerAction(action);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册单个操作,支持的用法同registerActions
|
||||
* @param action 操作
|
||||
*/
|
||||
registerAction(action: Action) {
|
||||
let originAction = '';
|
||||
let getMetaData = null;
|
||||
let getUserInfo = null;
|
||||
let getSourceAndTarget = null;
|
||||
if (typeof action === 'string') {
|
||||
originAction = action;
|
||||
} else {
|
||||
originAction = action.name;
|
||||
getMetaData = action.getMetaData;
|
||||
getUserInfo = action.getUserInfo;
|
||||
getSourceAndTarget = action.getSourceAndTarget;
|
||||
}
|
||||
// 解析originAction, 获取actionName, resourceName
|
||||
const nameRegex = /^[a-zA-Z0-9_-]+$/;
|
||||
const resourceWildcardRegex = /^([a-zA-Z0-9_-]+):\*$/;
|
||||
const resourceAndActionRegex = /^([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]+)$/;
|
||||
let resourceName = '';
|
||||
let actionName = '';
|
||||
if (nameRegex.test(originAction)) {
|
||||
actionName = originAction;
|
||||
resourceName = '__default__';
|
||||
}
|
||||
if (resourceWildcardRegex.test(originAction)) {
|
||||
const match = originAction.match(resourceWildcardRegex);
|
||||
resourceName = match[1];
|
||||
actionName = '__default__';
|
||||
}
|
||||
if (resourceAndActionRegex.test(originAction)) {
|
||||
const match = originAction.match(resourceAndActionRegex);
|
||||
resourceName = match[1];
|
||||
actionName = match[2];
|
||||
}
|
||||
if (!resourceName && !actionName) {
|
||||
return;
|
||||
}
|
||||
let resource = this.resources.get(resourceName);
|
||||
if (!resource) {
|
||||
resource = new Map();
|
||||
this.resources.set(resourceName, resource);
|
||||
}
|
||||
const saveAction: Action = {
|
||||
name: originAction,
|
||||
};
|
||||
if (getMetaData) {
|
||||
saveAction.getMetaData = getMetaData;
|
||||
}
|
||||
if (getUserInfo) {
|
||||
saveAction.getUserInfo = getUserInfo;
|
||||
}
|
||||
if (getSourceAndTarget) {
|
||||
saveAction.getSourceAndTarget = getSourceAndTarget;
|
||||
}
|
||||
resource.set(actionName, saveAction);
|
||||
}
|
||||
|
||||
getAction(action: string, resource?: string) {
|
||||
let resourceName = resource;
|
||||
if (!resource) {
|
||||
resourceName = '__default__';
|
||||
}
|
||||
if (resourceName === '__default__') {
|
||||
const resourceActions = this.resources.get(resourceName);
|
||||
if (resourceActions) {
|
||||
const resourceAction = resourceActions.get(action);
|
||||
if (resourceAction) {
|
||||
return resourceAction;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const resourceActions = this.resources.get(resourceName);
|
||||
if (resourceActions) {
|
||||
let resourceAction = resourceActions.get(action);
|
||||
if (resourceAction) {
|
||||
return resourceAction;
|
||||
} else {
|
||||
resourceAction = resourceActions.get('__default__');
|
||||
if (resourceAction) {
|
||||
return resourceAction;
|
||||
} else {
|
||||
const defaultResourceActions = this.resources.get('__default__');
|
||||
if (defaultResourceActions) {
|
||||
const defaultResourceAction = defaultResourceActions.get(action);
|
||||
if (defaultResourceAction) {
|
||||
return defaultResourceAction;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const resourceActions = this.resources.get('__default__');
|
||||
if (resourceActions) {
|
||||
const resourceAction = resourceActions.get(action);
|
||||
if (resourceAction) {
|
||||
return resourceAction;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getDefaultMetaData(ctx: any) {
|
||||
let body: any = null;
|
||||
if (ctx.body) {
|
||||
if (!Buffer.isBuffer(ctx.body) && !isStream(ctx.body)) {
|
||||
body = ctx.body;
|
||||
}
|
||||
}
|
||||
return {
|
||||
request: {
|
||||
params: ctx.request.params,
|
||||
query: ctx.request.query,
|
||||
body: ctx.request.body,
|
||||
path: ctx.request.path,
|
||||
headers: {
|
||||
'x-authenticator': ctx.request?.headers['x-authenticator'],
|
||||
'x-locale': ctx.request?.headers['x-locale'],
|
||||
'x-timezone': ctx.request?.headers['x-timezone'],
|
||||
},
|
||||
},
|
||||
response: {
|
||||
body,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
formatAuditData(ctx: Context) {
|
||||
const { resourceName } = ctx.action;
|
||||
// 获取ip,优先使用X-Forwarded-For,如果没有则使用ctx.request.ip
|
||||
const ipvalues = ctx.request.header['x-forwarded-for'];
|
||||
let ipvalue = '';
|
||||
if (ipvalues instanceof Array) {
|
||||
ipvalue = ipvalues[0];
|
||||
} else {
|
||||
ipvalue = ipvalues;
|
||||
}
|
||||
const ips = ipvalue ? ipvalue?.split(/\s*,\s*/) : [];
|
||||
|
||||
const auditLog: AuditLog = {
|
||||
uuid: ctx.reqId,
|
||||
dataSource: (ctx.request.header['x-data-source'] || 'main') as string,
|
||||
resource: resourceName,
|
||||
action: ctx.action.actionName,
|
||||
userId: ctx.state?.currentUser?.id,
|
||||
roleName: ctx.state?.currentRole,
|
||||
ip: ips.length > 0 ? ips[0] : ctx.request.ip,
|
||||
ua: ctx.request.header['user-agent'],
|
||||
status: ctx.response.status,
|
||||
};
|
||||
return auditLog;
|
||||
}
|
||||
|
||||
async output(ctx: any, reqId: any, metadata?: Record<string, any>) {
|
||||
try {
|
||||
const { resourceName, actionName } = ctx.action;
|
||||
const action: Action = this.getAction(actionName, resourceName);
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
const auditLog: AuditLog = this.formatAuditData(ctx);
|
||||
auditLog.uuid = reqId;
|
||||
auditLog.status = ctx.status;
|
||||
if (typeof action !== 'string') {
|
||||
if (action.getUserInfo) {
|
||||
const userInfo = await action.getUserInfo(ctx);
|
||||
if (userInfo) {
|
||||
if (userInfo.userId) {
|
||||
auditLog.userId = userInfo.userId;
|
||||
}
|
||||
if (userInfo.roleName) {
|
||||
auditLog.roleName = userInfo.roleName;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action.getMetaData) {
|
||||
const extra = await action.getMetaData(ctx);
|
||||
auditLog.metadata = { ...metadata, ...extra };
|
||||
} else {
|
||||
const defaultMetaData = await this.getDefaultMetaData(ctx);
|
||||
auditLog.metadata = { ...metadata, ...defaultMetaData };
|
||||
}
|
||||
if (action.getSourceAndTarget) {
|
||||
const sourceAndTarget = await action.getSourceAndTarget(ctx);
|
||||
if (sourceAndTarget) {
|
||||
auditLog.sourceCollection = sourceAndTarget.sourceCollection;
|
||||
auditLog.sourceRecordUK = sourceAndTarget.sourceRecordUK;
|
||||
auditLog.targetCollection = sourceAndTarget.targetCollection;
|
||||
auditLog.targetRecordUK = sourceAndTarget.targetRecordUK;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const defaultMetaData = await this.getDefaultMetaData(ctx);
|
||||
auditLog.metadata = { ...metadata, ...defaultMetaData };
|
||||
}
|
||||
this.logger.log(auditLog);
|
||||
} catch (err) {
|
||||
ctx.log?.error(err);
|
||||
}
|
||||
}
|
||||
// 中间件
|
||||
middleware() {
|
||||
return async (ctx: any, next: any) => {
|
||||
const reqId = ctx.reqId;
|
||||
let metadata = {};
|
||||
try {
|
||||
await next();
|
||||
} catch (err) {
|
||||
// 操作失败的时候
|
||||
// HTTP相应状态码和error message 放到 metadata
|
||||
metadata = {
|
||||
status: ctx.status,
|
||||
errMsg: err.message,
|
||||
};
|
||||
throw err;
|
||||
} finally {
|
||||
if (this.logger) {
|
||||
this.output(ctx, reqId, metadata);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
@ -47,6 +47,8 @@ export function registerMiddlewares(app: Application, options: ApplicationOption
|
||||
{ tag: 'generateReqId' },
|
||||
);
|
||||
|
||||
app.use(app.auditManager.middleware(), { tag: 'audit', after: 'generateReqId' });
|
||||
|
||||
app.use(requestLogger(app.name, app.requestLogger, options.logger?.request), { tag: 'logger' });
|
||||
|
||||
app.use(
|
||||
|
@ -15,6 +15,7 @@ export * as middlewares from './middlewares';
|
||||
export * from './migration';
|
||||
export * from './plugin';
|
||||
export * from './plugin-manager';
|
||||
export * from './audit-manager';
|
||||
export * from './pub-sub-manager';
|
||||
export const OFFICIAL_PLUGIN_PREFIX = '@nocobase/plugin-';
|
||||
|
||||
|
@ -455,6 +455,41 @@ export class PluginManager {
|
||||
await this.app.emitAsync('afterLoadPlugin', plugin, options);
|
||||
}
|
||||
|
||||
const getSourceAndTargetForAddAction = async (ctx: any) => {
|
||||
const { packageName } = ctx.action.params;
|
||||
return {
|
||||
targetCollection: 'applicationPlugins',
|
||||
targetRecordUK: packageName,
|
||||
};
|
||||
};
|
||||
|
||||
const getSourceAndTargetForUpdateAction = async (ctx: any) => {
|
||||
let { packageName } = ctx.action.params;
|
||||
if (ctx.file) {
|
||||
packageName = ctx.request.body.packageName;
|
||||
}
|
||||
return {
|
||||
targetCollection: 'applicationPlugins',
|
||||
targetRecordUK: packageName,
|
||||
};
|
||||
};
|
||||
|
||||
const getSourceAndTargetForOtherActions = async (ctx: any) => {
|
||||
const { filterByTk } = ctx.action.params;
|
||||
return {
|
||||
targetCollection: 'applicationPlugins',
|
||||
targetRecordUK: filterByTk,
|
||||
};
|
||||
};
|
||||
|
||||
this.app.auditManager.registerActions([
|
||||
{ name: 'pm:add', getSourceAndTarget: getSourceAndTargetForAddAction },
|
||||
{ name: 'pm:update', getSourceAndTarget: getSourceAndTargetForUpdateAction },
|
||||
{ name: 'pm:enable', getSourceAndTarget: getSourceAndTargetForOtherActions },
|
||||
{ name: 'pm:disable', getSourceAndTarget: getSourceAndTargetForOtherActions },
|
||||
{ name: 'pm:remove', getSourceAndTarget: getSourceAndTargetForOtherActions },
|
||||
]);
|
||||
|
||||
this.app.log.debug('plugins loaded');
|
||||
this.app.setMaintainingMessage('plugins loaded');
|
||||
}
|
||||
|
@ -10,7 +10,6 @@
|
||||
import { Cache } from '@nocobase/cache';
|
||||
import { Model } from '@nocobase/database';
|
||||
import { InstallOptions, Plugin } from '@nocobase/server';
|
||||
import { resolve } from 'path';
|
||||
import { namespace, presetAuthType, presetAuthenticator } from '../preset';
|
||||
import authActions from './actions/auth';
|
||||
import authenticatorsActions from './actions/authenticators';
|
||||
@ -112,6 +111,126 @@ export class PluginAuthServer extends Plugin {
|
||||
this.app.on('cache:del:auth', async ({ userId }) => {
|
||||
await this.cache.del(`auth:${userId}`);
|
||||
});
|
||||
|
||||
this.app.auditManager.registerActions([
|
||||
{
|
||||
name: 'auth:signIn',
|
||||
getMetaData: async (ctx: any) => {
|
||||
let body = {};
|
||||
if (ctx.status === 200) {
|
||||
body = {
|
||||
data: {
|
||||
...ctx.body.data,
|
||||
token: undefined,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
body = ctx.body;
|
||||
}
|
||||
return {
|
||||
request: {
|
||||
params: ctx.request?.params,
|
||||
body: {
|
||||
...ctx.request?.body,
|
||||
password: undefined,
|
||||
},
|
||||
path: ctx.request?.path,
|
||||
headers: {
|
||||
'x-authenticator': ctx.request?.headers['x-authenticator'],
|
||||
'x-locale': ctx.request?.headers['x-locale'],
|
||||
'x-timezone': ctx.request?.headers['x-timezone'],
|
||||
},
|
||||
},
|
||||
response: {
|
||||
body,
|
||||
},
|
||||
};
|
||||
},
|
||||
getUserInfo: async (ctx: any) => {
|
||||
if (!ctx.body?.data?.user) {
|
||||
return null;
|
||||
}
|
||||
// 查询用户角色
|
||||
const userId = ctx.body.data.user.id;
|
||||
const user = await ctx.db.getRepository('users').findOne({
|
||||
filterByTk: userId,
|
||||
});
|
||||
const roles = await user?.getRoles();
|
||||
if (!roles) {
|
||||
return {
|
||||
userId,
|
||||
};
|
||||
} else {
|
||||
if (roles.length === 1) {
|
||||
return {
|
||||
userId,
|
||||
roleName: roles[0].name,
|
||||
};
|
||||
} else {
|
||||
// 多角色的情况下暂时不返回角色名
|
||||
return {
|
||||
userId,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'auth:signUp',
|
||||
getMetaData: async (ctx: any) => {
|
||||
return {
|
||||
request: {
|
||||
params: ctx.request?.params,
|
||||
body: {
|
||||
...ctx.request?.body,
|
||||
password: undefined,
|
||||
confirm_password: undefined,
|
||||
},
|
||||
path: ctx.request?.path,
|
||||
headers: {
|
||||
'x-authenticator': ctx.request?.headers['x-authenticator'],
|
||||
'x-locale': ctx.request?.headers['x-locale'],
|
||||
'x-timezone': ctx.request?.headers['x-timezone'],
|
||||
},
|
||||
},
|
||||
response: {
|
||||
body: {
|
||||
...ctx.response?.body,
|
||||
token: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'auth:changePassword',
|
||||
getMetaData: async (ctx: any) => {
|
||||
return {
|
||||
request: {
|
||||
params: ctx.request.params,
|
||||
query: ctx.request.query,
|
||||
body: {},
|
||||
path: ctx.request.path,
|
||||
headers: {
|
||||
'x-authenticator': ctx.request?.headers['x-authenticator'],
|
||||
'x-locale': ctx.request?.headers['x-locale'],
|
||||
'x-timezone': ctx.request?.headers['x-timezone'],
|
||||
},
|
||||
},
|
||||
response: {
|
||||
body: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
getSourceAndTarget: async (ctx: any) => {
|
||||
return {
|
||||
targetCollection: 'users',
|
||||
targetRecordUK: ctx.auth.user.id,
|
||||
};
|
||||
},
|
||||
},
|
||||
'auth:signOut',
|
||||
]);
|
||||
}
|
||||
|
||||
async install(options?: InstallOptions) {
|
||||
|
@ -118,6 +118,8 @@ export class PluginClientServer extends Plugin {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.app.auditManager.registerActions(['app:restart', 'app:clearCache']);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -97,6 +97,33 @@ export class PluginUISchemaStorageServer extends Plugin {
|
||||
},
|
||||
});
|
||||
|
||||
const getSourceAndTargetForRemoveAction = async (ctx: any) => {
|
||||
const { filterByTk } = ctx.action.params;
|
||||
return {
|
||||
targetCollection: 'uiSchemas',
|
||||
targetRecordUK: filterByTk,
|
||||
};
|
||||
};
|
||||
|
||||
const getSourceAndTargetForInsertAdjacentAction = async (ctx: any) => {
|
||||
return {
|
||||
targetCollection: 'uiSchemas',
|
||||
targetRecordUK: ctx.request.body?.schema?.['x-uid'],
|
||||
};
|
||||
};
|
||||
|
||||
const getSourceAndTargetForPatchAction = async (ctx: any) => {
|
||||
return {
|
||||
targetCollection: 'uiSchemas',
|
||||
targetRecordUK: ctx.request.body?.['x-uid'],
|
||||
};
|
||||
};
|
||||
this.app.auditManager.registerActions([
|
||||
{ name: 'uiSchemas:remove', getSourceAndTarget: getSourceAndTargetForRemoveAction },
|
||||
{ name: 'uiSchemas:insertAdjacent', getSourceAndTarget: getSourceAndTargetForInsertAdjacentAction },
|
||||
{ name: 'uiSchemas:patch', getSourceAndTarget: getSourceAndTargetForPatchAction },
|
||||
]);
|
||||
|
||||
await this.importCollections(resolve(__dirname, 'collections'));
|
||||
}
|
||||
}
|
||||
|
@ -150,6 +150,45 @@ export default class PluginUsersServer extends Plugin {
|
||||
name: `pm.${this.name}`,
|
||||
actions: ['users:*'],
|
||||
});
|
||||
|
||||
const getMetaDataForUpdateProfileAction = async (ctx: any) => {
|
||||
return {
|
||||
request: {
|
||||
params: ctx.request.params,
|
||||
query: ctx.request.query,
|
||||
body: {
|
||||
...ctx.request.body,
|
||||
password: '******',
|
||||
},
|
||||
path: ctx.request.path,
|
||||
},
|
||||
response: {
|
||||
body: ctx.body,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getSourceAndTargetForUpdateProfileAction = async (ctx: any) => {
|
||||
const { id } = ctx.state.currentUser;
|
||||
let idStr = '';
|
||||
if (typeof id === 'number') {
|
||||
idStr = id.toString();
|
||||
} else if (typeof id === 'string') {
|
||||
idStr = id;
|
||||
}
|
||||
return {
|
||||
targetCollection: 'users',
|
||||
targetRecordUK: idStr,
|
||||
};
|
||||
};
|
||||
|
||||
this.app.auditManager.registerActions([
|
||||
{
|
||||
name: 'users:updateProfile',
|
||||
getMetaData: getMetaDataForUpdateProfileAction,
|
||||
getSourceAndTarget: getSourceAndTargetForUpdateProfileAction,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async load() {
|
||||
|
Loading…
x
Reference in New Issue
Block a user