From bda7935714d3992a9db05451f7a8d292850bf7ec Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Mon, 30 Sep 2024 13:11:01 -0400 Subject: [PATCH] (core) Add remaining audit log events Summary: Adds the remaining batch of audit log events, and a CLI utility to generate documentation for installation and site audit events. Test Plan: Manual. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D4356 --- app/common/AuditEvent.ts | 521 +++++++++++++--- app/gen-server/ApiServer.ts | 368 ++++++++--- app/gen-server/lib/homedb/HomeDBManager.ts | 152 +++-- app/gen-server/lib/homedb/UsersManager.ts | 23 +- app/server/companion.ts | 14 + app/server/lib/ActiveDoc.ts | 54 +- app/server/lib/AppEndpoint.ts | 62 +- app/server/lib/AuditLogger.ts | 14 +- app/server/lib/DocApi.ts | 167 ++++- app/server/lib/FlexServer.ts | 36 +- app/server/lib/GoogleExport.ts | 10 +- app/server/lib/Triggers.ts | 15 +- app/server/utils/showAuditLogEvents.ts | 688 +++++++++++++++++++++ test/gen-server/lib/HomeDBManager.ts | 8 +- test/server/fixSiteProducts.ts | 16 +- 15 files changed, 1837 insertions(+), 311 deletions(-) create mode 100644 app/server/utils/showAuditLogEvents.ts diff --git a/app/common/AuditEvent.ts b/app/common/AuditEvent.ts index e88cbf2f..89950f4a 100644 --- a/app/common/AuditEvent.ts +++ b/app/common/AuditEvent.ts @@ -1,3 +1,6 @@ +import {BasicRole, NonGuestRole} from 'app/common/roles'; +import {StringUnion} from 'app/common/StringUnion'; + export interface AuditEvent { /** * The event. @@ -12,35 +15,73 @@ export interface AuditEvent { */ user: AuditEventUser; /** - * The event details. + * Event-specific details (e.g. IDs of affected resources). */ details: AuditEventDetails[Name] | {}; /** - * The context of the event. + * The context that the event occurred in (e.g. workspace, document). */ context: AuditEventContext; /** - * The source of the event. + * Information about the source of the event (e.g. IP address). */ source: AuditEventSource; }; /** - * ISO 8601 timestamp of when the event occurred. + * ISO 8601 timestamp (e.g. `2024-09-04T14:54:50Z`) of when the event occurred. */ timestamp: string; } -export type AuditEventName = - | 'createDocument' - | 'moveDocument' - | 'removeDocument' - | 'deleteDocument' - | 'restoreDocumentFromTrash' - | 'runSQLQuery'; +export const SiteAuditEventName = StringUnion( + 'createDocument', + 'sendToGoogleDrive', + 'renameDocument', + 'pinDocument', + 'unpinDocument', + 'moveDocument', + 'removeDocument', + 'deleteDocument', + 'restoreDocumentFromTrash', + 'changeDocumentAccess', + 'openDocument', + 'duplicateDocument', + 'forkDocument', + 'replaceDocument', + 'reloadDocument', + 'truncateDocumentHistory', + 'deliverWebhookEvents', + 'clearWebhookQueue', + 'clearAllWebhookQueues', + 'runSQLQuery', + 'createWorkspace', + 'renameWorkspace', + 'removeWorkspace', + 'deleteWorkspace', + 'restoreWorkspaceFromTrash', + 'changeWorkspaceAccess', + 'renameSite', + 'changeSiteAccess', +); + +export type SiteAuditEventName = typeof SiteAuditEventName.type; + +export const AuditEventName = StringUnion( + ...SiteAuditEventName.values, + 'createSite', + 'deleteSite', + 'changeUserName', + 'createUserAPIKey', + 'deleteUserAPIKey', + 'deleteUser', +); + +export type AuditEventName = typeof AuditEventName.type; export type AuditEventUser = | User | Anonymous + | System | Unknown; interface User { @@ -54,14 +95,15 @@ interface Anonymous { type: 'anonymous'; } +interface System { + type: 'system'; +} + interface Unknown { type: 'unknown'; } export interface AuditEventDetails { - /** - * A new document was created. - */ createDocument: { /** * The ID of the document. @@ -72,54 +114,78 @@ export interface AuditEventDetails { */ name?: string; }; - /** - * A document was moved to a new workspace. - */ + sendToGoogleDrive: { + /** + * The ID of the document. + */ + id: string; + }; + renameDocument: { + /** + * The ID of the document. + */ + id: string; + /** + * The previous name of the document. + */ + previousName: string; + /** + * The current name of the document. + */ + currentName: string; + }; + pinDocument: { + /** + * The ID of the document. + */ + id: string; + /** + * The name of the document. + */ + name: string; + }; + unpinDocument: { + /** + * The ID of the document. + */ + id: string; + /** + * The name of the document. + */ + name: string; + }; moveDocument: { /** * The ID of the document. */ id: string; /** - * The previous workspace. + * The workspace the document was moved from. */ - previous: { + previousWorkspace: { /** - * The workspace the document was moved from. + * The ID of the workspace. */ - workspace: { - /** - * The ID of the workspace. - */ - id: number; - /** - * The name of the workspace. - */ - name: string; - }; + id: number; + /** + * The name of the workspace. + */ + name: string; }; /** - * The current workspace. + * The workspace the document was moved to. */ - current: { + newWorkspace: { /** - * The workspace the document was moved to. + * The ID of the workspace. */ - workspace: { - /** - * The ID of the workspace. - */ - id: number; - /** - * The name of the workspace. - */ - name: string; - }; + id: number; + /** + * The name of the workspace. + */ + name: string; }; }; - /** - * A document was moved to the trash. - */ removeDocument: { /** * The ID of the document. @@ -130,9 +196,6 @@ export interface AuditEventDetails { */ name: string; }; - /** - * A document was permanently deleted. - */ deleteDocument: { /** * The ID of the document. @@ -143,25 +206,17 @@ export interface AuditEventDetails { */ name: string; }; - /** - * A document was restored from the trash. - */ restoreDocumentFromTrash: { /** - * The restored document. + * The ID of the document. */ - document: { - /** - * The ID of the document. - */ - id: string; - /** - * The name of the document. - */ - name: string; - }; + id: string; /** - * The workspace of the restored document. + * The name of the document. + */ + name: string; + /** + * The workspace of the document. */ workspace: { /** @@ -174,9 +229,176 @@ export interface AuditEventDetails { name: string; }; }; - /** - * A SQL query was run against a document. - */ + changeDocumentAccess: { + /** + * The ID of the document. + */ + id: string; + /** + * The access level of the document. + */ + access: { + /** + * The max inherited role. + */ + maxInheritedRole?: BasicRole | null; + /** + * The access level by user ID. + */ + users?: Record; + }; + }; + openDocument: { + /** + * The ID of the document. + */ + id: string; + /** + * The name of the document. + */ + name: string; + /** + * The URL ID of the document. + */ + urlId: string; + /** + * The ID of the fork, if the document is a fork. + */ + forkId?: string; + /** + * The ID of the snapshot, if the document is a snapshot. + */ + snapshotId?: string; + }; + duplicateDocument: { + /** + * The document that was duplicated. + */ + original: { + /** + * The ID of the document. + */ + id: string; + /** + * The name of the document. + */ + name: string; + /** + * The workspace of the document. + */ + workspace: { + /** + * The ID of the workspace. + */ + id: number; + /** + * The name of the workspace. + */ + name: string; + }; + }; + /** + * The newly-duplicated document. + */ + duplicate: { + /** + * The ID of the document. + */ + id: string; + /** + * The name of the document. + */ + name: string; + }; + /** + * If the document was duplicated without any data from the original document. + */ + asTemplate: boolean; + }; + forkDocument: { + /** + * The document that was forked. + */ + original: { + /** + * The ID of the document. + */ + id: string; + /** + * The name of the document. + */ + name: string; + }; + /** + * The newly-forked document. + */ + fork: { + /** + * The ID of the fork. + */ + id: string; + /** + * The ID of the fork with the trunk ID. + */ + documentId: string; + /** + * The ID of the fork with the trunk URL ID. + */ + urlId: string; + }; + }; + replaceDocument: { + /** + * The document that was replaced. + */ + previous: { + /** + * The ID of the document. + */ + id: string; + }; + /** + * The newly-replaced document. + */ + current: { + /** + * The ID of the document. + */ + id: string; + /** + * The ID of the snapshot, if the document was replaced with one. + */ + snapshotId?: string; + }; + }; + reloadDocument: {}, + truncateDocumentHistory: { + /** + * The number of history items kept. + */ + keep: number; + }, + deliverWebhookEvents: { + /** + * The ID of the webhook. + */ + id: string; + /** + * The host the webhook events were delivered to. + */ + host: string; + /** + * The number of webhook events delivered. + */ + quantity: number; + }, + clearWebhookQueue: { + /** + * The ID of the webhook. + */ + id: string; + }, + clearAllWebhookQueues: {}, runSQLQuery: { /** * The SQL query. @@ -185,12 +407,169 @@ export interface AuditEventDetails { /** * The arguments used for query parameters, if any. */ - arguments?: (string | number)[]; + arguments?: Array; /** - * The duration in milliseconds until query execution should time out. + * The query execution timeout duration in milliseconds. */ - timeout?: number; + timeoutMs?: number; }; + createWorkspace: { + /** + * The ID of the workspace. + */ + id: number; + /** + * The name of the workspace. + */ + name: string; + }; + renameWorkspace: { + /** + * The ID of the workspace. + */ + id: number; + /** + * The previous name of the workspace. + */ + previousName: string; + /** + * The current name of the workspace. + */ + currentName: string; + }; + removeWorkspace: { + /** + * The ID of the workspace. + */ + id: number; + /** + * The name of the workspace. + */ + name: string; + }; + deleteWorkspace: { + /** + * The ID of the workspace. + */ + id: number; + /** + * The name of the workspace. + */ + name: string; + }; + restoreWorkspaceFromTrash: { + /** + * The ID of the workspace. + */ + id: number; + /** + * The name of the workspace. + */ + name: string; + }; + changeWorkspaceAccess: { + /** + * The ID of the workspace. + */ + id: number; + /** + * The access level of the workspace. + */ + access: { + /** + * The max inherited role. + */ + maxInheritedRole?: BasicRole | null; + /** + * The access level by user ID. + */ + users?: Record; + }; + }; + createSite: { + /** + * The ID of the site. + */ + id: number; + /** + * The name of the site. + */ + name: string; + /** + * The domain of the site. + */ + domain: string; + }; + renameSite: { + /** + * The ID of the site. + */ + id: number; + /** + * The previous name and domain of the site. + */ + previous: { + /** + * The name of the site. + */ + name: string; + /** + * The domain of the site. + */ + domain: string; + }; + /** + * The current name and domain of the site. + */ + current: { + /** + * The name of the site. + */ + name: string; + /** + * The domain of the site. + */ + domain: string; + }; + }; + deleteSite: { + /** + * The ID of the site. + */ + id: number; + /** + * The name of the site. + */ + name: string; + }; + changeSiteAccess: { + /** + * The ID of the site. + */ + id: number; + /** + * The access level of the site. + */ + access: { + /** + * The access level by user ID. + */ + users?: Record; + }; + }; + changeUserName: { + /** + * The previous name of the user. + */ + previousName: string; + /** + * The current name of the user. + */ + currentName: string; + }; + createUserAPIKey: {}; + deleteUserAPIKey: {}; + deleteUser: {}; } export interface AuditEventContext { @@ -206,7 +585,7 @@ export interface AuditEventContext { export interface AuditEventSource { /** - * The domain of the org tied to the originating request. + * The domain of the site tied to the originating request. */ org?: string; /** diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 3acc243d..1289db66 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -9,7 +9,9 @@ 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 {Organization} from 'app/gen-server/entity/Organization'; import {User} from 'app/gen-server/entity/User'; +import {Workspace} from 'app/gen-server/entity/Workspace'; 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'; @@ -77,7 +79,7 @@ export function addOrg( product?: string, billing?: BillingOptions, } -): Promise { +): Promise { return dbManager.connection.transaction(async manager => { const user = await manager.findOne(User, {where: {id: userId}}); if (!user) { return handleDeletedUser(); } @@ -167,8 +169,9 @@ export class ApiServer { // doesn't have access to that information yet, so punting on this. // TODO: figure out who should be allowed to create organizations const userId = getAuthorizedUserId(req); - const orgId = await addOrg(this._dbManager, userId, req.body); - return sendOkReply(req, res, orgId); + const org = await addOrg(this._dbManager, userId, req.body); + this._logCreateSiteEvents(req, org); + return sendOkReply(req, res, org.id); })); // PATCH /api/orgs/:oid @@ -176,32 +179,30 @@ export class ApiServer { // Update the specified org. this._app.patch('/api/orgs/:oid', expressWrap(async (req, res) => { const org = getOrgKey(req); - const query = await this._dbManager.updateOrg(getScope(req), org, req.body); - return sendReply(req, res, query); + const {data, ...result} = await this._dbManager.updateOrg(getScope(req), org, req.body); + if (data && (req.body.name || req.body.domain)) { + this._logRenameSiteEvents(req as RequestWithLogin, data); + } + return sendReply(req, res, result); })); // DELETE /api/orgs/:oid // Delete the specified org and all included workspaces and docs. this._app.delete('/api/orgs/:oid', expressWrap(async (req, res) => { const org = getOrgKey(req); - const query = await this._dbManager.deleteOrg(getScope(req), org); - return sendReply(req, res, query); + const {data, ...result} = await this._dbManager.deleteOrg(getScope(req), org); + if (data) { this._logDeleteSiteEvents(req, data); } + return sendReply(req, res, {...result, data: data?.id}); })); // POST /api/orgs/:oid/workspaces // Body params: name // Create a new workspace owned by the specific organization. this._app.post('/api/orgs/:oid/workspaces', expressWrap(async (req, res) => { - const mreq = req as RequestWithLogin; const org = getOrgKey(req); - const query = await this._dbManager.addWorkspace(getScope(req), org, req.body); - this._gristServer.getTelemetry().logEvent(mreq, 'createdWorkspace', { - full: { - workspaceId: query.data, - userId: mreq.userId, - }, - }); - return sendReply(req, res, query); + const {data, ...result} = await this._dbManager.addWorkspace(getScope(req), org, req.body); + if (data) { this._logCreateWorkspaceEvents(req, data); } + return sendReply(req, res, {...result, data: data?.id}); })); // PATCH /api/workspaces/:wid @@ -209,23 +210,18 @@ export class ApiServer { // Update the specified workspace. this._app.patch('/api/workspaces/:wid', expressWrap(async (req, res) => { const wsId = integerParam(req.params.wid, 'wid'); - const query = await this._dbManager.updateWorkspace(getScope(req), wsId, req.body); - return sendReply(req, res, query); + const {data, ...result} = await this._dbManager.updateWorkspace(getScope(req), wsId, req.body); + if (data && 'name' in req.body) { this._logRenameWorkspaceEvents(req, data); } + return sendReply(req, res, {...result, data: data?.current.id}); })); // DELETE /api/workspaces/:wid // Delete the specified workspace and all included docs. this._app.delete('/api/workspaces/:wid', expressWrap(async (req, res) => { - const mreq = req as RequestWithLogin; const wsId = integerParam(req.params.wid, 'wid'); - const query = await this._dbManager.deleteWorkspace(getScope(req), wsId); - this._gristServer.getTelemetry().logEvent(mreq, 'deletedWorkspace', { - full: { - workspaceId: wsId, - userId: mreq.userId, - }, - }); - return sendReply(req, res, query); + const {data, ...result} = await this._dbManager.deleteWorkspace(getScope(req), wsId); + if (data) { this._logDeleteWorkspaceEvents(req, data); } + return sendReply(req, res, {...result, data: data?.id}); })); // POST /api/workspaces/:wid/remove @@ -234,17 +230,12 @@ export class ApiServer { this._app.post('/api/workspaces/:wid/remove', expressWrap(async (req, res) => { const wsId = integerParam(req.params.wid, 'wid'); if (isParameterOn(req.query.permanent)) { - const mreq = req as RequestWithLogin; - const query = await this._dbManager.deleteWorkspace(getScope(req), wsId); - this._gristServer.getTelemetry().logEvent(mreq, 'deletedWorkspace', { - full: { - workspaceId: query.data, - userId: mreq.userId, - }, - }); - return sendReply(req, res, query); + const {data, ...result} = await this._dbManager.deleteWorkspace(getScope(req), wsId); + if (data) { this._logDeleteWorkspaceEvents(req, data); } + return sendReply(req, res, {...result, data: data?.id}); } else { - await this._dbManager.softDeleteWorkspace(getScope(req), wsId); + const {data} = await this._dbManager.softDeleteWorkspace(getScope(req), wsId); + if (data) { this._logRemoveWorkspaceEvents(req, data); } return sendOkReply(req, res); } })); @@ -254,7 +245,8 @@ export class ApiServer { // still available. this._app.post('/api/workspaces/:wid/unremove', expressWrap(async (req, res) => { const wsId = integerParam(req.params.wid, 'wid'); - await this._dbManager.undeleteWorkspace(getScope(req), wsId); + const {data} = await this._dbManager.undeleteWorkspace(getScope(req), wsId); + if (data) { this._logRestoreWorkspaceEvents(req, data); } return sendOkReply(req, res); })); @@ -262,9 +254,9 @@ export class ApiServer { // Create a new doc owned by the specific workspace. this._app.post('/api/workspaces/:wid/docs', expressWrap(async (req, res) => { const wsId = integerParam(req.params.wid, 'wid'); - 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}); + const {data, ...result} = await this._dbManager.addDocument(getScope(req), wsId, req.body); + if (data) { this._logCreateDocumentEvents(req, data); } + return sendReply(req, res, {...result, data: data?.id}); })); // GET /api/templates/ @@ -301,16 +293,17 @@ export class ApiServer { // PATCH /api/docs/:did // Update the specified doc. this._app.patch('/api/docs/:did', expressWrap(async (req, res) => { - const query = await this._dbManager.updateDocument(getDocScope(req), req.body); - return sendReply(req, res, query); + const {data, ...result} = await this._dbManager.updateDocument(getDocScope(req), req.body); + if (data && 'name' in req.body) { this._logRenameDocumentEvents(req, data); } + return sendReply(req, res, {...result, data: data?.current.id}); })); // POST /api/docs/:did/unremove // 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) => { - const {status, data} = await this._dbManager.undeleteDocument(getDocScope(req)); - if (status === 200) { this._logRestoreDocumentEvents(req, data!); } + const {data} = await this._dbManager.undeleteDocument(getDocScope(req)); + if (data) { this._logRestoreDocumentEvents(req, data); } return sendOkReply(req, res); })); @@ -319,8 +312,9 @@ export class ApiServer { this._app.patch('/api/orgs/:oid/access', expressWrap(async (req, res) => { const org = getOrgKey(req); const delta = req.body.delta; - const query = await this._dbManager.updateOrgPermissions(getScope(req), org, delta); - return sendReply(req, res, query); + const {data, ...result} = await this._dbManager.updateOrgPermissions(getScope(req), org, delta); + if (data) { this._logChangeSiteAccessEvents(req as RequestWithLogin, data); } + return sendReply(req, res, result); })); // PATCH /api/workspaces/:wid/access @@ -328,8 +322,9 @@ export class ApiServer { this._app.patch('/api/workspaces/:wid/access', expressWrap(async (req, res) => { const workspaceId = integerParam(req.params.wid, 'wid'); const delta = req.body.delta; - const query = await this._dbManager.updateWorkspacePermissions(getScope(req), workspaceId, delta); - return sendReply(req, res, query); + const {data, ...result} = await this._dbManager.updateWorkspacePermissions(getScope(req), workspaceId, delta); + if (data) { this._logChangeWorkspaceAccessEvents(req as RequestWithLogin, data); } + return sendReply(req, res, result); })); // GET /api/docs/:did @@ -343,28 +338,30 @@ export class ApiServer { // Update the specified doc acl rules. this._app.patch('/api/docs/:did/access', expressWrap(async (req, res) => { const delta = req.body.delta; - const query = await this._dbManager.updateDocPermissions(getDocScope(req), delta); - this._logInvitedDocUserTelemetryEvents(req as RequestWithLogin, delta); - return sendReply(req, res, query); + const {data, ...result} = await this._dbManager.updateDocPermissions(getDocScope(req), delta); + if (data) { this._logChangeDocumentAccessEvents(req as RequestWithLogin, data); } + return sendReply(req, res, result); })); // 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 = 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}); + const {data, ...result} = await this._dbManager.moveDoc(getDocScope(req), workspaceId); + if (data) { this._logMoveDocumentEvents(req, data); } + return sendReply(req, res, {...result, data: data?.current.id}); })); this._app.patch('/api/docs/:did/pin', expressWrap(async (req, res) => { - const query = await this._dbManager.pinDoc(getDocScope(req), true); - return sendReply(req, res, query); + const {data, ...result} = await this._dbManager.pinDoc(getDocScope(req), true); + if (data) { this._logPinDocumentEvents(req, data); } + return sendReply(req, res, result); })); this._app.patch('/api/docs/:did/unpin', expressWrap(async (req, res) => { - const query = await this._dbManager.pinDoc(getDocScope(req), false); - return sendReply(req, res, query); + const {data, ...result} = await this._dbManager.pinDoc(getDocScope(req), false); + if (data) { this._logUnpinDocumentEvents(req, data); } + return sendReply(req, res, result); })); // GET /api/orgs/:oid/access @@ -408,7 +405,8 @@ export class ApiServer { throw new ApiError('Name expected in the body', 400); } const name = req.body.name; - await this._dbManager.updateUser(userId, { name }); + const {previous, current} = await this._dbManager.updateUser(userId, { name }); + this._logChangeUserNameEvents(req, {previous, current}); res.sendStatus(200); })); @@ -489,6 +487,7 @@ export class ApiServer { if (!user) { return handleDeletedUser(); } if (!user.apiKey || force) { user = await updateApiKeyWithRetry(manager, user); + this._logCreateUserAPIKeyEvents(req); res.status(200).send(user.apiKey); } else { res.status(400).send({error: "An apikey is already set, use `{force: true}` to override it."}); @@ -504,6 +503,7 @@ export class ApiServer { if (!user) { return handleDeletedUser(); } user.apiKey = null; await manager.save(User, user); + this._logDeleteUserAPIKeyEvents(req); }); res.sendStatus(200); })); @@ -656,16 +656,31 @@ export class ApiServer { }); } + private _logRenameDocumentEvents( + req: Request, + {previous, current}: PreviousAndCurrent + ) { + this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, { + event: { + name: 'renameDocument', + details: { + id: current.id, + previousName: previous.name, + currentName: current.name, + }, + context: {workspaceId: current.workspace.id}, + }, + }); + } + private _logRestoreDocumentEvents(req: Request, document: Document) { - const {workspace} = document; + const {id, name, workspace} = document; this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, { event: { name: 'restoreDocumentFromTrash', details: { - document: { - id: document.id, - name: document.name, - }, + id, + name, workspace: { id: workspace.id, name: workspace.name, @@ -675,6 +690,27 @@ export class ApiServer { }); } + private _logChangeDocumentAccessEvents( + req: RequestWithLogin, + {document, maxInheritedRole, users}: PermissionDelta & {document: Document} + ) { + const {id, workspace: {id: workspaceId}} = document; + this._gristServer.getAuditLogger().logEvent(req, { + event: { + name: 'changeDocumentAccess', + details: { + id, + access: { + maxInheritedRole, + users, + }, + }, + context: {workspaceId}, + }, + }); + this._logInvitedDocUserTelemetryEvents(req, {maxInheritedRole, users}); + } + private _logInvitedDocUserTelemetryEvents(mreq: RequestWithLogin, delta: PermissionDelta) { if (!delta.users) { return; } @@ -722,17 +758,13 @@ export class ApiServer { name: 'moveDocument', details: { id: current.id, - previous: { - workspace: { - id: previous.workspace.id, - name: previous.workspace.name, - }, + previousWorkspace: { + id: previous.workspace.id, + name: previous.workspace.name, }, - current: { - workspace: { - id: current.workspace.id, - name: current.workspace.name, - }, + newWorkspace: { + id: current.workspace.id, + name: current.workspace.name, }, }, context: { @@ -741,6 +773,192 @@ export class ApiServer { }, }); } + + private _logPinDocumentEvents(req: Request, document: Document) { + const {id, name, workspace: {id: workspaceId}} = document; + this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, { + event: { + name: 'pinDocument', + details: {id, name}, + context: {workspaceId}, + }, + }); + } + + private _logUnpinDocumentEvents(req: Request, document: Document) { + const {id, name, workspace: {id: workspaceId}} = document; + this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, { + event: { + name: 'unpinDocument', + details: {id, name}, + context: {workspaceId}, + }, + }); + } + + private _logCreateWorkspaceEvents(req: Request, {id, name}: Workspace) { + const mreq = req as RequestWithLogin; + this._gristServer.getAuditLogger().logEvent(mreq, { + event: { + name: 'createWorkspace', + details: {id, name}, + }, + }); + this._gristServer.getTelemetry().logEvent(mreq, 'createdWorkspace', { + full: { + workspaceId: id, + userId: mreq.userId, + }, + }); + } + + private _logRenameWorkspaceEvents( + req: Request, + {previous, current}: PreviousAndCurrent + ) { + this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, { + event: { + name: 'renameWorkspace', + details: { + id: current.id, + previousName: previous.name, + currentName: current.name, + }, + }, + }); + } + + private _logRemoveWorkspaceEvents(req: Request, {id, name}: Workspace) { + this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, { + event: { + name: 'removeWorkspace', + details: {id, name}, + }, + }); + } + + private _logDeleteWorkspaceEvents(req: Request, {id, name}: Workspace) { + const mreq = req as RequestWithLogin; + this._gristServer.getAuditLogger().logEvent(mreq, { + event: { + name: 'deleteWorkspace', + details: {id, name}, + }, + }); + this._gristServer.getTelemetry().logEvent(mreq, 'deletedWorkspace', { + full: { + workspaceId: id, + userId: mreq.userId, + }, + }); + } + + private _logRestoreWorkspaceEvents(req: Request, {id, name}: Workspace) { + this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, { + event: { + name: 'restoreWorkspaceFromTrash', + details: {id, name}, + }, + }); + } + + private _logChangeWorkspaceAccessEvents( + req: RequestWithLogin, + {workspace: {id}, maxInheritedRole, users}: PermissionDelta & {workspace: Workspace} + ) { + this._gristServer.getAuditLogger().logEvent(req, { + event: { + name: 'changeWorkspaceAccess', + details: { + id, + access: { + maxInheritedRole, + users, + }, + }, + }, + }); + } + + private _logCreateSiteEvents(req: Request, {id, name, domain}: Organization) { + this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, { + event: { + name: 'createSite', + details: {id, name, domain}, + }, + }); + } + + private _logRenameSiteEvents( + req: Request, + {previous, current}: PreviousAndCurrent + ) { + this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, { + event: { + name: 'renameSite', + details: { + id: current.id, + previous: { + name: previous.name, + domain: previous.domain, + }, + current: { + name: current.name, + domain: current.domain, + }, + }, + }, + }); + } + + private _logDeleteSiteEvents(req: Request, {id, name}: Organization) { + this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, { + event: { + name: 'deleteSite', + details: {id, name}, + }, + }); + } + + private _logChangeSiteAccessEvents( + req: RequestWithLogin, + {organization: {id}, users}: PermissionDelta & {organization: Organization} + ) { + this._gristServer.getAuditLogger().logEvent(req, { + event: { + name: 'changeSiteAccess', + details: {id, access: {users}}, + }, + }); + } + + private _logChangeUserNameEvents( + req: Request, + {previous: {name: previousName}, current: {name: currentName}}: PreviousAndCurrent + ) { + this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, { + event: { + name: 'changeUserName', + details: {previousName, currentName}, + }, + }); + } + + private _logCreateUserAPIKeyEvents(req: Request) { + this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, { + event: { + name: 'createUserAPIKey', + }, + }); + } + + private _logDeleteUserAPIKeyEvents(req: Request) { + this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, { + event: { + name: 'deleteUserAPIKey', + }, + }); + } } /** diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index e9c4cc1c..58e6a561 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -459,11 +459,15 @@ export class HomeDBManager extends EventEmitter { return await this._usersManager.ensureExternalUser(profile); } - public async updateUser(userId: number, props: UserProfileChange) { - const { user, isWelcomed } = await this._usersManager.updateUser(userId, props); - if (user && isWelcomed) { - this.emit('firstLogin', this.makeFullUser(user)); + public async updateUser( + userId: number, + props: UserProfileChange + ): Promise> { + const {previous, current, isWelcomed} = await this._usersManager.updateUser(userId, props); + if (current && isWelcomed) { + this.emit('firstLogin', this.makeFullUser(current)); } + return {previous, current}; } public async updateUserOptions(userId: number, props: Partial) { @@ -1058,7 +1062,7 @@ export class HomeDBManager extends EventEmitter { /** * - * Adds an org with the given name. Returns a query result with the id of the added org. + * Adds an org with the given name. Returns a query result with the added org. * * @param user: user doing the adding * @param name: desired org name @@ -1073,12 +1077,17 @@ export class HomeDBManager extends EventEmitter { * meaningful for team sites currently. * @param billing: if set, controls the billing account settings for the org. */ - public async addOrg(user: User, props: Partial, - options: { setUserAsOwner: boolean, - useNewPlan: boolean, - product?: string, // Default to PERSONAL_FREE_PLAN or TEAM_FREE_PLAN env variable. - billing?: BillingOptions}, - transaction?: EntityManager): Promise> { + public async addOrg( + user: User, + props: Partial, + options: { + setUserAsOwner: boolean, + useNewPlan: boolean, + product?: string, // Default to PERSONAL_FREE_PLAN or TEAM_FREE_PLAN env variable. + billing?: BillingOptions + }, + transaction?: EntityManager + ): Promise> { const notifications: Array<() => void> = []; const name = props.name; const domain = props.domain; @@ -1219,10 +1228,7 @@ export class HomeDBManager extends EventEmitter { // Emit a notification. notifications.push(this._teamCreatorNotification(user.id)); } - return { - status: 200, - data: savedOrg.id - }; + return {status: 200, data: savedOrg}; }); for (const notification of notifications) { notification(); } return orgResult; @@ -1230,8 +1236,8 @@ export class HomeDBManager extends EventEmitter { // If setting anything more than prefs: // Checks that the user has UPDATE permissions to the given org. If not, throws an - // error. Otherwise updates the given org with the given name. Returns an empty - // query result with status 200 on success. + // error. Otherwise updates the given org with the given name. Returns a query + // result with status 200 on success. // For setting userPrefs or userOrgPrefs: // These are user-specific setting, so are allowed with VIEW access (that includes // guests). Prefs are replaced in their entirety, not merged. @@ -1242,7 +1248,7 @@ export class HomeDBManager extends EventEmitter { orgKey: string|number, props: Partial, transaction?: EntityManager, - ): Promise> { + ): Promise>> { // Check the scope of the modifications. let markPermissions: number = Permissions.VIEW; @@ -1272,11 +1278,12 @@ export class HomeDBManager extends EventEmitter { }); const queryResult = await verifyEntity(orgQuery); if (queryResult.status !== 200) { - // If the query for the workspace failed, return the failure result. + // If the query for the org failed, return the failure result. return queryResult; } // Update the fields and save. const org: Organization = queryResult.data; + const previous = structuredClone(org); org.checkProperties(props); if (modifyOrg) { if (props.domain) { @@ -1312,15 +1319,18 @@ export class HomeDBManager extends EventEmitter { .execute(); } } - return {status: 200}; + return {status: 200, data: {previous, current: org}}; }); } // Checks that the user has REMOVE permissions to the given org. If not, throws an - // error. Otherwise deletes the given org. Returns an empty query result with - // status 200 on success. - public async deleteOrg(scope: Scope, orgKey: string|number, - transaction?: EntityManager): Promise> { + // error. Otherwise deletes the given org. Returns a query result with status 200 + // on success. + public async deleteOrg( + scope: Scope, + orgKey: string|number, + transaction?: EntityManager + ): Promise> { return await this._runInTransaction(transaction, async manager => { const orgQuery = this.org(scope, orgKey, { manager, @@ -1344,6 +1354,7 @@ export class HomeDBManager extends EventEmitter { return queryResult; } const org: Organization = queryResult.data; + const deletedOrg = structuredClone(org); // Delete the org, org ACLs/groups, workspaces, workspace ACLs/groups, workspace docs // and doc ACLs/groups. const orgGroups = org.aclRules.map(orgAcl => orgAcl.group); @@ -1363,15 +1374,18 @@ export class HomeDBManager extends EventEmitter { if (billingAccount && billingAccount.orgs.length === 0) { await manager.remove([billingAccount]); } - return {status: 200}; + return {status: 200, data: deletedOrg}; }); } // Checks that the user has ADD permissions to the given org. If not, throws an error. - // Otherwise adds a workspace with the given name. Returns a query result with the id - // of the added workspace. - public async addWorkspace(scope: Scope, orgKey: string|number, - props: Partial): Promise> { + // Otherwise adds a workspace with the given name. Returns a query result with the + // added workspace. + public async addWorkspace( + scope: Scope, + orgKey: string|number, + props: Partial + ): Promise> { const name = props.name; if (!name) { return { @@ -1414,18 +1428,18 @@ export class HomeDBManager extends EventEmitter { } } const workspace = await this._doAddWorkspace({org, props, ownerId: scope.userId}, manager); - return { - status: 200, - data: workspace.id - }; + return {status: 200, data: workspace}; }); } // Checks that the user has UPDATE permissions to the given workspace. If not, throws an - // error. Otherwise updates the given workspace with the given name. Returns an empty - // query result with status 200 on success. - public async updateWorkspace(scope: Scope, wsId: number, - props: Partial): Promise> { + // error. Otherwise updates the given workspace with the given name. Returns a query result + // with status 200 on success. + public async updateWorkspace( + scope: Scope, + wsId: number, + props: Partial + ): Promise>> { return await this._connection.transaction(async manager => { const wsQuery = this._workspace(scope, wsId, { manager, @@ -1438,17 +1452,18 @@ export class HomeDBManager extends EventEmitter { } // Update the name and save. const workspace: Workspace = queryResult.data; + const previous = structuredClone(workspace); workspace.checkProperties(props); workspace.updateFromProperties(props); await manager.save(workspace); - return {status: 200}; + return {status: 200, data: {previous, current: workspace}}; }); } // Checks that the user has REMOVE permissions to the given workspace. If not, throws an - // error. Otherwise deletes the given workspace. Returns an empty query result with - // status 200 on success. - public async deleteWorkspace(scope: Scope, wsId: number): Promise> { + // error. Otherwise deletes the given workspace. Returns a query result with status 200 + // on success. + public async deleteWorkspace(scope: Scope, wsId: number): Promise> { return await this._connection.transaction(async manager => { const wsQuery = this._workspace(scope, wsId, { manager, @@ -1469,6 +1484,7 @@ export class HomeDBManager extends EventEmitter { return queryResult; } const workspace: Workspace = queryResult.data; + const deletedWorkspace = structuredClone(workspace); // Delete the workspace, workspace docs, doc ACLs/groups and workspace ACLs/groups. const wsGroups = workspace.aclRules.map(wsAcl => wsAcl.group); const docAcls = ([] as AclRule[]).concat(...workspace.docs.map(doc => doc.aclRules)); @@ -1477,15 +1493,15 @@ export class HomeDBManager extends EventEmitter { ...workspace.aclRules, ...docGroups]); // Update the guests in the org after removing this workspace. await this._repairOrgGuests(scope, workspace.org.id, manager); - return {status: 200}; + return {status: 200, data: deletedWorkspace}; }); } - public softDeleteWorkspace(scope: Scope, wsId: number): Promise { + public softDeleteWorkspace(scope: Scope, wsId: number): Promise> { return this._setWorkspaceRemovedAt(scope, wsId, new Date()); } - public async undeleteWorkspace(scope: Scope, wsId: number): Promise { + public async undeleteWorkspace(scope: Scope, wsId: number): Promise> { return this._setWorkspaceRemovedAt(scope, wsId, null); } @@ -1691,15 +1707,15 @@ export class HomeDBManager extends EventEmitter { } // Checks that the user has SCHEMA_EDIT permissions to the given doc. If not, throws an - // error. Otherwise updates the given doc with the given name. Returns an empty - // query result with status 200 on success. + // error. Otherwise updates the given doc with the given name. Returns a query result with + // status 200 on success. // NOTE: This does not update the updateAt date indicating the last modified time of the doc. // We may want to make it do so. public async updateDocument( scope: DocScope, props: Partial, transaction?: EntityManager - ): Promise> { + ): Promise>> { const markPermissions = Permissions.SCHEMA_EDIT; return await this._runInTransaction(transaction, async (manager) => { const {forkId} = parseUrlId(scope.urlId); @@ -1721,6 +1737,7 @@ export class HomeDBManager extends EventEmitter { } // Update the name and save. const doc: Document = queryResult.data; + const previous = structuredClone(doc); doc.checkProperties(props); doc.updateFromProperties(props); if (forkId) { @@ -1752,7 +1769,7 @@ export class HomeDBManager extends EventEmitter { .execute(); // TODO: we could limit the max number of aliases stored per document. } - return {status: 200}; + return {status: 200, data: {previous, current: doc}}; }); } @@ -1909,7 +1926,7 @@ export class HomeDBManager extends EventEmitter { scope: Scope, orgKey: string|number, delta: PermissionDelta - ): Promise> { + ): Promise> { const {userId} = scope; const notifications: Array<() => void> = []; const result = await this._connection.transaction(async manager => { @@ -1955,7 +1972,10 @@ export class HomeDBManager extends EventEmitter { // Notify any added users that they've been added to this resource. notifications.push(this._inviteNotification(userId, org, userIdDelta, membersBefore)); } - return {status: 200}; + return {status: 200, data: { + organization: org, + users: userIdDelta ?? undefined, + }}; }); for (const notification of notifications) { notification(); } return result; @@ -1966,7 +1986,7 @@ export class HomeDBManager extends EventEmitter { scope: Scope, wsId: number, delta: PermissionDelta - ): Promise> { + ): Promise> { const {userId} = scope; const notifications: Array<() => void> = []; const result = await this._connection.transaction(async manager => { @@ -2031,7 +2051,14 @@ export class HomeDBManager extends EventEmitter { await this._repairOrgGuests(scope, ws.org.id, manager); notifications.push(this._inviteNotification(userId, ws, userIdDelta, membersBefore)); } - return {status: 200}; + return { + status: 200, + data: { + workspace: ws, + maxInheritedRole: delta.maxInheritedRole, + users: userIdDelta ?? undefined, + }, + }; }); for (const notification of notifications) { notification(); } return result; @@ -2041,7 +2068,7 @@ export class HomeDBManager extends EventEmitter { public async updateDocPermissions( scope: DocScope, delta: PermissionDelta - ): Promise> { + ): Promise> { const notifications: Array<() => void> = []; const result = await this._connection.transaction(async manager => { const {userId} = scope; @@ -2082,7 +2109,14 @@ export class HomeDBManager extends EventEmitter { await this._repairOrgGuests(scope, doc.workspace.org.id, manager); notifications.push(this._inviteNotification(userId, doc, userIdDelta, membersBefore)); } - return {status: 200}; + return { + status: 200, + data: { + document: doc, + maxInheritedRole: delta.maxInheritedRole, + users: userIdDelta ?? undefined, + }, + }; }); for (const notification of notifications) { notification(); } return result; @@ -2386,7 +2420,7 @@ export class HomeDBManager extends EventEmitter { public async pinDoc( scope: DocScope, setPinned: boolean - ): Promise> { + ): Promise> { return await this._connection.transaction(async manager => { // Find the doc to assert that it exists. Assert that the user has edit access to the // parent org. @@ -2410,7 +2444,7 @@ export class HomeDBManager extends EventEmitter { // Save and return success status. await manager.save(doc); } - return { status: 200 }; + return {status: 200, data: doc}; }); } @@ -4291,9 +4325,9 @@ export class HomeDBManager extends EventEmitter { markPermissions: Permissions.REMOVE }); const workspace: Workspace = this.unwrapQueryResult(await verifyEntity(wsQuery)); - await manager.createQueryBuilder() - .update(Workspace).set({removedAt}).where({id: workspace.id}) - .execute(); + workspace.removedAt = removedAt; + const data = await manager.save(workspace); + return {status: 200, data}; }); } diff --git a/app/gen-server/lib/homedb/UsersManager.ts b/app/gen-server/lib/homedb/UsersManager.ts index 295e4de1..e2be5b1d 100644 --- a/app/gen-server/lib/homedb/UsersManager.ts +++ b/app/gen-server/lib/homedb/UsersManager.ts @@ -256,14 +256,17 @@ export class UsersManager { }); } - public async updateUser(userId: number, props: UserProfileChange) { - let isWelcomed: boolean = false; - let user: User|null = null; - await this._connection.transaction(async manager => { - user = await manager.findOne(User, {relations: ['logins'], - where: {id: userId}}); + public async updateUser(userId: number, props: UserProfileChange){ + return await this._connection.transaction(async manager => { + let isWelcomed = false; let needsSave = false; + const user = await manager.findOne(User, { + relations: ['logins'], + where: {id: userId}, + }); if (!user) { throw new ApiError("unable to find user", 400); } + + const previous = structuredClone(user); if (props.name && props.name !== user.name) { user.name = props.name; needsSave = true; @@ -279,8 +282,8 @@ export class UsersManager { if (needsSave) { await manager.save(user); } + return {previous, current: user, isWelcomed}; }); - return { user, isWelcomed }; } // TODO: rather use the updateUser() method, if that makes sense? @@ -454,9 +457,9 @@ export class UsersManager { // We just created a personal org; set userOrgPrefs that should apply for new users only. const userOrgPrefs: UserOrgPrefs = {showGristTour: true}; - const orgId = result.data; - if (orgId) { - await this._homeDb.updateOrg({userId: user.id}, orgId, {userOrgPrefs}, manager); + const org = result.data; + if (org) { + await this._homeDb.updateOrg({userId: user.id}, org.id, {userOrgPrefs}, manager); } } if (needUpdate) { diff --git a/app/server/companion.ts b/app/server/companion.ts index ab454cb5..e4ed8c93 100644 --- a/app/server/companion.ts +++ b/app/server/companion.ts @@ -9,6 +9,7 @@ import { getDatabaseUrl } from 'app/server/lib/serverUtils'; import { getTelemetryPrefs } from 'app/server/lib/Telemetry'; import { Gristifier } from 'app/server/utils/gristify'; import { pruneActionHistory } from 'app/server/utils/pruneActionHistory'; +import { showAuditLogEvents } from 'app/server/utils/showAuditLogEvents'; import * as commander from 'commander'; import { Connection } from 'typeorm'; @@ -43,6 +44,7 @@ export function getProgram(): commander.Command { // want to reserve "grist" for electron app? .description('a toolbox of handy Grist-related utilities'); + addAuditLogsCommand(program, {nested: true}); addDbCommand(program, {nested: true}); addHistoryCommand(program, {nested: true}); addSettingsCommand(program, {nested: true}); @@ -52,6 +54,18 @@ export function getProgram(): commander.Command { return program; } +function addAuditLogsCommand(program: commander.Command, options: CommandOptions) { + const sub = section(program, { + sectionName: 'audit-logs', + sectionDescription: 'show information about audit logs', + ...options, + }); + sub('events') + .description('show audit log events') + .addOption(new commander.Option('--type ').choices(['installation', 'site'])) + .action(showAuditLogEvents); +} + // Add commands related to document history: // history prune [N] export function addHistoryCommand(program: commander.Command, options: CommandOptions) { diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index b6810ae9..ed512b03 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -36,6 +36,7 @@ import { import {ApiError} from 'app/common/ApiError'; import {mapGetOrSet, MapWithTTL} from 'app/common/AsyncCreate'; import {AttachmentColumns, gatherAttachmentIds, getAttachmentColumns} from 'app/common/AttachmentColumns'; +import {AuditEventName} from 'app/common/AuditEvent'; import {WebhookMessageType} from 'app/common/CommTypes'; import { BulkAddRecord, @@ -92,6 +93,7 @@ import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI'; import {AccessTokenOptions, AccessTokenResult, GristDocAPI, UIRowId} from 'app/plugin/GristAPI'; import {AssistanceSchemaPromptV1Context} from 'app/server/lib/Assistance'; import {AssistanceContext} from 'app/common/AssistancePrompts'; +import {AuditEventProperties} from 'app/server/lib/AuditLogger'; import {Authorizer, RequestWithLogin} from 'app/server/lib/Authorizer'; import {checksumFile} from 'app/server/lib/checksumFile'; import {Client} from 'app/server/lib/Client'; @@ -115,6 +117,7 @@ import { getFullUser, getLogMeta, getUserId, + RequestOrSession, } from 'app/server/lib/sessionUtils'; import {shortDesc} from 'app/server/lib/shortDesc'; import {TableMetadataLoader} from 'app/server/lib/TableMetadataLoader'; @@ -1451,17 +1454,7 @@ export class ActiveDoc extends EventEmitter { } await dbManager.forkDoc(userId, doc, forkIds.forkId); - - const isTemplate = doc.type === 'template'; - this.logTelemetryEvent(docSession, 'documentForked', { - limited: { - forkIdDigest: forkIds.forkId, - forkDocIdDigest: forkIds.docId, - trunkIdDigest: doc.trunkId, - isTemplate, - lastActivity: doc.updatedAt, - }, - }); + this._logForkDocumentEvents(docSession, {originalDocument: doc, forkIds}); } finally { await permitStore.removePermit(permitKey); } @@ -1865,6 +1858,13 @@ export class ActiveDoc extends EventEmitter { }); } + public logAuditEvent( + requestOrSession: RequestOrSession, + properties: AuditEventProperties + ) { + this._docManager.gristServer.getAuditLogger().logEvent(requestOrSession, properties); + } + public logTelemetryEvent( docSession: OptDocSession | null, event: TelemetryEvent, @@ -2961,6 +2961,38 @@ export class ActiveDoc extends EventEmitter { return this._pyCall('start_timing'); } + private _logForkDocumentEvents(docSession: OptDocSession, options: { + originalDocument: Document; + forkIds: ForkResult; + }) { + const {originalDocument, forkIds} = options; + this.logAuditEvent(docSession, { + event: { + name: 'forkDocument', + details: { + original: { + id: originalDocument.id, + name: originalDocument.name, + }, + fork: { + id: forkIds.forkId, + documentId: forkIds.docId, + urlId: forkIds.urlId, + }, + }, + context: {documentId: originalDocument.id}, + }, + }); + this.logTelemetryEvent(docSession, 'documentForked', { + limited: { + forkIdDigest: forkIds.forkId, + forkDocIdDigest: forkIds.docId, + trunkIdDigest: originalDocument.trunkId, + isTemplate: originalDocument.type === 'template', + lastActivity: originalDocument.updatedAt, + }, + }); + } } // Helper to initialize a sandbox action bundle with no values. diff --git a/app/server/lib/AppEndpoint.ts b/app/server/lib/AppEndpoint.ts index 8147bfcf..09a88191 100644 --- a/app/server/lib/AppEndpoint.ts +++ b/app/server/lib/AppEndpoint.ts @@ -153,30 +153,8 @@ export function attachAppEndpoint(options: AttachOptions): void { docStatus = workerInfo.docStatus; body = await workerInfo.resp.json(); } - - const isPublic = ((doc as unknown) as APIDocument).public ?? false; - const isSnapshot = Boolean(parseUrlId(urlId).snapshotId); - const isTemplate = doc.type === 'template'; - if (isPublic || isTemplate) { - gristServer.getTelemetry().logEvent(mreq, 'documentOpened', { - limited: { - docIdDigest: docId, - access: doc.access, - isPublic, - isSnapshot, - isTemplate, - lastUpdated: doc.updatedAt, - }, - full: { - siteId: doc.workspace.org.id, - siteType: doc.workspace.org.billingAccount.product.name, - userId: mreq.userId, - altSessionId: mreq.altSessionId, - }, - }); - } - - if (isTemplate) { + logOpenDocumentEvents(mreq, {server: gristServer, doc, urlId}); + if (doc.type === 'template') { // Keep track of the last template a user visited in the last hour. // If a sign-up occurs within that time period, we'll know which // template, if any, was viewed most recently. @@ -232,3 +210,39 @@ export function attachAppEndpoint(options: AttachOptions): void { app.get('/:urlId([^-/]{12,})(/:slug([^/]+):remainder(*))?', ...docMiddleware, docHandler); } + +function logOpenDocumentEvents(req: RequestWithLogin, options: { + server: GristServer; + doc: Document; + urlId: string; +}) { + const {server, doc, urlId} = options; + const {forkId, snapshotId} = parseUrlId(urlId); + server.getAuditLogger().logEvent(req, { + event: { + name: 'openDocument', + details: {id: doc.id, name: doc.name, urlId, forkId, snapshotId}, + }, + }); + + const isPublic = ((doc as unknown) as APIDocument).public ?? false; + const isTemplate = doc.type === 'template'; + if (isPublic || isTemplate) { + server.getTelemetry().logEvent(req, 'documentOpened', { + limited: { + docIdDigest: doc.id, + access: doc.access, + isPublic, + isSnapshot: Boolean(snapshotId), + isTemplate, + lastUpdated: doc.updatedAt, + }, + full: { + siteId: doc.workspace.org.id, + siteType: doc.workspace.org.billingAccount.product.name, + userId: req.userId, + altSessionId: req.altSessionId, + }, + }); + } +} diff --git a/app/server/lib/AuditLogger.ts b/app/server/lib/AuditLogger.ts index 11d9cbf9..732ba2f7 100644 --- a/app/server/lib/AuditLogger.ts +++ b/app/server/lib/AuditLogger.ts @@ -1,4 +1,4 @@ -import {AuditEvent, AuditEventContext, AuditEventDetails, AuditEventName} from 'app/common/AuditEvent'; +import {AuditEvent, AuditEventContext, AuditEventDetails, AuditEventName, AuditEventUser} from 'app/common/AuditEvent'; import {RequestOrSession} from 'app/server/lib/sessionUtils'; export interface IAuditLogger { @@ -23,20 +23,24 @@ export interface IAuditLogger { export interface AuditEventProperties { event: { /** - * The event name. + * The name of the event. */ name: Name; /** - * Additional event details. + * Event-specific details (e.g. properties of affected resources). */ details?: AuditEventDetails[Name]; /** - * The context of the event. + * The context that the event occurred in (e.g. workspace, document). */ context?: AuditEventContext; + /** + * The user that triggered the event. + */ + user?: AuditEventUser; }; /** - * ISO 8601 timestamp (e.g. `2024-09-04T14:54:50Z`) of when the event occured. + * ISO 8601 timestamp (e.g. `2024-09-04T14:54:50Z`) of when the event occurred. * * Defaults to now. */ diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index 26148903..be2fce47 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -906,8 +906,10 @@ export class DocWorkerApi { // Clears all outgoing webhooks in the queue for this document. this._app.delete('/api/docs/:docId/webhooks/queue', isOwner, withDocTriggersLock(async (activeDoc, req, res) => { + const docId = getDocId(req); await activeDoc.clearWebhookQueue(); await activeDoc.sendWebhookNotification(); + this._logClearAllWebhookQueueEvents(req, {docId}); res.json({success: true}); }) ); @@ -933,7 +935,7 @@ export class DocWorkerApi { const webhookId = req.params.webhookId; const {fields, url, authorization} = await getWebhookSettings(activeDoc, req, webhookId, req.body); if (fields.enabled === false) { - await activeDoc.triggers.clearSingleWebhookQueue(webhookId); + await activeDoc.clearSingleWebhookQueue(webhookId); } const triggerRowId = activeDoc.triggers.getWebhookTriggerRecord(webhookId).id; @@ -960,9 +962,11 @@ export class DocWorkerApi { // Clears a single webhook in the queue for this document. this._app.delete('/api/docs/:docId/webhooks/queue/:webhookId', isOwner, withDocTriggersLock(async (activeDoc, req, res) => { + const docId = getDocId(req); const webhookId = req.params.webhookId; await activeDoc.clearSingleWebhookQueue(webhookId); await activeDoc.sendWebhookNotification(); + this._logClearWebhookQueueEvents(req, {docId, webhookId}); res.json({success: true}); }) ); @@ -978,8 +982,10 @@ export class DocWorkerApi { // reopened on use). this._app.post('/api/docs/:docId/force-reload', canEdit, async (req, res) => { const mreq = req as RequestWithLogin; + const docId = getDocId(req); const activeDoc = await this._getActiveDoc(mreq); await activeDoc.reloadDoc(); + this._logReloadDocumentEvents(mreq, {docId}); res.json(null); }); @@ -997,16 +1003,16 @@ export class DocWorkerApi { // DELETE /api/docs/:docId // Delete the specified doc. this._app.delete('/api/docs/:docId', canEditMaybeRemoved, throttled(async (req, res) => { - const {status, data} = await this._removeDoc(req, res, true); - if (status === 200) { this._logDeleteDocumentEvents(req, data!); } + const {data} = await this._removeDoc(req, res, true); + if (data) { this._logDeleteDocumentEvents(req, data); } })); // POST /api/docs/:docId/remove // Soft-delete the specified doc. If query parameter "permanent" is set, // delete permanently. this._app.post('/api/docs/:docId/remove', canEditMaybeRemoved, throttled(async (req, res) => { - const {status, data} = await this._removeDoc(req, res, isParameterOn(req.query.permanent)); - if (status === 200) { this._logRemoveDocumentEvents(req, data!); } + const {data} = await this._removeDoc(req, res, isParameterOn(req.query.permanent)); + if (data) { this._logRemoveDocumentEvents(req, data); } })); this._app.get('/api/docs/:docId/snapshots', canView, withDoc(async (activeDoc, req, res) => { @@ -1100,6 +1106,7 @@ export class DocWorkerApi { // This endpoint cannot use withDoc since it is expected behavior for the ActiveDoc it // starts with to become muted. this._app.post('/api/docs/:docId/replace', canEdit, throttled(async (req, res) => { + const docId = getDocId(req); const docSession = docSessionFromRequest(req); const activeDoc = await this._getActiveDoc(req); const options: DocReplacementOptions = {}; @@ -1160,6 +1167,9 @@ export class DocWorkerApi { options.snapshotId = String(req.body.snapshotId); } await activeDoc.replace(docSession, options); + const previous = {id: docId}; + const current = {id: options.sourceDocId || docId, snapshotId: options.snapshotId}; + this._logReplaceDocumentEvents(req, {previous, current}); res.json(null); })); @@ -1169,9 +1179,12 @@ export class DocWorkerApi { })); this._app.post('/api/docs/:docId/states/remove', isOwner, withDoc(async (activeDoc, req, res) => { + const docId = getDocId(req); const docSession = docSessionFromRequest(req); const keep = integerParam(req.body.keep, 'keep'); - res.json(await activeDoc.deleteActions(docSession, keep)); + await activeDoc.deleteActions(docSession, keep); + this._logTruncateDocumentHistoryEvents(req, {docId, keep}); + res.json(null); })); this._app.get('/api/docs/:docId/compare/:docId2', canView, withDoc(async (activeDoc, req, res) => { @@ -1675,7 +1688,11 @@ export class DocWorkerApi { }, }, }); - this._logDuplicateDocumentEvents(mreq, {id: sourceDocumentId}, {id, name}) + this._logDuplicateDocumentEvents(mreq, { + originalDocument: {id: sourceDocumentId}, + duplicateDocument: {id, name}, + asTemplate, + }) .catch(e => log.error('DocApi failed to log duplicate document events', e)); return id; } @@ -2029,8 +2046,13 @@ export class DocWorkerApi { return result; } - private async _runSql(activeDoc: ActiveDoc, req: RequestWithLogin, res: Response, - options: Types.SqlPost) { + private async _runSql( + activeDoc: ActiveDoc, + req: RequestWithLogin, + res: Response, + options: Types.SqlPost + ) { + const docId = getDocId(req); if (!await activeDoc.canCopyEverything(docSessionFromRequest(req))) { throw new ApiError('insufficient document access', 403); } @@ -2071,7 +2093,7 @@ export class DocWorkerApi { try { const records = await activeDoc.docStorage.all(wrappedStatement, ...(options.args || [])); - this._logRunSQLQueryEvents(req, options); + this._logRunSQLQueryEvents(req, {docId, ...options}); res.status(200).json({ statement, records: records.map( @@ -2124,13 +2146,6 @@ export class DocWorkerApi { }, }); this._grist.getTelemetry().logEvent(mreq, 'createdDoc-Empty', { - limited: { - docIdDigest: id, - sourceDocIdDigest: undefined, - isImport: false, - fileType: undefined, - isSaved: workspaceId !== undefined, - }, full: { docIdDigest: id, userId: mreq.userId, @@ -2179,17 +2194,64 @@ export class DocWorkerApi { }); } - private async _logDuplicateDocumentEvents( - req: RequestWithLogin, - originalDocument: {id: string}, - newDocument: {id: string; name: string} - ) { - const document = await this._dbManager.getRawDocById(originalDocument.id); - const isTemplateCopy = document.type === 'template'; + private _logReplaceDocumentEvents(req: RequestWithLogin, options: { + previous: {id: string}; + current: {id: string; snapshotId?: string}; + }) { + const {previous, current} = options; + this._grist.getAuditLogger().logEvent(req, { + event: { + name: 'replaceDocument', + details: { + previous: { + id: previous.id, + }, + current: { + id: current.id, + snapshotId: current.snapshotId, + }, + }, + context: {documentId: previous.id}, + }, + }); + } + + private async _logDuplicateDocumentEvents(req: RequestWithLogin, options: { + originalDocument: {id: string}; + duplicateDocument: {id: string; name: string}; + asTemplate: boolean; + }) { + const {originalDocument: {id}, duplicateDocument, asTemplate} = options; + const originalDocument = await this._dbManager.getRawDocById(id); + this._grist.getAuditLogger().logEvent(req, { + event: { + name: 'duplicateDocument', + details: { + original: { + id: originalDocument.id, + name: originalDocument.name, + workspace: { + id: originalDocument.workspace.id, + name: originalDocument.workspace.name, + }, + }, + duplicate: { + id: duplicateDocument.id, + name: duplicateDocument.name, + }, + asTemplate, + }, + context: { + workspaceId: originalDocument.workspace.id, + documentId: originalDocument.id, + }, + }, + }); + const isTemplateCopy = originalDocument.type === 'template'; if (isTemplateCopy) { this._grist.getTelemetry().logEvent(req, 'copiedTemplate', { full: { - templateId: parseUrlId(document.urlId || document.id).trunkId, + templateId: parseUrlId(originalDocument.urlId || originalDocument.id).trunkId, userId: req.userId, altSessionId: req.altSessionId, }, @@ -2200,7 +2262,7 @@ export class DocWorkerApi { `createdDoc-${isTemplateCopy ? 'CopyTemplate' : 'CopyDoc'}`, { full: { - docIdDigest: newDocument.id, + docIdDigest: duplicateDocument.id, userId: req.userId, altSessionId: req.altSessionId, }, @@ -2208,15 +2270,60 @@ export class DocWorkerApi { ); } - private _logRunSQLQueryEvents( + private _logReloadDocumentEvents(req: RequestWithLogin, {docId: documentId}: {docId: string}) { + this._grist.getAuditLogger().logEvent(req, { + event: { + name: 'reloadDocument', + context: {documentId}, + }, + }); + } + + private _logTruncateDocumentHistoryEvents( req: RequestWithLogin, - {sql: query, args, timeout}: Types.SqlPost + {docId: documentId, keep}: {docId: string; keep: number} ) { + this._grist.getAuditLogger().logEvent(req, { + event: { + name: 'truncateDocumentHistory', + details: {keep}, + context: {documentId}, + }, + }); + } + + private _logClearWebhookQueueEvents( + req: RequestWithLogin, + {docId: documentId, webhookId: id}: {docId: string; webhookId: string} + ) { + this._grist.getAuditLogger().logEvent(req, { + event: { + name: 'clearWebhookQueue', + details: {id}, + context: {documentId}, + }, + }); + } + + private _logClearAllWebhookQueueEvents( + req: RequestWithLogin, + {docId: documentId}: {docId: string} + ) { + this._grist.getAuditLogger().logEvent(req, { + event: { + name: 'clearAllWebhookQueues', + context: {documentId}, + }, + }); + } + + private _logRunSQLQueryEvents(req: RequestWithLogin, options: {docId: string} & Types.SqlPost) { + const {docId: documentId, sql: query, args, timeout: timeoutMs} = options; this._grist.getAuditLogger().logEvent(req, { event: { name: 'runSQLQuery', - details: {query, arguments: args, timeout}, - context: {documentId: getDocId(req)}, + details: {query, arguments: args, timeoutMs}, + context: {documentId}, }, }); } diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 4ce03fcb..da716502 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -1492,7 +1492,7 @@ export class FlexServer implements GristServer { // to other (not public) team sites. const doom = await createDoom(); await doom.deleteUser(userId); - this.getTelemetry().logEvent(req as RequestWithLogin, 'deletedAccount'); + this._logDeleteUserEvents(req as RequestWithLogin); return resp.status(200).json(true); })); @@ -1523,16 +1523,10 @@ export class FlexServer implements GristServer { } // Reuse Doom cli tool for org deletion. Note, this removes everything as a super user. + const deletedOrg = structuredClone(org); const doom = await createDoom(); await doom.deleteOrg(org.id); - - this.getTelemetry().logEvent(req as RequestWithLogin, 'deletedSite', { - full: { - siteId: org.id, - userId: mreq.userId, - }, - }); - + this._logDeleteSiteEvents(mreq, deletedOrg); return resp.status(200).send(); })); } @@ -2548,6 +2542,30 @@ export class FlexServer implements GristServer { return isGristLogHttpEnabled || deprecatedOptionEnablesLog; } + + private _logDeleteUserEvents(req: RequestWithLogin) { + this.getAuditLogger().logEvent(req, { + event: { + name: 'deleteUser', + }, + }); + this.getTelemetry().logEvent(req, 'deletedAccount'); + } + + private _logDeleteSiteEvents(req: RequestWithLogin, {id, name}: Organization) { + this.getAuditLogger().logEvent(req, { + event: { + name: 'deleteSite', + details: {id, name}, + } + }); + this.getTelemetry().logEvent(req, 'deletedSite', { + full: { + siteId: id, + userId: req.userId, + }, + }); + } } /** diff --git a/app/server/lib/GoogleExport.ts b/app/server/lib/GoogleExport.ts index c4e228bc..caccce6c 100644 --- a/app/server/lib/GoogleExport.ts +++ b/app/server/lib/GoogleExport.ts @@ -3,7 +3,7 @@ import {ActiveDoc} from 'app/server/lib/ActiveDoc'; import {RequestWithLogin} from 'app/server/lib/Authorizer'; import {streamXLSX} from 'app/server/lib/ExportXLSX'; import log from 'app/server/lib/log'; -import {optStringParam} from 'app/server/lib/requestUtils'; +import {getDocId, optStringParam} from 'app/server/lib/requestUtils'; import {Request, Response} from 'express'; import {PassThrough, Stream} from 'stream'; @@ -22,6 +22,7 @@ export async function exportToDrive( throw new Error("No access token - Can't send file to Google Drive"); } + const docId = getDocId(req); const mreq = req as RequestWithLogin; const meta = { docId: activeDoc.docName, @@ -39,6 +40,13 @@ export async function exportToDrive( streamXLSX(activeDoc, req, stream, {tableId: ''}), sendFileToDrive(name, stream, access_token), ]); + activeDoc.logAuditEvent(mreq, { + event: { + name: 'sendToGoogleDrive', + details: {id: docId}, + context: {documentId: docId}, + }, + }); log.debug(`Export to drive - File exported, redirecting to Google Spreadsheet ${url}`, meta); res.json({ url }); } catch (err) { diff --git a/app/server/lib/Triggers.ts b/app/server/lib/Triggers.ts index e9d51484..db262608 100644 --- a/app/server/lib/Triggers.ts +++ b/app/server/lib/Triggers.ts @@ -691,17 +691,16 @@ export class DocTriggers { if (this._loopAbort.signal.aborted) { continue; } - let meta: Record|undefined; - + let meta: {webhookId: string; host: string, quantity: number} | undefined; let success: boolean; if (!url) { success = true; } else { await this._stats.logStatus(id, 'sending'); - meta = {numEvents: batch.length, webhookId: id, host: new URL(url).host}; + meta = {webhookId: id, host: new URL(url).host, quantity: batch.length}; this._log("Sending batch of webhook events", meta); this._activeDoc.logTelemetryEvent(null, 'sendingWebhooks', { - limited: {numEvents: meta.numEvents}, + limited: {numEvents: meta.quantity}, }); success = await this._sendWebhookWithRetries( id, url, authorization, body, batch.length, this._loopAbort.signal); @@ -743,6 +742,14 @@ export class DocTriggers { await this._stats.logStatus(id, 'idle'); if (meta) { this._log("Successfully sent batch of webhook events", meta); + const {webhookId, host, quantity} = meta; + this._activeDoc.logAuditEvent(null, { + event: { + name: 'deliverWebhookEvents', + details: {id: webhookId, host, quantity}, + user: {type: 'system'}, + }, + }); } } diff --git a/app/server/utils/showAuditLogEvents.ts b/app/server/utils/showAuditLogEvents.ts new file mode 100644 index 00000000..1e02a975 --- /dev/null +++ b/app/server/utils/showAuditLogEvents.ts @@ -0,0 +1,688 @@ +import {AuditEventDetails, AuditEventName, SiteAuditEventName} from 'app/common/AuditEvent'; + +interface Options { + /** + * The type of audit log events to show. + * + * Defaults to `"installation"`. + */ + type?: AuditEventType; +} + +type AuditEventType = 'installation' | 'site'; + +export function showAuditLogEvents({type = 'installation'}: Options) { + showTitle(type); + const events = getAuditEvents(type); + showTableOfContents(events); + showEvents(events); +} + +function showTitle(type: AuditEventType) { + if (type === 'installation') { + console.log('# Installation audit log events {: .tag-core .tag-ee }\n'); + } else { + console.log('# Site audit log events\n'); + } +} + +function getAuditEvents(type: AuditEventType): [string, AuditEvent][] { + if (type === 'installation') { + return Object.entries(AuditEvents).filter(([name]) => AuditEventName.guard(name)); + } else { + return Object.entries(AuditEvents).filter(([name]) => SiteAuditEventName.guard(name)); + } +} + +function showTableOfContents(events: [string, AuditEvent][]) { + for (const [name] of events) { + console.log(` - [${name}](#${name.toLowerCase()})`); + } + console.log(''); +} + +function showEvents(events: [string, AuditEvent][]) { + for (const [name, event] of events) { + const {description, properties} = event; + console.log(`## ${name}\n`); + console.log(`${description}\n`); + if (Object.keys(properties).length === 0) { continue; } + + console.log('### Properties\n'); + console.log('| Name | Type | Description |'); + console.log('| ---- | ---- | ----------- |'); + showEventProperties(properties); + console.log(''); + } +} + +function showEventProperties( + properties: AuditEventProperties, + prefix = '' +) { + for (const [key, {type, description, optional, ...rest}] of Object.entries(properties)) { + const name = prefix + key + (optional ? ' *(optional)*' : ''); + const types = (Array.isArray(type) ? type : [type]).map(t => `\`${t}\``); + console.log(`| ${name} | ${types.join(' or ')} | ${description} |`); + if ('properties' in rest) { + showEventProperties(rest.properties, prefix + `${name}.`); + } + } +} + +type AuditEvents = { + [Name in keyof AuditEventDetails]: Name extends AuditEventName + ? AuditEvent + : never +} + +interface AuditEvent { + description: string; + properties: AuditEventProperties; +} + +type AuditEventProperties = { + [K in keyof T]: T[K] extends object + ? AuditEventProperty & {properties: AuditEventProperties} + : AuditEventProperty +} + +interface AuditEventProperty { + type: string | string[]; + description: string; + optional?: boolean; +} + +const AuditEvents: AuditEvents = { + createDocument: { + description: 'A new document was created.', + properties: { + id: { + type: 'string', + description: 'The ID of the document.', + }, + name: { + type: 'string', + description: 'The name of the document.', + optional: true, + }, + }, + }, + sendToGoogleDrive: { + description: 'A document was sent to Google Drive.', + properties: { + id: { + type: 'string', + description: 'The ID of the document.', + }, + }, + }, + renameDocument: { + description: 'A document was renamed.', + properties: { + id: { + type: 'string', + description: 'The ID of the document.', + }, + previousName: { + type: 'string', + description: 'The previous name of the document.', + }, + currentName: { + type: 'string', + description: 'The current name of the document.', + }, + }, + }, + pinDocument: { + description: 'A document was pinned.', + properties: { + id: { + type: 'string', + description: 'The ID of the document.', + }, + name: { + type: 'string', + description: 'The name of the document.', + }, + }, + }, + unpinDocument: { + description: 'A document was unpinned.', + properties: { + id: { + type: 'string', + description: 'The ID of the document.', + }, + name: { + type: 'string', + description: 'The name of the document.', + }, + }, + }, + moveDocument: { + description: 'A document was moved to a new workspace.', + properties: { + id: { + type: 'string', + description: 'The ID of the document.', + }, + previousWorkspace: { + type: 'object', + description: 'The workspace the document was moved from.', + properties: { + id: { + type: 'number', + description: 'The ID of the workspace.', + }, + name: { + type: 'string', + description: 'The name of the workspace.', + }, + }, + }, + newWorkspace: { + type: 'object', + description: 'The workspace the document was moved to.', + properties: { + id: { + type: 'number', + description: 'The ID of the workspace.', + }, + name: { + type: 'string', + description: 'The name of the workspace.', + }, + }, + }, + }, + }, + removeDocument: { + description: 'A document was moved to the trash.', + properties: { + id: { + type: 'string', + description: 'The ID of the document.', + }, + name: { + type: 'string', + description: 'The name of the document.', + }, + }, + }, + deleteDocument: { + description: 'A document was permanently deleted.', + properties: { + id: { + type: 'string', + description: 'The ID of the document.', + }, + name: { + type: 'string', + description: 'The name of the document.', + }, + }, + }, + restoreDocumentFromTrash: { + description: 'A document was restored from the trash.', + properties: { + id: { + type: 'string', + description: 'The ID of the document.', + }, + name: { + type: 'string', + description: 'The name of the document.', + }, + workspace: { + type: 'object', + description: 'The workspace of the document.', + properties: { + id: { + type: 'number', + description: 'The ID of the workspace.', + }, + name: { + type: 'string', + description: 'The name of the workspace.', + }, + }, + }, + }, + }, + changeDocumentAccess: { + description: 'Access to a document was changed.', + properties: { + id: { + type: 'string', + description: 'The ID of the document.', + }, + access: { + type: 'object', + description: 'The access level of the document.', + properties: { + maxInheritedRole: { + type: ['"owners"', '"editors"', '"viewers"', 'null'], + description: 'The max inherited role.', + optional: true, + }, + users: { + type: 'object', + description: 'The access level by user ID.', + optional: true, + }, + }, + }, + }, + }, + openDocument: { + description: 'A document was opened.', + properties: { + id: { + type: 'string', + description: 'The ID of the document.', + }, + name: { + type: 'string', + description: 'The name of the document.', + }, + urlId: { + type: 'string', + description: 'The URL ID of the document.', + }, + forkId: { + type: 'string', + description: 'The fork ID of the document, if the document is a fork.', + }, + snapshotId: { + type: 'string', + description: 'The snapshot ID of the document, if the document is a snapshot.', + }, + }, + }, + duplicateDocument: { + description: 'A document was duplicated.', + properties: { + original: { + type: 'object', + description: 'The document that was duplicated.', + properties: { + id: { + type: 'string', + description: 'The ID of the document.', + }, + name: { + type: 'string', + description: 'The name of the document.', + }, + workspace: { + type: 'object', + description: 'The workspace of the document.', + properties: { + id: { + type: 'number', + description: 'The ID of the workspace', + }, + name: { + type: 'string', + description: 'The name of the workspace.', + }, + }, + }, + }, + }, + duplicate: { + description: 'The newly-duplicated document.', + type: 'object', + properties: { + id: { + type: 'string', + description: 'The ID of the document.', + }, + name: { + type: 'string', + description: 'The name of the document.', + }, + }, + }, + asTemplate: { + type: 'boolean', + description: 'If the document was duplicated without any data.', + }, + }, + }, + forkDocument: { + description: 'A document was forked.', + properties: { + original: { + type: 'object', + description: 'The document that was forked.', + properties: { + id: { + type: 'string', + description: 'The ID of the document.', + }, + name: { + type: 'string', + description: 'The name of the document.', + }, + }, + }, + fork: { + type: 'object', + description: 'The newly-forked document.', + properties: { + id: { + type: 'string', + description: 'The ID of the fork.', + }, + documentId: { + type: 'string', + description: 'The ID of the fork with the trunk ID.', + }, + urlId: { + type: 'string', + description: 'The ID of the fork with the trunk URL ID.', + }, + }, + }, + }, + }, + replaceDocument: { + description: 'A document was replaced.', + properties: { + previous: { + type: 'object', + description: 'The document that was replaced.', + properties: { + id: { + type: 'string', + description: 'The ID of the document.', + }, + }, + }, + current: { + type: 'object', + description: 'The newly-replaced document.', + properties: { + id: { + type: 'string', + description: 'The ID of the document.', + }, + snapshotId: { + type: 'string', + description: 'The ID of the snapshot, if the document was replaced with one.', + }, + }, + }, + }, + }, + reloadDocument: { + description: 'A document was reloaded.', + properties: {}, + }, + truncateDocumentHistory: { + description: "A document's history was truncated.", + properties: { + keep: { + type: 'number', + description: 'The number of history items kept.', + }, + }, + }, + deliverWebhookEvents: { + description: 'A batch of webhook events was delivered.', + properties: { + id: { + type: 'string', + description: 'The ID of the webhook.', + }, + host: { + type: 'string', + description: 'The host the webhook events were delivered to.', + }, + quantity: { + type: 'number', + description: 'The number of webhook events delivered.', + }, + }, + }, + clearWebhookQueue: { + description: 'A webhook queue was cleared.', + properties: { + id: { + type: 'string', + description: 'The ID of the webhook.', + }, + }, + }, + clearAllWebhookQueues: { + description: 'All webhook queues were cleared.', + properties: {}, + }, + runSQLQuery: { + description: 'A SQL query was run on a document.', + properties: { + query: { + type: 'string', + description: 'The SQL query.' + }, + arguments: { + type: 'Array', + description: 'The arguments used for query parameters, if any.', + optional: true, + }, + timeoutMs: { + type: 'number', + description: 'The query execution timeout duration in milliseconds.', + optional: true, + }, + }, + }, + createWorkspace: { + description: 'A new workspace was created.', + properties: { + id: { + type: 'number', + description: 'The ID of the workspace.', + }, + name: { + type: 'string', + description: 'The name of the workspace.', + }, + }, + }, + renameWorkspace: { + description: 'A workspace was renamed.', + properties: { + id: { + type: 'number', + description: 'The ID of the workspace.', + }, + previousName: { + type: 'string', + description: 'The previous name of the workspace.', + }, + currentName: { + type: 'string', + description: 'The current name of the workspace.', + }, + }, + }, + removeWorkspace: { + description: 'A workspace was moved to the trash.', + properties: { + id: { + type: 'number', + description: 'The ID of the workspace.', + }, + name: { + type: 'string', + description: 'The name of the workspace.', + }, + }, + }, + deleteWorkspace: { + description: 'A workspace was permanently deleted.', + properties: { + id: { + type: 'number', + description: 'The ID of the workspace.', + }, + name: { + type: 'string', + description: 'The name of the workspace.', + }, + }, + }, + restoreWorkspaceFromTrash: { + description: 'A workspace was restored from the trash.', + properties: { + id: { + type: 'number', + description: 'The ID of the workspace.', + }, + name: { + type: 'string', + description: 'The name of the workspace.', + }, + }, + }, + changeWorkspaceAccess: { + description: 'Access to a workspace was changed.', + properties: { + id: { + type: 'number', + description: 'The ID of the workspace.', + }, + access: { + type: 'object', + description: 'The access level of the workspace.', + properties: { + maxInheritedRole: { + type: ['"owners"', '"editors"', '"viewers"', 'null'], + description: 'The max inherited role.', + optional: true, + }, + users: { + type: 'object', + description: 'The access level by user ID.', + optional: true, + }, + }, + }, + }, + }, + createSite: { + description: 'A new site was created.', + properties: { + id: { + type: 'number', + description: 'The ID of the site.', + }, + name: { + type: 'string', + description: 'The name of the site.', + }, + domain: { + type: 'string', + description: 'The domain of the site.', + }, + }, + }, + renameSite: { + description: 'A site was renamed.', + properties: { + id: { + type: 'number', + description: 'The ID of the site.', + }, + previous: { + type: 'object', + description: 'The previous name and domain of the site.', + properties: { + name: { + type: 'string', + description: 'The name of the site.', + }, + domain: { + type: 'string', + description: 'The domain of the site.', + }, + }, + }, + current: { + type: 'object', + description: 'The current name and domain of the site.', + properties: { + name: { + type: 'string', + description: 'The name of the site.', + }, + domain: { + type: 'string', + description: 'The domain of the site.', + }, + }, + }, + }, + }, + changeSiteAccess: { + description: 'Access to a site was changed.', + properties: { + id: { + type: 'number', + description: 'The ID of the site.', + }, + access: { + type: 'object', + description: 'The access level of the site.', + properties: { + users: { + type: 'object', + description: 'The access level by user ID.', + optional: true, + }, + }, + }, + }, + }, + deleteSite: { + description: 'A site was deleted.', + properties: { + id: { + type: 'number', + description: 'The ID of the site.', + }, + name: { + type: 'string', + description: 'The name of the site.', + }, + }, + }, + changeUserName: { + description: 'The name of a user was changed.', + properties: { + previousName: { + type: 'string', + description: 'The previous name of the user.', + }, + currentName: { + type: 'string', + description: 'The current name of the user.', + }, + }, + }, + createUserAPIKey: { + description: 'A user API key was created.', + properties: {}, + }, + deleteUserAPIKey: { + description: 'A user API key was deleted.', + properties: {}, + }, + deleteUser: { + description: 'A user was deleted.', + properties: {}, + }, +}; diff --git a/test/gen-server/lib/HomeDBManager.ts b/test/gen-server/lib/HomeDBManager.ts index a22c5547..d5ccb2d1 100644 --- a/test/gen-server/lib/HomeDBManager.ts +++ b/test/gen-server/lib/HomeDBManager.ts @@ -90,7 +90,7 @@ describe('HomeDBManager', function() { it('can add an org', async function() { const user = await home.getUserByLogin('chimpy@getgrist.com'); - const orgId = (await home.addOrg(user, {name: 'NewOrg', domain: 'novel-org'}, teamOptions)).data!; + const orgId = (await home.addOrg(user, {name: 'NewOrg', domain: 'novel-org'}, teamOptions)).data!.id; const org = await home.getOrg({userId: user.id}, orgId); assert.equal(org.data!.name, 'NewOrg'); assert.equal(org.data!.domain, 'novel-org'); @@ -109,7 +109,7 @@ describe('HomeDBManager', function() { useNewPlan: true, // omit plan, to use a default one (teamInitial) // it will either be 'stub' or anything set in GRIST_DEFAULT_PRODUCT - })).data!; + })).data!.id; let org = await home.getOrg({userId: user.id}, orgId); assert.equal(org.data!.name, 'NewOrg'); assert.equal(org.data!.domain, 'novel-org'); @@ -121,7 +121,7 @@ describe('HomeDBManager', function() { orgId = (await home.addOrg(user, {name: 'NewOrg', domain: 'novel-org'}, { setUserAsOwner: false, useNewPlan: true, - })).data!; + })).data!.id; org = await home.getOrg({userId: user.id}, orgId); assert.equal(org.data!.billingAccount.product.name, STUB_PLAN); @@ -135,7 +135,7 @@ describe('HomeDBManager', function() { const user = await home.getUserByLogin('chimpy@getgrist.com'); const domain = 'repeated-domain'; const result = await home.addOrg(user, {name: `${domain}!`, domain}, teamOptions); - const orgId = result.data!; + const orgId = result.data!.id; assert.equal(result.status, 200); await assert.isRejected(home.addOrg(user, {name: `${domain}!`, domain}, teamOptions), /Domain already in use/); diff --git a/test/server/fixSiteProducts.ts b/test/server/fixSiteProducts.ts index 353b4ac8..e14c44d2 100644 --- a/test/server/fixSiteProducts.ts +++ b/test/server/fixSiteProducts.ts @@ -45,7 +45,7 @@ describe('fixSiteProducts', function() { const productOrg = (id: number) => getOrg(id)?.then(org => org?.billingAccount?.product?.name); - const freeOrgId = db.unwrapQueryResult(await db.addOrg(user, { + const freeOrg = db.unwrapQueryResult(await db.addOrg(user, { name: org, domain: org, }, { @@ -54,7 +54,7 @@ describe('fixSiteProducts', function() { product: 'teamFree', })); - const teamOrgId = db.unwrapQueryResult(await db.addOrg(user, { + const teamOrg = db.unwrapQueryResult(await db.addOrg(user, { name: 'fix-team-org', domain: 'fix-team-org', }, { @@ -64,7 +64,7 @@ describe('fixSiteProducts', function() { })); // Make sure it is created with teamFree product. - assert.equal(await productOrg(freeOrgId), 'teamFree'); + assert.equal(await productOrg(freeOrg.id), 'teamFree'); // Run the fixer. assert.isTrue(await fixSiteProducts({ @@ -73,10 +73,10 @@ describe('fixSiteProducts', function() { })); // Make sure we fixed the product is on Free product. - assert.equal(await productOrg(freeOrgId), 'Free'); + assert.equal(await productOrg(freeOrg.id), 'Free'); // Make sure the other org is still on team product. - assert.equal(await productOrg(teamOrgId), 'team'); + assert.equal(await productOrg(teamOrg.id), 'team'); }); it("doesn't run when on saas deployment", async function() { @@ -123,7 +123,7 @@ describe('fixSiteProducts', function() { const db = server.dbManager; const user = await db.getUserByLogin(email, {profile}); - const orgId = db.unwrapQueryResult(await db.addOrg(user, { + const org = db.unwrapQueryResult(await db.addOrg(user, { name: 'sanity-check-org', domain: 'sanity-check-org', }, { @@ -135,12 +135,12 @@ describe('fixSiteProducts', function() { const getOrg = (id: number) => db.connection.manager.findOne(Organization, {where: {id}, relations: ['billingAccount', 'billingAccount.product']}); const productOrg = (id: number) => getOrg(id)?.then(org => org?.billingAccount?.product?.name); - assert.equal(await productOrg(orgId), 'teamFree'); + assert.equal(await productOrg(org.id), 'teamFree'); assert.isFalse(await fixSiteProducts({ db: server.dbManager, deploymentType: server.server.getDeploymentType(), })); - assert.equal(await productOrg(orgId), 'teamFree'); + assert.equal(await productOrg(org.id), 'teamFree'); }); });