mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-18 09:04:54 +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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue