(core) Add methods for quarantining documents

Summary:
Adds a new CLI command, doc, with a subcommand that quarantines
an active document. Adds a group query param to a housekeeping
endpoint for updating the document group prior to checking if a doc
needs to be reassigned. Both methods require support user credentials.

Test Plan: Server tests. (Additional testing will be done manually on staging.)

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3570
This commit is contained in:
George Gevoian 2022-08-09 08:50:18 -07:00
parent ee109e9186
commit fbba6b8f52
4 changed files with 34 additions and 11 deletions

View File

@ -137,6 +137,10 @@ class DummyDocWorkerMap implements IDocWorkerMap {
return null; return null;
} }
public async updateDocGroup(docId: string, docGroup: string): Promise<void> {
// nothing to do
}
public getRedisClient() { public getRedisClient() {
return null; return null;
} }
@ -517,6 +521,10 @@ export class DocWorkerMap implements IDocWorkerMap {
return this._client.getAsync(`doc-${docId}-group`); return this._client.getAsync(`doc-${docId}-group`);
} }
public async updateDocGroup(docId: string, docGroup: string): Promise<void> {
await this._client.setAsync(`doc-${docId}-group`, docGroup);
}
public getRedisClient(): RedisClient { public getRedisClient(): RedisClient {
return this._client; return this._client;
} }

View File

