Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

196 lines · 12.3 KBRaw History
1import type { Env } from "../env.js";
2import type { Account, Directory } from "../lib/directory.js";
3import { deletePrefixes } from "../lib/storage-keys.js";
4import { HttpError, now } from "../lib/util.js";
5import { enqueueMail } from "../mail/send.js";
6import { RegistryStore } from "../registry/store.js";
7
8/**
9 * The end of an account's life, and who runs the instance.
10 *
11 * Deleting an account (Settings → Account, or a site administrator in Site administration) removes, at once,
12 * the person's repositories (their rows; each repository's Durable Object and R2 objects go in the background
13 * through the same `destroy()` as deleting one repository), container images, access tokens, browser
14 * sessions, passkeys, agents (and their tokens), organization memberships, collaborator grants, invitations,
15 * notifications and settings, linked accounts, model keys, quota rows, the email log and any exports. What
16 * the person wrote in other people's repositories stays, attributed to the `ghost` user as on GitHub
17 * (RepoDO.forgetAccounts, also in the background, which also removes self-hosted runners they registered).
18 * The account row stays, disabled under the placeholder login `deleted-<id>`, because the audit log and import
19 * history refer to it; its username can be taken again after {@link LOGIN_COOLDOWN_DAYS} days.
20 *
21 * Deleting is refused while the person is the only owner of an organization, the only site administrator, or
22 * has an import into their account in progress.
23 */
24
25/** Days before a deleted account's username can be used again (GitHub uses 90 too). */
26export const LOGIN_COOLDOWN_DAYS = 90;
27
28/** What the background part of deleting an account works on (AccountWorkflow). */
29export interface DeletionCleanup {
30 kind: "delete";
31 accountId: string;
32 /** The person and their agents: whose work becomes the ghost's. */
33 ids: string[];
34 /** Their logins before the deletion (runners and merges are recorded by login). */
35 logins: string[];
36 repoIds: string[];
37}
38
39const list = (names: string[]) => (names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
40
41/** Why `account` can't be deleted now, as sentences addressed to `self` (the person) or to an administrator. Empty when it can. */
42export async function deletionBlockers(env: Env, account: Account, self: boolean): Promise<string[]> {
43 const who = self ? "You are" : `${account.login} is`;
44 const out: string[] = [];
45 const orgs = await env.DB.prepare(
46 `SELECT o.login FROM org_members m JOIN accounts o ON o.id = m.org_id
47 WHERE m.user_id = ? AND m.role = 'owner'
48 AND NOT EXISTS (SELECT 1 FROM org_members x WHERE x.org_id = m.org_id AND x.role = 'owner' AND x.user_id != m.user_id)
49 ORDER BY o.login`,
50 )
51 .bind(account.id)
52 .all<{ login: string }>();
53 const logins = orgs.results.map((o) => o.login);
54 if (logins.length) {
55 const one = logins.length === 1;
56 out.push(`${who} the only owner of ${one ? "the organization" : "the organizations"} ${list(logins)}: make someone else an owner of ${one ? "it" : "each"}, or delete ${one ? "it" : "them"}, first.`);
57 }
58 if (account.site_admin) {
59 const others = await env.DB.prepare("SELECT count(*) AS n FROM accounts WHERE kind = 'user' AND site_admin = 1 AND status = 'active' AND deleted_at IS NULL AND id != ?").bind(account.id).first<{ n: number }>();
60 if (!others?.n) out.push(`${who} the only site administrator: make someone else a site administrator first.`);
61 }
62 const imports = await env.DB.prepare("SELECT count(*) AS n FROM imports WHERE (target_owner_id = ? OR created_by = ?) AND status IN ('queued', 'running')").bind(account.id, account.id).first<{ n: number }>();
63 if (imports?.n) out.push(`An import ${self ? "you started, or into your account," : `by or into ${account.login}`} is still in progress: wait for it to finish or cancel it first.`);
64 return out;
65}
66
67/**
68 * Delete a person's account (see the top of this file). `by` is the person themselves or a site administrator;
69 * the caller has checked who may and asked them to sign in again. Returns how much went.
70 */
71export async function deleteAccount(env: Env, dir: Directory, account: Account, by: Account, via: "settings" | "admin"): Promise<{ repositories: number; packages: number; agents: number }> {
72 if (account.kind !== "user" || account.deleted_at) throw new HttpError(404, "no such person");
73 const blockers = await deletionBlockers(env, account, by.id === account.id);
74 if (blockers.length) throw new HttpError(422, blockers.join(" "));
75 const db = env.DB;
76 const repos = (await db.prepare("SELECT id FROM repos WHERE owner_id = ?").bind(account.id).all<{ id: string }>()).results.map((r) => r.id);
77 const agents = (await db.prepare("SELECT id, login FROM accounts WHERE kind = 'agent' AND owner_id = ? AND deleted_at IS NULL").bind(account.id).all<{ id: string; login: string }>()).results;
78 const ids = [account.id, ...agents.map((a) => a.id)];
79 const logins = [account.login, ...agents.map((a) => a.login)];
80 const inIds = ids.map(() => "?").join(", ");
81 const packages = (await db.prepare(`SELECT id FROM packages WHERE owner_id IN (${inIds})`).bind(...ids).all<{ id: string }>()).results.map((p) => p.id);
82
83 // Container images first: their tags and manifests go now, unused bytes at the registry's next cleanup.
84 const registry = new RegistryStore(env);
85 for (const id of packages) await registry.deletePackage(id);
86
87 const t = now();
88 const until = new Date(Date.now() + LOGIN_COOLDOWN_DAYS * 86400_000).toISOString();
89 const forIds = (q: string) => db.prepare(q.replace("(?)", `(${inIds})`)).bind(...ids);
90 const forMe = (q: string) => db.prepare(q).bind(account.id);
91 await db.batch([
92 ...dir.repoRowDeletes({ ownerId: account.id }),
93 forIds("DELETE FROM tokens WHERE account_id IN (?)"),
94 forMe("DELETE FROM sessions WHERE account_id = ?"),
95 forMe("DELETE FROM passkeys WHERE account_id = ?"),
96 forMe("DELETE FROM signin_links WHERE account_id = ? AND used_at IS NULL"),
97 forMe("DELETE FROM org_members WHERE user_id = ?"),
98 forIds("DELETE FROM repo_access WHERE account_id IN (?)"),
99 db.prepare("DELETE FROM invites WHERE accepted_at IS NULL AND (account_id = ? OR invited_by = ?)").bind(account.id, account.id),
100 forIds("DELETE FROM notifications WHERE account_id IN (?)"),
101 forMe("DELETE FROM notification_prefs WHERE account_id = ?"),
102 forMe("DELETE FROM repo_watches WHERE account_id = ?"),
103 forMe("DELETE FROM thread_subscriptions WHERE account_id = ?"),
104 forMe("DELETE FROM known_devices WHERE account_id = ?"),
105 forMe("DELETE FROM access_requests WHERE account_id = ?"),
106 forMe("DELETE FROM account_links WHERE account_id = ?"),
107 forIds("DELETE FROM model_keys WHERE account_id IN (?)"),
108 forMe("DELETE FROM terminal_terms WHERE account_id = ?"),
109 forIds("DELETE FROM account_quotas WHERE account_id IN (?)"),
110 forIds("DELETE FROM compute_usage WHERE account_id IN (?)"),
111 forIds("DELETE FROM login_redirects WHERE account_id IN (?)"),
112 forMe("DELETE FROM email_log WHERE account_id = ?"),
113 forMe("DELETE FROM account_exports WHERE account_id = ?"),
114 // A GitHub token kept for a draft import goes with the draft.
115 forMe("UPDATE imports SET token_ciphertext = NULL, status = CASE WHEN status = 'draft' THEN 'cancelled' ELSE status END WHERE created_by = ?"),
116 // The usernames wait before anyone can take them, so old links and mentions don't lead to someone else.
117 ...logins.map((login, i) => db.prepare("INSERT OR REPLACE INTO released_logins (login, account_id, released_at, available_at) VALUES (?, ?, ?, ?)").bind(login, ids[i]!, t, until)),
118 db
119 .prepare(
120 `UPDATE accounts SET login = 'deleted-' || id, name = NULL, email = NULL, status = 'disabled', site_admin = 0, avatar_version = NULL, appearance = NULL,
121 actions_suspended_at = NULL, actions_suspended_by = NULL, actions_suspended_reason = NULL, deleted_at = ? WHERE id IN (${inIds})`,
122 )
123 .bind(t, ...ids),
124 ]);
125
126 await dir.audit({ actorId: by.id, actorLogin: by.login, action: "account.delete", target: account.login, detail: { via, repositories: repos.length, packages: packages.length, agents: agents.map((a) => a.login) } });
127 if (account.email) await enqueueMail(env, [{ kind: "account_deleted", data: { login: account.login, by: by.id === account.id ? undefined : by.login, cooldownDays: LOGIN_COOLDOWN_DAYS }, to: { email: account.email, name: account.name ?? undefined }, reason: "account", at: t }]);
128 await startCleanup(env, { kind: "delete", accountId: account.id, ids, logins, repoIds: repos });
129 return { repositories: repos.length, packages: packages.length, agents: agents.length };
130}
131
132/** Hand the slow part of a deletion to the background (AccountWorkflow); without the binding (unit tests) it runs now. */
133async function startCleanup(env: Env, job: DeletionCleanup): Promise<void> {
134 if (env.ACCOUNT_WORKFLOW) {
135 await env.ACCOUNT_WORKFLOW.create({ id: `delete-${job.accountId}`, params: job });
136 return;
137 }
138 for (const id of job.repoIds) await destroyRepository(env, id);
139 await deleteAccountObjects(env, job.accountId);
140 for (let after: string | null = ""; after !== null; ) after = await ghostPage(env, job, after);
141}
142
143/** Wipe one of a deleted account's repositories: its Durable Object and R2 objects, as deleting a repository does. */
144export async function destroyRepository(env: Env, repoId: string): Promise<void> {
145 await env.REPO.get(env.REPO.idFromName(repoId)).destroy();
146}
147
148/** A deleted account's own R2 objects: profile pictures and exports. True once they are all gone. */
149export async function deleteAccountObjects(env: Env, accountId: string): Promise<boolean> {
150 return deletePrefixes(env.BUCKET, [`avatars/${accountId}/`, `exports/${accountId}/`]);
151}
152
153/** How many repositories one background step visits. */
154export const GHOST_PAGE = 25;
155
156/**
157 * Attribute a deleted account's work to the ghost in the next page of repositories (by id, after `after`).
158 * Every repository is visited: someone can comment on any public repository, and keeps what they wrote in
159 * repositories they lost access to. Returns where the next page starts, or null when done.
160 */
161export async function ghostPage(env: Env, job: DeletionCleanup, after: string): Promise<string | null> {
162 const page = await env.DB.prepare("SELECT id FROM repos WHERE id > ? ORDER BY id LIMIT ?").bind(after, GHOST_PAGE).all<{ id: string }>();
163 await Promise.all(page.results.map((r) => env.REPO.get(env.REPO.idFromName(r.id)).forgetAccounts(job.ids, job.logins)));
164 return page.results.length < GHOST_PAGE ? null : page.results.at(-1)!.id;
165}
166
167// ---------------------------------------------------------------- site administrators
168
169/**
170 * Make a person a site administrator, or stop them being one. Only an active person can be made one, and the
171 * last active site administrator can't be removed (the check and the change are one statement, so two
172 * administrators removing each other can't both succeed). Audited as `site_admin.grant` or `site_admin.revoke`,
173 * and the person gets an email. False when nothing changed.
174 */
175export async function setSiteAdmin(env: Env, dir: Directory, target: Account, grant: boolean, by: Account): Promise<boolean> {
176 if (target.kind !== "user" || target.deleted_at) throw new HttpError(404, "no such person");
177 let changed: number;
178 if (grant) {
179 if (target.status !== "active") throw new HttpError(422, `${target.login}'s account is ${target.status}: enable it before making them a site administrator`);
180 changed = (await env.DB.prepare("UPDATE accounts SET site_admin = 1 WHERE id = ? AND site_admin = 0 AND status = 'active'").bind(target.id).run()).meta.changes ?? 0;
181 } else {
182 changed =
183 (
184 await env.DB.prepare(
185 "UPDATE accounts SET site_admin = 0 WHERE id = ? AND site_admin = 1 AND EXISTS (SELECT 1 FROM accounts WHERE kind = 'user' AND site_admin = 1 AND status = 'active' AND deleted_at IS NULL AND id != ?)",
186 )
187 .bind(target.id, target.id)
188 .run()
189 ).meta.changes ?? 0;
190 if (!changed && target.site_admin) throw new HttpError(422, "Diffinery needs at least one site administrator: make someone else a site administrator first");
191 }
192 if (!changed) return false;
193 await dir.audit({ actorId: by.id, actorLogin: by.login, action: grant ? "site_admin.grant" : "site_admin.revoke", target: target.login });
194 await enqueueMail(env, [{ kind: "site_admin", data: { granted: grant, by: by.login }, to: { accountId: target.id }, reason: "security_alert", at: now() }]);
195 return true;
196}