Summary: Adds machinery to support audit logging in the backend. Logging is currently implemented by streaming events to external HTTP endpoints. All flavors of Grist support a default "grist" payload format, and Grist Enterprise additionally supports an HEC-compatible payload format. Logging of all audit events will be added at a later date. Test Plan: Server tests. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D4331dependabot/npm_and_yarn/express-4.20.0
parent
14718120bd
commit
3e22b89fa2
@ -0,0 +1,31 @@
|
||||
export interface AuditEvent<Name extends AuditEventName> {
|
||||
event: {
|
||||
/** The event name. */
|
||||
name: Name;
|
||||
/** The user that triggered the event. */
|
||||
user: AuditEventUser | null;
|
||||
/** Additional event details. */
|
||||
details: AuditEventDetails[Name] | null;
|
||||
};
|
||||
/** ISO 8601 timestamp of when the event was logged. */
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export type AuditEventName =
|
||||
| 'createDocument';
|
||||
|
||||
export interface AuditEventUser {
|
||||
/** The user's id. */
|
||||
id: number | null;
|
||||
/** The user's email address. */
|
||||
email: string | null;
|
||||
/** The user's name. */
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface AuditEventDetails {
|
||||
createDocument: {
|
||||
/** The ID of the document. */
|
||||
id: string;
|
||||
};
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
import {AuditEventDetails, AuditEventName} from 'app/common/AuditEvent';
|
||||
import {RequestOrSession} from 'app/server/lib/requestUtils';
|
||||
|
||||
export interface IAuditLogger {
|
||||
/**
|
||||
* Logs an audit event.
|
||||
*/
|
||||
logEvent<Name extends AuditEventName>(
|
||||
requestOrSession: RequestOrSession,
|
||||
props: AuditEventProperties<Name>
|
||||
): void;
|
||||
/**
|
||||
* Asynchronous variant of `logEvent`.
|
||||
*
|
||||
* Throws on failure to log an event.
|
||||
*/
|
||||
logEventAsync<Name extends AuditEventName>(
|
||||
requestOrSession: RequestOrSession,
|
||||
props: AuditEventProperties<Name>
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AuditEventProperties<Name extends AuditEventName> {
|
||||
event: {
|
||||
/**
|
||||
* The event name.
|
||||
*/
|
||||
name: Name;
|
||||
/**
|
||||
* Additional event details.
|
||||
*/
|
||||
details?: AuditEventDetails[Name];
|
||||
};
|
||||
/**
|
||||
* ISO 8601 timestamp (e.g. `2024-09-04T14:54:50Z`) of when the event occured.
|
||||
*
|
||||
* Defaults to now.
|
||||
*/
|
||||
timestamp?: string;
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import {AuditEvent, AuditEventName} from 'app/common/AuditEvent';
|
||||
import {IAuditLogger} from 'app/server/lib/AuditLogger';
|
||||
import {HTTPAuditLogger} from 'app/server/lib/HTTPAuditLogger';
|
||||
|
||||
export class GristAuditLogger extends HTTPAuditLogger implements IAuditLogger {
|
||||
protected toJSON<Name extends AuditEventName>(event: AuditEvent<Name>): string {
|
||||
return JSON.stringify(event);
|
||||
}
|
||||
}
|
@ -0,0 +1,135 @@
|
||||
import {AuditEvent, AuditEventName, AuditEventUser} from 'app/common/AuditEvent';
|
||||
import {AuditEventProperties, IAuditLogger} from 'app/server/lib/AuditLogger';
|
||||
import {getDocSessionUser} from 'app/server/lib/DocSession';
|
||||
import {ILogMeta, LogMethods} from 'app/server/lib/LogMethods';
|
||||
import {RequestOrSession} from 'app/server/lib/requestUtils';
|
||||
import {getLogMetaFromDocSession} from 'app/server/lib/serverUtils';
|
||||
import moment from 'moment-timezone';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
interface HTTPAuditLoggerOptions {
|
||||
/**
|
||||
* The HTTP endpoint to send audit events to.
|
||||
*/
|
||||
endpoint: string;
|
||||
/**
|
||||
* If set, the value to include in the `Authorization` header of each
|
||||
* request to `endpoint`.
|
||||
*/
|
||||
authorizationHeader?: string;
|
||||
}
|
||||
|
||||
const MAX_PENDING_REQUESTS = 25;
|
||||
|
||||
/**
|
||||
* Base class for an audit event logger that logs events by sending them to an JSON-based HTTP
|
||||
* endpoint.
|
||||
*
|
||||
* Subclasses are expected to provide a suitable `toJSON` implementation to handle serialization
|
||||
* of audit events to JSON.
|
||||
*
|
||||
* See `GristAuditLogger` for an example.
|
||||
*/
|
||||
export abstract class HTTPAuditLogger implements IAuditLogger {
|
||||
private _endpoint = this._options.endpoint;
|
||||
private _authorizationHeader = this._options.authorizationHeader;
|
||||
private _numPendingRequests = 0;
|
||||
private readonly _logger = new LogMethods('AuditLogger ', (requestOrSession: RequestOrSession | undefined) =>
|
||||
getLogMeta(requestOrSession));
|
||||
|
||||
constructor(private _options: HTTPAuditLoggerOptions) {}
|
||||
|
||||
/**
|
||||
* Logs an audit event.
|
||||
*/
|
||||
public logEvent<Name extends AuditEventName>(
|
||||
requestOrSession: RequestOrSession,
|
||||
event: AuditEventProperties<Name>
|
||||
): void {
|
||||
this._logEventOrThrow(requestOrSession, event)
|
||||
.catch((e) => this._logger.error(requestOrSession, `failed to log audit event`, event, e));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronous variant of `logEvent`.
|
||||
*
|
||||
* Throws on failure to log an event.
|
||||
*/
|
||||
public async logEventAsync<Name extends AuditEventName>(
|
||||
requestOrSession: RequestOrSession,
|
||||
event: AuditEventProperties<Name>
|
||||
): Promise<void> {
|
||||
await this._logEventOrThrow(requestOrSession, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an audit event to JSON.
|
||||
*/
|
||||
protected abstract toJSON<Name extends AuditEventName>(event: AuditEvent<Name>): string;
|
||||
|
||||
private async _logEventOrThrow<Name extends AuditEventName>(
|
||||
requestOrSession: RequestOrSession,
|
||||
{event: {name, details}, timestamp}: AuditEventProperties<Name>
|
||||
) {
|
||||
if (this._numPendingRequests === MAX_PENDING_REQUESTS) {
|
||||
throw new Error(`exceeded the maximum number of pending audit event calls (${MAX_PENDING_REQUESTS})`);
|
||||
}
|
||||
|
||||
try {
|
||||
this._numPendingRequests += 1;
|
||||
const resp = await fetch(this._endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(this._authorizationHeader ? {'Authorization': this._authorizationHeader} : undefined),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: this.toJSON({
|
||||
event: {
|
||||
name,
|
||||
user: getAuditEventUser(requestOrSession),
|
||||
details: details ?? null,
|
||||
},
|
||||
timestamp: timestamp ?? moment().toISOString(),
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`received a non-200 response from ${resp.url}: ${resp.status} ${await resp.text()}`);
|
||||
}
|
||||
} finally {
|
||||
this._numPendingRequests -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAuditEventUser(requestOrSession: RequestOrSession): AuditEventUser | null {
|
||||
if (!requestOrSession) { return null; }
|
||||
|
||||
if ('get' in requestOrSession) {
|
||||
return {
|
||||
id: requestOrSession.userId ?? null,
|
||||
email: requestOrSession.user?.loginEmail ?? null,
|
||||
name: requestOrSession.user?.name ?? null,
|
||||
};
|
||||
} else {
|
||||
const user = getDocSessionUser(requestOrSession);
|
||||
if (!user) { return null; }
|
||||
|
||||
const {id, email, name} = user;
|
||||
return {id, email, name};
|
||||
}
|
||||
}
|
||||
|
||||
function getLogMeta(requestOrSession?: RequestOrSession): ILogMeta {
|
||||
if (!requestOrSession) { return {}; }
|
||||
|
||||
if ('get' in requestOrSession) {
|
||||
return {
|
||||
org: requestOrSession.org,
|
||||
email: requestOrSession.user?.loginEmail,
|
||||
userId: requestOrSession.userId,
|
||||
altSessionId: requestOrSession.altSessionId,
|
||||
};
|
||||
} else {
|
||||
return getLogMetaFromDocSession(requestOrSession);
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
import {appSettings} from 'app/server/lib/AppSettings';
|
||||
import {GristAuditLogger} from 'app/server/lib/GristAuditLogger';
|
||||
|
||||
export function configureGristAuditLogger() {
|
||||
const options = checkGristAuditLogger();
|
||||
if (!options) { return undefined; }
|
||||
|
||||
return new GristAuditLogger(options);
|
||||
}
|
||||
|
||||
export function checkGristAuditLogger() {
|
||||
const settings = appSettings.section('auditLogger').section('http');
|
||||
const endpoint = settings.flag('endpoint').readString({
|
||||
envVar: 'GRIST_AUDIT_HTTP_ENDPOINT',
|
||||
});
|
||||
if (!endpoint) { return undefined; }
|
||||
|
||||
const payloadFormat = settings.flag('payloadFormat').readString({
|
||||
envVar: 'GRIST_AUDIT_HTTP_PAYLOAD_FORMAT',
|
||||
defaultValue: 'grist',
|
||||
});
|
||||
if (payloadFormat !== 'grist') { return undefined; }
|
||||
|
||||
const authorizationHeader = settings.flag('authorizationHeader').readString({
|
||||
envVar: 'GRIST_AUDIT_HTTP_AUTHORIZATION_HEADER',
|
||||
censor: true,
|
||||
});
|
||||
|
||||
return {endpoint, authorizationHeader};
|
||||
}
|
@ -0,0 +1,117 @@
|
||||
import {IAuditLogger} from 'app/server/lib/AuditLogger';
|
||||
import {LogMethods} from 'app/server/lib/LogMethods';
|
||||
import {assert} from 'chai';
|
||||
import moment from 'moment-timezone';
|
||||
import nock from 'nock';
|
||||
import * as sinon from 'sinon';
|
||||
import {TestServer} from 'test/gen-server/apiUtils';
|
||||
import * as testUtils from 'test/server/testUtils';
|
||||
|
||||
describe('GristAuditLogger', function() {
|
||||
let auditLogger: IAuditLogger;
|
||||
let oldEnv: testUtils.EnvironmentSnapshot;
|
||||
let server: TestServer;
|
||||
|
||||
before(async function() {
|
||||
oldEnv = new testUtils.EnvironmentSnapshot();
|
||||
process.env.GRIST_AUDIT_HTTP_ENDPOINT = 'https://api.getgrist.com/events';
|
||||
process.env.GRIST_AUDIT_HTTP_AUTHORIZATION_HEADER = 'Grist bb48d1f8-8f6c-4065-8951-8543a8e70597';
|
||||
server = new TestServer(this);
|
||||
await server.start();
|
||||
auditLogger = server.server.getAuditLogger();
|
||||
});
|
||||
|
||||
after(async function() {
|
||||
await server.stop();
|
||||
oldEnv.restore();
|
||||
});
|
||||
|
||||
describe('logEventAsync', function() {
|
||||
const sandbox: sinon.SinonSandbox = sinon.createSandbox();
|
||||
let logErrorCallArguments: any[] = [];
|
||||
|
||||
before(async function() {
|
||||
sandbox
|
||||
.stub(LogMethods.prototype, 'error')
|
||||
.callsFake((...args) => logErrorCallArguments.push([...args]));
|
||||
});
|
||||
|
||||
after(async function() {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
logErrorCallArguments = [];
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('logs audit events', async function() {
|
||||
const timestamp = moment().toISOString();
|
||||
const scope = nock('https://api.getgrist.com')
|
||||
.matchHeader('Authorization', 'Grist bb48d1f8-8f6c-4065-8951-8543a8e70597')
|
||||
.post('/events', {
|
||||
event: {
|
||||
name: 'createDocument',
|
||||
user: null,
|
||||
details: {
|
||||
id: 'docId',
|
||||
},
|
||||
},
|
||||
timestamp,
|
||||
})
|
||||
.reply(200);
|
||||
await assert.isFulfilled(
|
||||
auditLogger.logEventAsync(null, {
|
||||
event: {
|
||||
name: 'createDocument',
|
||||
details: {id: 'docId'},
|
||||
},
|
||||
timestamp,
|
||||
})
|
||||
);
|
||||
assert.isTrue(scope.isDone());
|
||||
});
|
||||
|
||||
it('throws on failure to log', async function() {
|
||||
nock('https://api.getgrist.com')
|
||||
.post('/events')
|
||||
.reply(404, 'Not found');
|
||||
await assert.isRejected(
|
||||
auditLogger.logEventAsync(null, {
|
||||
event: {
|
||||
name: 'createDocument',
|
||||
details: {id: 'docId'},
|
||||
},
|
||||
}),
|
||||
'received a non-200 response from https://api.getgrist.com/events: 404 Not found'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if max pending requests exceeded', async function() {
|
||||
nock('https://api.getgrist.com')
|
||||
.persist()
|
||||
.post('/events')
|
||||
.delay(2000)
|
||||
.reply(200);
|
||||
// Queue up enough pending requests to reach the limit (25).
|
||||
for (let i = 0; i < 25; i++) {
|
||||
void auditLogger.logEvent(null, {
|
||||
event: {
|
||||
name: 'createDocument',
|
||||
details: {id: 'docId'},
|
||||
},
|
||||
});
|
||||
}
|
||||
await assert.isRejected(
|
||||
auditLogger.logEventAsync(null, {
|
||||
event: {
|
||||
name: 'createDocument',
|
||||
details: {id: 'docId'},
|
||||
},
|
||||
}),
|
||||
'exceeded the maximum number of pending audit event calls (25)'
|
||||
);
|
||||
nock.abortPendingRequests();
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in new issue