@ -9,7 +9,7 @@ import { GristServer } from 'app/server/lib/GristServer';
import { IElectionStore } from 'app/server/lib/IElectionStore'; import { IElectionStore } from 'app/server/lib/IElectionStore';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import { IPermitStore } from 'app/server/lib/Permit'; import { IPermitStore } from 'app/server/lib/Permit';
import { stringParam } from 'app/server/lib/requestUtils'; import { optStringParam, stringParam } from 'app/server/lib/requestUtils';
import * as express from 'express'; import * as express from 'express';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import * as Fetch from 'node-fetch'; import * as Fetch from 'node-fetch';
@ -130,7 +130,7 @@ export class Housekeeper {
// Remove unlisted snapshots that are not recorded in inventory. // Remove unlisted snapshots that are not recorded in inventory.
// Once all such snapshots have been removed, there should be no // Once all such snapshots have been removed, there should be no
// further need for this endpoint. // further need for this endpoint.
app.post('/api/housekeeping/docs/:docId/snapshots/clean', this._withSupport(async (docId, headers) => { app.post('/api/housekeeping/docs/:docId/snapshots/clean', this._withSupport(async (_req, docId, headers) => {
const url = await this._server.getHomeUrlByDocId(docId, `/api/docs/${docId}/snapshots/remove`); const url = await this._server.getHomeUrlByDocId(docId, `/api/docs/${docId}/snapshots/remove`);
return fetch(url, { return fetch(url, {
method: 'POST', method: 'POST',
@ -143,7 +143,7 @@ export class Housekeeper {
// use, for allowing support to help users looking to purge some // use, for allowing support to help users looking to purge some
// information that leaked into document history that they'd // information that leaked into document history that they'd
// prefer not be there, until there's an alternative. // prefer not be there, until there's an alternative.
app.post('/api/housekeeping/docs/:docId/states/remove', this._withSupport(async (docId, headers) => { app.post('/api/housekeeping/docs/:docId/states/remove', this._withSupport(async (_req, docId, headers) => {
const url = await this._server.getHomeUrlByDocId(docId, `/api/docs/${docId}/states/remove`); const url = await this._server.getHomeUrlByDocId(docId, `/api/docs/${docId}/states/remove`);
return fetch(url, { return fetch(url, {
method: 'POST', method: 'POST',
@ -154,7 +154,7 @@ export class Housekeeper {
// Force a document to reload. Can be useful during administrative // Force a document to reload. Can be useful during administrative
// actions. // actions.
app.post('/api/housekeeping/docs/:docId/force-reload', this._withSupport(async (docId, headers) => { app.post('/api/housekeeping/docs/:docId/force-reload', this._withSupport(async (_req, docId, headers) => {
const url = await this._server.getHomeUrlByDocId(docId, `/api/docs/${docId}/force-reload`); const url = await this._server.getHomeUrlByDocId(docId, `/api/docs/${docId}/force-reload`);
return fetch(url, { return fetch(url, {
method: 'POST', method: 'POST',
@ -164,13 +164,19 @@ export class Housekeeper {
// Move a document to its assigned worker. Can be useful during administrative // Move a document to its assigned worker. Can be useful during administrative
// actions. // actions.
app.post('/api/housekeeping/docs/:docId/assign', this._withSupport(async (docId, headers) => { //
const url = await this._server.getHomeUrlByDocId(docId, `/api/docs/${docId}/assign`); // Optionally accepts a `group` query param for updating the document's group prior
return fetch(url, { // to moving. This is useful for controlling which worker group the document is assigned
// a worker from.
app.post('/api/housekeeping/docs/:docId/assign', this._withSupport(async (req, docId, headers) => {
const url = new URL(await this._server.getHomeUrlByDocId(docId, `/api/docs/${docId}/assign`));
const group = optStringParam(req.query.group);
if (group) { url.searchParams.set('group', group); }
return fetch(url.toString(), {
method: 'POST', method: 'POST',
headers, headers,
}); });
})); }, 'assign-doc'));
} }
/** /**
@ -221,7 +227,8 @@ export class Housekeeper {
// Call a document endpoint with a permit, cleaning up after the call. // Call a document endpoint with a permit, cleaning up after the call.
// Checks that the user is the support user. // Checks that the user is the support user.
private _withSupport( private _withSupport(
callback: (docId: string, headers: Record<string, string>) => Promise<Fetch.Response> callback: (req: express.Request, docId: string, headers: Record<string, string>) => Promise<Fetch.Response>,
permitAction?: string,
): express.RequestHandler { ): express.RequestHandler {
return expressWrap(async (req, res) => { return expressWrap(async (req, res) => {
const userId = getAuthorizedUserId(req); const userId = getAuthorizedUserId(req);
@ -229,9 +236,9 @@ export class Housekeeper {
throw new ApiError('access denied', 403); throw new ApiError('access denied', 403);
} }
const docId = stringParam(req.params.docId, 'docId'); const docId = stringParam(req.params.docId, 'docId');
const permitKey = await this._permitStore.setPermit({docId}); const permitKey = await this._permitStore.setPermit({docId, action: permitAction});
try { try {
const result = await callback(docId, { const result = await callback(req, docId, {
Permit: permitKey, Permit: permitKey,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}); });

View File

@ -599,8 +599,15 @@ export class DocWorkerApi {
// and frees it for reassignment if not. Has no effect if document is in the // and frees it for reassignment if not. Has no effect if document is in the
// expected group. Does not require specific rights. Returns true if the document // expected group. Does not require specific rights. Returns true if the document
// is freed up for reassignment, otherwise false. // is freed up for reassignment, otherwise false.
//
// Optionally accepts a `group` query param for updating the document's group prior
// to (possible) reassignment. (Note: Requires special permit.)
this._app.post('/api/docs/:docId/assign', canEdit, throttled(async (req, res) => { this._app.post('/api/docs/:docId/assign', canEdit, throttled(async (req, res) => {
const docId = getDocId(req); const docId = getDocId(req);
const group = optStringParam(req.query.group);
if (group && req.specialPermit?.action === 'assign-doc') {
await this._docWorkerMap.updateDocGroup(docId, group);
}
const status = await this._docWorkerMap.getDocWorker(docId); const status = await this._docWorkerMap.getDocWorker(docId);
if (!status) { res.json(false); return; } if (!status) { res.json(false); return; }
const workerGroup = await this._docWorkerMap.getWorkerGroup(status.docWorker.id); const workerGroup = await this._docWorkerMap.getWorkerGroup(status.docWorker.id);

View File

@ -67,6 +67,7 @@ export interface IDocWorkerMap extends IPermitStores, IElectionStore, IChecksumS
getWorkerGroup(workerId: string): Promise<string|null>; getWorkerGroup(workerId: string): Promise<string|null>;
getDocGroup(docId: string): Promise<string|null>; getDocGroup(docId: string): Promise<string|null>;
updateDocGroup(docId: string, docGroup: string): Promise<void>;
getRedisClient(): RedisClient|null; getRedisClient(): RedisClient|null;
} }