Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

247 linesCodeBlameRaw
1import { ArrowLeft } from "lucide-react";
2import { Form, Link, data, redirect } from "react-router";
3
4import type { AdminRunner, IngestToken } from "@g1t/contracts";
5
6import type { Route } from "./+types/workspace-abuse";
7import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, When } from "~/components/ui";
8import { parseSlug, text } from "~/lib/forms";
9import { admin, identity, runnersAdmin, telemetry, workspaceProjects } from "~/lib/services.server";
10import { settle } from "~/lib/settle";
11import { requireStaff } from "~/lib/staff";
12import { SUSPEND_WORKSPACE_ABOUT, isSuspensionHold, parseReason, suspensionHold } from "~/lib/suspension";
13
14export const meta: Route.MetaFunction = ({ params }) => [
15 { title: `Suspend and clean up · ${params.slug} · sudo` },
16 { name: "robots", content: "noindex, nofollow" },
17];
18
19function slugOf(raw: string): string {
20 const parsed = parseSlug(raw);
21 if (!parsed.ok) throw data("That is not a workspace slug.", { status: 404 });
22 return parsed.value;
23}
24
25/** Each project's ingest tokens, the ones people made (g1t's own for deployments are not listed). */
26async function telemetryTokens(slug: string): Promise<{ project: { id: string; slug: string; name: string }; tokens: IngestToken[] }[]> {
27 const projects = await workspaceProjects(slug);
28 if (!projects.ok) throw new Error(projects.error.message);
29 return Promise.all(
30 projects.value.map(async (project) => ({
31 project: { id: project.id, slug: project.slug, name: project.name },
32 tokens: await telemetry.tokens({ project_id: project.id }).catch(() => [] as IngestToken[]),
33 })),
34 );
35}
36
37export async function loader({ params, request, context }: Route.LoaderArgs) {
38 requireStaff(context);
39 const slug = slugOf(params.slug);
40 const [detail, runners, tokens] = await Promise.all([settle(identity.workspace(slug)), settle(runnersAdmin.list(slug)), settle(telemetryTokens(slug))]);
41 if (detail.ok && !detail.value) throw data(`There is no workspace called ${slug}.`, { status: 404 });
42 const done = new URL(request.url).searchParams.get("done");
43 const DONE: Record<string, string> = {
44 suspended: `Suspended ${slug}, and put a hold on its compute.`,
45 lifted: `Lifted the suspension of ${slug}, and the hold it put on.`,
46 runners: "Removed the runners. What they were running failed; their credentials stopped at once.",
47 tokens: "Revoked the tokens. Anything sending with them is refused from now on.",
48 };
49 return {
50 slug,
51 suspended: detail.ok ? (detail.value?.suspended ?? null) : null,
52 runners: runners.ok ? runners.value : ([] as AdminRunner[]),
53 runnersError: runners.ok ? null : runners.error,
54 tokens: tokens.ok ? tokens.value : [],
55 tokensError: tokens.ok ? null : tokens.error,
56 error: detail.ok ? null : detail.error,
57 done: done ? (DONE[done] ?? null) : null,
58 };
59}
60
61/** Puts the suspension's hold on, or takes it off, keeping every other allowance. */
62async function setHold(slug: string, hold: string | null, staff: string, note: string): Promise<string | null> {
63 const account = await admin.account(slug);
64 if (!account.ok) return account.error.message;
65 const { allowances, id, kind } = account.value.summary.account;
66 if (kind === "enterprise") return null;
67 const current = allowances?.hold ?? null;
68 // Lifting takes off only a hold a suspension put there.
69 if (hold === null && !isSuspensionHold(current)) return null;
70 const set = await admin.setAllowances(id, { plan: false, ossRepoMicros: null, trialMicros: null, ...allowances, hold }, note, staff);
71 return set.ok ? null : set.error.message;
72}
73
74export async function action({ params, request, context }: Route.ActionArgs) {
75 const staff = requireStaff(context);
76 const slug = slugOf(params.slug);
77 const form = await request.formData();
78 const intent = text(form, "intent");
79 const back = (done: string) => redirect(`/workspaces/${encodeURIComponent(slug)}/abuse?done=${done}`);
80 if (intent === "suspend") {
81 const reason = parseReason(text(form, "reason"));
82 if (!reason.ok) return data({ error: reason.error, section: "suspend" }, { status: 422 });
83 const result = await identity.suspendWorkspace(slug, true, reason.value, staff.email);
84 if (!result.ok) return data({ error: result.error.message, section: "suspend" }, { status: 422 });
85 const held = await setHold(slug, suspensionHold(reason.value), staff.email, `Suspended: ${reason.value}`);
86 if (held) return data({ error: `Suspended, but the hold on its compute was not set: ${held}`, section: "suspend" }, { status: 422 });
87 throw back("suspended");
88 }
89 if (intent === "lift") {
90 const result = await identity.suspendWorkspace(slug, false, "", staff.email);
91 if (!result.ok) return data({ error: result.error.message, section: "suspend" }, { status: 422 });
92 const held = await setHold(slug, null, staff.email, "Suspension lifted");
93 if (held) return data({ error: `Lifted, but the hold on its compute was not taken off: ${held}`, section: "suspend" }, { status: 422 });
94 throw back("lifted");
95 }
96 if (intent === "remove-runners") {
97 const id = text(form, "id") || null;
98 const result = await runnersAdmin.remove(slug, id, staff.email);
99 if (!result.ok) return data({ error: result.error.message, section: "runners" }, { status: 422 });
100 throw back("runners");
101 }
102 if (intent === "revoke-tokens") {
103 const project = text(form, "project");
104 const only = text(form, "id");
105 const tokens = await telemetry.tokens({ project_id: project });
106 for (const token of tokens.filter((token) => !only || token.id === only)) {
107 await telemetry.revokeToken({ project_id: project, id: token.id });
108 }
109 console.log(`sudo: ${staff.email} revoked ingest tokens of ${slug}'s project ${project}`);
110 throw back("tokens");
111 }
112 return data({ error: "Unknown action.", section: "top" }, { status: 400 });
113}
114
115export default function WorkspaceAbuse({ loaderData, actionData }: Route.ComponentProps) {
116 const { slug, suspended, runners, runnersError, tokens, tokensError, error, done } = loaderData;
117 const errorFor = (section: string) => (actionData && "section" in actionData && actionData.section === section ? actionData.error : null);
118 return (
119 <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10">
120 <Link to={`/workspaces/${slug}`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
121 <ArrowLeft size={14} /> {slug}
122 </Link>
123 <PageHeader
124 title="Suspend and clean up"
125 description={`What staff can stop in ${slug} after abuse: the workspace itself, its self-hosted runners, and its projects' telemetry tokens.`}
126 />
127 <div className="mt-5 space-y-3 empty:hidden">
128 {done && <Notice tone="ok">{done}</Notice>}
129 {error && <Notice tone="warn">Identity did not answer: {error}</Notice>}
130 </div>
131
132 <Section id="suspend" title="Suspension" description={SUSPEND_WORKSPACE_ABOUT} className="mt-6">
133 {suspended ? (
134 <Form method="post" className="space-y-3">
135 <Notice tone="error">
136 Suspended <When at={suspended.at} /> by {suspended.by}: {suspended.reason}
137 </Notice>
138 {errorFor("suspend") && <Notice tone="error">{errorFor("suspend")}</Notice>}
139 <Button type="submit" name="intent" value="lift" variant="quiet">
140 Lift the suspension
141 </Button>
142 </Form>
143 ) : (
144 <Form method="post" className="space-y-3">
145 <Field label="Why" hint="Kept with the suspension and in the audit log.">
146 <Input name="reason" required maxLength={500} placeholder="e.g. Mining on self-hosted runners" />
147 </Field>
148 {errorFor("suspend") && <Notice tone="error">{errorFor("suspend")}</Notice>}
149 <Button type="submit" name="intent" value="suspend" variant="danger">
150 Suspend {slug}
151 </Button>
152 </Form>
153 )}
154 </Section>
155
156 <Section
157 id="runners"
158 title="Self-hosted runners"
159 description="Removing a runner fails what it was running and stops its credential at once. Removing all of them also ends the registration tokens the workspace has out."
160 className="mt-6"
161 >
162 {runnersError && <Notice tone="warn">Actions did not answer: {runnersError}</Notice>}
163 {errorFor("runners") && <Notice tone="error">{errorFor("runners")}</Notice>}
164 {runners.length === 0 ? (
165 <EmptyState title="No runners">{slug} has no self-hosted runners.</EmptyState>
166 ) : (
167 <div className="space-y-2">
168 {runners.map((runner) => (
169 <Form
170 method="post"
171 key={runner.id}
172 className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-line bg-surface px-3 py-2 text-sm"
173 >
174 <span className="min-w-0">
175 <span className="font-mono">{runner.name}</span>
176 {runner.repo && <span className="text-muted"> · {runner.repo}</span>}
177 <span className="block text-xs text-faint">
178 {runner.os} · {runner.version} · last seen {runner.lastSeenAt ? <When at={runner.lastSeenAt} /> : "never"}
179 {runner.createdBy ? ` · added by ${runner.createdBy}` : ""}
180 </span>
181 </span>
182 <span className="flex items-center gap-2">
183 {runner.busy && <Badge tone="warn">Running a job</Badge>}
184 <input type="hidden" name="id" value={runner.id} />
185 <Button type="submit" name="intent" value="remove-runners" variant="danger">
186 Remove
187 </Button>
188 </span>
189 </Form>
190 ))}
191 <Form method="post">
192 <Button type="submit" name="intent" value="remove-runners" variant="danger">
193 Remove all {runners.length} and end registration tokens
194 </Button>
195 </Form>
196 </div>
197 )}
198 </Section>
199
200 <Section
201 id="telemetry"
202 title="Telemetry ingest tokens"
203 description="Tokens people made to send OpenTelemetry to a project. Revoking one refuses anything sent with it from then on. g1t's own token for its deployments is not listed."
204 className="mt-6"
205 >
206 {tokensError && <Notice tone="warn">Projects or telemetry did not answer: {tokensError}</Notice>}
207 {tokens.every((row) => row.tokens.length === 0) ? (
208 <EmptyState title="No ingest tokens">No project in {slug} has one.</EmptyState>
209 ) : (
210 <div className="space-y-4">
211 {tokens
212 .filter((row) => row.tokens.length > 0)
213 .map((row) => (
214 <div key={row.project.id} className="rounded-lg border border-line bg-surface p-3 text-sm">
215 <div className="flex flex-wrap items-center justify-between gap-2">
216 <span className="font-medium">{row.project.name}</span>
217 <Form method="post">
218 <input type="hidden" name="project" value={row.project.id} />
219 <Button type="submit" name="intent" value="revoke-tokens" variant="danger">
220 Revoke all {row.tokens.length}
221 </Button>
222 </Form>
223 </div>
224 <ul className="mt-2 space-y-1">
225 {row.tokens.map((token) => (
226 <li key={token.id} className="flex flex-wrap items-center justify-between gap-2">
227 <span>
228 {token.name} <span className="font-mono text-xs text-faint">{token.hint}</span>
229 </span>
230 <Form method="post">
231 <input type="hidden" name="project" value={row.project.id} />
232 <input type="hidden" name="id" value={token.id} />
233 <Button type="submit" name="intent" value="revoke-tokens" variant="quiet">
234 Revoke
235 </Button>
236 </Form>
237 </li>
238 ))}
239 </ul>
240 </div>
241 ))}
242 </div>
243 )}
244 </Section>
245 </main>
246 );
247}