mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(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
This commit is contained in:
		
							parent
							
								
									1927c87413
								
							
						
					
					
						commit
						bda7935714
					
				| @ -1,3 +1,6 @@ | ||||
| import {BasicRole, NonGuestRole} from 'app/common/roles'; | ||||
| import {StringUnion} from 'app/common/StringUnion'; | ||||
| 
 | ||||
| export interface AuditEvent<Name extends AuditEventName> { | ||||
|   /** | ||||
|    * The event. | ||||
| @ -12,35 +15,73 @@ export interface AuditEvent<Name extends AuditEventName> { | ||||
|      */ | ||||
|     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<string, NonGuestRole | null>; | ||||
|     }; | ||||
|   }; | ||||
|   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<string | number>; | ||||
|     /** | ||||
|      * 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<string, NonGuestRole | null>; | ||||
|     }; | ||||
|   }; | ||||
|   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<string, NonGuestRole | null>; | ||||
|     }; | ||||
|   }; | ||||
|   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; | ||||
|   /** | ||||
|  | ||||
| @ -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<number> { | ||||
| ): Promise<Organization> { | ||||
|   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<Document> | ||||
|   ) { | ||||
|     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<Workspace> | ||||
|   ) { | ||||
|     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<Organization> | ||||
|   ) { | ||||
|     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<User> | ||||
|   ) { | ||||
|     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', | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  | ||||
| @ -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<PreviousAndCurrent<User>> { | ||||
|     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<UserOptions>) { | ||||
| @ -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<OrganizationProperties>, | ||||
|                       options: { setUserAsOwner: boolean, | ||||
|                                  useNewPlan: boolean, | ||||
|                                  product?: string, // Default to PERSONAL_FREE_PLAN or TEAM_FREE_PLAN env variable.
 | ||||
|                                  billing?: BillingOptions}, | ||||
|                       transaction?: EntityManager): Promise<QueryResult<number>> { | ||||
|   public async addOrg( | ||||
|     user: User, | ||||
|     props: Partial<OrganizationProperties>, | ||||
|     options: { | ||||
|       setUserAsOwner: boolean, | ||||
|       useNewPlan: boolean, | ||||
|       product?: string, // Default to PERSONAL_FREE_PLAN or TEAM_FREE_PLAN env variable.
 | ||||
|       billing?: BillingOptions | ||||
|     }, | ||||
|     transaction?: EntityManager | ||||
|   ): Promise<QueryResult<Organization>> { | ||||
|     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<OrganizationProperties>, | ||||
|     transaction?: EntityManager, | ||||
|   ): Promise<QueryResult<number>> { | ||||
|   ): Promise<QueryResult<PreviousAndCurrent<Organization>>> { | ||||
| 
 | ||||
|     // 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<QueryResult<number>> { | ||||
|   // 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<QueryResult<Organization>> { | ||||
|     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<WorkspaceProperties>): Promise<QueryResult<number>> { | ||||
|   // 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<WorkspaceProperties> | ||||
|   ): Promise<QueryResult<Workspace>> { | ||||
|     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<WorkspaceProperties>): Promise<QueryResult<number>> { | ||||
|   // 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<WorkspaceProperties> | ||||
|   ): Promise<QueryResult<PreviousAndCurrent<Workspace>>> { | ||||
|     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<QueryResult<number>> { | ||||
|   // error. Otherwise deletes the given workspace. Returns a query result with status 200
 | ||||
|   // on success.
 | ||||
|   public async deleteWorkspace(scope: Scope, wsId: number): Promise<QueryResult<Workspace>> { | ||||
|     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<void> { | ||||
|   public softDeleteWorkspace(scope: Scope, wsId: number): Promise<QueryResult<Workspace>> { | ||||
|     return this._setWorkspaceRemovedAt(scope, wsId, new Date()); | ||||
|   } | ||||
| 
 | ||||
|   public async undeleteWorkspace(scope: Scope, wsId: number): Promise<void> { | ||||
|   public async undeleteWorkspace(scope: Scope, wsId: number): Promise<QueryResult<Workspace>> { | ||||
|     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<DocumentProperties>, | ||||
|     transaction?: EntityManager | ||||
|   ): Promise<QueryResult<number>> { | ||||
|   ): Promise<QueryResult<PreviousAndCurrent<Document>>> { | ||||
|     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<QueryResult<void>> { | ||||
|   ): Promise<QueryResult<PermissionDelta & {organization: Organization}>> { | ||||
|     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<QueryResult<void>> { | ||||
|   ): Promise<QueryResult<PermissionDelta & {workspace: Workspace}>> { | ||||
|     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<QueryResult<void>> { | ||||
|   ): Promise<QueryResult<PermissionDelta & {document: Document}>> { | ||||
|     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<QueryResult<void>> { | ||||
|   ): Promise<QueryResult<Document>> { | ||||
|     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}; | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
| @ -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 <type>').choices(['installation', 'site'])) | ||||
|     .action(showAuditLogEvents); | ||||
| } | ||||
| 
 | ||||
| // Add commands related to document history:
 | ||||
| //   history prune <docId> [N]
 | ||||
| export function addHistoryCommand(program: commander.Command, options: CommandOptions) { | ||||
|  | ||||
| @ -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<Name extends AuditEventName>( | ||||
|     requestOrSession: RequestOrSession, | ||||
|     properties: AuditEventProperties<Name> | ||||
|   ) { | ||||
|     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.
 | ||||
|  | ||||
| @ -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, | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -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<Name extends AuditEventName> { | ||||
|   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. | ||||
|    */ | ||||
|  | ||||
| @ -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}, | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
| @ -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, | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
| @ -691,17 +691,16 @@ export class DocTriggers { | ||||
|       if (this._loopAbort.signal.aborted) { | ||||
|         continue; | ||||
|       } | ||||
|       let meta: Record<string, any>|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'}, | ||||
|             }, | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										688
									
								
								app/server/utils/showAuditLogEvents.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										688
									
								
								app/server/utils/showAuditLogEvents.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<AuditEventName>][] { | ||||
|   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<AuditEventName>][]) { | ||||
|   for (const [name] of events) { | ||||
|     console.log(` - [${name}](#${name.toLowerCase()})`); | ||||
|   } | ||||
|   console.log(''); | ||||
| } | ||||
| 
 | ||||
| function showEvents(events: [string, AuditEvent<AuditEventName>][]) { | ||||
|   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<object>, | ||||
|   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<Name> | ||||
|   : never | ||||
| } | ||||
| 
 | ||||
| interface AuditEvent<Name extends AuditEventName> { | ||||
|   description: string; | ||||
|   properties: AuditEventProperties<AuditEventDetails[Name]>; | ||||
| } | ||||
| 
 | ||||
| type AuditEventProperties<T> = { | ||||
|   [K in keyof T]: T[K] extends object | ||||
|   ? AuditEventProperty & {properties: AuditEventProperties<T[K]>} | ||||
|   : 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<string | number>', | ||||
|         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: {}, | ||||
|   }, | ||||
| }; | ||||
| @ -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/); | ||||
|  | ||||
| @ -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'); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user