mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Add audit logging machinery
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/D4331
This commit is contained in:
parent
14718120bd
commit
3e22b89fa2
31
app/common/AuditEvent.ts
Normal file
31
app/common/AuditEvent.ts
Normal file
@ -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;
|
||||||
|
};
|
||||||
|
}
|
@ -283,6 +283,12 @@ export class ApiServer {
|
|||||||
altSessionId: mreq.altSessionId,
|
altSessionId: mreq.altSessionId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this._gristServer.getAuditLogger().logEvent(mreq, {
|
||||||
|
event: {
|
||||||
|
name: 'createDocument',
|
||||||
|
details: {id: docId},
|
||||||
|
},
|
||||||
|
});
|
||||||
return sendReply(req, res, query);
|
return sendReply(req, res, query);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ import { create } from 'app/server/lib/create';
|
|||||||
import { DocManager } from 'app/server/lib/DocManager';
|
import { DocManager } from 'app/server/lib/DocManager';
|
||||||
import { makeExceptionalDocSession } from 'app/server/lib/DocSession';
|
import { makeExceptionalDocSession } from 'app/server/lib/DocSession';
|
||||||
import { DocStorageManager } from 'app/server/lib/DocStorageManager';
|
import { DocStorageManager } from 'app/server/lib/DocStorageManager';
|
||||||
import { createDummyTelemetry } from 'app/server/lib/GristServer';
|
import { createDummyAuditLogger, createDummyTelemetry } from 'app/server/lib/GristServer';
|
||||||
import { PluginManager } from 'app/server/lib/PluginManager';
|
import { PluginManager } from 'app/server/lib/PluginManager';
|
||||||
|
|
||||||
import * as childProcess from 'child_process';
|
import * as childProcess from 'child_process';
|
||||||
@ -34,6 +34,7 @@ export async function main(baseName: string) {
|
|||||||
}
|
}
|
||||||
const docManager = new DocManager(storageManager, pluginManager, null as any, {
|
const docManager = new DocManager(storageManager, pluginManager, null as any, {
|
||||||
create,
|
create,
|
||||||
|
getAuditLogger() { return createDummyAuditLogger(); },
|
||||||
getTelemetry() { return createDummyTelemetry(); },
|
getTelemetry() { return createDummyTelemetry(); },
|
||||||
} as any);
|
} as any);
|
||||||
const activeDoc = new ActiveDoc(docManager, baseName);
|
const activeDoc = new ActiveDoc(docManager, baseName);
|
||||||
|
40
app/server/lib/AuditLogger.ts
Normal file
40
app/server/lib/AuditLogger.ts
Normal file
@ -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;
|
||||||
|
}
|
@ -1730,6 +1730,12 @@ export class DocWorkerApi {
|
|||||||
docIdDigest: docId,
|
docIdDigest: docId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this._grist.getAuditLogger().logEvent(req as RequestWithLogin, {
|
||||||
|
event: {
|
||||||
|
name: 'createDocument',
|
||||||
|
details: {id: docId},
|
||||||
|
},
|
||||||
|
});
|
||||||
return docId;
|
return docId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1737,6 +1743,7 @@ export class DocWorkerApi {
|
|||||||
userId: number,
|
userId: number,
|
||||||
browserSettings?: BrowserSettings,
|
browserSettings?: BrowserSettings,
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
|
const mreq = req as RequestWithLogin;
|
||||||
const {userId, browserSettings} = options;
|
const {userId, browserSettings} = options;
|
||||||
const isAnonymous = isAnonymousUser(req);
|
const isAnonymous = isAnonymousUser(req);
|
||||||
const result = makeForkIds({
|
const result = makeForkIds({
|
||||||
@ -1747,10 +1754,7 @@ export class DocWorkerApi {
|
|||||||
});
|
});
|
||||||
const docId = result.docId;
|
const docId = result.docId;
|
||||||
await this._docManager.createNamedDoc(
|
await this._docManager.createNamedDoc(
|
||||||
makeExceptionalDocSession('nascent', {
|
makeExceptionalDocSession('nascent', {req: mreq, browserSettings}),
|
||||||
req: req as RequestWithLogin,
|
|
||||||
browserSettings,
|
|
||||||
}),
|
|
||||||
docId
|
docId
|
||||||
);
|
);
|
||||||
this._logDocumentCreatedTelemetryEvent(req, {
|
this._logDocumentCreatedTelemetryEvent(req, {
|
||||||
@ -1767,6 +1771,12 @@ export class DocWorkerApi {
|
|||||||
docIdDigest: docId,
|
docIdDigest: docId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this._grist.getAuditLogger().logEvent(mreq, {
|
||||||
|
event: {
|
||||||
|
name: 'createDocument',
|
||||||
|
details: {id: docId},
|
||||||
|
},
|
||||||
|
});
|
||||||
return docId;
|
return docId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -254,6 +254,12 @@ export class DocManager extends EventEmitter {
|
|||||||
isSaved: workspaceId !== undefined,
|
isSaved: workspaceId !== undefined,
|
||||||
},
|
},
|
||||||
}, telemetryMetadata));
|
}, telemetryMetadata));
|
||||||
|
this.gristServer.getAuditLogger().logEvent(mreq, {
|
||||||
|
event: {
|
||||||
|
name: 'createDocument',
|
||||||
|
details: {id: docCreationInfo.id},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return docCreationInfo;
|
return docCreationInfo;
|
||||||
// The imported document is associated with the worker that did the import.
|
// The imported document is associated with the worker that did the import.
|
||||||
|
@ -27,6 +27,7 @@ import {AccessTokens, IAccessTokens} from 'app/server/lib/AccessTokens';
|
|||||||
import {createSandbox} from 'app/server/lib/ActiveDoc';
|
import {createSandbox} from 'app/server/lib/ActiveDoc';
|
||||||
import {attachAppEndpoint} from 'app/server/lib/AppEndpoint';
|
import {attachAppEndpoint} from 'app/server/lib/AppEndpoint';
|
||||||
import {appSettings} from 'app/server/lib/AppSettings';
|
import {appSettings} from 'app/server/lib/AppSettings';
|
||||||
|
import {IAuditLogger} from 'app/server/lib/AuditLogger';
|
||||||
import {addRequestUser, getTransitiveHeaders, getUser, getUserId, isAnonymousUser,
|
import {addRequestUser, getTransitiveHeaders, getUser, getUserId, isAnonymousUser,
|
||||||
isSingleUserMode, redirectToLoginUnconditionally} from 'app/server/lib/Authorizer';
|
isSingleUserMode, redirectToLoginUnconditionally} from 'app/server/lib/Authorizer';
|
||||||
import {redirectToLogin, RequestWithLogin, signInStatusMiddleware} from 'app/server/lib/Authorizer';
|
import {redirectToLogin, RequestWithLogin, signInStatusMiddleware} from 'app/server/lib/Authorizer';
|
||||||
@ -151,6 +152,7 @@ export class FlexServer implements GristServer {
|
|||||||
private _sessions: Sessions;
|
private _sessions: Sessions;
|
||||||
private _sessionStore: SessionStore;
|
private _sessionStore: SessionStore;
|
||||||
private _storageManager: IDocStorageManager;
|
private _storageManager: IDocStorageManager;
|
||||||
|
private _auditLogger: IAuditLogger;
|
||||||
private _telemetry: ITelemetry;
|
private _telemetry: ITelemetry;
|
||||||
private _processMonitorStop?: () => void; // Callback to stop the ProcessMonitor
|
private _processMonitorStop?: () => void; // Callback to stop the ProcessMonitor
|
||||||
private _docWorkerMap: IDocWorkerMap;
|
private _docWorkerMap: IDocWorkerMap;
|
||||||
@ -398,6 +400,11 @@ export class FlexServer implements GristServer {
|
|||||||
return this._storageManager;
|
return this._storageManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getAuditLogger(): IAuditLogger {
|
||||||
|
if (!this._auditLogger) { throw new Error('no audit logger available'); }
|
||||||
|
return this._auditLogger;
|
||||||
|
}
|
||||||
|
|
||||||
public getTelemetry(): ITelemetry {
|
public getTelemetry(): ITelemetry {
|
||||||
if (!this._telemetry) { throw new Error('no telemetry available'); }
|
if (!this._telemetry) { throw new Error('no telemetry available'); }
|
||||||
return this._telemetry;
|
return this._telemetry;
|
||||||
@ -911,6 +918,12 @@ export class FlexServer implements GristServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public addAuditLogger() {
|
||||||
|
if (this._check('audit-logger')) { return; }
|
||||||
|
|
||||||
|
this._auditLogger = this.create.AuditLogger();
|
||||||
|
}
|
||||||
|
|
||||||
public async addTelemetry() {
|
public async addTelemetry() {
|
||||||
if (this._check('telemetry', 'homedb', 'json', 'api-mw')) { return; }
|
if (this._check('telemetry', 'homedb', 'json', 'api-mw')) { return; }
|
||||||
|
|
||||||
|
9
app/server/lib/GristAuditLogger.ts
Normal file
9
app/server/lib/GristAuditLogger.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -9,6 +9,7 @@ import { User } from 'app/gen-server/entity/User';
|
|||||||
import { Workspace } from 'app/gen-server/entity/Workspace';
|
import { Workspace } from 'app/gen-server/entity/Workspace';
|
||||||
import { Activations } from 'app/gen-server/lib/Activations';
|
import { Activations } from 'app/gen-server/lib/Activations';
|
||||||
import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
|
import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
|
||||||
|
import { IAuditLogger } from 'app/server/lib/AuditLogger';
|
||||||
import { IAccessTokens } from 'app/server/lib/AccessTokens';
|
import { IAccessTokens } from 'app/server/lib/AccessTokens';
|
||||||
import { RequestWithLogin } from 'app/server/lib/Authorizer';
|
import { RequestWithLogin } from 'app/server/lib/Authorizer';
|
||||||
import { Comm } from 'app/server/lib/Comm';
|
import { Comm } from 'app/server/lib/Comm';
|
||||||
@ -54,6 +55,7 @@ export interface GristServer {
|
|||||||
getInstallAdmin(): InstallAdmin;
|
getInstallAdmin(): InstallAdmin;
|
||||||
getHomeDBManager(): HomeDBManager;
|
getHomeDBManager(): HomeDBManager;
|
||||||
getStorageManager(): IDocStorageManager;
|
getStorageManager(): IDocStorageManager;
|
||||||
|
getAuditLogger(): IAuditLogger;
|
||||||
getTelemetry(): ITelemetry;
|
getTelemetry(): ITelemetry;
|
||||||
hasNotifier(): boolean;
|
hasNotifier(): boolean;
|
||||||
getNotifier(): INotifier;
|
getNotifier(): INotifier;
|
||||||
@ -147,6 +149,7 @@ export function createDummyGristServer(): GristServer {
|
|||||||
getInstallAdmin() { throw new Error('no install admin'); },
|
getInstallAdmin() { throw new Error('no install admin'); },
|
||||||
getHomeDBManager() { throw new Error('no db'); },
|
getHomeDBManager() { throw new Error('no db'); },
|
||||||
getStorageManager() { throw new Error('no storage manager'); },
|
getStorageManager() { throw new Error('no storage manager'); },
|
||||||
|
getAuditLogger() { return createDummyAuditLogger(); },
|
||||||
getTelemetry() { return createDummyTelemetry(); },
|
getTelemetry() { return createDummyTelemetry(); },
|
||||||
getNotifier() { throw new Error('no notifier'); },
|
getNotifier() { throw new Error('no notifier'); },
|
||||||
hasNotifier() { return false; },
|
hasNotifier() { return false; },
|
||||||
@ -165,6 +168,13 @@ export function createDummyGristServer(): GristServer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createDummyAuditLogger(): IAuditLogger {
|
||||||
|
return {
|
||||||
|
logEvent() { /* do nothing */ },
|
||||||
|
logEventAsync() { return Promise.resolve(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createDummyTelemetry(): ITelemetry {
|
export function createDummyTelemetry(): ITelemetry {
|
||||||
return {
|
return {
|
||||||
addEndpoints() { /* do nothing */ },
|
addEndpoints() { /* do nothing */ },
|
||||||
|
135
app/server/lib/HTTPAuditLogger.ts
Normal file
135
app/server/lib/HTTPAuditLogger.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -2,8 +2,9 @@ import {GristDeploymentType} from 'app/common/gristUrls';
|
|||||||
import {getThemeBackgroundSnippet} from 'app/common/Themes';
|
import {getThemeBackgroundSnippet} from 'app/common/Themes';
|
||||||
import {Document} from 'app/gen-server/entity/Document';
|
import {Document} from 'app/gen-server/entity/Document';
|
||||||
import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
|
import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
|
||||||
|
import {IAuditLogger} from 'app/server/lib/AuditLogger';
|
||||||
import {ExternalStorage} from 'app/server/lib/ExternalStorage';
|
import {ExternalStorage} from 'app/server/lib/ExternalStorage';
|
||||||
import {createDummyTelemetry, GristServer} from 'app/server/lib/GristServer';
|
import {createDummyAuditLogger, createDummyTelemetry, GristServer} from 'app/server/lib/GristServer';
|
||||||
import {IBilling} from 'app/server/lib/IBilling';
|
import {IBilling} from 'app/server/lib/IBilling';
|
||||||
import {EmptyNotifier, INotifier} from 'app/server/lib/INotifier';
|
import {EmptyNotifier, INotifier} from 'app/server/lib/INotifier';
|
||||||
import {InstallAdmin, SimpleInstallAdmin} from 'app/server/lib/InstallAdmin';
|
import {InstallAdmin, SimpleInstallAdmin} from 'app/server/lib/InstallAdmin';
|
||||||
@ -40,6 +41,7 @@ export interface ICreate {
|
|||||||
|
|
||||||
Billing(dbManager: HomeDBManager, gristConfig: GristServer): IBilling;
|
Billing(dbManager: HomeDBManager, gristConfig: GristServer): IBilling;
|
||||||
Notifier(dbManager: HomeDBManager, gristConfig: GristServer): INotifier;
|
Notifier(dbManager: HomeDBManager, gristConfig: GristServer): INotifier;
|
||||||
|
AuditLogger(): IAuditLogger;
|
||||||
Telemetry(dbManager: HomeDBManager, gristConfig: GristServer): ITelemetry;
|
Telemetry(dbManager: HomeDBManager, gristConfig: GristServer): ITelemetry;
|
||||||
Shell?(): IShell; // relevant to electron version of Grist only.
|
Shell?(): IShell; // relevant to electron version of Grist only.
|
||||||
|
|
||||||
@ -91,6 +93,12 @@ export interface ICreateBillingOptions {
|
|||||||
create(dbManager: HomeDBManager, gristConfig: GristServer): IBilling|undefined;
|
create(dbManager: HomeDBManager, gristConfig: GristServer): IBilling|undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ICreateAuditLoggerOptions {
|
||||||
|
name: 'grist'|'hec';
|
||||||
|
check(): boolean;
|
||||||
|
create(): IAuditLogger|undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ICreateTelemetryOptions {
|
export interface ICreateTelemetryOptions {
|
||||||
create(dbManager: HomeDBManager, gristConfig: GristServer): ITelemetry|undefined;
|
create(dbManager: HomeDBManager, gristConfig: GristServer): ITelemetry|undefined;
|
||||||
}
|
}
|
||||||
@ -110,6 +118,7 @@ export function makeSimpleCreator(opts: {
|
|||||||
storage?: ICreateStorageOptions[],
|
storage?: ICreateStorageOptions[],
|
||||||
billing?: ICreateBillingOptions,
|
billing?: ICreateBillingOptions,
|
||||||
notifier?: ICreateNotifierOptions,
|
notifier?: ICreateNotifierOptions,
|
||||||
|
auditLogger?: ICreateAuditLoggerOptions[],
|
||||||
telemetry?: ICreateTelemetryOptions,
|
telemetry?: ICreateTelemetryOptions,
|
||||||
sandboxFlavor?: string,
|
sandboxFlavor?: string,
|
||||||
shell?: IShell,
|
shell?: IShell,
|
||||||
@ -118,7 +127,7 @@ export function makeSimpleCreator(opts: {
|
|||||||
getSandboxVariants?: () => Record<string, SpawnFn>,
|
getSandboxVariants?: () => Record<string, SpawnFn>,
|
||||||
createInstallAdmin?: (dbManager: HomeDBManager) => Promise<InstallAdmin>,
|
createInstallAdmin?: (dbManager: HomeDBManager) => Promise<InstallAdmin>,
|
||||||
}): ICreate {
|
}): ICreate {
|
||||||
const {deploymentType, sessionSecret, storage, notifier, billing, telemetry} = opts;
|
const {deploymentType, sessionSecret, storage, notifier, billing, auditLogger, telemetry} = opts;
|
||||||
return {
|
return {
|
||||||
deploymentType() { return deploymentType; },
|
deploymentType() { return deploymentType; },
|
||||||
Billing(dbManager, gristConfig) {
|
Billing(dbManager, gristConfig) {
|
||||||
@ -141,6 +150,9 @@ export function makeSimpleCreator(opts: {
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
|
AuditLogger() {
|
||||||
|
return auditLogger?.find(({check}) => check())?.create() ?? createDummyAuditLogger();
|
||||||
|
},
|
||||||
Telemetry(dbManager, gristConfig) {
|
Telemetry(dbManager, gristConfig) {
|
||||||
return telemetry?.create(dbManager, gristConfig) ?? createDummyTelemetry();
|
return telemetry?.create(dbManager, gristConfig) ?? createDummyTelemetry();
|
||||||
},
|
},
|
||||||
|
@ -19,12 +19,12 @@ import {Activation} from 'app/gen-server/entity/Activation';
|
|||||||
import {Activations} from 'app/gen-server/lib/Activations';
|
import {Activations} from 'app/gen-server/lib/Activations';
|
||||||
import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
|
import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
|
||||||
import {RequestWithLogin} from 'app/server/lib/Authorizer';
|
import {RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||||
import {getDocSessionUser, OptDocSession} from 'app/server/lib/DocSession';
|
import {getDocSessionUser} from 'app/server/lib/DocSession';
|
||||||
import {expressWrap} from 'app/server/lib/expressWrap';
|
import {expressWrap} from 'app/server/lib/expressWrap';
|
||||||
import {GristServer} from 'app/server/lib/GristServer';
|
import {GristServer} from 'app/server/lib/GristServer';
|
||||||
import {hashId} from 'app/server/lib/hashingUtils';
|
import {hashId} from 'app/server/lib/hashingUtils';
|
||||||
import {LogMethods} from 'app/server/lib/LogMethods';
|
import {LogMethods} from 'app/server/lib/LogMethods';
|
||||||
import {stringParam} from 'app/server/lib/requestUtils';
|
import {RequestOrSession, stringParam} from 'app/server/lib/requestUtils';
|
||||||
import {getLogMetaFromDocSession} from 'app/server/lib/serverUtils';
|
import {getLogMetaFromDocSession} from 'app/server/lib/serverUtils';
|
||||||
import * as cookie from 'cookie';
|
import * as cookie from 'cookie';
|
||||||
import * as express from 'express';
|
import * as express from 'express';
|
||||||
@ -32,8 +32,6 @@ import fetch from 'node-fetch';
|
|||||||
import merge = require('lodash/merge');
|
import merge = require('lodash/merge');
|
||||||
import pickBy = require('lodash/pickBy');
|
import pickBy = require('lodash/pickBy');
|
||||||
|
|
||||||
type RequestOrSession = RequestWithLogin | OptDocSession | null;
|
|
||||||
|
|
||||||
interface RequestWithMatomoVisitorId extends RequestWithLogin {
|
interface RequestWithMatomoVisitorId extends RequestWithLogin {
|
||||||
/**
|
/**
|
||||||
* Extracted from a cookie set by Matomo.
|
* Extracted from a cookie set by Matomo.
|
||||||
|
30
app/server/lib/configureGristAuditLogger.ts
Normal file
30
app/server/lib/configureGristAuditLogger.ts
Normal file
@ -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};
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
import { checkGristAuditLogger, configureGristAuditLogger } from 'app/server/lib/configureGristAuditLogger';
|
||||||
import { checkMinIOBucket, checkMinIOExternalStorage,
|
import { checkMinIOBucket, checkMinIOExternalStorage,
|
||||||
configureMinIOExternalStorage } from 'app/server/lib/configureMinIOExternalStorage';
|
configureMinIOExternalStorage } from 'app/server/lib/configureMinIOExternalStorage';
|
||||||
import { makeSimpleCreator } from 'app/server/lib/ICreate';
|
import { makeSimpleCreator } from 'app/server/lib/ICreate';
|
||||||
@ -13,6 +14,13 @@ export const makeCoreCreator = () => makeSimpleCreator({
|
|||||||
create: configureMinIOExternalStorage,
|
create: configureMinIOExternalStorage,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
auditLogger: [
|
||||||
|
{
|
||||||
|
name: 'grist',
|
||||||
|
check: () => checkGristAuditLogger() !== undefined,
|
||||||
|
create: configureGristAuditLogger,
|
||||||
|
},
|
||||||
|
],
|
||||||
telemetry: {
|
telemetry: {
|
||||||
create: (dbManager, gristServer) => new Telemetry(dbManager, gristServer),
|
create: (dbManager, gristServer) => new Telemetry(dbManager, gristServer),
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ import { DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTa
|
|||||||
import * as gutil from 'app/common/gutil';
|
import * as gutil from 'app/common/gutil';
|
||||||
import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/homedb/HomeDBManager';
|
import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/homedb/HomeDBManager';
|
||||||
import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
|
import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||||
|
import {OptDocSession} from 'app/server/lib/DocSession';
|
||||||
import {RequestWithOrg} from 'app/server/lib/extractOrg';
|
import {RequestWithOrg} from 'app/server/lib/extractOrg';
|
||||||
import {RequestWithGrist} from 'app/server/lib/GristServer';
|
import {RequestWithGrist} from 'app/server/lib/GristServer';
|
||||||
import log from 'app/server/lib/log';
|
import log from 'app/server/lib/log';
|
||||||
@ -12,6 +13,8 @@ import { IncomingMessage } from 'http';
|
|||||||
import {Writable} from 'stream';
|
import {Writable} from 'stream';
|
||||||
import {TLSSocket} from 'tls';
|
import {TLSSocket} from 'tls';
|
||||||
|
|
||||||
|
export type RequestOrSession = RequestWithLogin | OptDocSession | null;
|
||||||
|
|
||||||
// log api details outside of dev environment (when GRIST_HOSTED_VERSION is set)
|
// log api details outside of dev environment (when GRIST_HOSTED_VERSION is set)
|
||||||
const shouldLogApiDetails = Boolean(process.env.GRIST_HOSTED_VERSION);
|
const shouldLogApiDetails = Boolean(process.env.GRIST_HOSTED_VERSION);
|
||||||
|
|
||||||
|
@ -156,6 +156,7 @@ export async function main(port: number, serverTypes: ServerType[],
|
|||||||
server.addHomeApi();
|
server.addHomeApi();
|
||||||
server.addBillingApi();
|
server.addBillingApi();
|
||||||
server.addNotifier();
|
server.addNotifier();
|
||||||
|
server.addAuditLogger();
|
||||||
await server.addTelemetry();
|
await server.addTelemetry();
|
||||||
await server.addHousekeeper();
|
await server.addHousekeeper();
|
||||||
await server.addLoginRoutes();
|
await server.addLoginRoutes();
|
||||||
@ -170,6 +171,7 @@ export async function main(port: number, serverTypes: ServerType[],
|
|||||||
|
|
||||||
if (includeDocs) {
|
if (includeDocs) {
|
||||||
server.addJsonSupport();
|
server.addJsonSupport();
|
||||||
|
server.addAuditLogger();
|
||||||
await server.addTelemetry();
|
await server.addTelemetry();
|
||||||
await server.addDoc();
|
await server.addDoc();
|
||||||
}
|
}
|
||||||
|
@ -97,6 +97,7 @@
|
|||||||
"mocha": "10.2.0",
|
"mocha": "10.2.0",
|
||||||
"mocha-webdriver": "0.3.3",
|
"mocha-webdriver": "0.3.3",
|
||||||
"moment-locales-webpack-plugin": "^1.2.0",
|
"moment-locales-webpack-plugin": "^1.2.0",
|
||||||
|
"nock": "13.5.5",
|
||||||
"nodemon": "^2.0.4",
|
"nodemon": "^2.0.4",
|
||||||
"otplib": "12.0.1",
|
"otplib": "12.0.1",
|
||||||
"proper-lockfile": "4.1.2",
|
"proper-lockfile": "4.1.2",
|
||||||
|
@ -34,6 +34,7 @@ async function activateServer(home: FlexServer, docManager: DocManager) {
|
|||||||
home.addJsonSupport();
|
home.addJsonSupport();
|
||||||
await home.addLandingPages();
|
await home.addLandingPages();
|
||||||
home.addHomeApi();
|
home.addHomeApi();
|
||||||
|
home.addAuditLogger();
|
||||||
await home.addTelemetry();
|
await home.addTelemetry();
|
||||||
await home.addDoc();
|
await home.addDoc();
|
||||||
home.addApiErrorHandlers();
|
home.addApiErrorHandlers();
|
||||||
|
117
test/server/lib/GristAuditLogger.ts
Normal file
117
test/server/lib/GristAuditLogger.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
19
yarn.lock
19
yarn.lock
@ -5139,6 +5139,11 @@ json-stable-stringify@~0.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
jsonify "~0.0.0"
|
jsonify "~0.0.0"
|
||||||
|
|
||||||
|
json-stringify-safe@^5.0.1:
|
||||||
|
version "5.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
|
||||||
|
integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==
|
||||||
|
|
||||||
json5@^1.0.1:
|
json5@^1.0.1:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593"
|
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593"
|
||||||
@ -5988,6 +5993,15 @@ nise@^5.1.5:
|
|||||||
just-extend "^6.2.0"
|
just-extend "^6.2.0"
|
||||||
path-to-regexp "^6.2.1"
|
path-to-regexp "^6.2.1"
|
||||||
|
|
||||||
|
nock@13.5.5:
|
||||||
|
version "13.5.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/nock/-/nock-13.5.5.tgz#cd1caaca281d42be17d51946367a3d53a6af3e78"
|
||||||
|
integrity sha512-XKYnqUrCwXC8DGG1xX4YH5yNIrlh9c065uaMZZHUoeUUINTOyt+x/G+ezYk0Ft6ExSREVIs+qBJDK503viTfFA==
|
||||||
|
dependencies:
|
||||||
|
debug "^4.1.0"
|
||||||
|
json-stringify-safe "^5.0.1"
|
||||||
|
propagate "^2.0.0"
|
||||||
|
|
||||||
node-abort-controller@3.0.1:
|
node-abort-controller@3.0.1:
|
||||||
version "3.0.1"
|
version "3.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.0.1.tgz#f91fa50b1dee3f909afabb7e261b1e1d6b0cb74e"
|
resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.0.1.tgz#f91fa50b1dee3f909afabb7e261b1e1d6b0cb74e"
|
||||||
@ -6631,6 +6645,11 @@ promise-retry@^2.0.1:
|
|||||||
err-code "^2.0.2"
|
err-code "^2.0.2"
|
||||||
retry "^0.12.0"
|
retry "^0.12.0"
|
||||||
|
|
||||||
|
propagate@^2.0.0:
|
||||||
|
version "2.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45"
|
||||||
|
integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==
|
||||||
|
|
||||||
proper-lockfile@4.1.2:
|
proper-lockfile@4.1.2:
|
||||||
version "4.1.2"
|
version "4.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f"
|
resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f"
|
||||||
|
Loading…
Reference in New Issue
Block a user