(core) Add more audit logging data/events

Summary:
Adds a few additional audit events and enhances audit logging
to capture more data (request origin, active org, user type).

Test Plan: Server and manual tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D4348
This commit is contained in:
George Gevoian
2024-09-23 11:04:22 -04:00
parent 126db2f91a
commit 8b1d1c5d25
23 changed files with 1013 additions and 541 deletions

View File

@@ -8,8 +8,10 @@ import {ApiError} from 'app/common/ApiError';
import {FullUser} from 'app/common/LoginSessionAPI';
import {BasicRole} from 'app/common/roles';
import {OrganizationProperties, PermissionDelta} from 'app/common/UserAPI';
import {Document} from "app/gen-server/entity/Document";
import {User} from 'app/gen-server/entity/User';
import {BillingOptions, HomeDBManager, QueryResult, Scope} from 'app/gen-server/lib/homedb/HomeDBManager';
import {BillingOptions, HomeDBManager, Scope} from 'app/gen-server/lib/homedb/HomeDBManager';
import {PreviousAndCurrent, QueryResult} from 'app/gen-server/lib/homedb/Interfaces';
import {getAuthorizedUserId, getUserId, getUserProfiles, RequestWithLogin} from 'app/server/lib/Authorizer';
import {getSessionUser, linkOrgWithEmail} from 'app/server/lib/BrowserSession';
import {expressWrap} from 'app/server/lib/expressWrap';
@@ -259,37 +261,10 @@ export class ApiServer {
// POST /api/workspaces/:wid/docs
// Create a new doc owned by the specific workspace.
this._app.post('/api/workspaces/:wid/docs', expressWrap(async (req, res) => {
const mreq = req as RequestWithLogin;
const wsId = integerParam(req.params.wid, 'wid');
const query = await this._dbManager.addDocument(getScope(req), wsId, req.body);
const docId = query.data!;
this._gristServer.getTelemetry().logEvent(mreq, 'documentCreated', {
limited: {
docIdDigest: docId,
sourceDocIdDigest: undefined,
isImport: false,
fileType: undefined,
isSaved: true,
},
full: {
userId: mreq.userId,
altSessionId: mreq.altSessionId,
},
});
this._gristServer.getTelemetry().logEvent(mreq, 'createdDoc-Empty', {
full: {
docIdDigest: docId,
userId: mreq.userId,
altSessionId: mreq.altSessionId,
},
});
this._gristServer.getAuditLogger().logEvent(mreq, {
event: {
name: 'createDocument',
details: {id: docId},
},
});
return sendReply(req, res, query);
const result = await this._dbManager.addDocument(getScope(req), wsId, req.body);
if (result.status === 200) { this._logCreateDocumentEvents(req, result.data!); }
return sendReply(req, res, {...result, data: result.data?.id});
}));
// GET /api/templates/
@@ -334,7 +309,8 @@ export class ApiServer {
// Recover the specified doc if it was previously soft-deleted and is
// still available.
this._app.post('/api/docs/:did/unremove', expressWrap(async (req, res) => {
await this._dbManager.undeleteDocument(getDocScope(req));
const {status, data} = await this._dbManager.undeleteDocument(getDocScope(req));
if (status === 200) { this._logRestoreDocumentEvents(req, data!); }
return sendOkReply(req, res);
}));
@@ -375,9 +351,10 @@ export class ApiServer {
// PATCH /api/docs/:did/move
// Move the doc to the workspace specified in the body.
this._app.patch('/api/docs/:did/move', expressWrap(async (req, res) => {
const workspaceId = req.body.workspace;
const query = await this._dbManager.moveDoc(getDocScope(req), workspaceId);
return sendReply(req, res, query);
const workspaceId = integerParam(req.body.workspace, 'workspace');
const result = await this._dbManager.moveDoc(getDocScope(req), workspaceId);
if (result.status === 200) { this._logMoveDocumentEvents(req, result.data!); }
return sendReply(req, res, {...result, data: result.data?.current.id});
}));
this._app.patch('/api/docs/:did/pin', expressWrap(async (req, res) => {
@@ -647,6 +624,57 @@ export class ApiServer {
return result;
}
private _logCreateDocumentEvents(req: Request, document: Document) {
const mreq = req as RequestWithLogin;
const {id, name, workspace: {id: workspaceId}} = document;
this._gristServer.getAuditLogger().logEvent(mreq, {
event: {
name: 'createDocument',
details: {id, name},
context: {workspaceId},
},
});
this._gristServer.getTelemetry().logEvent(mreq, 'documentCreated', {
limited: {
docIdDigest: id,
sourceDocIdDigest: undefined,
isImport: false,
fileType: undefined,
isSaved: true,
},
full: {
userId: mreq.userId,
altSessionId: mreq.altSessionId,
},
});
this._gristServer.getTelemetry().logEvent(mreq, 'createdDoc-Empty', {
full: {
docIdDigest: id,
userId: mreq.userId,
altSessionId: mreq.altSessionId,
},
});
}
private _logRestoreDocumentEvents(req: Request, document: Document) {
const {workspace} = document;
this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {
event: {
name: 'restoreDocumentFromTrash',
details: {
document: {
id: document.id,
name: document.name,
},
workspace: {
id: workspace.id,
name: workspace.name,
},
},
},
});
}
private _logInvitedDocUserTelemetryEvents(mreq: RequestWithLogin, delta: PermissionDelta) {
if (!delta.users) { return; }
@@ -687,6 +715,32 @@ export class ApiServer {
);
}
}
private _logMoveDocumentEvents(req: Request, {previous, current}: PreviousAndCurrent<Document>) {
this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {
event: {
name: 'moveDocument',
details: {
id: current.id,
previous: {
workspace: {
id: previous.workspace.id,
name: previous.workspace.name,
},
},
current: {
workspace: {
id: current.workspace.id,
name: current.workspace.name,
},
},
},
context: {
workspaceId: previous.workspace.id,
},
},
});
}
}
/**

View File

@@ -41,7 +41,13 @@ import {User} from "app/gen-server/entity/User";
import {Workspace} from "app/gen-server/entity/Workspace";
import {Limit} from 'app/gen-server/entity/Limit';
import {
AvailableUsers, GetUserOptions, NonGuestGroup, Resource, UserProfileChange
AvailableUsers,
GetUserOptions,
NonGuestGroup,
PreviousAndCurrent,
QueryResult,
Resource,
UserProfileChange,
} from 'app/gen-server/lib/homedb/Interfaces';
import {SUPPORT_EMAIL, UsersManager} from 'app/gen-server/lib/homedb/UsersManager';
import {Permissions} from 'app/gen-server/lib/Permissions';
@@ -111,12 +117,6 @@ const listPublicSites = appSettings.section('access').flag('listPublicSites').re
// which is a burden under heavy traffic.
const DOC_AUTH_CACHE_TTL = 5000;
export interface QueryResult<T> {
status: number;
data?: T;
errMessage?: string;
}
// Maps from userId to group name, or null to inherit.
export interface UserIdDelta {
[userId: string]: roles.NonGuestRole|null;
@@ -1496,8 +1496,12 @@ export class HomeDBManager extends EventEmitter {
// by makeId(). The client should not be given control of the choice of docId.
// This option is used during imports, where it is convenient not to add a row to the
// document database until the document has actually been imported.
public async addDocument(scope: Scope, wsId: number, props: Partial<DocumentProperties>,
docId?: string): Promise<QueryResult<string>> {
public async addDocument(
scope: Scope,
wsId: number,
props: Partial<DocumentProperties>,
docId?: string
): Promise<QueryResult<Document>> {
const name = props.name;
if (!name) {
return {
@@ -1577,7 +1581,12 @@ export class HomeDBManager extends EventEmitter {
});
// Saves the document as well as its new ACL Rules and Group.
const groups = doc.aclRules.map(rule => rule.group);
const result = await manager.save([doc, ...doc.aclRules, ...doc.aliases, ...groups]);
const [data] = await manager.save<[Document, ...(AclRuleDoc|Alias|Group)[]]>([
doc,
...doc.aclRules,
...doc.aliases,
...groups,
]);
// Ensure that the creator is in the ws and org's guests group. Creator already has
// access to the workspace (he is at least an editor), but we need to be sure that
// even if he is removed from the workspace, he will still have access to this doc.
@@ -1587,10 +1596,7 @@ export class HomeDBManager extends EventEmitter {
// time), but they are ignoring any unique constraints errors.
await this._repairWorkspaceGuests(scope, workspace.id, manager);
await this._repairOrgGuests(scope, workspace.org.id, manager);
return {
status: 200,
data: (result[0] as Document).id
};
return {status: 200, data};
});
}
@@ -1751,9 +1757,9 @@ export class HomeDBManager extends EventEmitter {
}
// Checks that the user has REMOVE permissions to the given document. If not, throws an
// error. Otherwise deletes the given document. Returns an empty query result with
// status 200 on success.
public async deleteDocument(scope: DocScope): Promise<QueryResult<number>> {
// error. Otherwise deletes the given document. Returns a query result with status 200
// and the deleted document on success.
public async deleteDocument(scope: DocScope): Promise<QueryResult<Document>> {
return await this._connection.transaction(async manager => {
const {forkId} = parseUrlId(scope.urlId);
if (forkId) {
@@ -1767,8 +1773,9 @@ export class HomeDBManager extends EventEmitter {
return queryResult;
}
const fork: Document = queryResult.data;
await manager.remove([fork]);
return {status: 200};
const data = structuredClone(fork);
await manager.remove(fork);
return {status: 200, data};
} else {
const docQuery = this._doc(scope, {
manager,
@@ -1785,22 +1792,23 @@ export class HomeDBManager extends EventEmitter {
return queryResult;
}
const doc: Document = queryResult.data;
// Delete the doc and doc ACLs/groups.
const data = structuredClone(doc);
const docGroups = doc.aclRules.map(docAcl => docAcl.group);
// Delete the doc and doc ACLs/groups.
await manager.remove([doc, ...docGroups, ...doc.aclRules]);
// Update guests of the workspace and org after removing this doc.
await this._repairWorkspaceGuests(scope, doc.workspace.id, manager);
await this._repairOrgGuests(scope, doc.workspace.org.id, manager);
return {status: 200};
return {status: 200, data};
}
});
}
public softDeleteDocument(scope: DocScope): Promise<void> {
public softDeleteDocument(scope: DocScope): Promise<QueryResult<Document>> {
return this._setDocumentRemovedAt(scope, new Date());
}
public async undeleteDocument(scope: DocScope): Promise<void> {
public async undeleteDocument(scope: DocScope): Promise<QueryResult<Document>> {
return this._setDocumentRemovedAt(scope, null);
}
@@ -2263,7 +2271,7 @@ export class HomeDBManager extends EventEmitter {
public async moveDoc(
scope: DocScope,
wsId: number
): Promise<QueryResult<void>> {
): Promise<QueryResult<PreviousAndCurrent<Document>>> {
return await this._connection.transaction(async manager => {
// Get the doc
const docQuery = this._doc(scope, {
@@ -2285,6 +2293,7 @@ export class HomeDBManager extends EventEmitter {
return docQueryResult;
}
const doc: Document = docQueryResult.data;
const previous = structuredClone(doc);
if (doc.workspace.id === wsId) {
return {
status: 400,
@@ -2354,7 +2363,11 @@ export class HomeDBManager extends EventEmitter {
doc.aliases = undefined as any;
// Saves the document as well as its new ACL Rules and Groups and the
// updated guest group in the workspace.
await manager.save([doc, ...doc.aclRules, ...docGroups]);
const [current] = await manager.save<[Document, ...(AclRuleDoc|Group)[]]>([
doc,
...doc.aclRules,
...docGroups,
]);
if (firstLevelUsers.length > 0) {
// If the doc has first-level users, update the source and destination workspaces.
await this._repairWorkspaceGuests(scope, oldWs.id, manager);
@@ -2365,9 +2378,7 @@ export class HomeDBManager extends EventEmitter {
await this._repairOrgGuests(scope, doc.workspace.org.id, manager);
}
}
return {
status: 200
};
return {status: 200, data: {previous, current}};
});
}
@@ -4301,9 +4312,9 @@ export class HomeDBManager extends EventEmitter {
if (!removedAt) {
await this._checkRoomForAnotherDoc(doc.workspace, manager);
}
await manager.createQueryBuilder()
.update(Document).set({removedAt}).where({id: doc.id})
.execute();
doc.removedAt = removedAt;
const data = await manager.save(doc);
return {status: 200, data};
});
}

View File

@@ -14,6 +14,11 @@ export interface QueryResult<T> {
errMessage?: string;
}
export interface PreviousAndCurrent<T> {
previous: T;
current: T;
}
export interface GetUserOptions {
manager?: EntityManager;
profile?: UserProfile;