feat(audit-log): version 1 (#4740)

Fixes #4653 #701
This commit is contained in:
badrAZ 2020-02-26 16:25:35 +01:00 committed by GitHub
parent 59e8b26015
commit 6475b58541
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 2006 additions and 7 deletions

View File

@ -0,0 +1,3 @@
module.exports = require('../../@xen-orchestra/babel-config')(
require('./package.json')
)

View 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__/

View 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
}

View 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)
}
}
}

View 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)
})
})

View 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> {}
}

View File

@ -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

View File

@ -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',
}))

View File

@ -0,0 +1,3 @@
module.exports = require('../../@xen-orchestra/babel-config')(
require('./package.json')
)

View File

@ -0,0 +1,10 @@
/examples/
example.js
example.js.map
*.example.js
*.example.js.map
/test/
/tests/
*.spec.js
*.spec.js.map

View File

@ -0,0 +1,50 @@
# xo-server-audit [![Build Status](https://api.travis-ci.org/vatesfr/xo-server-audit.png?branch=master)](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)

View 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',
]

View 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
}

View 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)

View File

@ -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})`)
})
}

View File

@ -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,

View File

@ -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}',

View File

@ -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>

View File

@ -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 })

View File

@ -103,6 +103,10 @@
@extend .fa;
@extend .fa-cube;
}
&-audit {
@extend .fa;
@extend .fa-list-alt;
}
&-grab {
@extend .fa;

View File

@ -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',

View 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' />
&nbsp;
{_('noAuditRecordAvailable')}
</span>
}
individualActions={INDIVIDUAL_ACTIONS}
stateUrlParam='s'
/>
</div>
),
])

View File

@ -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,

841
yarn.lock

File diff suppressed because it is too large Load Diff