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.

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