(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:
George Gevoian 2024-09-30 13:11:01 -04:00
parent 1927c87413
commit bda7935714
15 changed files with 1837 additions and 311 deletions

View File

@ -1,3 +1,6 @@
import {BasicRole, NonGuestRole} from 'app/common/roles';
import {StringUnion} from 'app/common/StringUnion';
export interface AuditEvent<Name extends AuditEventName> { export interface AuditEvent<Name extends AuditEventName> {
/** /**
* The event. * The event.
@ -12,35 +15,73 @@ export interface AuditEvent<Name extends AuditEventName> {
*/ */
user: AuditEventUser; user: AuditEventUser;
/** /**
* The event details. * Event-specific details (e.g. IDs of affected resources).
*/ */
details: AuditEventDetails[Name] | {}; details: AuditEventDetails[Name] | {};
/** /**
* The context of the event. * The context that the event occurred in (e.g. workspace, document).
*/ */
context: AuditEventContext; context: AuditEventContext;
/** /**
* The source of the event. * Information about the source of the event (e.g. IP address).
*/ */
source: AuditEventSource; 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; timestamp: string;
} }
export type AuditEventName = export const SiteAuditEventName = StringUnion(
| 'createDocument' 'createDocument',
| 'moveDocument' 'sendToGoogleDrive',
| 'removeDocument' 'renameDocument',
| 'deleteDocument' 'pinDocument',
| 'restoreDocumentFromTrash' 'unpinDocument',
| 'runSQLQuery'; '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 = export type AuditEventUser =
| User | User
| Anonymous | Anonymous
| System
| Unknown; | Unknown;
interface User { interface User {
@ -54,14 +95,15 @@ interface Anonymous {
type: 'anonymous'; type: 'anonymous';
} }
interface System {
type: 'system';
}
interface Unknown { interface Unknown {
type: 'unknown'; type: 'unknown';
} }
export interface AuditEventDetails { export interface AuditEventDetails {
/**
* A new document was created.
*/
createDocument: { createDocument: {
/** /**
* The ID of the document. * The ID of the document.
@ -72,22 +114,55 @@ export interface AuditEventDetails {
*/ */
name?: string; name?: string;
}; };
sendToGoogleDrive: {
/** /**
* A document was moved to a new workspace. * 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: { moveDocument: {
/** /**
* The ID of the document. * The ID of the document.
*/ */
id: string; id: string;
/**
* The previous workspace.
*/
previous: {
/** /**
* The workspace the document was moved from. * The workspace the document was moved from.
*/ */
workspace: { previousWorkspace: {
/** /**
* The ID of the workspace. * The ID of the workspace.
*/ */
@ -97,15 +172,10 @@ export interface AuditEventDetails {
*/ */
name: string; name: string;
}; };
};
/**
* The current workspace.
*/
current: {
/** /**
* The workspace the document was moved to. * The workspace the document was moved to.
*/ */
workspace: { newWorkspace: {
/** /**
* The ID of the workspace. * The ID of the workspace.
*/ */
@ -116,10 +186,6 @@ export interface AuditEventDetails {
name: string; name: string;
}; };
}; };
};
/**
* A document was moved to the trash.
*/
removeDocument: { removeDocument: {
/** /**
* The ID of the document. * The ID of the document.
@ -130,9 +196,6 @@ export interface AuditEventDetails {
*/ */
name: string; name: string;
}; };
/**
* A document was permanently deleted.
*/
deleteDocument: { deleteDocument: {
/** /**
* The ID of the document. * The ID of the document.
@ -143,14 +206,7 @@ export interface AuditEventDetails {
*/ */
name: string; name: string;
}; };
/**
* A document was restored from the trash.
*/
restoreDocumentFromTrash: { restoreDocumentFromTrash: {
/**
* The restored document.
*/
document: {
/** /**
* The ID of the document. * The ID of the document.
*/ */
@ -159,9 +215,76 @@ export interface AuditEventDetails {
* The name of the document. * The name of the document.
*/ */
name: string; name: string;
};
/** /**
* The workspace of the restored document. * The workspace of the document.
*/
workspace: {
/**
* The ID of the workspace.
*/
id: number;
/**
* The name of the workspace.
*/
name: string;
};
};
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: { workspace: {
/** /**
@ -175,8 +298,107 @@ export interface AuditEventDetails {
}; };
}; };
/** /**
* A SQL query was run against a document. * 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: { runSQLQuery: {
/** /**
* The SQL query. * The SQL query.
@ -185,12 +407,169 @@ export interface AuditEventDetails {
/** /**
* The arguments used for query parameters, if any. * 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 { export interface AuditEventContext {
@ -206,7 +585,7 @@ export interface AuditEventContext {
export interface AuditEventSource { 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; org?: string;
/** /**

View File

@ -9,7 +9,9 @@ import {FullUser} from 'app/common/LoginSessionAPI';
import {BasicRole} from 'app/common/roles'; import {BasicRole} from 'app/common/roles';
import {OrganizationProperties, PermissionDelta} from 'app/common/UserAPI'; import {OrganizationProperties, PermissionDelta} from 'app/common/UserAPI';
import {Document} from "app/gen-server/entity/Document"; 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 {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 {BillingOptions, HomeDBManager, Scope} from 'app/gen-server/lib/homedb/HomeDBManager';
import {PreviousAndCurrent, QueryResult} from 'app/gen-server/lib/homedb/Interfaces'; import {PreviousAndCurrent, QueryResult} from 'app/gen-server/lib/homedb/Interfaces';
import {getAuthorizedUserId, getUserId, getUserProfiles, RequestWithLogin} from 'app/server/lib/Authorizer'; import {getAuthorizedUserId, getUserId, getUserProfiles, RequestWithLogin} from 'app/server/lib/Authorizer';
@ -77,7 +79,7 @@ export function addOrg(
product?: string, product?: string,
billing?: BillingOptions, billing?: BillingOptions,
} }
): Promise<number> { ): Promise<Organization> {
return dbManager.connection.transaction(async manager => { return dbManager.connection.transaction(async manager => {
const user = await manager.findOne(User, {where: {id: userId}}); const user = await manager.findOne(User, {where: {id: userId}});
if (!user) { return handleDeletedUser(); } if (!user) { return handleDeletedUser(); }
@ -167,8 +169,9 @@ export class ApiServer {
// doesn't have access to that information yet, so punting on this. // doesn't have access to that information yet, so punting on this.
// TODO: figure out who should be allowed to create organizations // TODO: figure out who should be allowed to create organizations
const userId = getAuthorizedUserId(req); const userId = getAuthorizedUserId(req);
const orgId = await addOrg(this._dbManager, userId, req.body); const org = await addOrg(this._dbManager, userId, req.body);
return sendOkReply(req, res, orgId); this._logCreateSiteEvents(req, org);
return sendOkReply(req, res, org.id);
})); }));
// PATCH /api/orgs/:oid // PATCH /api/orgs/:oid
@ -176,32 +179,30 @@ export class ApiServer {
// Update the specified org. // Update the specified org.
this._app.patch('/api/orgs/:oid', expressWrap(async (req, res) => { this._app.patch('/api/orgs/:oid', expressWrap(async (req, res) => {
const org = getOrgKey(req); const org = getOrgKey(req);
const query = await this._dbManager.updateOrg(getScope(req), org, req.body); const {data, ...result} = await this._dbManager.updateOrg(getScope(req), org, req.body);
return sendReply(req, res, query); if (data && (req.body.name || req.body.domain)) {
this._logRenameSiteEvents(req as RequestWithLogin, data);
}
return sendReply(req, res, result);
})); }));
// DELETE /api/orgs/:oid // DELETE /api/orgs/:oid
// Delete the specified org and all included workspaces and docs. // Delete the specified org and all included workspaces and docs.
this._app.delete('/api/orgs/:oid', expressWrap(async (req, res) => { this._app.delete('/api/orgs/:oid', expressWrap(async (req, res) => {
const org = getOrgKey(req); const org = getOrgKey(req);
const query = await this._dbManager.deleteOrg(getScope(req), org); const {data, ...result} = await this._dbManager.deleteOrg(getScope(req), org);
return sendReply(req, res, query); if (data) { this._logDeleteSiteEvents(req, data); }
return sendReply(req, res, {...result, data: data?.id});
})); }));
// POST /api/orgs/:oid/workspaces // POST /api/orgs/:oid/workspaces
// Body params: name // Body params: name
// Create a new workspace owned by the specific organization. // Create a new workspace owned by the specific organization.
this._app.post('/api/orgs/:oid/workspaces', expressWrap(async (req, res) => { this._app.post('/api/orgs/:oid/workspaces', expressWrap(async (req, res) => {
const mreq = req as RequestWithLogin;
const org = getOrgKey(req); const org = getOrgKey(req);
const query = await this._dbManager.addWorkspace(getScope(req), org, req.body); const {data, ...result} = await this._dbManager.addWorkspace(getScope(req), org, req.body);
this._gristServer.getTelemetry().logEvent(mreq, 'createdWorkspace', { if (data) { this._logCreateWorkspaceEvents(req, data); }
full: { return sendReply(req, res, {...result, data: data?.id});
workspaceId: query.data,
userId: mreq.userId,
},
});
return sendReply(req, res, query);
})); }));
// PATCH /api/workspaces/:wid // PATCH /api/workspaces/:wid
@ -209,23 +210,18 @@ export class ApiServer {
// Update the specified workspace. // Update the specified workspace.
this._app.patch('/api/workspaces/:wid', expressWrap(async (req, res) => { this._app.patch('/api/workspaces/:wid', expressWrap(async (req, res) => {
const wsId = integerParam(req.params.wid, 'wid'); const wsId = integerParam(req.params.wid, 'wid');
const query = await this._dbManager.updateWorkspace(getScope(req), wsId, req.body); const {data, ...result} = await this._dbManager.updateWorkspace(getScope(req), wsId, req.body);
return sendReply(req, res, query); if (data && 'name' in req.body) { this._logRenameWorkspaceEvents(req, data); }
return sendReply(req, res, {...result, data: data?.current.id});
})); }));
// DELETE /api/workspaces/:wid // DELETE /api/workspaces/:wid
// Delete the specified workspace and all included docs. // Delete the specified workspace and all included docs.
this._app.delete('/api/workspaces/:wid', expressWrap(async (req, res) => { this._app.delete('/api/workspaces/:wid', expressWrap(async (req, res) => {
const mreq = req as RequestWithLogin;
const wsId = integerParam(req.params.wid, 'wid'); const wsId = integerParam(req.params.wid, 'wid');
const query = await this._dbManager.deleteWorkspace(getScope(req), wsId); const {data, ...result} = await this._dbManager.deleteWorkspace(getScope(req), wsId);
this._gristServer.getTelemetry().logEvent(mreq, 'deletedWorkspace', { if (data) { this._logDeleteWorkspaceEvents(req, data); }
full: { return sendReply(req, res, {...result, data: data?.id});
workspaceId: wsId,
userId: mreq.userId,
},
});
return sendReply(req, res, query);
})); }));
// POST /api/workspaces/:wid/remove // POST /api/workspaces/:wid/remove
@ -234,17 +230,12 @@ export class ApiServer {
this._app.post('/api/workspaces/:wid/remove', expressWrap(async (req, res) => { this._app.post('/api/workspaces/:wid/remove', expressWrap(async (req, res) => {
const wsId = integerParam(req.params.wid, 'wid'); const wsId = integerParam(req.params.wid, 'wid');
if (isParameterOn(req.query.permanent)) { if (isParameterOn(req.query.permanent)) {
const mreq = req as RequestWithLogin; const {data, ...result} = await this._dbManager.deleteWorkspace(getScope(req), wsId);
const query = await this._dbManager.deleteWorkspace(getScope(req), wsId); if (data) { this._logDeleteWorkspaceEvents(req, data); }
this._gristServer.getTelemetry().logEvent(mreq, 'deletedWorkspace', { return sendReply(req, res, {...result, data: data?.id});
full: {
workspaceId: query.data,
userId: mreq.userId,
},
});
return sendReply(req, res, query);
} else { } 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); return sendOkReply(req, res);
} }
})); }));
@ -254,7 +245,8 @@ export class ApiServer {
// still available. // still available.
this._app.post('/api/workspaces/:wid/unremove', expressWrap(async (req, res) => { this._app.post('/api/workspaces/:wid/unremove', expressWrap(async (req, res) => {
const wsId = integerParam(req.params.wid, 'wid'); 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); return sendOkReply(req, res);
})); }));
@ -262,9 +254,9 @@ export class ApiServer {
// Create a new doc owned by the specific workspace. // Create a new doc owned by the specific workspace.
this._app.post('/api/workspaces/:wid/docs', expressWrap(async (req, res) => { this._app.post('/api/workspaces/:wid/docs', expressWrap(async (req, res) => {
const wsId = integerParam(req.params.wid, 'wid'); const wsId = integerParam(req.params.wid, 'wid');
const result = await this._dbManager.addDocument(getScope(req), wsId, req.body); const {data, ...result} = await this._dbManager.addDocument(getScope(req), wsId, req.body);
if (result.status === 200) { this._logCreateDocumentEvents(req, result.data!); } if (data) { this._logCreateDocumentEvents(req, data); }
return sendReply(req, res, {...result, data: result.data?.id}); return sendReply(req, res, {...result, data: data?.id});
})); }));
// GET /api/templates/ // GET /api/templates/
@ -301,16 +293,17 @@ export class ApiServer {
// PATCH /api/docs/:did // PATCH /api/docs/:did
// Update the specified doc. // Update the specified doc.
this._app.patch('/api/docs/:did', expressWrap(async (req, res) => { this._app.patch('/api/docs/:did', expressWrap(async (req, res) => {
const query = await this._dbManager.updateDocument(getDocScope(req), req.body); const {data, ...result} = await this._dbManager.updateDocument(getDocScope(req), req.body);
return sendReply(req, res, query); if (data && 'name' in req.body) { this._logRenameDocumentEvents(req, data); }
return sendReply(req, res, {...result, data: data?.current.id});
})); }));
// POST /api/docs/:did/unremove // POST /api/docs/:did/unremove
// Recover the specified doc if it was previously soft-deleted and is // Recover the specified doc if it was previously soft-deleted and is
// still available. // still available.
this._app.post('/api/docs/:did/unremove', expressWrap(async (req, res) => { this._app.post('/api/docs/:did/unremove', expressWrap(async (req, res) => {
const {status, data} = await this._dbManager.undeleteDocument(getDocScope(req)); const {data} = await this._dbManager.undeleteDocument(getDocScope(req));
if (status === 200) { this._logRestoreDocumentEvents(req, data!); } if (data) { this._logRestoreDocumentEvents(req, data); }
return sendOkReply(req, res); return sendOkReply(req, res);
})); }));
@ -319,8 +312,9 @@ export class ApiServer {
this._app.patch('/api/orgs/:oid/access', expressWrap(async (req, res) => { this._app.patch('/api/orgs/:oid/access', expressWrap(async (req, res) => {
const org = getOrgKey(req); const org = getOrgKey(req);
const delta = req.body.delta; const delta = req.body.delta;
const query = await this._dbManager.updateOrgPermissions(getScope(req), org, delta); const {data, ...result} = await this._dbManager.updateOrgPermissions(getScope(req), org, delta);
return sendReply(req, res, query); if (data) { this._logChangeSiteAccessEvents(req as RequestWithLogin, data); }
return sendReply(req, res, result);
})); }));
// PATCH /api/workspaces/:wid/access // PATCH /api/workspaces/:wid/access
@ -328,8 +322,9 @@ export class ApiServer {
this._app.patch('/api/workspaces/:wid/access', expressWrap(async (req, res) => { this._app.patch('/api/workspaces/:wid/access', expressWrap(async (req, res) => {
const workspaceId = integerParam(req.params.wid, 'wid'); const workspaceId = integerParam(req.params.wid, 'wid');
const delta = req.body.delta; const delta = req.body.delta;
const query = await this._dbManager.updateWorkspacePermissions(getScope(req), workspaceId, delta); const {data, ...result} = await this._dbManager.updateWorkspacePermissions(getScope(req), workspaceId, delta);
return sendReply(req, res, query); if (data) { this._logChangeWorkspaceAccessEvents(req as RequestWithLogin, data); }
return sendReply(req, res, result);
})); }));
// GET /api/docs/:did // GET /api/docs/:did
@ -343,28 +338,30 @@ export class ApiServer {
// Update the specified doc acl rules. // Update the specified doc acl rules.
this._app.patch('/api/docs/:did/access', expressWrap(async (req, res) => { this._app.patch('/api/docs/:did/access', expressWrap(async (req, res) => {
const delta = req.body.delta; const delta = req.body.delta;
const query = await this._dbManager.updateDocPermissions(getDocScope(req), delta); const {data, ...result} = await this._dbManager.updateDocPermissions(getDocScope(req), delta);
this._logInvitedDocUserTelemetryEvents(req as RequestWithLogin, delta); if (data) { this._logChangeDocumentAccessEvents(req as RequestWithLogin, data); }
return sendReply(req, res, query); return sendReply(req, res, result);
})); }));
// PATCH /api/docs/:did/move // PATCH /api/docs/:did/move
// Move the doc to the workspace specified in the body. // Move the doc to the workspace specified in the body.
this._app.patch('/api/docs/:did/move', expressWrap(async (req, res) => { this._app.patch('/api/docs/:did/move', expressWrap(async (req, res) => {
const workspaceId = integerParam(req.body.workspace, 'workspace'); const workspaceId = integerParam(req.body.workspace, 'workspace');
const result = await this._dbManager.moveDoc(getDocScope(req), workspaceId); const {data, ...result} = await this._dbManager.moveDoc(getDocScope(req), workspaceId);
if (result.status === 200) { this._logMoveDocumentEvents(req, result.data!); } if (data) { this._logMoveDocumentEvents(req, data); }
return sendReply(req, res, {...result, data: result.data?.current.id}); return sendReply(req, res, {...result, data: data?.current.id});
})); }));
this._app.patch('/api/docs/:did/pin', expressWrap(async (req, res) => { this._app.patch('/api/docs/:did/pin', expressWrap(async (req, res) => {
const query = await this._dbManager.pinDoc(getDocScope(req), true); const {data, ...result} = await this._dbManager.pinDoc(getDocScope(req), true);
return sendReply(req, res, query); if (data) { this._logPinDocumentEvents(req, data); }
return sendReply(req, res, result);
})); }));
this._app.patch('/api/docs/:did/unpin', expressWrap(async (req, res) => { this._app.patch('/api/docs/:did/unpin', expressWrap(async (req, res) => {
const query = await this._dbManager.pinDoc(getDocScope(req), false); const {data, ...result} = await this._dbManager.pinDoc(getDocScope(req), false);
return sendReply(req, res, query); if (data) { this._logUnpinDocumentEvents(req, data); }
return sendReply(req, res, result);
})); }));
// GET /api/orgs/:oid/access // GET /api/orgs/:oid/access
@ -408,7 +405,8 @@ export class ApiServer {
throw new ApiError('Name expected in the body', 400); throw new ApiError('Name expected in the body', 400);
} }
const name = req.body.name; 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); res.sendStatus(200);
})); }));
@ -489,6 +487,7 @@ export class ApiServer {
if (!user) { return handleDeletedUser(); } if (!user) { return handleDeletedUser(); }
if (!user.apiKey || force) { if (!user.apiKey || force) {
user = await updateApiKeyWithRetry(manager, user); user = await updateApiKeyWithRetry(manager, user);
this._logCreateUserAPIKeyEvents(req);
res.status(200).send(user.apiKey); res.status(200).send(user.apiKey);
} else { } else {
res.status(400).send({error: "An apikey is already set, use `{force: true}` to override it."}); 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(); } if (!user) { return handleDeletedUser(); }
user.apiKey = null; user.apiKey = null;
await manager.save(User, user); await manager.save(User, user);
this._logDeleteUserAPIKeyEvents(req);
}); });
res.sendStatus(200); 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) { private _logRestoreDocumentEvents(req: Request, document: Document) {
const {workspace} = document; const {id, name, workspace} = document;
this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, { this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {
event: { event: {
name: 'restoreDocumentFromTrash', name: 'restoreDocumentFromTrash',
details: { details: {
document: { id,
id: document.id, name,
name: document.name,
},
workspace: { workspace: {
id: workspace.id, id: workspace.id,
name: workspace.name, 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) { private _logInvitedDocUserTelemetryEvents(mreq: RequestWithLogin, delta: PermissionDelta) {
if (!delta.users) { return; } if (!delta.users) { return; }
@ -722,25 +758,207 @@ export class ApiServer {
name: 'moveDocument', name: 'moveDocument',
details: { details: {
id: current.id, id: current.id,
previous: { previousWorkspace: {
workspace: {
id: previous.workspace.id, id: previous.workspace.id,
name: previous.workspace.name, name: previous.workspace.name,
}, },
}, newWorkspace: {
current: {
workspace: {
id: current.workspace.id, id: current.workspace.id,
name: current.workspace.name, name: current.workspace.name,
}, },
}, },
},
context: { context: {
workspaceId: previous.workspace.id, workspaceId: previous.workspace.id,
}, },
}, },
}); });
} }
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',
},
});
}
} }
/** /**

View File

@ -459,11 +459,15 @@ export class HomeDBManager extends EventEmitter {
return await this._usersManager.ensureExternalUser(profile); return await this._usersManager.ensureExternalUser(profile);
} }
public async updateUser(userId: number, props: UserProfileChange) { public async updateUser(
const { user, isWelcomed } = await this._usersManager.updateUser(userId, props); userId: number,
if (user && isWelcomed) { props: UserProfileChange
this.emit('firstLogin', this.makeFullUser(user)); ): 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>) { 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 user: user doing the adding
* @param name: desired org name * @param name: desired org name
@ -1073,12 +1077,17 @@ export class HomeDBManager extends EventEmitter {
* meaningful for team sites currently. * meaningful for team sites currently.
* @param billing: if set, controls the billing account settings for the org. * @param billing: if set, controls the billing account settings for the org.
*/ */
public async addOrg(user: User, props: Partial<OrganizationProperties>, public async addOrg(
options: { setUserAsOwner: boolean, user: User,
props: Partial<OrganizationProperties>,
options: {
setUserAsOwner: boolean,
useNewPlan: boolean, useNewPlan: boolean,
product?: string, // Default to PERSONAL_FREE_PLAN or TEAM_FREE_PLAN env variable. product?: string, // Default to PERSONAL_FREE_PLAN or TEAM_FREE_PLAN env variable.
billing?: BillingOptions}, billing?: BillingOptions
transaction?: EntityManager): Promise<QueryResult<number>> { },
transaction?: EntityManager
): Promise<QueryResult<Organization>> {
const notifications: Array<() => void> = []; const notifications: Array<() => void> = [];
const name = props.name; const name = props.name;
const domain = props.domain; const domain = props.domain;
@ -1219,10 +1228,7 @@ export class HomeDBManager extends EventEmitter {
// Emit a notification. // Emit a notification.
notifications.push(this._teamCreatorNotification(user.id)); notifications.push(this._teamCreatorNotification(user.id));
} }
return { return {status: 200, data: savedOrg};
status: 200,
data: savedOrg.id
};
}); });
for (const notification of notifications) { notification(); } for (const notification of notifications) { notification(); }
return orgResult; return orgResult;
@ -1230,8 +1236,8 @@ export class HomeDBManager extends EventEmitter {
// If setting anything more than prefs: // If setting anything more than prefs:
// Checks that the user has UPDATE permissions to the given org. If not, throws an // 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 // error. Otherwise updates the given org with the given name. Returns a query
// query result with status 200 on success. // result with status 200 on success.
// For setting userPrefs or userOrgPrefs: // For setting userPrefs or userOrgPrefs:
// These are user-specific setting, so are allowed with VIEW access (that includes // These are user-specific setting, so are allowed with VIEW access (that includes
// guests). Prefs are replaced in their entirety, not merged. // guests). Prefs are replaced in their entirety, not merged.
@ -1242,7 +1248,7 @@ export class HomeDBManager extends EventEmitter {
orgKey: string|number, orgKey: string|number,
props: Partial<OrganizationProperties>, props: Partial<OrganizationProperties>,
transaction?: EntityManager, transaction?: EntityManager,
): Promise<QueryResult<number>> { ): Promise<QueryResult<PreviousAndCurrent<Organization>>> {
// Check the scope of the modifications. // Check the scope of the modifications.
let markPermissions: number = Permissions.VIEW; let markPermissions: number = Permissions.VIEW;
@ -1272,11 +1278,12 @@ export class HomeDBManager extends EventEmitter {
}); });
const queryResult = await verifyEntity(orgQuery); const queryResult = await verifyEntity(orgQuery);
if (queryResult.status !== 200) { 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; return queryResult;
} }
// Update the fields and save. // Update the fields and save.
const org: Organization = queryResult.data; const org: Organization = queryResult.data;
const previous = structuredClone(org);
org.checkProperties(props); org.checkProperties(props);
if (modifyOrg) { if (modifyOrg) {
if (props.domain) { if (props.domain) {
@ -1312,15 +1319,18 @@ export class HomeDBManager extends EventEmitter {
.execute(); .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 // 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 // error. Otherwise deletes the given org. Returns a query result with status 200
// status 200 on success. // on success.
public async deleteOrg(scope: Scope, orgKey: string|number, public async deleteOrg(
transaction?: EntityManager): Promise<QueryResult<number>> { scope: Scope,
orgKey: string|number,
transaction?: EntityManager
): Promise<QueryResult<Organization>> {
return await this._runInTransaction(transaction, async manager => { return await this._runInTransaction(transaction, async manager => {
const orgQuery = this.org(scope, orgKey, { const orgQuery = this.org(scope, orgKey, {
manager, manager,
@ -1344,6 +1354,7 @@ export class HomeDBManager extends EventEmitter {
return queryResult; return queryResult;
} }
const org: Organization = queryResult.data; const org: Organization = queryResult.data;
const deletedOrg = structuredClone(org);
// Delete the org, org ACLs/groups, workspaces, workspace ACLs/groups, workspace docs // Delete the org, org ACLs/groups, workspaces, workspace ACLs/groups, workspace docs
// and doc ACLs/groups. // and doc ACLs/groups.
const orgGroups = org.aclRules.map(orgAcl => orgAcl.group); const orgGroups = org.aclRules.map(orgAcl => orgAcl.group);
@ -1363,15 +1374,18 @@ export class HomeDBManager extends EventEmitter {
if (billingAccount && billingAccount.orgs.length === 0) { if (billingAccount && billingAccount.orgs.length === 0) {
await manager.remove([billingAccount]); 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. // 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 // Otherwise adds a workspace with the given name. Returns a query result with the
// of the added workspace. // added workspace.
public async addWorkspace(scope: Scope, orgKey: string|number, public async addWorkspace(
props: Partial<WorkspaceProperties>): Promise<QueryResult<number>> { scope: Scope,
orgKey: string|number,
props: Partial<WorkspaceProperties>
): Promise<QueryResult<Workspace>> {
const name = props.name; const name = props.name;
if (!name) { if (!name) {
return { return {
@ -1414,18 +1428,18 @@ export class HomeDBManager extends EventEmitter {
} }
} }
const workspace = await this._doAddWorkspace({org, props, ownerId: scope.userId}, manager); const workspace = await this._doAddWorkspace({org, props, ownerId: scope.userId}, manager);
return { return {status: 200, data: workspace};
status: 200,
data: workspace.id
};
}); });
} }
// Checks that the user has UPDATE permissions to the given workspace. If not, throws an // 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 // error. Otherwise updates the given workspace with the given name. Returns a query result
// query result with status 200 on success. // with status 200 on success.
public async updateWorkspace(scope: Scope, wsId: number, public async updateWorkspace(
props: Partial<WorkspaceProperties>): Promise<QueryResult<number>> { scope: Scope,
wsId: number,
props: Partial<WorkspaceProperties>
): Promise<QueryResult<PreviousAndCurrent<Workspace>>> {
return await this._connection.transaction(async manager => { return await this._connection.transaction(async manager => {
const wsQuery = this._workspace(scope, wsId, { const wsQuery = this._workspace(scope, wsId, {
manager, manager,
@ -1438,17 +1452,18 @@ export class HomeDBManager extends EventEmitter {
} }
// Update the name and save. // Update the name and save.
const workspace: Workspace = queryResult.data; const workspace: Workspace = queryResult.data;
const previous = structuredClone(workspace);
workspace.checkProperties(props); workspace.checkProperties(props);
workspace.updateFromProperties(props); workspace.updateFromProperties(props);
await manager.save(workspace); 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 // 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 // error. Otherwise deletes the given workspace. Returns a query result with status 200
// status 200 on success. // on success.
public async deleteWorkspace(scope: Scope, wsId: number): Promise<QueryResult<number>> { public async deleteWorkspace(scope: Scope, wsId: number): Promise<QueryResult<Workspace>> {
return await this._connection.transaction(async manager => { return await this._connection.transaction(async manager => {
const wsQuery = this._workspace(scope, wsId, { const wsQuery = this._workspace(scope, wsId, {
manager, manager,
@ -1469,6 +1484,7 @@ export class HomeDBManager extends EventEmitter {
return queryResult; return queryResult;
} }
const workspace: Workspace = queryResult.data; const workspace: Workspace = queryResult.data;
const deletedWorkspace = structuredClone(workspace);
// Delete the workspace, workspace docs, doc ACLs/groups and workspace ACLs/groups. // Delete the workspace, workspace docs, doc ACLs/groups and workspace ACLs/groups.
const wsGroups = workspace.aclRules.map(wsAcl => wsAcl.group); const wsGroups = workspace.aclRules.map(wsAcl => wsAcl.group);
const docAcls = ([] as AclRule[]).concat(...workspace.docs.map(doc => doc.aclRules)); const docAcls = ([] as AclRule[]).concat(...workspace.docs.map(doc => doc.aclRules));
@ -1477,15 +1493,15 @@ export class HomeDBManager extends EventEmitter {
...workspace.aclRules, ...docGroups]); ...workspace.aclRules, ...docGroups]);
// Update the guests in the org after removing this workspace. // Update the guests in the org after removing this workspace.
await this._repairOrgGuests(scope, workspace.org.id, manager); 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()); 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); 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 // 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 // error. Otherwise updates the given doc with the given name. Returns a query result with
// query result with status 200 on success. // status 200 on success.
// NOTE: This does not update the updateAt date indicating the last modified time of the doc. // NOTE: This does not update the updateAt date indicating the last modified time of the doc.
// We may want to make it do so. // We may want to make it do so.
public async updateDocument( public async updateDocument(
scope: DocScope, scope: DocScope,
props: Partial<DocumentProperties>, props: Partial<DocumentProperties>,
transaction?: EntityManager transaction?: EntityManager
): Promise<QueryResult<number>> { ): Promise<QueryResult<PreviousAndCurrent<Document>>> {
const markPermissions = Permissions.SCHEMA_EDIT; const markPermissions = Permissions.SCHEMA_EDIT;
return await this._runInTransaction(transaction, async (manager) => { return await this._runInTransaction(transaction, async (manager) => {
const {forkId} = parseUrlId(scope.urlId); const {forkId} = parseUrlId(scope.urlId);
@ -1721,6 +1737,7 @@ export class HomeDBManager extends EventEmitter {
} }
// Update the name and save. // Update the name and save.
const doc: Document = queryResult.data; const doc: Document = queryResult.data;
const previous = structuredClone(doc);
doc.checkProperties(props); doc.checkProperties(props);
doc.updateFromProperties(props); doc.updateFromProperties(props);
if (forkId) { if (forkId) {
@ -1752,7 +1769,7 @@ export class HomeDBManager extends EventEmitter {
.execute(); .execute();
// TODO: we could limit the max number of aliases stored per document. // 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, scope: Scope,
orgKey: string|number, orgKey: string|number,
delta: PermissionDelta delta: PermissionDelta
): Promise<QueryResult<void>> { ): Promise<QueryResult<PermissionDelta & {organization: Organization}>> {
const {userId} = scope; const {userId} = scope;
const notifications: Array<() => void> = []; const notifications: Array<() => void> = [];
const result = await this._connection.transaction(async manager => { 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. // Notify any added users that they've been added to this resource.
notifications.push(this._inviteNotification(userId, org, userIdDelta, membersBefore)); 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(); } for (const notification of notifications) { notification(); }
return result; return result;
@ -1966,7 +1986,7 @@ export class HomeDBManager extends EventEmitter {
scope: Scope, scope: Scope,
wsId: number, wsId: number,
delta: PermissionDelta delta: PermissionDelta
): Promise<QueryResult<void>> { ): Promise<QueryResult<PermissionDelta & {workspace: Workspace}>> {
const {userId} = scope; const {userId} = scope;
const notifications: Array<() => void> = []; const notifications: Array<() => void> = [];
const result = await this._connection.transaction(async manager => { 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); await this._repairOrgGuests(scope, ws.org.id, manager);
notifications.push(this._inviteNotification(userId, ws, userIdDelta, membersBefore)); 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(); } for (const notification of notifications) { notification(); }
return result; return result;
@ -2041,7 +2068,7 @@ export class HomeDBManager extends EventEmitter {
public async updateDocPermissions( public async updateDocPermissions(
scope: DocScope, scope: DocScope,
delta: PermissionDelta delta: PermissionDelta
): Promise<QueryResult<void>> { ): Promise<QueryResult<PermissionDelta & {document: Document}>> {
const notifications: Array<() => void> = []; const notifications: Array<() => void> = [];
const result = await this._connection.transaction(async manager => { const result = await this._connection.transaction(async manager => {
const {userId} = scope; const {userId} = scope;
@ -2082,7 +2109,14 @@ export class HomeDBManager extends EventEmitter {
await this._repairOrgGuests(scope, doc.workspace.org.id, manager); await this._repairOrgGuests(scope, doc.workspace.org.id, manager);
notifications.push(this._inviteNotification(userId, doc, userIdDelta, membersBefore)); 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(); } for (const notification of notifications) { notification(); }
return result; return result;
@ -2386,7 +2420,7 @@ export class HomeDBManager extends EventEmitter {
public async pinDoc( public async pinDoc(
scope: DocScope, scope: DocScope,
setPinned: boolean setPinned: boolean
): Promise<QueryResult<void>> { ): Promise<QueryResult<Document>> {
return await this._connection.transaction(async manager => { return await this._connection.transaction(async manager => {
// Find the doc to assert that it exists. Assert that the user has edit access to the // Find the doc to assert that it exists. Assert that the user has edit access to the
// parent org. // parent org.
@ -2410,7 +2444,7 @@ export class HomeDBManager extends EventEmitter {
// Save and return success status. // Save and return success status.
await manager.save(doc); await manager.save(doc);
} }
return { status: 200 }; return {status: 200, data: doc};
}); });
} }
@ -4291,9 +4325,9 @@ export class HomeDBManager extends EventEmitter {
markPermissions: Permissions.REMOVE markPermissions: Permissions.REMOVE
}); });
const workspace: Workspace = this.unwrapQueryResult(await verifyEntity(wsQuery)); const workspace: Workspace = this.unwrapQueryResult(await verifyEntity(wsQuery));
await manager.createQueryBuilder() workspace.removedAt = removedAt;
.update(Workspace).set({removedAt}).where({id: workspace.id}) const data = await manager.save(workspace);
.execute(); return {status: 200, data};
}); });
} }

View File

@ -256,14 +256,17 @@ export class UsersManager {
}); });
} }
public async updateUser(userId: number, props: UserProfileChange) { public async updateUser(userId: number, props: UserProfileChange){
let isWelcomed: boolean = false; return await this._connection.transaction(async manager => {
let user: User|null = null; let isWelcomed = false;
await this._connection.transaction(async manager => {
user = await manager.findOne(User, {relations: ['logins'],
where: {id: userId}});
let needsSave = 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); } if (!user) { throw new ApiError("unable to find user", 400); }
const previous = structuredClone(user);
if (props.name && props.name !== user.name) { if (props.name && props.name !== user.name) {
user.name = props.name; user.name = props.name;
needsSave = true; needsSave = true;
@ -279,8 +282,8 @@ export class UsersManager {
if (needsSave) { if (needsSave) {
await manager.save(user); await manager.save(user);
} }
return {previous, current: user, isWelcomed};
}); });
return { user, isWelcomed };
} }
// TODO: rather use the updateUser() method, if that makes sense? // 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. // We just created a personal org; set userOrgPrefs that should apply for new users only.
const userOrgPrefs: UserOrgPrefs = {showGristTour: true}; const userOrgPrefs: UserOrgPrefs = {showGristTour: true};
const orgId = result.data; const org = result.data;
if (orgId) { if (org) {
await this._homeDb.updateOrg({userId: user.id}, orgId, {userOrgPrefs}, manager); await this._homeDb.updateOrg({userId: user.id}, org.id, {userOrgPrefs}, manager);
} }
} }
if (needUpdate) { if (needUpdate) {

View File

@ -9,6 +9,7 @@ import { getDatabaseUrl } from 'app/server/lib/serverUtils';
import { getTelemetryPrefs } from 'app/server/lib/Telemetry'; import { getTelemetryPrefs } from 'app/server/lib/Telemetry';
import { Gristifier } from 'app/server/utils/gristify'; import { Gristifier } from 'app/server/utils/gristify';
import { pruneActionHistory } from 'app/server/utils/pruneActionHistory'; import { pruneActionHistory } from 'app/server/utils/pruneActionHistory';
import { showAuditLogEvents } from 'app/server/utils/showAuditLogEvents';
import * as commander from 'commander'; import * as commander from 'commander';
import { Connection } from 'typeorm'; import { Connection } from 'typeorm';
@ -43,6 +44,7 @@ export function getProgram(): commander.Command {
// want to reserve "grist" for electron app? // want to reserve "grist" for electron app?
.description('a toolbox of handy Grist-related utilities'); .description('a toolbox of handy Grist-related utilities');
addAuditLogsCommand(program, {nested: true});
addDbCommand(program, {nested: true}); addDbCommand(program, {nested: true});
addHistoryCommand(program, {nested: true}); addHistoryCommand(program, {nested: true});
addSettingsCommand(program, {nested: true}); addSettingsCommand(program, {nested: true});
@ -52,6 +54,18 @@ export function getProgram(): commander.Command {
return program; 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: // Add commands related to document history:
// history prune <docId> [N] // history prune <docId> [N]
export function addHistoryCommand(program: commander.Command, options: CommandOptions) { export function addHistoryCommand(program: commander.Command, options: CommandOptions) {

View File

@ -36,6 +36,7 @@ import {
import {ApiError} from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import {mapGetOrSet, MapWithTTL} from 'app/common/AsyncCreate'; import {mapGetOrSet, MapWithTTL} from 'app/common/AsyncCreate';
import {AttachmentColumns, gatherAttachmentIds, getAttachmentColumns} from 'app/common/AttachmentColumns'; import {AttachmentColumns, gatherAttachmentIds, getAttachmentColumns} from 'app/common/AttachmentColumns';
import {AuditEventName} from 'app/common/AuditEvent';
import {WebhookMessageType} from 'app/common/CommTypes'; import {WebhookMessageType} from 'app/common/CommTypes';
import { import {
BulkAddRecord, BulkAddRecord,
@ -92,6 +93,7 @@ import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI';
import {AccessTokenOptions, AccessTokenResult, GristDocAPI, UIRowId} from 'app/plugin/GristAPI'; import {AccessTokenOptions, AccessTokenResult, GristDocAPI, UIRowId} from 'app/plugin/GristAPI';
import {AssistanceSchemaPromptV1Context} from 'app/server/lib/Assistance'; import {AssistanceSchemaPromptV1Context} from 'app/server/lib/Assistance';
import {AssistanceContext} from 'app/common/AssistancePrompts'; import {AssistanceContext} from 'app/common/AssistancePrompts';
import {AuditEventProperties} from 'app/server/lib/AuditLogger';
import {Authorizer, RequestWithLogin} from 'app/server/lib/Authorizer'; import {Authorizer, RequestWithLogin} from 'app/server/lib/Authorizer';
import {checksumFile} from 'app/server/lib/checksumFile'; import {checksumFile} from 'app/server/lib/checksumFile';
import {Client} from 'app/server/lib/Client'; import {Client} from 'app/server/lib/Client';
@ -115,6 +117,7 @@ import {
getFullUser, getFullUser,
getLogMeta, getLogMeta,
getUserId, getUserId,
RequestOrSession,
} from 'app/server/lib/sessionUtils'; } from 'app/server/lib/sessionUtils';
import {shortDesc} from 'app/server/lib/shortDesc'; import {shortDesc} from 'app/server/lib/shortDesc';
import {TableMetadataLoader} from 'app/server/lib/TableMetadataLoader'; import {TableMetadataLoader} from 'app/server/lib/TableMetadataLoader';
@ -1451,17 +1454,7 @@ export class ActiveDoc extends EventEmitter {
} }
await dbManager.forkDoc(userId, doc, forkIds.forkId); await dbManager.forkDoc(userId, doc, forkIds.forkId);
this._logForkDocumentEvents(docSession, {originalDocument: doc, forkIds});
const isTemplate = doc.type === 'template';
this.logTelemetryEvent(docSession, 'documentForked', {
limited: {
forkIdDigest: forkIds.forkId,
forkDocIdDigest: forkIds.docId,
trunkIdDigest: doc.trunkId,
isTemplate,
lastActivity: doc.updatedAt,
},
});
} finally { } finally {
await permitStore.removePermit(permitKey); 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( public logTelemetryEvent(
docSession: OptDocSession | null, docSession: OptDocSession | null,
event: TelemetryEvent, event: TelemetryEvent,
@ -2961,6 +2961,38 @@ export class ActiveDoc extends EventEmitter {
return this._pyCall('start_timing'); 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. // Helper to initialize a sandbox action bundle with no values.

View File

@ -153,30 +153,8 @@ export function attachAppEndpoint(options: AttachOptions): void {
docStatus = workerInfo.docStatus; docStatus = workerInfo.docStatus;
body = await workerInfo.resp.json(); body = await workerInfo.resp.json();
} }
logOpenDocumentEvents(mreq, {server: gristServer, doc, urlId});
const isPublic = ((doc as unknown) as APIDocument).public ?? false; if (doc.type === 'template') {
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) {
// Keep track of the last template a user visited in the last hour. // 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 // If a sign-up occurs within that time period, we'll know which
// template, if any, was viewed most recently. // template, if any, was viewed most recently.
@ -232,3 +210,39 @@ export function attachAppEndpoint(options: AttachOptions): void {
app.get('/:urlId([^-/]{12,})(/:slug([^/]+):remainder(*))?', app.get('/:urlId([^-/]{12,})(/:slug([^/]+):remainder(*))?',
...docMiddleware, docHandler); ...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,
},
});
}
}

View File

@ -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'; import {RequestOrSession} from 'app/server/lib/sessionUtils';
export interface IAuditLogger { export interface IAuditLogger {
@ -23,20 +23,24 @@ export interface IAuditLogger {
export interface AuditEventProperties<Name extends AuditEventName> { export interface AuditEventProperties<Name extends AuditEventName> {
event: { event: {
/** /**
* The event name. * The name of the event.
*/ */
name: Name; name: Name;
/** /**
* Additional event details. * Event-specific details (e.g. properties of affected resources).
*/ */
details?: AuditEventDetails[Name]; details?: AuditEventDetails[Name];
/** /**
* The context of the event. * The context that the event occurred in (e.g. workspace, document).
*/ */
context?: AuditEventContext; 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. * Defaults to now.
*/ */

View File

@ -906,8 +906,10 @@ export class DocWorkerApi {
// Clears all outgoing webhooks in the queue for this document. // Clears all outgoing webhooks in the queue for this document.
this._app.delete('/api/docs/:docId/webhooks/queue', isOwner, this._app.delete('/api/docs/:docId/webhooks/queue', isOwner,
withDocTriggersLock(async (activeDoc, req, res) => { withDocTriggersLock(async (activeDoc, req, res) => {
const docId = getDocId(req);
await activeDoc.clearWebhookQueue(); await activeDoc.clearWebhookQueue();
await activeDoc.sendWebhookNotification(); await activeDoc.sendWebhookNotification();
this._logClearAllWebhookQueueEvents(req, {docId});
res.json({success: true}); res.json({success: true});
}) })
); );
@ -933,7 +935,7 @@ export class DocWorkerApi {
const webhookId = req.params.webhookId; const webhookId = req.params.webhookId;
const {fields, url, authorization} = await getWebhookSettings(activeDoc, req, webhookId, req.body); const {fields, url, authorization} = await getWebhookSettings(activeDoc, req, webhookId, req.body);
if (fields.enabled === false) { if (fields.enabled === false) {
await activeDoc.triggers.clearSingleWebhookQueue(webhookId); await activeDoc.clearSingleWebhookQueue(webhookId);
} }
const triggerRowId = activeDoc.triggers.getWebhookTriggerRecord(webhookId).id; const triggerRowId = activeDoc.triggers.getWebhookTriggerRecord(webhookId).id;
@ -960,9 +962,11 @@ export class DocWorkerApi {
// Clears a single webhook in the queue for this document. // Clears a single webhook in the queue for this document.
this._app.delete('/api/docs/:docId/webhooks/queue/:webhookId', isOwner, this._app.delete('/api/docs/:docId/webhooks/queue/:webhookId', isOwner,
withDocTriggersLock(async (activeDoc, req, res) => { withDocTriggersLock(async (activeDoc, req, res) => {
const docId = getDocId(req);
const webhookId = req.params.webhookId; const webhookId = req.params.webhookId;
await activeDoc.clearSingleWebhookQueue(webhookId); await activeDoc.clearSingleWebhookQueue(webhookId);
await activeDoc.sendWebhookNotification(); await activeDoc.sendWebhookNotification();
this._logClearWebhookQueueEvents(req, {docId, webhookId});
res.json({success: true}); res.json({success: true});
}) })
); );
@ -978,8 +982,10 @@ export class DocWorkerApi {
// reopened on use). // reopened on use).
this._app.post('/api/docs/:docId/force-reload', canEdit, async (req, res) => { this._app.post('/api/docs/:docId/force-reload', canEdit, async (req, res) => {
const mreq = req as RequestWithLogin; const mreq = req as RequestWithLogin;
const docId = getDocId(req);
const activeDoc = await this._getActiveDoc(mreq); const activeDoc = await this._getActiveDoc(mreq);
await activeDoc.reloadDoc(); await activeDoc.reloadDoc();
this._logReloadDocumentEvents(mreq, {docId});
res.json(null); res.json(null);
}); });
@ -997,16 +1003,16 @@ export class DocWorkerApi {
// DELETE /api/docs/:docId // DELETE /api/docs/:docId
// Delete the specified doc. // Delete the specified doc.
this._app.delete('/api/docs/:docId', canEditMaybeRemoved, throttled(async (req, res) => { this._app.delete('/api/docs/:docId', canEditMaybeRemoved, throttled(async (req, res) => {
const {status, data} = await this._removeDoc(req, res, true); const {data} = await this._removeDoc(req, res, true);
if (status === 200) { this._logDeleteDocumentEvents(req, data!); } if (data) { this._logDeleteDocumentEvents(req, data); }
})); }));
// POST /api/docs/:docId/remove // POST /api/docs/:docId/remove
// Soft-delete the specified doc. If query parameter "permanent" is set, // Soft-delete the specified doc. If query parameter "permanent" is set,
// delete permanently. // delete permanently.
this._app.post('/api/docs/:docId/remove', canEditMaybeRemoved, throttled(async (req, res) => { this._app.post('/api/docs/:docId/remove', canEditMaybeRemoved, throttled(async (req, res) => {
const {status, data} = await this._removeDoc(req, res, isParameterOn(req.query.permanent)); const {data} = await this._removeDoc(req, res, isParameterOn(req.query.permanent));
if (status === 200) { this._logRemoveDocumentEvents(req, data!); } if (data) { this._logRemoveDocumentEvents(req, data); }
})); }));
this._app.get('/api/docs/:docId/snapshots', canView, withDoc(async (activeDoc, req, res) => { 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 // This endpoint cannot use withDoc since it is expected behavior for the ActiveDoc it
// starts with to become muted. // starts with to become muted.
this._app.post('/api/docs/:docId/replace', canEdit, throttled(async (req, res) => { this._app.post('/api/docs/:docId/replace', canEdit, throttled(async (req, res) => {
const docId = getDocId(req);
const docSession = docSessionFromRequest(req); const docSession = docSessionFromRequest(req);
const activeDoc = await this._getActiveDoc(req); const activeDoc = await this._getActiveDoc(req);
const options: DocReplacementOptions = {}; const options: DocReplacementOptions = {};
@ -1160,6 +1167,9 @@ export class DocWorkerApi {
options.snapshotId = String(req.body.snapshotId); options.snapshotId = String(req.body.snapshotId);
} }
await activeDoc.replace(docSession, options); 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); res.json(null);
})); }));
@ -1169,9 +1179,12 @@ export class DocWorkerApi {
})); }));
this._app.post('/api/docs/:docId/states/remove', isOwner, withDoc(async (activeDoc, req, res) => { this._app.post('/api/docs/:docId/states/remove', isOwner, withDoc(async (activeDoc, req, res) => {
const docId = getDocId(req);
const docSession = docSessionFromRequest(req); const docSession = docSessionFromRequest(req);
const keep = integerParam(req.body.keep, 'keep'); 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) => { 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)); .catch(e => log.error('DocApi failed to log duplicate document events', e));
return id; return id;
} }
@ -2029,8 +2046,13 @@ export class DocWorkerApi {
return result; return result;
} }
private async _runSql(activeDoc: ActiveDoc, req: RequestWithLogin, res: Response, private async _runSql(
options: Types.SqlPost) { activeDoc: ActiveDoc,
req: RequestWithLogin,
res: Response,
options: Types.SqlPost
) {
const docId = getDocId(req);
if (!await activeDoc.canCopyEverything(docSessionFromRequest(req))) { if (!await activeDoc.canCopyEverything(docSessionFromRequest(req))) {
throw new ApiError('insufficient document access', 403); throw new ApiError('insufficient document access', 403);
} }
@ -2071,7 +2093,7 @@ export class DocWorkerApi {
try { try {
const records = await activeDoc.docStorage.all(wrappedStatement, const records = await activeDoc.docStorage.all(wrappedStatement,
...(options.args || [])); ...(options.args || []));
this._logRunSQLQueryEvents(req, options); this._logRunSQLQueryEvents(req, {docId, ...options});
res.status(200).json({ res.status(200).json({
statement, statement,
records: records.map( records: records.map(
@ -2124,13 +2146,6 @@ export class DocWorkerApi {
}, },
}); });
this._grist.getTelemetry().logEvent(mreq, 'createdDoc-Empty', { this._grist.getTelemetry().logEvent(mreq, 'createdDoc-Empty', {
limited: {
docIdDigest: id,
sourceDocIdDigest: undefined,
isImport: false,
fileType: undefined,
isSaved: workspaceId !== undefined,
},
full: { full: {
docIdDigest: id, docIdDigest: id,
userId: mreq.userId, userId: mreq.userId,
@ -2179,17 +2194,64 @@ export class DocWorkerApi {
}); });
} }
private async _logDuplicateDocumentEvents( private _logReplaceDocumentEvents(req: RequestWithLogin, options: {
req: RequestWithLogin, previous: {id: string};
originalDocument: {id: string}, current: {id: string; snapshotId?: string};
newDocument: {id: string; name: string} }) {
) { const {previous, current} = options;
const document = await this._dbManager.getRawDocById(originalDocument.id); this._grist.getAuditLogger().logEvent(req, {
const isTemplateCopy = document.type === 'template'; 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) { if (isTemplateCopy) {
this._grist.getTelemetry().logEvent(req, 'copiedTemplate', { this._grist.getTelemetry().logEvent(req, 'copiedTemplate', {
full: { full: {
templateId: parseUrlId(document.urlId || document.id).trunkId, templateId: parseUrlId(originalDocument.urlId || originalDocument.id).trunkId,
userId: req.userId, userId: req.userId,
altSessionId: req.altSessionId, altSessionId: req.altSessionId,
}, },
@ -2200,7 +2262,7 @@ export class DocWorkerApi {
`createdDoc-${isTemplateCopy ? 'CopyTemplate' : 'CopyDoc'}`, `createdDoc-${isTemplateCopy ? 'CopyTemplate' : 'CopyDoc'}`,
{ {
full: { full: {
docIdDigest: newDocument.id, docIdDigest: duplicateDocument.id,
userId: req.userId, userId: req.userId,
altSessionId: req.altSessionId, 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, 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, { this._grist.getAuditLogger().logEvent(req, {
event: { event: {
name: 'runSQLQuery', name: 'runSQLQuery',
details: {query, arguments: args, timeout}, details: {query, arguments: args, timeoutMs},
context: {documentId: getDocId(req)}, context: {documentId},
}, },
}); });
} }

View File

@ -1492,7 +1492,7 @@ export class FlexServer implements GristServer {
// to other (not public) team sites. // to other (not public) team sites.
const doom = await createDoom(); const doom = await createDoom();
await doom.deleteUser(userId); await doom.deleteUser(userId);
this.getTelemetry().logEvent(req as RequestWithLogin, 'deletedAccount'); this._logDeleteUserEvents(req as RequestWithLogin);
return resp.status(200).json(true); 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. // Reuse Doom cli tool for org deletion. Note, this removes everything as a super user.
const deletedOrg = structuredClone(org);
const doom = await createDoom(); const doom = await createDoom();
await doom.deleteOrg(org.id); await doom.deleteOrg(org.id);
this._logDeleteSiteEvents(mreq, deletedOrg);
this.getTelemetry().logEvent(req as RequestWithLogin, 'deletedSite', {
full: {
siteId: org.id,
userId: mreq.userId,
},
});
return resp.status(200).send(); return resp.status(200).send();
})); }));
} }
@ -2548,6 +2542,30 @@ export class FlexServer implements GristServer {
return isGristLogHttpEnabled || deprecatedOptionEnablesLog; 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,
},
});
}
} }
/** /**

View File

@ -3,7 +3,7 @@ import {ActiveDoc} from 'app/server/lib/ActiveDoc';
import {RequestWithLogin} from 'app/server/lib/Authorizer'; import {RequestWithLogin} from 'app/server/lib/Authorizer';
import {streamXLSX} from 'app/server/lib/ExportXLSX'; import {streamXLSX} from 'app/server/lib/ExportXLSX';
import log from 'app/server/lib/log'; 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 {Request, Response} from 'express';
import {PassThrough, Stream} from 'stream'; 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"); throw new Error("No access token - Can't send file to Google Drive");
} }
const docId = getDocId(req);
const mreq = req as RequestWithLogin; const mreq = req as RequestWithLogin;
const meta = { const meta = {
docId: activeDoc.docName, docId: activeDoc.docName,
@ -39,6 +40,13 @@ export async function exportToDrive(
streamXLSX(activeDoc, req, stream, {tableId: ''}), streamXLSX(activeDoc, req, stream, {tableId: ''}),
sendFileToDrive(name, stream, access_token), 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); log.debug(`Export to drive - File exported, redirecting to Google Spreadsheet ${url}`, meta);
res.json({ url }); res.json({ url });
} catch (err) { } catch (err) {

View File

@ -691,17 +691,16 @@ export class DocTriggers {
if (this._loopAbort.signal.aborted) { if (this._loopAbort.signal.aborted) {
continue; continue;
} }
let meta: Record<string, any>|undefined; let meta: {webhookId: string; host: string, quantity: number} | undefined;
let success: boolean; let success: boolean;
if (!url) { if (!url) {
success = true; success = true;
} else { } else {
await this._stats.logStatus(id, 'sending'); 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._log("Sending batch of webhook events", meta);
this._activeDoc.logTelemetryEvent(null, 'sendingWebhooks', { this._activeDoc.logTelemetryEvent(null, 'sendingWebhooks', {
limited: {numEvents: meta.numEvents}, limited: {numEvents: meta.quantity},
}); });
success = await this._sendWebhookWithRetries( success = await this._sendWebhookWithRetries(
id, url, authorization, body, batch.length, this._loopAbort.signal); id, url, authorization, body, batch.length, this._loopAbort.signal);
@ -743,6 +742,14 @@ export class DocTriggers {
await this._stats.logStatus(id, 'idle'); await this._stats.logStatus(id, 'idle');
if (meta) { if (meta) {
this._log("Successfully sent batch of webhook events", 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'},
},
});
} }
} }

View 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: {},
},
};

View File

@ -90,7 +90,7 @@ describe('HomeDBManager', function() {
it('can add an org', async function() { it('can add an org', async function() {
const user = await home.getUserByLogin('chimpy@getgrist.com'); 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); const org = await home.getOrg({userId: user.id}, orgId);
assert.equal(org.data!.name, 'NewOrg'); assert.equal(org.data!.name, 'NewOrg');
assert.equal(org.data!.domain, 'novel-org'); assert.equal(org.data!.domain, 'novel-org');
@ -109,7 +109,7 @@ describe('HomeDBManager', function() {
useNewPlan: true, useNewPlan: true,
// omit plan, to use a default one (teamInitial) // omit plan, to use a default one (teamInitial)
// it will either be 'stub' or anything set in GRIST_DEFAULT_PRODUCT // it will either be 'stub' or anything set in GRIST_DEFAULT_PRODUCT
})).data!; })).data!.id;
let org = await home.getOrg({userId: user.id}, orgId); let org = await home.getOrg({userId: user.id}, orgId);
assert.equal(org.data!.name, 'NewOrg'); assert.equal(org.data!.name, 'NewOrg');
assert.equal(org.data!.domain, 'novel-org'); assert.equal(org.data!.domain, 'novel-org');
@ -121,7 +121,7 @@ describe('HomeDBManager', function() {
orgId = (await home.addOrg(user, {name: 'NewOrg', domain: 'novel-org'}, { orgId = (await home.addOrg(user, {name: 'NewOrg', domain: 'novel-org'}, {
setUserAsOwner: false, setUserAsOwner: false,
useNewPlan: true, useNewPlan: true,
})).data!; })).data!.id;
org = await home.getOrg({userId: user.id}, orgId); org = await home.getOrg({userId: user.id}, orgId);
assert.equal(org.data!.billingAccount.product.name, STUB_PLAN); 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 user = await home.getUserByLogin('chimpy@getgrist.com');
const domain = 'repeated-domain'; const domain = 'repeated-domain';
const result = await home.addOrg(user, {name: `${domain}!`, domain}, teamOptions); const result = await home.addOrg(user, {name: `${domain}!`, domain}, teamOptions);
const orgId = result.data!; const orgId = result.data!.id;
assert.equal(result.status, 200); assert.equal(result.status, 200);
await assert.isRejected(home.addOrg(user, {name: `${domain}!`, domain}, teamOptions), await assert.isRejected(home.addOrg(user, {name: `${domain}!`, domain}, teamOptions),
/Domain already in use/); /Domain already in use/);

View File

@ -45,7 +45,7 @@ describe('fixSiteProducts', function() {
const productOrg = (id: number) => getOrg(id)?.then(org => org?.billingAccount?.product?.name); 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, name: org,
domain: org, domain: org,
}, { }, {
@ -54,7 +54,7 @@ describe('fixSiteProducts', function() {
product: 'teamFree', product: 'teamFree',
})); }));
const teamOrgId = db.unwrapQueryResult(await db.addOrg(user, { const teamOrg = db.unwrapQueryResult(await db.addOrg(user, {
name: 'fix-team-org', name: 'fix-team-org',
domain: 'fix-team-org', domain: 'fix-team-org',
}, { }, {
@ -64,7 +64,7 @@ describe('fixSiteProducts', function() {
})); }));
// Make sure it is created with teamFree product. // Make sure it is created with teamFree product.
assert.equal(await productOrg(freeOrgId), 'teamFree'); assert.equal(await productOrg(freeOrg.id), 'teamFree');
// Run the fixer. // Run the fixer.
assert.isTrue(await fixSiteProducts({ assert.isTrue(await fixSiteProducts({
@ -73,10 +73,10 @@ describe('fixSiteProducts', function() {
})); }));
// Make sure we fixed the product is on Free product. // 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. // 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() { it("doesn't run when on saas deployment", async function() {
@ -123,7 +123,7 @@ describe('fixSiteProducts', function() {
const db = server.dbManager; const db = server.dbManager;
const user = await db.getUserByLogin(email, {profile}); 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', name: 'sanity-check-org',
domain: 'sanity-check-org', domain: 'sanity-check-org',
}, { }, {
@ -135,12 +135,12 @@ describe('fixSiteProducts', function() {
const getOrg = (id: number) => db.connection.manager.findOne(Organization, const getOrg = (id: number) => db.connection.manager.findOne(Organization,
{where: {id}, relations: ['billingAccount', 'billingAccount.product']}); {where: {id}, relations: ['billingAccount', 'billingAccount.product']});
const productOrg = (id: number) => getOrg(id)?.then(org => org?.billingAccount?.product?.name); 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({ assert.isFalse(await fixSiteProducts({
db: server.dbManager, db: server.dbManager,
deploymentType: server.server.getDeploymentType(), deploymentType: server.server.getDeploymentType(),
})); }));
assert.equal(await productOrg(orgId), 'teamFree'); assert.equal(await productOrg(org.id), 'teamFree');
}); });
}); });