mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-12 13:48:58 +02:00
fix: recover legacy repository owners through GitHub (#799)
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user