parent
59e8b26015
commit
6475b58541
3
@xen-orchestra/audit-core/.babelrc.js
Normal file
3
@xen-orchestra/audit-core/.babelrc.js
Normal file
@ -0,0 +1,3 @@
|
||||
module.exports = require('../../@xen-orchestra/babel-config')(
|
||||
require('./package.json')
|
||||
)
|
24
@xen-orchestra/audit-core/.npmignore
Normal file
24
@xen-orchestra/audit-core/.npmignore
Normal file
@ -0,0 +1,24 @@
|
||||
/benchmark/
|
||||
/benchmarks/
|
||||
*.bench.js
|
||||
*.bench.js.map
|
||||
|
||||
/examples/
|
||||
example.js
|
||||
example.js.map
|
||||
*.example.js
|
||||
*.example.js.map
|
||||
|
||||
/fixture/
|
||||
/fixtures/
|
||||
*.fixture.js
|
||||
*.fixture.js.map
|
||||
*.fixtures.js
|
||||
*.fixtures.js.map
|
||||
|
||||
/test/
|
||||
/tests/
|
||||
*.spec.js
|
||||
*.spec.js.map
|
||||
|
||||
__snapshots__/
|
40
@xen-orchestra/audit-core/package.json
Normal file
40
@xen-orchestra/audit-core/package.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@xen-orchestra/audit-core",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/audit-core",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@xen-orchestra/audit-core",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.0.0",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
"main": "dist/",
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",
|
||||
"dev": "cross-env NODE_ENV=development babel --watch --source-maps --out-dir=dist/ src/",
|
||||
"postversion": "npm publish --access public",
|
||||
"prebuild": "rimraf dist/",
|
||||
"predev": "yarn run prebuild",
|
||||
"prepublishOnly": "yarn run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.7.4",
|
||||
"@babel/core": "^7.7.4",
|
||||
"@babel/plugin-proposal-decorators": "^7.8.0",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.0",
|
||||
"@babel/preset-env": "^7.7.4",
|
||||
"@babel/preset-typescript": "^7.7.4",
|
||||
"cross": "^1.0.0",
|
||||
"rimraf": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "^3.6.4",
|
||||
"golike-defer": "^0.4.1",
|
||||
"lodash": "^4.17.15",
|
||||
"object-hash": "^2.0.1"
|
||||
},
|
||||
"private": false
|
||||
}
|
127
@xen-orchestra/audit-core/src/index.js
Normal file
127
@xen-orchestra/audit-core/src/index.js
Normal file
@ -0,0 +1,127 @@
|
||||
// see https://github.com/babel/babel/issues/8450
|
||||
import 'core-js/features/symbol/async-iterator'
|
||||
|
||||
import assert from 'assert'
|
||||
import defer from 'golike-defer'
|
||||
import hash from 'object-hash'
|
||||
|
||||
export class Storage {
|
||||
constructor() {
|
||||
this._lock = Promise.resolve()
|
||||
}
|
||||
|
||||
async acquireLock() {
|
||||
const lock = this._lock
|
||||
let releaseLock
|
||||
this._lock = new Promise(resolve => {
|
||||
releaseLock = resolve
|
||||
})
|
||||
await lock
|
||||
return releaseLock
|
||||
}
|
||||
}
|
||||
|
||||
// Format: $<algorithm>$<salt>$<encrypted>
|
||||
//
|
||||
// http://man7.org/linux/man-pages/man3/crypt.3.html#NOTES
|
||||
const ID_TO_ALGORITHM = {
|
||||
'5': 'sha256',
|
||||
}
|
||||
|
||||
export class AlteredRecordError extends Error {
|
||||
constructor(id, nValid, record) {
|
||||
super('altered record')
|
||||
|
||||
this.id = id
|
||||
this.nValid = nValid
|
||||
this.record = record
|
||||
}
|
||||
}
|
||||
|
||||
export class MissingRecordError extends Error {
|
||||
constructor(id, nValid) {
|
||||
super('missing record')
|
||||
|
||||
this.id = id
|
||||
this.nValid = nValid
|
||||
}
|
||||
}
|
||||
|
||||
export const NULL_ID = 'nullId'
|
||||
|
||||
const HASH_ALGORITHM_ID = '5'
|
||||
const createHash = (data, algorithmId = HASH_ALGORITHM_ID) =>
|
||||
`$${algorithmId}$$${hash(data, {
|
||||
algorithm: ID_TO_ALGORITHM[algorithmId],
|
||||
excludeKeys: key => key === 'id',
|
||||
})}`
|
||||
|
||||
export class AuditCore {
|
||||
constructor(storage) {
|
||||
assert.notStrictEqual(storage, undefined)
|
||||
this._storage = storage
|
||||
}
|
||||
|
||||
@defer
|
||||
async add($defer, subject, event, data) {
|
||||
const time = Date.now()
|
||||
const storage = this._storage
|
||||
$defer(await storage.acquireLock())
|
||||
|
||||
// delete "undefined" properties and normalize data with JSON.stringify
|
||||
const record = JSON.parse(
|
||||
JSON.stringify({
|
||||
data,
|
||||
event,
|
||||
previousId: (await storage.getLastId()) ?? NULL_ID,
|
||||
subject,
|
||||
time,
|
||||
})
|
||||
)
|
||||
record.id = createHash(record)
|
||||
await storage.put(record)
|
||||
await storage.setLastId(record.id)
|
||||
return record
|
||||
}
|
||||
|
||||
// TODO: https://github.com/vatesfr/xen-orchestra/pull/4733#discussion_r366897798
|
||||
async checkIntegrity(oldest, newest) {
|
||||
let nValid = 0
|
||||
while (newest !== oldest) {
|
||||
const record = await this._storage.get(newest)
|
||||
if (record === undefined) {
|
||||
throw new MissingRecordError(newest, nValid)
|
||||
}
|
||||
if (
|
||||
newest !== createHash(record, newest.slice(1, newest.indexOf('$', 1)))
|
||||
) {
|
||||
throw new AlteredRecordError(newest, nValid, record)
|
||||
}
|
||||
newest = record.previousId
|
||||
nValid++
|
||||
}
|
||||
return nValid
|
||||
}
|
||||
|
||||
async *getFrom(newest) {
|
||||
const storage = this._storage
|
||||
|
||||
let id = newest ?? (await storage.getLastId())
|
||||
if (id === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
let record
|
||||
while ((record = await storage.get(id)) !== undefined) {
|
||||
yield record
|
||||
id = record.previousId
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFrom(newest) {
|
||||
assert.notStrictEqual(newest, undefined)
|
||||
for await (const { id } of this.getFrom(newest)) {
|
||||
await this._storage.del(id)
|
||||
}
|
||||
}
|
||||
}
|
126
@xen-orchestra/audit-core/src/index.spec.js
Normal file
126
@xen-orchestra/audit-core/src/index.spec.js
Normal file
@ -0,0 +1,126 @@
|
||||
/* eslint-env jest */
|
||||
|
||||
import {
|
||||
AlteredRecordError,
|
||||
AuditCore,
|
||||
MissingRecordError,
|
||||
NULL_ID,
|
||||
Storage,
|
||||
} from '.'
|
||||
|
||||
const asyncIteratorToArray = async asyncIterator => {
|
||||
const array = []
|
||||
for await (const entry of asyncIterator) {
|
||||
array.push(entry)
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
class DB extends Storage {
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this._db = new Map()
|
||||
this._lastId = undefined
|
||||
}
|
||||
|
||||
async put(record) {
|
||||
this._db.set(record.id, record)
|
||||
}
|
||||
|
||||
async setLastId(id) {
|
||||
this._lastId = id
|
||||
}
|
||||
|
||||
async getLastId() {
|
||||
return this._lastId
|
||||
}
|
||||
|
||||
async del(id) {
|
||||
this._db.delete(id)
|
||||
}
|
||||
|
||||
async get(id) {
|
||||
return this._db.get(id)
|
||||
}
|
||||
|
||||
_clear() {
|
||||
return this._db.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const DATA = [
|
||||
[
|
||||
{
|
||||
name: 'subject0',
|
||||
},
|
||||
'event0',
|
||||
{},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'subject1',
|
||||
},
|
||||
'event1',
|
||||
{},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'subject2',
|
||||
},
|
||||
'event2',
|
||||
{},
|
||||
],
|
||||
]
|
||||
|
||||
const db = new DB()
|
||||
const auditCore = new AuditCore(db)
|
||||
const storeAuditRecords = async () => {
|
||||
await Promise.all(DATA.map(data => auditCore.add(...data)))
|
||||
const records = await asyncIteratorToArray(auditCore.getFrom())
|
||||
expect(records.length).toBe(DATA.length)
|
||||
return records
|
||||
}
|
||||
|
||||
describe('auditCore', () => {
|
||||
afterEach(() => db._clear())
|
||||
|
||||
it('detects that a record is missing', async () => {
|
||||
const [newestRecord, deletedRecord] = await storeAuditRecords()
|
||||
|
||||
const nValidRecords = await auditCore.checkIntegrity(
|
||||
NULL_ID,
|
||||
newestRecord.id
|
||||
)
|
||||
expect(nValidRecords).toBe(DATA.length)
|
||||
|
||||
await db.del(deletedRecord.id)
|
||||
await expect(
|
||||
auditCore.checkIntegrity(NULL_ID, newestRecord.id)
|
||||
).rejects.toEqual(new MissingRecordError(deletedRecord.id, 1))
|
||||
})
|
||||
|
||||
it('detects that a record has been altered', async () => {
|
||||
const [newestRecord, alteredRecord] = await storeAuditRecords()
|
||||
|
||||
alteredRecord.event = ''
|
||||
await db.put(alteredRecord)
|
||||
|
||||
await expect(
|
||||
auditCore.checkIntegrity(NULL_ID, newestRecord.id)
|
||||
).rejects.toEqual(
|
||||
new AlteredRecordError(alteredRecord.id, 1, alteredRecord)
|
||||
)
|
||||
})
|
||||
|
||||
it('confirms interval integrity after deletion of records outside of the interval', async () => {
|
||||
const [thirdRecord, secondRecord, firstRecord] = await storeAuditRecords()
|
||||
|
||||
await auditCore.deleteFrom(secondRecord.id)
|
||||
|
||||
expect(await db.get(firstRecord.id)).toBe(undefined)
|
||||
expect(await db.get(secondRecord.id)).toBe(undefined)
|
||||
|
||||
await auditCore.checkIntegrity(secondRecord.id, thirdRecord.id)
|
||||
})
|
||||
})
|
25
@xen-orchestra/audit-core/src/specification.ts
Normal file
25
@xen-orchestra/audit-core/src/specification.ts
Normal file
@ -0,0 +1,25 @@
|
||||
class Storage {
|
||||
acquire: () => Promise<() => undefined>
|
||||
del: (id: string) => Promise<void>
|
||||
get: (id: string) => Promise<Record | void>
|
||||
getLastId: () => Promise<string | void>
|
||||
put: (record: Record) => Promise<void>
|
||||
setLastId: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
interface Record {
|
||||
data: object
|
||||
event: string
|
||||
id: string
|
||||
previousId: string
|
||||
subject: object
|
||||
time: number
|
||||
}
|
||||
|
||||
export class AuditCore {
|
||||
constructor(storage: Storage) {}
|
||||
public add(subject: any, event: string, data: any): Promise<Record> {}
|
||||
public checkIntegrity(oldest: string, newest: string): Promise<number> {}
|
||||
public getFrom(newest?: string): AsyncIterator {}
|
||||
public deleteFrom(newest: string): Promise<void> {}
|
||||
}
|
@ -13,6 +13,7 @@
|
||||
- [Menu] Display a warning icon in case of missing patches [#4475](https://github.com/vatesfr/xen-orchestra/issues/4475) (PR [#4683](https://github.com/vatesfr/xen-orchestra/pull/4683))
|
||||
- [SR/general] Clickable SR usage graph: shows the corresponding disks when you click on one of the sections [#4747](https://github.com/vatesfr/xen-orchestra/issues/4747) (PR [#4754](https://github.com/vatesfr/xen-orchestra/pull/4754))
|
||||
- [New VM] Ability to copy host BIOS strings [#4204](https://github.com/vatesfr/xen-orchestra/issues/4204) (PR [4755](https://github.com/vatesfr/xen-orchestra/pull/4755))
|
||||
- [Audit log] Record side effects triggered by users [#4653](https://github.com/vatesfr/xen-orchestra/issues/4653) [#701](https://github.com/vatesfr/xen-orchestra/issues/701) (PR [#4740](https://github.com/vatesfr/xen-orchestra/pull/4740))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
@ -29,6 +30,9 @@
|
||||
>
|
||||
> Rule of thumb: add packages on top.
|
||||
|
||||
- xo-common v0.4.0
|
||||
- @xen-orchestra/audit-core v0.1.0
|
||||
- xo-server-audit v0.1.0
|
||||
- xo-server-auth-ldap v0.7.0
|
||||
- xo-server-usage-report v0.7.4
|
||||
- xo-server-sdn-controller v0.4.0
|
||||
|
@ -180,3 +180,20 @@ export const operationFailed = create(21, ({ objectId, code }) => ({
|
||||
},
|
||||
message: 'operation failed',
|
||||
}))
|
||||
|
||||
export const missingAuditRecord = create(22, ({ id, nValid }) => ({
|
||||
data: {
|
||||
id,
|
||||
nValid,
|
||||
},
|
||||
message: 'missing record',
|
||||
}))
|
||||
|
||||
export const alteredAuditRecord = create(23, ({ id, record, nValid }) => ({
|
||||
data: {
|
||||
id,
|
||||
record,
|
||||
nValid,
|
||||
},
|
||||
message: 'altered record',
|
||||
}))
|
||||
|
3
packages/xo-server-audit/.babelrc.js
Normal file
3
packages/xo-server-audit/.babelrc.js
Normal file
@ -0,0 +1,3 @@
|
||||
module.exports = require('../../@xen-orchestra/babel-config')(
|
||||
require('./package.json')
|
||||
)
|
10
packages/xo-server-audit/.npmignore
Normal file
10
packages/xo-server-audit/.npmignore
Normal file
@ -0,0 +1,10 @@
|
||||
/examples/
|
||||
example.js
|
||||
example.js.map
|
||||
*.example.js
|
||||
*.example.js.map
|
||||
|
||||
/test/
|
||||
/tests/
|
||||
*.spec.js
|
||||
*.spec.js.map
|
50
packages/xo-server-audit/README.md
Normal file
50
packages/xo-server-audit/README.md
Normal file
@ -0,0 +1,50 @@
|
||||
# xo-server-audit [](https://travis-ci.org/vatesfr/xen-orchestra)
|
||||
|
||||
> **TODO**
|
||||
|
||||
## Install
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/xo-server-audit):
|
||||
|
||||
```
|
||||
> npm install --global xo-server-audit
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Like all other xo-server plugins, it can be configured directly via
|
||||
the web interface, see [the plugin documentation](https://xen-orchestra.com/docs/plugins.html).
|
||||
|
||||
## Development
|
||||
|
||||
```
|
||||
# Install dependencies
|
||||
> yarn
|
||||
|
||||
# Run the tests
|
||||
> yarn test
|
||||
|
||||
# Continuously compile
|
||||
> yarn dev
|
||||
|
||||
# Continuously run the tests
|
||||
> yarn dev-test
|
||||
|
||||
# Build for production
|
||||
> yarn build
|
||||
```
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
the code.
|
||||
|
||||
You may:
|
||||
|
||||
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
|
||||
you've encountered;
|
||||
- fork and create a pull request.
|
||||
|
||||
## License
|
||||
|
||||
AGPL3 © [Vates SAS](http://vates.fr)
|
39
packages/xo-server-audit/config.toml
Normal file
39
packages/xo-server-audit/config.toml
Normal file
@ -0,0 +1,39 @@
|
||||
# List of skipped methods
|
||||
blockedList = [
|
||||
'acl.get',
|
||||
'acl.getCurrentPermissions',
|
||||
'audit.checkIntegrity',
|
||||
'audit.generateFingerprint',
|
||||
'audit.getRecords',
|
||||
'backupNg.getAllJobs',
|
||||
'backupNg.getAllLogs',
|
||||
'cloud.getResourceCatalog',
|
||||
'cloudConfig.getAll',
|
||||
'group.getAll',
|
||||
'host.stats',
|
||||
'ipPool.getAll',
|
||||
'job.getAll',
|
||||
'log.get',
|
||||
'metadataBackup.getAllJobs',
|
||||
'plugin.get',
|
||||
'pool.listMissingPatches',
|
||||
'proxy.getAll',
|
||||
'remote.getAll',
|
||||
'remote.getAllInfo',
|
||||
'resourceSet.getAll',
|
||||
'role.getAll',
|
||||
'schedule.getAll',
|
||||
'server.getAll',
|
||||
'session.getUser',
|
||||
'sr.getUnhealthyVdiChainsLength',
|
||||
'sr.stats',
|
||||
'system.getMethodsInfo',
|
||||
'system.getServerTimezone',
|
||||
'system.getServerVersion',
|
||||
'user.getAll',
|
||||
'vm.stats',
|
||||
'xo.getAllObjects',
|
||||
'xoa.supportTunnel.getState',
|
||||
'xosan.checkSrCurrentState',
|
||||
'xosan.getVolumeInfo',
|
||||
]
|
60
packages/xo-server-audit/package.json
Normal file
60
packages/xo-server-audit/package.json
Normal file
@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "xo-server-audit",
|
||||
"version": "0.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Audit plugin for XO-Server",
|
||||
"keywords": [
|
||||
"audit",
|
||||
"log",
|
||||
"logs",
|
||||
"orchestra",
|
||||
"plugin",
|
||||
"xen-orchestra",
|
||||
"xen",
|
||||
"xo-server"
|
||||
],
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-audit",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-audit",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Julien Fontanet",
|
||||
"email": "julien.fontanet@isonoe.net"
|
||||
},
|
||||
"preferGlobal": false,
|
||||
"main": "dist/",
|
||||
"bin": {},
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.7.0",
|
||||
"@babel/core": "^7.7.2",
|
||||
"@babel/preset-env": "^7.7.1",
|
||||
"cross-env": "^6.0.3",
|
||||
"rimraf": "^3.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",
|
||||
"dev": "cross-env NODE_ENV=development babel --watch --source-maps --out-dir=dist/ src/",
|
||||
"prebuild": "rimraf dist/",
|
||||
"predev": "yarn run prebuild",
|
||||
"prepublishOnly": "yarn run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/audit-core": "^0.0.0",
|
||||
"@xen-orchestra/log": "^0.2.0",
|
||||
"app-conf": "^0.7.1",
|
||||
"async-iterator-to-stream": "^1.1.0",
|
||||
"promise-toolbox": "^0.15.0",
|
||||
"readable-stream": "^3.5.0",
|
||||
"xo-common": "^0.3.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
222
packages/xo-server-audit/src/index.js
Normal file
222
packages/xo-server-audit/src/index.js
Normal file
@ -0,0 +1,222 @@
|
||||
import appConf from 'app-conf'
|
||||
import asyncIteratorToStream from 'async-iterator-to-stream'
|
||||
import createLogger from '@xen-orchestra/log'
|
||||
import path from 'path'
|
||||
import { alteredAuditRecord, missingAuditRecord } from 'xo-common/api-errors'
|
||||
import { fromCallback } from 'promise-toolbox'
|
||||
import { pipeline } from 'readable-stream'
|
||||
import {
|
||||
AlteredRecordError,
|
||||
AuditCore,
|
||||
MissingRecordError,
|
||||
NULL_ID,
|
||||
Storage,
|
||||
} from '@xen-orchestra/audit-core'
|
||||
|
||||
const log = createLogger('xo:xo-server-audit')
|
||||
|
||||
const LAST_ID = 'lastId'
|
||||
class Db extends Storage {
|
||||
constructor(db) {
|
||||
super()
|
||||
this._db = db
|
||||
}
|
||||
|
||||
async put(record) {
|
||||
await this._db.put(record.id, record)
|
||||
}
|
||||
|
||||
async del(id) {
|
||||
await this._db.del(id)
|
||||
}
|
||||
|
||||
get(id) {
|
||||
return this._db.get(id).catch(error => {
|
||||
if (!error.notFound) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async setLastId(id) {
|
||||
await this._db.put(LAST_ID, id)
|
||||
}
|
||||
|
||||
getLastId() {
|
||||
return this.get(LAST_ID)
|
||||
}
|
||||
}
|
||||
|
||||
const NAMESPACE = 'audit'
|
||||
class AuditXoPlugin {
|
||||
constructor({ xo }) {
|
||||
this._cleaners = []
|
||||
this._xo = xo
|
||||
|
||||
this._auditCore = undefined
|
||||
this._blockedList = undefined
|
||||
this._storage = undefined
|
||||
}
|
||||
|
||||
async load() {
|
||||
const cleaners = this._cleaners
|
||||
|
||||
try {
|
||||
const storage = (this._storage = new Db(
|
||||
await this._xo.getStore(NAMESPACE)
|
||||
))
|
||||
this._auditCore = new AuditCore(storage)
|
||||
this._blockedList = (
|
||||
await appConf.load('xo-server-audit', {
|
||||
appDir: path.join(__dirname, '..'),
|
||||
})
|
||||
).blockedList
|
||||
|
||||
cleaners.push(() => {
|
||||
this._auditCore = undefined
|
||||
this._blockedList = undefined
|
||||
this._storage = undefined
|
||||
})
|
||||
} catch (error) {
|
||||
this._auditCore = undefined
|
||||
this._blockedList = undefined
|
||||
this._storage = undefined
|
||||
throw error
|
||||
}
|
||||
|
||||
this._addListener('xo:postCall', this._handleEvent.bind(this, 'apiCall'))
|
||||
this._addListener('xo:audit', this._handleEvent.bind(this))
|
||||
|
||||
const getRecords = this._getRecords.bind(this)
|
||||
getRecords.description = 'Get records from a passed record ID'
|
||||
getRecords.permission = 'admin'
|
||||
getRecords.params = {
|
||||
id: { type: 'string', optional: true },
|
||||
ndjson: { type: 'boolean', optional: true },
|
||||
}
|
||||
|
||||
const checkIntegrity = this._checkIntegrity.bind(this)
|
||||
checkIntegrity.description =
|
||||
'Check records integrity between oldest and newest'
|
||||
checkIntegrity.permission = 'admin'
|
||||
checkIntegrity.params = {
|
||||
newest: { type: 'string', optional: true },
|
||||
oldest: { type: 'string', optional: true },
|
||||
}
|
||||
|
||||
const generateFingerprint = this._generateFingerprint.bind(this)
|
||||
generateFingerprint.description =
|
||||
'Generate a fingerprint of the chain oldest-newest'
|
||||
generateFingerprint.permission = 'admin'
|
||||
generateFingerprint.params = {
|
||||
newest: { type: 'string', optional: true },
|
||||
oldest: { type: 'string', optional: true },
|
||||
}
|
||||
|
||||
cleaners.push(
|
||||
this._xo.addApiMethods({
|
||||
audit: {
|
||||
checkIntegrity,
|
||||
generateFingerprint,
|
||||
getRecords,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
unload() {
|
||||
this._cleaners.forEach(cleaner => cleaner())
|
||||
this._cleaners.length = 0
|
||||
}
|
||||
|
||||
_addListener(event, listener_) {
|
||||
const listener = async (...args) => {
|
||||
try {
|
||||
await listener_(...args)
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
}
|
||||
}
|
||||
const xo = this._xo
|
||||
xo.on(event, listener)
|
||||
this._cleaners.push(() => xo.removeListener(event, listener))
|
||||
}
|
||||
|
||||
_handleEvent(event, { userId, userIp, userName, ...data }) {
|
||||
if (event !== 'apiCall' || this._blockedList.indexOf(data.method) === -1) {
|
||||
return this._auditCore.add(
|
||||
{
|
||||
userId,
|
||||
userIp,
|
||||
userName,
|
||||
},
|
||||
event,
|
||||
data
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
_handleGetRecords(req, res, id) {
|
||||
res.set('Content-Type', 'application/json')
|
||||
return fromCallback(
|
||||
pipeline,
|
||||
asyncIteratorToStream(async function*(asyncIterator) {
|
||||
for await (const record of asyncIterator) {
|
||||
yield JSON.stringify(record)
|
||||
yield '\n'
|
||||
}
|
||||
})(this._auditCore.getFrom(id)),
|
||||
res
|
||||
)
|
||||
}
|
||||
|
||||
async _getRecords({ id, ndjson = false }) {
|
||||
if (ndjson) {
|
||||
return this._xo
|
||||
.registerHttpRequest(this._handleGetRecords.bind(this), id)
|
||||
.then($getFrom => ({
|
||||
$getFrom,
|
||||
}))
|
||||
}
|
||||
|
||||
const records = []
|
||||
for await (const record of this._auditCore.getFrom(id)) {
|
||||
records.push(record)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
async _checkIntegrity(props) {
|
||||
const { oldest = NULL_ID, newest = await this._storage.getLastId() } = props
|
||||
return this._auditCore.checkIntegrity(oldest, newest).catch(error => {
|
||||
if (error instanceof MissingRecordError) {
|
||||
throw missingAuditRecord(error)
|
||||
}
|
||||
if (error instanceof AlteredRecordError) {
|
||||
throw alteredAuditRecord(error)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
async _generateFingerprint(props) {
|
||||
const { oldest = NULL_ID, newest = await this._storage.getLastId() } = props
|
||||
try {
|
||||
return {
|
||||
fingerprint: `${oldest}|${newest}`,
|
||||
nValid: await this._checkIntegrity({ oldest, newest }),
|
||||
}
|
||||
} catch (error) {
|
||||
if (missingAuditRecord.is(error) || alteredAuditRecord.is(error)) {
|
||||
return {
|
||||
fingerprint: `${error.data.id}|${newest}`,
|
||||
nValid: error.data.nValid,
|
||||
error,
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default opts => new AuditXoPlugin(opts)
|
@ -525,6 +525,7 @@ const setUpApi = (webServer, xo, config) => {
|
||||
|
||||
// Create the abstract XO object for this connection.
|
||||
const connection = xo.createUserConnection()
|
||||
connection.set('user_ip', remoteAddress)
|
||||
connection.once('close', () => {
|
||||
socket.close()
|
||||
})
|
||||
@ -606,7 +607,21 @@ const setUpConsoleProxy = (webServer, xo) => {
|
||||
|
||||
const { remoteAddress } = socket
|
||||
log.info(`+ Console proxy (${user.name} - ${remoteAddress})`)
|
||||
|
||||
const data = {
|
||||
timestamp: Date.now(),
|
||||
userId: user.id,
|
||||
userIp: remoteAddress,
|
||||
userName: user.name,
|
||||
vmId: id,
|
||||
}
|
||||
xo.emit('xo:audit', 'consoleOpened', data)
|
||||
|
||||
socket.on('close', () => {
|
||||
xo.emit('xo:audit', 'consoleClosed', {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
log.info(`- Console proxy (${user.name} - ${remoteAddress})`)
|
||||
})
|
||||
}
|
||||
|
@ -255,6 +255,7 @@ export default class Api {
|
||||
.slice(2),
|
||||
userId,
|
||||
userName,
|
||||
userIp: session.get('user_ip'),
|
||||
method: name,
|
||||
params: sensitiveValues.replace(params, '* obfuscated *'),
|
||||
timestamp: Date.now(),
|
||||
@ -303,6 +304,13 @@ export default class Api {
|
||||
)}] ==> ${kindOf(result)}`
|
||||
)
|
||||
|
||||
// it's a special case in which the user is defined at the end of the call
|
||||
if (data.method === 'session.signIn') {
|
||||
const { id, email } = await xo.getUser(session.get('user_id'))
|
||||
data.userId = id
|
||||
data.userName = email
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
xo.emit('xo:postCall', {
|
||||
...data,
|
||||
|
@ -63,6 +63,8 @@ const messages = {
|
||||
dhcp: 'DHCP',
|
||||
ip: 'IP',
|
||||
static: 'Static',
|
||||
user: 'User',
|
||||
deletedUser: 'deleted ({ name })',
|
||||
|
||||
// ----- Modals -----
|
||||
alertOk: 'OK',
|
||||
@ -114,6 +116,7 @@ const messages = {
|
||||
notificationsPage: 'Notifications',
|
||||
supportPage: 'Support',
|
||||
settingsPage: 'Settings',
|
||||
settingsAuditPage: 'Audit',
|
||||
settingsServersPage: 'Servers',
|
||||
settingsUsersPage: 'Users',
|
||||
settingsGroupsPage: 'Groups',
|
||||
@ -2220,6 +2223,29 @@ const messages = {
|
||||
recipeSshKeyLabel: 'SSH key',
|
||||
recipeNetworkCidr: 'Network CIDR',
|
||||
|
||||
// Audit
|
||||
auditActionEvent: 'Action/Event',
|
||||
auditAlteredRecord:
|
||||
'The record ({ id }) was altered ({ n, number } valid records)',
|
||||
auditCheckIntegrity: 'Check integrity',
|
||||
auditCopyFingerprintToClipboard: 'Copy fingerprint to clipboard',
|
||||
auditGenerateNewFingerprint: 'Generate a new fingerprint',
|
||||
auditMissingRecord:
|
||||
'The record ({ id }) is missing ({ n, number } valid records)',
|
||||
auditEnterFingerprint: 'Fingerprint',
|
||||
auditEnterFingerprintInfo:
|
||||
"Enter the saved fingerprint to check the previous logs' integrity. If you don't have any, click OK.",
|
||||
auditRecord: 'Audit record',
|
||||
auditIntegrityVerified: 'Integrity verified',
|
||||
auditSaveFingerprintInfo:
|
||||
'Keep this fingerprint to be able to check the integrity of the current records later.',
|
||||
auditSaveFingerprintInErrorInfo:
|
||||
'However, if you trust the current state of the records, keep this fingerprint to be able to check their integrity later.',
|
||||
auditNewFingerprint: 'New fingerprint',
|
||||
displayAuditRecord: 'Display record',
|
||||
noAuditRecordAvailable: 'No audit record available',
|
||||
refreshAuditRecordsList: 'Refresh records list',
|
||||
|
||||
// Licenses
|
||||
xosanUnregisteredDisclaimer:
|
||||
'You are not registered and therefore will not be able to create or manage your XOSAN SRs. {link}',
|
||||
|
@ -12,7 +12,12 @@ import Tooltip from './tooltip'
|
||||
import { addSubscriptions, connectStore, formatSize } from './utils'
|
||||
import { createGetObject, createSelector } from './selectors'
|
||||
import { FormattedDate } from 'react-intl'
|
||||
import { isSrWritable, subscribeProxies, subscribeRemotes } from './xo'
|
||||
import {
|
||||
isSrWritable,
|
||||
subscribeProxies,
|
||||
subscribeRemotes,
|
||||
subscribeUsers,
|
||||
} from './xo'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@ -407,6 +412,47 @@ Vgpu.propTypes = {
|
||||
|
||||
// ===================================================================
|
||||
|
||||
export const User = decorate([
|
||||
addSubscriptions(({ id }) => ({
|
||||
user: cb =>
|
||||
subscribeUsers(users => {
|
||||
const user = users.find(user => user.id === id)
|
||||
cb(user === undefined ? null : user)
|
||||
}),
|
||||
})),
|
||||
({ defaultRender, id, link, newTab, user }) => {
|
||||
if (user === undefined) {
|
||||
return <Icon icon='loading' />
|
||||
}
|
||||
if (user === null) {
|
||||
return defaultRender || unknowItem(id, 'user')
|
||||
}
|
||||
return (
|
||||
<LinkWrapper
|
||||
link={link}
|
||||
newTab={newTab}
|
||||
to={`/settings/users?s=id:${id}`}
|
||||
>
|
||||
<Icon icon='user' /> {user.email}
|
||||
</LinkWrapper>
|
||||
)
|
||||
},
|
||||
])
|
||||
|
||||
User.propTypes = {
|
||||
defaultRender: PropTypes.node,
|
||||
id: PropTypes.string.isRequired,
|
||||
link: PropTypes.bool,
|
||||
newTab: PropTypes.bool,
|
||||
}
|
||||
|
||||
User.defaultProps = {
|
||||
link: false,
|
||||
newTab: false,
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const xoItemToRender = {
|
||||
// Subscription objects.
|
||||
cloudConfig: template => (
|
||||
@ -422,11 +468,7 @@ const xoItemToRender = {
|
||||
remote: ({ value: { id } }) => <Remote id={id} />,
|
||||
proxy: ({ id }) => <Proxy id={id} />,
|
||||
role: role => <span>{role.name}</span>,
|
||||
user: user => (
|
||||
<span>
|
||||
<Icon icon='user' /> {user.email}
|
||||
</span>
|
||||
),
|
||||
user: ({ id }) => <User id={id} />,
|
||||
resourceSet: resourceSet => (
|
||||
<span>
|
||||
<strong>
|
||||
|
@ -3109,3 +3109,31 @@ export const checkProxyHealth = proxy =>
|
||||
_('proxyTestSuccessMessage')
|
||||
)
|
||||
)
|
||||
|
||||
// Audit plugin ---------------------------------------------------------
|
||||
|
||||
const METHOD_NOT_FOUND_CODE = -32601
|
||||
export const fetchAuditRecords = async () => {
|
||||
try {
|
||||
const { $getFrom } = await _call('audit.getRecords', { ndjson: true })
|
||||
const response = await fetch(`.${$getFrom}`)
|
||||
const data = await response.text()
|
||||
|
||||
const records = []
|
||||
parseNdJson(data, record => {
|
||||
records.push(record)
|
||||
})
|
||||
return records
|
||||
} catch (error) {
|
||||
if (error.code === METHOD_NOT_FOUND_CODE) {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const checkAuditRecordsIntegrity = (oldest, newest) =>
|
||||
_call('audit.checkIntegrity', { oldest, newest })
|
||||
|
||||
export const generateAuditFingerprint = oldest =>
|
||||
_call('audit.generateFingerprint', { oldest })
|
||||
|
@ -103,6 +103,10 @@
|
||||
@extend .fa;
|
||||
@extend .fa-cube;
|
||||
}
|
||||
&-audit {
|
||||
@extend .fa;
|
||||
@extend .fa-list-alt;
|
||||
}
|
||||
|
||||
&-grab {
|
||||
@extend .fa;
|
||||
|
@ -364,6 +364,11 @@ export default class Menu extends Component {
|
||||
icon: 'menu-settings-logs',
|
||||
label: 'settingsLogsPage',
|
||||
},
|
||||
{
|
||||
to: '/settings/audit',
|
||||
icon: 'audit',
|
||||
label: 'settingsAuditPage',
|
||||
},
|
||||
{ to: '/settings/ips', icon: 'ip', label: 'settingsIpsPage' },
|
||||
{
|
||||
to: '/settings/cloud-configs',
|
||||
|
277
packages/xo-web/src/xo-app/settings/audit/index.js
Normal file
277
packages/xo-web/src/xo-app/settings/audit/index.js
Normal file
@ -0,0 +1,277 @@
|
||||
import _, { messages } from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import Button from 'button'
|
||||
import Copiable from 'copiable'
|
||||
import CopyToClipboard from 'react-copy-to-clipboard'
|
||||
import decorate from 'apply-decorators'
|
||||
import Icon from 'icon'
|
||||
import NoObjects from 'no-objects'
|
||||
import React from 'react'
|
||||
import SortedTable from 'sorted-table'
|
||||
import Tooltip from 'tooltip'
|
||||
import { alert, chooseAction, form } from 'modal'
|
||||
import { alteredAuditRecord, missingAuditRecord } from 'xo-common/api-errors'
|
||||
import { FormattedDate, injectIntl } from 'react-intl'
|
||||
import { injectState, provideState } from 'reaclette'
|
||||
import { noop, startCase } from 'lodash'
|
||||
import { User } from 'render-xo-item'
|
||||
import {
|
||||
checkAuditRecordsIntegrity,
|
||||
fetchAuditRecords,
|
||||
generateAuditFingerprint,
|
||||
} from 'xo'
|
||||
|
||||
const getIntegrityErrorRender = ({ nValid, error }) => (
|
||||
<p className='text-danger'>
|
||||
<Icon icon='alarm' />{' '}
|
||||
{_(
|
||||
missingAuditRecord.is(error)
|
||||
? 'auditMissingRecord'
|
||||
: 'auditAlteredRecord',
|
||||
{
|
||||
id: (
|
||||
<Tooltip content={_('copyToClipboard')}>
|
||||
<CopyToClipboard text={error.data.id}>
|
||||
<span style={{ cursor: 'pointer' }}>
|
||||
{error.data.id.slice(4, 8)}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
),
|
||||
n: nValid,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
|
||||
const openGeneratedFingerprintModal = ({ fingerprint, nValid, error }) =>
|
||||
alert(
|
||||
<span>
|
||||
<Icon icon='diagnosis' /> {_('auditNewFingerprint')}
|
||||
</span>,
|
||||
<div>
|
||||
{error !== undefined ? (
|
||||
<div>
|
||||
{getIntegrityErrorRender({ nValid, error })}
|
||||
<p>{_('auditSaveFingerprintInErrorInfo')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>{_('auditSaveFingerprintInfo')}</p>
|
||||
)}
|
||||
<p className='input-group mt-1'>
|
||||
<input className='form-control' value={fingerprint} disabled />
|
||||
<span className='input-group-btn'>
|
||||
<Tooltip content={_('auditCopyFingerprintToClipboard')}>
|
||||
<CopyToClipboard text={fingerprint}>
|
||||
<Button>
|
||||
<Icon icon='clipboard' />
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
).catch(noop)
|
||||
|
||||
const openIntegrityFeedbackModal = error =>
|
||||
chooseAction({
|
||||
icon: 'diagnosis',
|
||||
title: _('auditCheckIntegrity'),
|
||||
body:
|
||||
error !== undefined ? (
|
||||
getIntegrityErrorRender(error)
|
||||
) : (
|
||||
<p className='text-success'>
|
||||
{_('auditIntegrityVerified')} <Icon icon='success' />
|
||||
</p>
|
||||
),
|
||||
buttons: [
|
||||
{
|
||||
btnStyle: 'success',
|
||||
label: _('auditGenerateNewFingerprint'),
|
||||
},
|
||||
],
|
||||
}).then(
|
||||
() => true,
|
||||
() => false
|
||||
)
|
||||
|
||||
const FingerPrintModalBody = injectIntl(
|
||||
({ intl: { formatMessage }, onChange, value }) => (
|
||||
<div>
|
||||
<p>{_('auditEnterFingerprintInfo')}</p>
|
||||
<div className='form-group'>
|
||||
<input
|
||||
className='form-control'
|
||||
onChange={onChange}
|
||||
pattern='[^|]+\|[^|]+'
|
||||
placeholder={formatMessage(messages.auditEnterFingerprint)}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
const DEFAULT_HASH = 'nullId|nullId'
|
||||
const openFingerprintPromptModal = () =>
|
||||
form({
|
||||
render: ({ onChange, value }) => (
|
||||
<FingerPrintModalBody onChange={onChange} value={value} />
|
||||
),
|
||||
header: (
|
||||
<span>
|
||||
<Icon icon='diagnosis' /> {_('auditCheckIntegrity')}
|
||||
</span>
|
||||
),
|
||||
}).then((value = '') => {
|
||||
value = value.trim()
|
||||
return value !== '' ? value : DEFAULT_HASH
|
||||
}, noop)
|
||||
|
||||
const checkIntegrity = async () => {
|
||||
const fingerprint = await openFingerprintPromptModal()
|
||||
if (fingerprint === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const [oldest, newest] = fingerprint.split('|')
|
||||
const error = await checkAuditRecordsIntegrity(oldest, newest).then(
|
||||
noop,
|
||||
error => {
|
||||
if (missingAuditRecord.is(error) || alteredAuditRecord.is(error)) {
|
||||
return {
|
||||
nValid: error.data.nValid,
|
||||
error,
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
)
|
||||
|
||||
const shouldGenerateFingerprint = await openIntegrityFeedbackModal(error)
|
||||
if (!shouldGenerateFingerprint) {
|
||||
return
|
||||
}
|
||||
|
||||
await openGeneratedFingerprintModal(await generateAuditFingerprint(newest))
|
||||
}
|
||||
|
||||
const displayRecord = record =>
|
||||
alert(
|
||||
<span>
|
||||
<Icon icon='audit' /> {_('auditRecord')}
|
||||
</span>,
|
||||
<Copiable tagName='pre'>{JSON.stringify(record, null, 2)}</Copiable>
|
||||
)
|
||||
|
||||
const INDIVIDUAL_ACTIONS = [
|
||||
{
|
||||
handler: displayRecord,
|
||||
icon: 'preview',
|
||||
label: _('displayAuditRecord'),
|
||||
},
|
||||
]
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
itemRenderer: ({ subject: { userId, userName } }) =>
|
||||
userId !== undefined ? (
|
||||
<User
|
||||
defaultRender={
|
||||
<Copiable tagName='p' text={userId} className='text-muted'>
|
||||
{_('deletedUser', { name: userName })}
|
||||
</Copiable>
|
||||
}
|
||||
id={userId}
|
||||
link
|
||||
newTab
|
||||
/>
|
||||
) : (
|
||||
<p className='text-muted'>{_('noUser')}</p>
|
||||
),
|
||||
name: _('user'),
|
||||
sortCriteria: 'subject.userName',
|
||||
},
|
||||
{
|
||||
name: _('ip'),
|
||||
valuePath: 'subject.userIp',
|
||||
},
|
||||
{
|
||||
itemRenderer: ({ data, event }) =>
|
||||
event === 'apiCall' ? data.method : startCase(event),
|
||||
name: _('auditActionEvent'),
|
||||
sortCriteria: ({ data, event }) =>
|
||||
event === 'apiCall' ? data.method : event,
|
||||
},
|
||||
{
|
||||
itemRenderer: ({ time }) => (
|
||||
<FormattedDate
|
||||
day='numeric'
|
||||
hour='2-digit'
|
||||
minute='2-digit'
|
||||
month='short'
|
||||
second='2-digit'
|
||||
value={new Date(time)}
|
||||
year='numeric'
|
||||
/>
|
||||
),
|
||||
name: _('date'),
|
||||
sortCriteria: 'time',
|
||||
sortOrder: 'desc',
|
||||
},
|
||||
]
|
||||
|
||||
export default decorate([
|
||||
provideState({
|
||||
initialState: () => ({
|
||||
records: undefined,
|
||||
}),
|
||||
effects: {
|
||||
initialize({ fetchRecords }) {
|
||||
return fetchRecords()
|
||||
},
|
||||
async fetchRecords() {
|
||||
this.state.records = await fetchAuditRecords()
|
||||
},
|
||||
},
|
||||
}),
|
||||
injectState,
|
||||
({ state, effects }) => (
|
||||
<div>
|
||||
<div className='mt-1 mb-1'>
|
||||
<ActionButton
|
||||
btnStyle='primary'
|
||||
handler={effects.fetchRecords}
|
||||
icon='refresh'
|
||||
size='large'
|
||||
>
|
||||
{_('refreshAuditRecordsList')}
|
||||
</ActionButton>{' '}
|
||||
<ActionButton
|
||||
btnStyle='success'
|
||||
handler={checkIntegrity}
|
||||
icon='diagnosis'
|
||||
size='large'
|
||||
>
|
||||
{_('auditCheckIntegrity')}
|
||||
</ActionButton>
|
||||
</div>
|
||||
<NoObjects
|
||||
collection={state.records}
|
||||
columns={COLUMNS}
|
||||
component={SortedTable}
|
||||
defaultColumn={3}
|
||||
emptyMessage={
|
||||
<span className='text-muted'>
|
||||
<Icon icon='alarm' />
|
||||
|
||||
{_('noAuditRecordAvailable')}
|
||||
</span>
|
||||
}
|
||||
individualActions={INDIVIDUAL_ACTIONS}
|
||||
stateUrlParam='s'
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
])
|
@ -7,6 +7,7 @@ import { Container, Row, Col } from 'grid'
|
||||
import { NavLink, NavTabs } from 'nav'
|
||||
|
||||
import Acls from './acls'
|
||||
import Audit from './audit'
|
||||
import CloudConfigs from './cloud-configs'
|
||||
import Config from './config'
|
||||
import Groups from './groups'
|
||||
@ -48,6 +49,9 @@ const HEADER = (
|
||||
<NavLink to='/settings/logs'>
|
||||
<Icon icon='menu-settings-logs' /> {_('settingsLogsPage')}
|
||||
</NavLink>
|
||||
<NavLink to='/settings/audit'>
|
||||
<Icon icon='audit' /> {_('settingsAuditPage')}
|
||||
</NavLink>
|
||||
<NavLink to='/settings/ips'>
|
||||
<Icon icon='ip' /> {_('settingsIpsPage')}
|
||||
</NavLink>
|
||||
@ -65,6 +69,7 @@ const HEADER = (
|
||||
|
||||
const Settings = routes('servers', {
|
||||
acls: Acls,
|
||||
audit: Audit,
|
||||
'cloud-configs': CloudConfigs,
|
||||
config: Config,
|
||||
groups: Groups,
|
||||
|
Loading…
Reference in New Issue
Block a user