mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user