feat(gdpr): author erasure (PR5 of #6701) (#7550)

* 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:
John McLear 2026-05-03 19:30:49 +08:00 committed by GitHub
parent 487842006c
commit 69bb1e19c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1093 additions and 6 deletions

View file

@ -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;

View file

@ -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,
};
};

View file

@ -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';

View file

@ -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.

View 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);
});
});

View 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;
}
});
});