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))
|
- [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))
|
- [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))
|
- [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
|
### Bug fixes
|
||||||
|
|
||||||
@ -29,6 +30,9 @@
|
|||||||
>
|
>
|
||||||
> Rule of thumb: add packages on top.
|
> 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-auth-ldap v0.7.0
|
||||||
- xo-server-usage-report v0.7.4
|
- xo-server-usage-report v0.7.4
|
||||||
- xo-server-sdn-controller v0.4.0
|
- xo-server-sdn-controller v0.4.0
|
||||||
|
@ -180,3 +180,20 @@ export const operationFailed = create(21, ({ objectId, code }) => ({
|
|||||||
},
|
},
|
||||||
message: 'operation failed',
|
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.
|
// Create the abstract XO object for this connection.
|
||||||
const connection = xo.createUserConnection()
|
const connection = xo.createUserConnection()
|
||||||
|
connection.set('user_ip', remoteAddress)
|
||||||
connection.once('close', () => {
|
connection.once('close', () => {
|
||||||
socket.close()
|
socket.close()
|
||||||
})
|
})
|
||||||
@ -606,7 +607,21 @@ const setUpConsoleProxy = (webServer, xo) => {
|
|||||||
|
|
||||||
const { remoteAddress } = socket
|
const { remoteAddress } = socket
|
||||||
log.info(`+ Console proxy (${user.name} - ${remoteAddress})`)
|
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', () => {
|
socket.on('close', () => {
|
||||||
|
xo.emit('xo:audit', 'consoleClosed', {
|
||||||
|
...data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
log.info(`- Console proxy (${user.name} - ${remoteAddress})`)
|
log.info(`- Console proxy (${user.name} - ${remoteAddress})`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -255,6 +255,7 @@ export default class Api {
|
|||||||
.slice(2),
|
.slice(2),
|
||||||
userId,
|
userId,
|
||||||
userName,
|
userName,
|
||||||
|
userIp: session.get('user_ip'),
|
||||||
method: name,
|
method: name,
|
||||||
params: sensitiveValues.replace(params, '* obfuscated *'),
|
params: sensitiveValues.replace(params, '* obfuscated *'),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@ -303,6 +304,13 @@ export default class Api {
|
|||||||
)}] ==> ${kindOf(result)}`
|
)}] ==> ${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()
|
const now = Date.now()
|
||||||
xo.emit('xo:postCall', {
|
xo.emit('xo:postCall', {
|
||||||
...data,
|
...data,
|
||||||
|
@ -63,6 +63,8 @@ const messages = {
|
|||||||
dhcp: 'DHCP',
|
dhcp: 'DHCP',
|
||||||
ip: 'IP',
|
ip: 'IP',
|
||||||
static: 'Static',
|
static: 'Static',
|
||||||
|
user: 'User',
|
||||||
|
deletedUser: 'deleted ({ name })',
|
||||||
|
|
||||||
// ----- Modals -----
|
// ----- Modals -----
|
||||||
alertOk: 'OK',
|
alertOk: 'OK',
|
||||||
@ -114,6 +116,7 @@ const messages = {
|
|||||||
notificationsPage: 'Notifications',
|
notificationsPage: 'Notifications',
|
||||||
supportPage: 'Support',
|
supportPage: 'Support',
|
||||||
settingsPage: 'Settings',
|
settingsPage: 'Settings',
|
||||||
|
settingsAuditPage: 'Audit',
|
||||||
settingsServersPage: 'Servers',
|
settingsServersPage: 'Servers',
|
||||||
settingsUsersPage: 'Users',
|
settingsUsersPage: 'Users',
|
||||||
settingsGroupsPage: 'Groups',
|
settingsGroupsPage: 'Groups',
|
||||||
@ -2220,6 +2223,29 @@ const messages = {
|
|||||||
recipeSshKeyLabel: 'SSH key',
|
recipeSshKeyLabel: 'SSH key',
|
||||||
recipeNetworkCidr: 'Network CIDR',
|
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
|
// Licenses
|
||||||
xosanUnregisteredDisclaimer:
|
xosanUnregisteredDisclaimer:
|
||||||
'You are not registered and therefore will not be able to create or manage your XOSAN SRs. {link}',
|
'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 { addSubscriptions, connectStore, formatSize } from './utils'
|
||||||
import { createGetObject, createSelector } from './selectors'
|
import { createGetObject, createSelector } from './selectors'
|
||||||
import { FormattedDate } from 'react-intl'
|
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 = {
|
const xoItemToRender = {
|
||||||
// Subscription objects.
|
// Subscription objects.
|
||||||
cloudConfig: template => (
|
cloudConfig: template => (
|
||||||
@ -422,11 +468,7 @@ const xoItemToRender = {
|
|||||||
remote: ({ value: { id } }) => <Remote id={id} />,
|
remote: ({ value: { id } }) => <Remote id={id} />,
|
||||||
proxy: ({ id }) => <Proxy id={id} />,
|
proxy: ({ id }) => <Proxy id={id} />,
|
||||||
role: role => <span>{role.name}</span>,
|
role: role => <span>{role.name}</span>,
|
||||||
user: user => (
|
user: ({ id }) => <User id={id} />,
|
||||||
<span>
|
|
||||||
<Icon icon='user' /> {user.email}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
resourceSet: resourceSet => (
|
resourceSet: resourceSet => (
|
||||||
<span>
|
<span>
|
||||||
<strong>
|
<strong>
|
||||||
|
@ -3109,3 +3109,31 @@ export const checkProxyHealth = proxy =>
|
|||||||
_('proxyTestSuccessMessage')
|
_('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;
|
||||||
@extend .fa-cube;
|
@extend .fa-cube;
|
||||||
}
|
}
|
||||||
|
&-audit {
|
||||||
|
@extend .fa;
|
||||||
|
@extend .fa-list-alt;
|
||||||
|
}
|
||||||
|
|
||||||
&-grab {
|
&-grab {
|
||||||
@extend .fa;
|
@extend .fa;
|
||||||
|
@ -364,6 +364,11 @@ export default class Menu extends Component {
|
|||||||
icon: 'menu-settings-logs',
|
icon: 'menu-settings-logs',
|
||||||
label: 'settingsLogsPage',
|
label: 'settingsLogsPage',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/settings/audit',
|
||||||
|
icon: 'audit',
|
||||||
|
label: 'settingsAuditPage',
|
||||||
|
},
|
||||||
{ to: '/settings/ips', icon: 'ip', label: 'settingsIpsPage' },
|
{ to: '/settings/ips', icon: 'ip', label: 'settingsIpsPage' },
|
||||||
{
|
{
|
||||||
to: '/settings/cloud-configs',
|
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 { NavLink, NavTabs } from 'nav'
|
||||||
|
|
||||||
import Acls from './acls'
|
import Acls from './acls'
|
||||||
|
import Audit from './audit'
|
||||||
import CloudConfigs from './cloud-configs'
|
import CloudConfigs from './cloud-configs'
|
||||||
import Config from './config'
|
import Config from './config'
|
||||||
import Groups from './groups'
|
import Groups from './groups'
|
||||||
@ -48,6 +49,9 @@ const HEADER = (
|
|||||||
<NavLink to='/settings/logs'>
|
<NavLink to='/settings/logs'>
|
||||||
<Icon icon='menu-settings-logs' /> {_('settingsLogsPage')}
|
<Icon icon='menu-settings-logs' /> {_('settingsLogsPage')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink to='/settings/audit'>
|
||||||
|
<Icon icon='audit' /> {_('settingsAuditPage')}
|
||||||
|
</NavLink>
|
||||||
<NavLink to='/settings/ips'>
|
<NavLink to='/settings/ips'>
|
||||||
<Icon icon='ip' /> {_('settingsIpsPage')}
|
<Icon icon='ip' /> {_('settingsIpsPage')}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@ -65,6 +69,7 @@ const HEADER = (
|
|||||||
|
|
||||||
const Settings = routes('servers', {
|
const Settings = routes('servers', {
|
||||||
acls: Acls,
|
acls: Acls,
|
||||||
|
audit: Audit,
|
||||||
'cloud-configs': CloudConfigs,
|
'cloud-configs': CloudConfigs,
|
||||||
config: Config,
|
config: Config,
|
||||||
groups: Groups,
|
groups: Groups,
|
||||||
|
Loading…
Reference in New Issue
Block a user