From 8de4a536cc1289d0bbf019677c4c42bc1ee5d798 Mon Sep 17 00:00:00 2001 From: Thomas Durieux <5577568+tdurieux@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:39:39 -1000 Subject: [PATCH] fix: recover legacy repository owners through GitHub (#799) --- docs/credential-encryption.md | 47 +++++++++ package.json | 3 +- src/core/recover-repository-owners.ts | 119 ++++++++++++++++++++++ src/scripts/recover-repository-owners.ts | 36 +++++++ test/recover-repository-owners.test.js | 123 +++++++++++++++++++++++ 5 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 src/core/recover-repository-owners.ts create mode 100644 src/scripts/recover-repository-owners.ts create mode 100644 test/recover-repository-owners.test.js diff --git a/docs/credential-encryption.md b/docs/credential-encryption.md index aac4b72..4080cb1 100644 --- a/docs/credential-encryption.md +++ b/docs/credential-encryption.md @@ -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. diff --git a/package.json b/package.json index b98d997..4b4d9f6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/recover-repository-owners.ts b/src/core/recover-repository-owners.ts new file mode 100644 index 0000000..ed204d9 --- /dev/null +++ b/src/core/recover-repository-owners.ts @@ -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 { + 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; + pause?: () => Promise; + 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(resolve => setTimeout(resolve, 1000))); + // Hashes deduplicate HTTP requests without retaining plaintext as cache keys. + const identities = new Map(); + 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(); + 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; +} diff --git a/src/scripts/recover-repository-owners.ts b/src/scripts/recover-repository-owners.ts new file mode 100644 index 0000000..2c58222 --- /dev/null +++ b/src/scripts/recover-repository-owners.ts @@ -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=] [--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()); diff --git a/test/recover-repository-owners.test.js b/test/recover-repository-owners.test.js new file mode 100644 index 0000000..e65792a --- /dev/null +++ b/test/recover-repository-owners.test.js @@ -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" }); + } + }); +});