mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-29 13:00:27 +00:00
* docs: PR5 GDPR author erasure design spec * docs: PR5 GDPR author erasure implementation plan * feat(gdpr): AuthorManager.anonymizeAuthor — Art. 17 erasure * test(gdpr): AuthorManager.anonymizeAuthor unit tests * feat(gdpr): REST anonymizeAuthor on API version 1.3.1 * test(gdpr): REST anonymizeAuthor end-to-end * docs(gdpr): right-to-erasure section + anonymizeAuthor example * fix(gdpr): make anonymizeAuthor resumable on partial failure Qodo review: the `erased: true` sentinel was written before the chat scrub loop, so a throw during scrub left chat messages untouched while subsequent calls short-circuited on `existing.erased` and never finished. Split the write: zero the display identity first (still hides the name), run the chat scrub, and only then stamp `erased: true` so a retry resumes the sweep. Regression test covers the partial-run → retry path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
487842006c
commit
69bb1e19c5
11 changed files with 1093 additions and 6 deletions
|
|
@ -63,6 +63,26 @@ exports.listAllPads = padManager.listAllPads;
|
|||
exports.createAuthor = authorManager.createAuthor;
|
||||
exports.createAuthorIfNotExistsFor = authorManager.createAuthorIfNotExistsFor;
|
||||
exports.getAuthorName = authorManager.getAuthorName;
|
||||
|
||||
/**
|
||||
* anonymizeAuthor(authorID) — GDPR Art. 17 erasure. See doc/privacy.md.
|
||||
*
|
||||
* Returns counters describing what was touched:
|
||||
* {affectedPads, removedTokenMappings, removedExternalMappings,
|
||||
* clearedChatMessages}.
|
||||
*/
|
||||
exports.anonymizeAuthor = async (authorID: string) => {
|
||||
if (!settings.gdprAuthorErasure || !settings.gdprAuthorErasure.enabled) {
|
||||
throw new CustomError(
|
||||
'anonymizeAuthor is disabled — set gdprAuthorErasure.enabled = true ' +
|
||||
'in settings.json to enable GDPR Art. 17 erasure',
|
||||
'apierror');
|
||||
}
|
||||
if (!authorID || typeof authorID !== 'string') {
|
||||
throw new CustomError('authorID is required', 'apierror');
|
||||
}
|
||||
return await authorManager.anonymizeAuthor(authorID);
|
||||
};
|
||||
exports.listPadsOfAuthor = authorManager.listPadsOfAuthor;
|
||||
exports.padUsers = padMessageHandler.padUsers;
|
||||
exports.padUsersCount = padMessageHandler.padUsersCount;
|
||||
|
|
|
|||
|
|
@ -313,3 +313,105 @@ exports.removePad = async (authorID: string, padID: string) => {
|
|||
await db.set(`globalAuthor:${authorID}`, author);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GDPR Art. 17: anonymise an author. Zeroes the display identity on
|
||||
* `globalAuthor:<authorID>`, deletes the token/mapper bindings that link a
|
||||
* person to this authorID, and nulls authorship on chat messages they
|
||||
* posted. Leaves pad content, revisions, and attribute pools intact —
|
||||
* changeset references are opaque without the identity record, so the
|
||||
* link to the real person is severed even though the bytes survive.
|
||||
*
|
||||
* Idempotent: once `erased: true` is set on the author record, subsequent
|
||||
* calls short-circuit and return zero counters.
|
||||
*/
|
||||
exports.anonymizeAuthor = async (authorID: string): Promise<{
|
||||
affectedPads: number,
|
||||
removedTokenMappings: number,
|
||||
removedExternalMappings: number,
|
||||
clearedChatMessages: number,
|
||||
}> => {
|
||||
// Lazy-require to dodge the AuthorManager ↔ PadManager ↔ Pad cycle.
|
||||
const padManager = require('./PadManager');
|
||||
const existing = await db.get(`globalAuthor:${authorID}`);
|
||||
if (existing == null || existing.erased) {
|
||||
return {
|
||||
affectedPads: 0,
|
||||
removedTokenMappings: 0,
|
||||
removedExternalMappings: 0,
|
||||
clearedChatMessages: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Drop the token/mapper mappings first, before touching anything else, so
|
||||
// a concurrent getAuthorId() can no longer resolve this author through
|
||||
// its old bindings mid-erasure. These operations are independently
|
||||
// idempotent — rerunning a failed call later still produces the same
|
||||
// final state, just with zero counters for anything already done.
|
||||
let removedTokenMappings = 0;
|
||||
const tokenKeys: string[] = await db.findKeys('token2author:*', null);
|
||||
for (const key of tokenKeys) {
|
||||
if (await db.get(key) === authorID) {
|
||||
await db.remove(key);
|
||||
removedTokenMappings++;
|
||||
}
|
||||
}
|
||||
let removedExternalMappings = 0;
|
||||
const mapperKeys: string[] = await db.findKeys('mapper2author:*', null);
|
||||
for (const key of mapperKeys) {
|
||||
if (await db.get(key) === authorID) {
|
||||
await db.remove(key);
|
||||
removedExternalMappings++;
|
||||
}
|
||||
}
|
||||
|
||||
// Zero the display identity now — without the `erased` sentinel — so a
|
||||
// partial run still hides the name. The sentinel itself is only set at
|
||||
// the end (below) so a failure in chat scrub lets the next call resume.
|
||||
await db.set(`globalAuthor:${authorID}`, {
|
||||
colorId: 0,
|
||||
name: null,
|
||||
timestamp: Date.now(),
|
||||
padIDs: existing.padIDs || {},
|
||||
});
|
||||
|
||||
// Null authorship on chat messages the author posted. If this throws
|
||||
// partway through, the function re-runs the loop on the next call
|
||||
// because `erased: true` is not set yet.
|
||||
const padIDs = Object.keys(existing.padIDs || {});
|
||||
let clearedChatMessages = 0;
|
||||
for (const padID of padIDs) {
|
||||
if (!await padManager.doesPadExist(padID)) continue;
|
||||
const pad = await padManager.getPad(padID);
|
||||
const chatHead = pad.chatHead;
|
||||
if (typeof chatHead !== 'number' || chatHead < 0) continue;
|
||||
for (let i = 0; i <= chatHead; i++) {
|
||||
const chatKey = `pad:${padID}:chat:${i}`;
|
||||
const msg = await db.get(chatKey);
|
||||
if (msg != null && msg.authorId === authorID) {
|
||||
msg.authorId = null;
|
||||
await db.set(chatKey, msg);
|
||||
clearedChatMessages++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Everything succeeded — stamp the sentinel so subsequent calls
|
||||
// short-circuit. Merge with the zeroed record we just wrote so padIDs
|
||||
// and timestamp persist.
|
||||
await db.set(`globalAuthor:${authorID}`, {
|
||||
colorId: 0,
|
||||
name: null,
|
||||
timestamp: Date.now(),
|
||||
padIDs: existing.padIDs || {},
|
||||
erased: true,
|
||||
erasedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
affectedPads: padIDs.length,
|
||||
removedTokenMappings,
|
||||
removedExternalMappings,
|
||||
clearedChatMessages,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -145,9 +145,9 @@ version['1.3.0'] = {
|
|||
version['1.3.1'] = {
|
||||
...version['1.3.0'],
|
||||
compactPad: ['padID', 'keepRevisions'],
|
||||
anonymizeAuthor: ['authorID'],
|
||||
};
|
||||
|
||||
|
||||
// set the latest available API version here
|
||||
exports.latestApiVersion = '1.3.1';
|
||||
|
||||
|
|
|
|||
|
|
@ -286,6 +286,9 @@ export type SettingsType = {
|
|||
enabled: boolean,
|
||||
keepRevisions: number,
|
||||
},
|
||||
gdprAuthorErasure: {
|
||||
enabled: boolean,
|
||||
},
|
||||
scrollWhenFocusLineIsOutOfViewport: {
|
||||
percentage: {
|
||||
editionAboveViewport: number,
|
||||
|
|
@ -639,6 +642,13 @@ const settings: SettingsType = {
|
|||
enabled: false,
|
||||
keepRevisions: 100,
|
||||
},
|
||||
/*
|
||||
* GDPR Art. 17 author erasure REST endpoint (anonymizeAuthor).
|
||||
* Disabled by default; operators must opt in.
|
||||
*/
|
||||
gdprAuthorErasure: {
|
||||
enabled: false,
|
||||
},
|
||||
/*
|
||||
* By default, when caret is moved out of viewport, it scrolls the minimum
|
||||
* height needed to make this line visible.
|
||||
|
|
|
|||
93
src/tests/backend/specs/anonymizeAuthor.ts
Normal file
93
src/tests/backend/specs/anonymizeAuthor.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
'use strict';
|
||||
|
||||
import {strict as assert} from 'assert';
|
||||
|
||||
const common = require('../common');
|
||||
const authorManager = require('../../../node/db/AuthorManager');
|
||||
const DB = require('../../../node/db/DB');
|
||||
|
||||
describe(__filename, function () {
|
||||
before(async function () {
|
||||
this.timeout(60000);
|
||||
await common.init();
|
||||
});
|
||||
|
||||
it('zeroes the display identity on globalAuthor:<id>', async function () {
|
||||
const mapper = `mapper-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const {authorID} = await authorManager.createAuthorIfNotExistsFor(mapper, 'Alice');
|
||||
assert.equal(await authorManager.getAuthorName(authorID), 'Alice');
|
||||
|
||||
const res = await authorManager.anonymizeAuthor(authorID);
|
||||
assert.ok(res.removedExternalMappings >= 1,
|
||||
`removedExternalMappings=${res.removedExternalMappings}`);
|
||||
|
||||
const record = await DB.db.get(`globalAuthor:${authorID}`);
|
||||
assert.equal(record.name, null);
|
||||
assert.equal(record.colorId, 0);
|
||||
assert.equal(record.erased, true);
|
||||
assert.ok(typeof record.erasedAt === 'string');
|
||||
});
|
||||
|
||||
it('drops token2author and mapper2author mappings pointing at the author',
|
||||
async function () {
|
||||
const mapper = `mapper-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const {authorID} = await authorManager.createAuthorIfNotExistsFor(mapper, 'Bob');
|
||||
const token =
|
||||
`t.${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||
// Seed a token2author:<token> → authorID mapping directly so the test
|
||||
// does not depend on getAuthorId creating a fresh author.
|
||||
await DB.db.set(`token2author:${token}`, authorID);
|
||||
|
||||
assert.equal(await DB.db.get(`token2author:${token}`), authorID);
|
||||
assert.equal(await DB.db.get(`mapper2author:${mapper}`), authorID);
|
||||
|
||||
const res = await authorManager.anonymizeAuthor(authorID);
|
||||
assert.ok(res.removedTokenMappings >= 1,
|
||||
`removedTokenMappings=${res.removedTokenMappings}`);
|
||||
assert.ok(res.removedExternalMappings >= 1);
|
||||
assert.ok((await DB.db.get(`token2author:${token}`)) == null);
|
||||
assert.ok((await DB.db.get(`mapper2author:${mapper}`)) == null);
|
||||
});
|
||||
|
||||
it('is idempotent — second call returns zero counters', async function () {
|
||||
const mapper = `mapper-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const {authorID} = await authorManager.createAuthorIfNotExistsFor(mapper, 'Carol');
|
||||
await authorManager.anonymizeAuthor(authorID);
|
||||
const second = await authorManager.anonymizeAuthor(authorID);
|
||||
assert.deepEqual(second, {
|
||||
affectedPads: 0,
|
||||
removedTokenMappings: 0,
|
||||
removedExternalMappings: 0,
|
||||
clearedChatMessages: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zero counters for an unknown authorID', async function () {
|
||||
const res = await authorManager.anonymizeAuthor('a.does-not-exist');
|
||||
assert.deepEqual(res, {
|
||||
affectedPads: 0,
|
||||
removedTokenMappings: 0,
|
||||
removedExternalMappings: 0,
|
||||
clearedChatMessages: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('re-runs the sweep when a prior call errored before setting erased=true',
|
||||
async function () {
|
||||
const mapper = `mapper-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const {authorID} = await authorManager.createAuthorIfNotExistsFor(mapper, 'Dan');
|
||||
|
||||
// Simulate a partial run: zero the display identity but leave
|
||||
// erased=false, matching a crash between the two writes.
|
||||
const partial = await DB.db.get(`globalAuthor:${authorID}`);
|
||||
partial.name = null;
|
||||
partial.colorId = 0;
|
||||
await DB.db.set(`globalAuthor:${authorID}`, partial);
|
||||
|
||||
const res = await authorManager.anonymizeAuthor(authorID);
|
||||
assert.equal(res.removedExternalMappings >= 1, true,
|
||||
`retry must still clean mapper2author; got ${res.removedExternalMappings}`);
|
||||
const record = await DB.db.get(`globalAuthor:${authorID}`);
|
||||
assert.equal(record.erased, true);
|
||||
});
|
||||
});
|
||||
73
src/tests/backend/specs/api/anonymizeAuthor.ts
Normal file
73
src/tests/backend/specs/api/anonymizeAuthor.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
'use strict';
|
||||
|
||||
import {strict as assert} from 'assert';
|
||||
|
||||
const common = require('../../common');
|
||||
const settings = require('../../../../node/utils/Settings');
|
||||
|
||||
let agent: any;
|
||||
let apiVersion = 1;
|
||||
const endPoint = (point: string) => `/api/${apiVersion}/${point}`;
|
||||
|
||||
const callApi = async (point: string, query: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams(query).toString();
|
||||
const path = qs ? `${endPoint(point)}?${qs}` : endPoint(point);
|
||||
return await agent.get(path)
|
||||
.set('authorization', await common.generateJWTToken())
|
||||
.expect(200)
|
||||
.expect('Content-Type', /json/);
|
||||
};
|
||||
|
||||
describe(__filename, function () {
|
||||
let originalErasureFlag: boolean | undefined;
|
||||
|
||||
before(async function () {
|
||||
this.timeout(60000);
|
||||
agent = await common.init();
|
||||
const res = await agent.get('/api/').expect(200);
|
||||
apiVersion = res.body.currentVersion;
|
||||
settings.gdprAuthorErasure = settings.gdprAuthorErasure || {enabled: false};
|
||||
originalErasureFlag = settings.gdprAuthorErasure.enabled;
|
||||
settings.gdprAuthorErasure.enabled = true;
|
||||
});
|
||||
|
||||
after(function () {
|
||||
settings.gdprAuthorErasure.enabled = originalErasureFlag;
|
||||
});
|
||||
|
||||
it('anonymizeAuthor zeroes the author and returns counters', async function () {
|
||||
const create = await callApi('createAuthor', {name: 'Alice'});
|
||||
assert.equal(create.body.code, 0);
|
||||
const authorID = create.body.data.authorID;
|
||||
|
||||
const res = await callApi('anonymizeAuthor', {authorID});
|
||||
assert.equal(res.body.code, 0, JSON.stringify(res.body));
|
||||
assert.ok(res.body.data.affectedPads >= 0);
|
||||
|
||||
const name = await callApi('getAuthorName', {authorID});
|
||||
// getAuthorName returns the raw string/null directly in `data`.
|
||||
// Post-erasure, the name is null.
|
||||
assert.equal(name.body.data, null);
|
||||
});
|
||||
|
||||
it('anonymizeAuthor with missing authorID returns an error', async function () {
|
||||
const res = await agent.get(`${endPoint('anonymizeAuthor')}?authorID=`)
|
||||
.set('authorization', await common.generateJWTToken())
|
||||
.expect(200)
|
||||
.expect('Content-Type', /json/);
|
||||
assert.equal(res.body.code, 1);
|
||||
assert.match(res.body.message, /authorID is required/);
|
||||
});
|
||||
|
||||
it('anonymizeAuthor returns an apierror when gdprAuthorErasure is disabled',
|
||||
async function () {
|
||||
settings.gdprAuthorErasure.enabled = false;
|
||||
try {
|
||||
const res = await callApi('anonymizeAuthor', {authorID: 'a.dummy'});
|
||||
assert.equal(res.body.code, 1);
|
||||
assert.match(res.body.message, /gdprAuthorErasure\.enabled/);
|
||||
} finally {
|
||||
settings.gdprAuthorErasure.enabled = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue