fix: recover legacy repository owners through GitHub (#799)

This commit is contained in:
Thomas Durieux
2026-09-08 13:39:39 +02:00
committed by GitHub
parent 3fb376dd53
commit 8de4a536cc
5 changed files with 327 additions and 1 deletions
+47
View File
@@ -278,3 +278,50 @@ points elsewhere, back up that database instead. The host needs Python 3 and GPG
The one-off invocation follows Docker's [Compose run documentation](https://docs.docker.com/reference/cli/docker/compose/run/).
The backup password handling uses MongoDB's [mongodump configuration-file support](https://www.mongodb.com/docs/database-tools/mongodump/).
## Recover missing repository owners using GitHub
For `missing_owner` repositories that still have a valid token, the recovery script
calls GitHub's [authenticated-user endpoint](https://docs.github.com/en/rest/users/users#get-the-authenticated-user)
and matches the returned ID against `users.externalIDs.github`. It assigns the
matching database user's `_id` to the repository's `owner`. This grants that user
management access to the repository, based on the identity of the stored token.
It does not create users or change tokens, and it never matches by username.
Build the updated image first, while keeping production writers stopped:
```bash
docker compose build anonymous_github
```
Preview matches for one document from the migration report:
```bash
docker compose run --rm --no-deps -T --entrypoint node anonymous_github \
build/scripts/recover-repository-owners.js --id=6136d362bf270d7f1688cd59
```
Omit `--id` to preview all repositories. Apply automatic matches with:
```bash
docker compose run --rm --no-deps -T --entrypoint node anonymous_github \
build/scripts/recover-repository-owners.js --apply --maintenance
```
The script skips existing owners, handles dangling owner references, and requires
exactly one matching, non-disabled database user. Duplicate user matches, revoked
tokens, and missing users remain unresolved. If the two legacy token locations
identify different accounts, it leaves the repository untouched. Conditional
updates avoid overwriting a repository whose owner or tokens changed after it was
read. Keep application writers stopped for apply runs.
Requests are sequential and spaced one second apart, with a 15-second timeout.
Repeated tokens share a bounded in-memory lookup cache. HTTP 403/429 responses,
other unexpected HTTP errors, and network failures stop the scan; fix the issue or
wait for GitHub's limit to reset, then rerun. Existing assignments are skipped on
reruns. Reports contain record IDs, matched GitHub/user IDs, actions, and issue
codes, never tokens or raw GitHub responses. A nonzero exit status means unresolved
records remain or the scan halted; successful assignments are retained.
After recovery, rerun the credential migration with `--prefer-owner-token`.
Recovery leaves legacy tokens in place so the migration can still encrypt them.
+2 -1
View File
@@ -15,7 +15,8 @@
"dev:ui": "node scripts/dev-proxy.js",
"build": "rm -rf build && tsc && gulp",
"knip": "knip",
"migrate:credentials": "node -r ts-node/register src/scripts/migrate-credentials.ts"
"migrate:credentials": "node -r ts-node/register src/scripts/migrate-credentials.ts",
"recover:owners": "node -r ts-node/register src/scripts/recover-repository-owners.ts"
},
"repository": {
"type": "git",
+119
View File
@@ -0,0 +1,119 @@
import { createHash } from "crypto";
import { mongo } from "mongoose";
export type IdentityResult = { githubId: string } | { issue: string; halt?: boolean };
/** Only this fixed endpoint receives legacy tokens. Never log request errors or bodies. */
export async function identifyGitHubToken(token: string): Promise<IdentityResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "anonymous-github-owner-recovery",
},
redirect: "error",
signal: controller.signal,
});
if (!response.ok) {
if (response.status === 401) return { issue: "invalid_or_revoked_token" };
// Stop on forbidden/rate-limited or transient failures instead of hammering GitHub.
return { issue: `github_http_${response.status}`, halt: true };
}
const body = await response.json() as { id?: unknown; type?: unknown };
if (body.type !== "User" || typeof body.id !== "number" ||
!Number.isSafeInteger(body.id) || body.id <= 0) {
return { issue: "unsupported_github_identity" };
}
return { githubId: String(body.id) };
} catch {
return { issue: "github_request_failed", halt: true };
} finally {
clearTimeout(timeout);
}
}
export interface RecoveryOptions {
apply?: boolean;
repositoryId?: mongo.ObjectId;
identify?: (token: string) => Promise<IdentityResult>;
pause?: () => Promise<void>;
report?: (event: { collection: string; id: string; issue?: string;
ownerId?: string; githubId?: string; action?: string }) => void;
}
export async function recoverRepositoryOwners(db: mongo.Db, options: RecoveryOptions = {}) {
const repositories = db.collection("anonymizedrepositories");
const users = db.collection("users");
const identify = options.identify || identifyGitHubToken;
const pause = options.pause || (() => new Promise<void>(resolve => setTimeout(resolve, 1000)));
// Hashes deduplicate HTTP requests without retaining plaintext as cache keys.
const identities = new Map<string, IdentityResult>();
const counts = { scanned: 0, candidates: 0, matched: 0, updated: 0, issues: 0, halted: false };
const report = options.report || (() => {});
const query = options.repositoryId ? { _id: options.repositoryId } : {};
for await (const row of repositories.find(query, {
projection: { owner: 1, "source.accessToken": 1, accessToken: 1 },
}).batchSize(100)) {
counts.scanned++;
if (row.owner && await users.findOne({ _id: row.owner }, { projection: { _id: 1 } })) continue;
counts.candidates++;
const event = { collection: "anonymizedrepositories", id: String(row._id) };
const fail = (issue: string) => { counts.issues++; report({ ...event, issue }); };
const values: unknown[] = [row.source?.accessToken, row.accessToken];
if (values.some(value => value != null && typeof value !== "string")) {
fail("malformed_token"); continue;
}
const tokens = [...new Set(values.filter((value): value is string => typeof value === "string" && value.length > 0))];
if (!tokens.length) { fail("missing_token"); continue; }
const ids = new Set<string>();
let failed = false;
for (const token of tokens) {
const hash = createHash("sha256").update(token).digest("hex");
let identity = identities.get(hash);
if (!identity) {
await pause();
identity = await identify(token);
if (identities.size >= 10000) identities.clear();
identities.set(hash, identity);
}
if ("issue" in identity) {
fail(identity.issue);
counts.halted = !!identity.halt;
failed = true;
break;
}
ids.add(identity.githubId);
}
if (counts.halted) break;
if (failed) continue;
if (ids.size !== 1) { fail("conflicting_token_identities"); continue; }
const githubId = [...ids][0];
// Match immutable GitHub IDs only. Include historical numeric storage.
const matches = await users.find({ "externalIDs.github": { $in: [githubId, Number(githubId)] } }, {
projection: { _id: 1, status: 1 },
}).limit(2).toArray();
if (matches.length !== 1) {
fail(matches.length ? "ambiguous_user" : "user_not_found"); continue;
}
const user = matches[0];
if (user.status === "removed" || user.status === "banned") { fail("disabled_user"); continue; }
counts.matched++;
if (options.apply) {
const unchanged = (value: unknown) => value === undefined ? { $exists: false } : { $eq: value, $exists: true };
const result = await repositories.updateOne({
_id: row._id,
owner: unchanged(row.owner),
"source.accessToken": unchanged(row.source?.accessToken),
accessToken: unchanged(row.accessToken),
}, { $set: { owner: user._id } });
if (!result.modifiedCount) { fail("repository_changed_retry"); continue; }
counts.updated++;
}
report({ ...event, githubId, ownerId: String(user._id), action: options.apply ? "owner_assigned" : "would_assign_owner" });
}
return counts;
}
+36
View File
@@ -0,0 +1,36 @@
import "dotenv/config";
import mongoose from "mongoose";
import config from "../config";
import { recoverRepositoryOwners } from "../core/recover-repository-owners";
async function main() {
const args = process.argv.slice(2);
const apply = args.includes("--apply");
let repositoryId: mongoose.mongo.ObjectId | undefined;
for (const arg of args) {
if (arg.startsWith("--id=") && /^[a-f0-9]{24}$/i.test(arg.slice(5))) {
repositoryId = new mongoose.mongo.ObjectId(arg.slice(5));
} else if (arg !== "--apply" && arg !== "--maintenance") {
process.stderr.write("Usage: recover-repository-owners.js [--id=<MongoDB document ID>] [--apply --maintenance]\n");
process.exitCode = 1;
return;
}
}
if (apply && !args.includes("--maintenance")) {
process.stderr.write("Stop application writers and pass --maintenance to assign owners.\n");
process.exitCode = 1;
return;
}
const uri = config.MONGODB_URI || `mongodb://${config.DB_USERNAME}:${config.DB_PASSWORD}@${config.DB_HOSTNAME}:27017/production`;
await mongoose.connect(uri, { ...(config.MONGODB_URI ? {} : { authSource: "admin" }), autoIndex: false });
const counts = await recoverRepositoryOwners(mongoose.connection.db, {
apply, repositoryId,
report: event => process.stdout.write(JSON.stringify(event) + "\n"),
});
process.stdout.write(JSON.stringify(counts) + "\n");
if (counts.issues || counts.halted) process.exitCode = 1;
}
main().catch(() => {
process.stderr.write("Owner recovery failed. Check database connectivity/configuration; no token values or request errors are logged.\n");
process.exitCode = 1;
}).finally(() => mongoose.disconnect());
+123
View File
@@ -0,0 +1,123 @@
const { expect } = require("chai");
require("ts-node/register/transpile-only");
const { recoverRepositoryOwners, identifyGitHubToken } = require("../src/core/recover-repository-owners");
function fixture(rows, users = []) {
const writes = [];
const db = { collection: name => name === "users" ? {
findOne: async query => users.find(user => user._id === query._id) || null,
find: query => ({ limit: () => ({ toArray: async () => users.filter(user =>
query["externalIDs.github"].$in.includes(user.githubId)).slice(0, 2) }) }),
} : {
find: () => ({ batchSize: () => ({ async *[Symbol.asyncIterator]() { yield* rows; } }) }),
updateOne: async (filter, update) => { writes.push({ filter, update }); return { modifiedCount: 1 }; },
} };
const events = [];
const options = { pause: async () => {}, report: event => events.push(event), identify: async () => ({ githubId: "42" }) };
return { db, writes, events, options };
}
describe("repository owner recovery", () => {
const user = { _id: "user-42", githubId: "42", status: "active" };
it("dry run reports the ID match without writing or exposing tokens", async () => {
const f = fixture([{ _id: "repo", source: { accessToken: "secret" } }], [user]);
const result = await recoverRepositoryOwners(f.db, f.options);
expect(result.matched).to.equal(1);
expect(result.updated).to.equal(0);
expect(f.writes).to.have.length(0);
expect(f.events[0].ownerId).to.equal(user._id);
expect(JSON.stringify(f.events)).not.to.include("secret");
});
it("assigns only owner and guards the original owner and both token locations", async () => {
const f = fixture([{ _id: "repo", owner: null, source: { accessToken: "secret" } }], [user]);
const result = await recoverRepositoryOwners(f.db, { ...f.options, apply: true });
expect(result.updated).to.equal(1);
expect(f.writes[0].update).to.deep.equal({ $set: { owner: user._id } });
expect(f.writes[0].filter.owner).to.deep.equal({ $eq: null, $exists: true });
expect(f.writes[0].filter["source.accessToken"]).to.deep.equal({ $eq: "secret", $exists: true });
expect(f.writes[0].filter.accessToken).to.deep.equal({ $exists: false });
});
it("skips repositories with existing owners and recovers dangling references", async () => {
const f = fixture([{ _id: "keep", owner: user._id }, { _id: "recover", owner: "deleted-user", accessToken: "secret" }], [user]);
const result = await recoverRepositoryOwners(f.db, { ...f.options, apply: true });
expect(result.candidates).to.equal(1);
expect(f.writes[0].filter._id).to.equal("recover");
});
it("deduplicates GitHub calls for shared tokens", async () => {
const f = fixture([{ _id: "a", accessToken: "same" }, { _id: "b", accessToken: "same" }], [user]);
let calls = 0;
await recoverRepositoryOwners(f.db, { ...f.options, identify: async () => { calls++; return { githubId: "42" }; } });
expect(calls).to.equal(1);
expect(f.events).to.have.length(2);
});
it("refuses tokens belonging to different accounts", async () => {
const f = fixture([{ _id: "repo", accessToken: "first", source: { accessToken: "second" } }], [user]);
await recoverRepositoryOwners(f.db, { ...f.options, apply: true, identify: async token => ({ githubId: token === "first" ? "42" : "43" }) });
expect(f.events[0].issue).to.equal("conflicting_token_identities");
expect(f.writes).to.have.length(0);
});
it("rejects unknown, ambiguous, and disabled users", async () => {
for (const [users, issue] of [[[], "user_not_found"], [[user, { ...user, _id: "duplicate" }], "ambiguous_user"], [[{ ...user, status: "banned" }], "disabled_user"], [[{ ...user, status: "removed" }], "disabled_user"]]) {
const f = fixture([{ _id: "repo", accessToken: "secret" }], users);
await recoverRepositoryOwners(f.db, { ...f.options, apply: true });
expect(f.events[0].issue).to.equal(issue);
expect(f.writes).to.have.length(0);
}
});
it("supports legacy numeric GitHub IDs", async () => {
const f = fixture([{ _id: "repo", accessToken: "secret" }], [{ ...user, githubId: 42 }]);
expect((await recoverRepositoryOwners(f.db, f.options)).matched).to.equal(1);
});
it("reports missing and malformed tokens", async () => {
const f = fixture([{ _id: "missing" }, { _id: "malformed", accessToken: { bad: true } }], [user]);
await recoverRepositoryOwners(f.db, f.options);
expect(f.events.map(e => e.issue)).to.deep.equal(["missing_token", "malformed_token"]);
});
it("stops immediately on rate limits", async () => {
const f = fixture([{ _id: "a", accessToken: "first" }, { _id: "b", accessToken: "second" }], [user]);
const result = await recoverRepositoryOwners(f.db, { ...f.options, identify: async () => ({ issue: "github_http_429", halt: true }) });
expect(result.halted).to.equal(true);
expect(result.scanned).to.equal(1);
expect(f.writes).to.have.length(0);
});
it("does not overwrite concurrent repository changes", async () => {
const f = fixture([{ _id: "repo", accessToken: "secret" }], [user]);
const original = f.db.collection;
f.db.collection = name => name === "users" ? original(name) : { ...original(name), updateOne: async () => ({ modifiedCount: 0 }) };
const result = await recoverRepositoryOwners(f.db, { ...f.options, apply: true });
expect(result.updated).to.equal(0);
expect(f.events[0].issue).to.equal("repository_changed_retry");
});
});
describe("GitHub token identification", () => {
let original;
beforeEach(() => { original = global.fetch; });
afterEach(() => { global.fetch = original; });
it("sends the token only to the fixed authenticated-user endpoint", async () => {
global.fetch = async (url, options) => {
expect(url).to.equal("https://api.github.com/user");
expect(options.headers.Authorization).to.equal("Bearer secret");
expect(options.redirect).to.equal("error");
return { ok: true, json: async () => ({ id: 42, type: "User" }) };
};
expect(await identifyGitHubToken("secret")).to.deep.equal({ githubId: "42" });
});
it("distinguishes revoked tokens from failures requiring a stop", async () => {
for (const status of [401, 403, 429, 503]) {
global.fetch = async () => ({ ok: false, status });
const result = await identifyGitHubToken("secret");
expect(!!result.halt).to.equal(status !== 401);
}
});
it("suppresses request errors that could contain credentials", async () => {
global.fetch = async () => { throw new Error("Bearer secret"); };
expect(await identifyGitHubToken("secret")).to.deep.equal({ issue: "github_request_failed", halt: true });
});
it("rejects invalid identities", async () => {
for (const body of [{ id: 42, type: "Bot" }, { id: "42", type: "User" }, { id: -1, type: "User" }]) {
global.fetch = async () => ({ ok: true, json: async () => body });
expect(await identifyGitHubToken("secret")).to.deep.equal({ issue: "unsupported_github_identity" });
}
});
});