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.

g1t.sh

Commit

Sudo can suspend an account or a workspace, remove a workspace's self-hosted runners and revoke its projects' telemetry tokens, and block email domains from signing up. Before, deleting an account was staff's only lever, and the person could sign up again.

A suspended account (identity's admin_suspend_user, with a reason) cannot sign in with a password or GitHub and is told why; its sessions end at once, and while it lasts its sessions, access tokens, SSH keys and git passwords find nobody, checked where every credential is resolved. A suspended workspace (admin_suspend_workspace) has its own tokens refused, and sudo puts a billing hold on it (Suspended: <reason>), which stops its runs, checks, deploys and agents as any hold does, and now also its new self-hosted runners and its telemetry: the telemetry worker reads billing's entitlements for the project's workspace, and a held one's projects take nothing. Lifting a suspension lifts the hold it put on, never one staff set otherwise. Migration 0047 adds the suspension columns and blocked_domains; nothing is deleted either way. A blocked domain, and its subdomains, cannot sign up (with a password or GitHub) or be added to an account (admin_blocked_domains, admin_block_domain). Actions gains staff-only admin_runners and admin_remove_runners, which remove one runner or all of a workspace's and end its registration tokens. Sudo has a Suspend and clean up page for each workspace, a Suspension section on each person's page, and a Blocked domains page, with ACTIONS, PROJECTS and TELEMETRY bindings. Every change is in sudo's audit log, and the sudo README says so.

syntaqxcommitted Parentf68a2ddBrowse files
32 files+990−160/32 viewed
+17−0
5454 still opens and says so in those sections. A protected workspace (one
5555 nobody can ever delete: identity's `PROTECTED_WORKSPACES`, and
5656 flagon-io always) says so beside its name.
57+- **Suspend and clean up** (`/workspaces/<slug>/abuse`, linked from a
58+ workspace's page): suspend the workspace, with a reason (identity's
59+ `admin_suspend_workspace`): its own tokens stop, and a billing hold
60+ (`Suspended: <reason>`) stops its runs, checks, deploys, agents, new
61+ self-hosted runners and telemetry. Lifting it lifts that hold, never one
62+ staff set otherwise. Remove its self-hosted runners one at a time or all
63+ at once, which also ends its registration tokens (actions'
64+ `admin_runners`, `admin_remove_runners`), and revoke its projects'
65+ telemetry ingest tokens. Every change is in sudo's audit log.
5766 - **Deleted workspaces** (`/workspaces/deleted`, linked from Workspaces):
5867 workspaces their owners deleted, newest first (`admin_deleted_workspaces`),
5968 each with who deleted it and when, when it is purged, what went with it
114123 first: once the account is purged its page is gone (they stay on
115124 Deleted workspaces). To undo it all, restore the account first, then
116125 each workspace, so it comes back with its owner.
126+- **Suspending an account**, on a person's page: with a reason, it cannot
127+ sign in (with a password or GitHub), is signed out everywhere, and its
128+ tokens, SSH keys and git passwords stop until the suspension is lifted
129+ (identity's `admin_suspend_user`). Nothing is deleted.
130+- **Blocked domains** (`/blocked-domains`, under Platform): email domains,
131+ and their subdomains, no new account may sign up with or add, each with
132+ a reason (`admin_blocked_domains`, `admin_block_domain`). Accounts
133+ already there are not touched.
117134 - **Deleted accounts** (`/users/deleted`, linked from Workspaces):
118135 accounts deleted by the person or by staff, newest first
119136 (`admin_deleted_accounts`), each with who deleted it (the person, or the
+1−1
4040
4141 test("the built pages are not marked soon", () => {
4242 const built = navItems().filter((item) => !item.soon).map((item) => item.to);
43− assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/aliases", "/requests", "/overages", "/velocity", "/invoices", "/credits", "/stripe", "/costs", "/agents", "/abuse", "/incidents", "/audit"]);
43+ assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/aliases", "/requests", "/overages", "/velocity", "/invoices", "/credits", "/stripe", "/costs", "/agents", "/abuse", "/blocked-domains", "/incidents", "/audit"]);
4444 });
4545
4646 test("every soon page says what it will do, why, and what it will have", () => {
+6−0
231231 about: "Sandboxes stopped for looking like mining, and disputes and declines.",
232232 },
233233 {
234+ label: "Blocked domains",
235+ to: "/blocked-domains",
236+ icon: "abuse",
237+ about: "Email domains no new account may sign up with.",
238+ },
239+ {
234240 label: "Incidents",
235241 to: "/incidents",
236242 icon: "incidents",
+16−0
88 billingClient,
99 eventsClient,
1010 identityAdminClient,
11+ projectsClient,
12+ runnersAdminClient,
13+ telemetryClient,
14+ type User,
1115 } from "@g1t/contracts";
1216
1317 /** Staff-only billing, on the billing service. */
3438 /** "Check for new models": the model proxy's `Discovery` entrypoint (services/models). */
3539 export const modelDiscovery = env.MODELS as unknown as ModelDiscoveryApi;
3640
41+/** A workspace's self-hosted runners, and removing them (actions' admin_* methods). */
42+export const runnersAdmin = runnersAdminClient(env.ACTIONS);
43+
44+/** A workspace's projects, read as the workspace itself, which sees them all. */
45+export const workspaceProjects = (slug: string) => {
46+ const workspace: User = { id: `staff:${slug}`, username: slug, kind: "workspace", verified: true, workspaces: [{ slug, role: "member" }] };
47+ return projectsClient(env.PROJECTS).list(slug, workspace);
48+};
49+
50+/** Projects' telemetry: their ingest tokens, to see and revoke. */
51+export const telemetry = telemetryClient(env.TELEMETRY);
52+
3753 /** STAFF_EMAILS, for suggesting staff in the incident roles. */
3854 export const staffEmails = (): string => env.STAFF_EMAILS ?? "";
+18−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { isSuspensionHold, parseReason, suspensionHold } from "./suspension.ts";
5+
6+test("a suspension's hold is known as one, so lifting it lifts the hold", () => {
7+ const hold = suspensionHold(" card testing ");
8+ assert.equal(hold, "Suspended: card testing");
9+ assert.ok(isSuspensionHold(hold));
10+ assert.ok(!isSuspensionHold("Unpaid invoice"));
11+ assert.ok(!isSuspensionHold(null));
12+});
13+
14+test("a reason is required, and kept short", () => {
15+ assert.deepEqual(parseReason(" spam sign-ups "), { ok: true, value: "spam sign-ups" });
16+ assert.equal(parseReason("x").ok, false);
17+ assert.equal(parseReason("x".repeat(501)).ok, false);
18+});
+31−0
1+/**
2+ * Suspending accounts and workspaces, and blocking email domains: the
3+ * forms' rules and words. No Workers imports, so it can be tested under
4+ * Node. Identity checks everything again.
5+ */
6+
7+/** The billing hold sudo puts on a suspended workspace, so its compute, deploys, new runners and telemetry stop. */
8+export const SUSPENSION_HOLD_PREFIX = "Suspended: ";
9+
10+export function suspensionHold(reason: string): string {
11+ return `${SUSPENSION_HOLD_PREFIX}${reason.trim()}`;
12+}
13+
14+/** Whether a billing hold is one a suspension put there, so lifting the suspension lifts it too. */
15+export function isSuspensionHold(hold: string | null | undefined): boolean {
16+ return (hold ?? "").startsWith(SUSPENSION_HOLD_PREFIX);
17+}
18+
19+/** The reason a suspension or a block needs: 3 to 500 characters. */
20+export function parseReason(raw: string): { ok: true; value: string } | { ok: false; error: string } {
21+ const reason = raw.trim().replace(/\s+/g, " ");
22+ if (reason.length < 3) return { ok: false, error: "Say why. It is kept with it, and in the audit log." };
23+ if (reason.length > 500) return { ok: false, error: "Keep the reason under 500 characters." };
24+ return { ok: true, value: reason };
25+}
26+
27+/** What a suspension does, for the forms. */
28+export const SUSPEND_ACCOUNT_ABOUT =
29+ "A suspended account cannot sign in, with a password or GitHub, and is signed out everywhere at once. Its tokens, SSH keys and git passwords stop working until the suspension is lifted; then they work as before. Nothing is deleted.";
30+export const SUSPEND_WORKSPACE_ABOUT =
31+ "A suspended workspace's own tokens stop working, and a hold goes on its compute in billing: no new runs, checks, deploys, agents or self-hosted runners, and its telemetry is not taken. Its members can still sign in and read it. Lifting the suspension lifts the hold. Nothing is deleted.";
+2−0
77 route("workspaces", "routes/workspaces.tsx"),
88 route("workspaces/deleted", "routes/deleted-workspaces.tsx"),
99 route("workspaces/:slug", "routes/workspace.tsx"),
10+ route("workspaces/:slug/abuse", "routes/workspace-abuse.tsx"),
1011 route("users/deleted", "routes/deleted-accounts.tsx"),
1112 route("users/:username", "routes/user.tsx"),
1213 route("enterprises", "routes/enterprises.tsx"),
2223 route("costs/bill", "routes/costs-bill.tsx"),
2324 route("agents", "routes/agents.tsx"),
2425 route("abuse", "routes/abuse.tsx"),
26+ route("blocked-domains", "routes/blocked-domains.tsx"),
2527 route("invoices", "routes/invoices.tsx"),
2628 route("credits", "routes/credits.tsx"),
2729 route("stripe", "routes/stripe.tsx"),
+87−0
1+import { Form, data, redirect } from "react-router";
2+
3+import type { Route } from "./+types/blocked-domains";
4+import { Button, EmptyState, Field, Input, Notice, PageHeader, Section, When } from "~/components/ui";
5+import { text } from "~/lib/forms";
6+import { identity } from "~/lib/services.server";
7+import { settle } from "~/lib/settle";
8+import { requireStaff } from "~/lib/staff";
9+import { parseReason } from "~/lib/suspension";
10+
11+export const meta: Route.MetaFunction = () => [{ title: "Blocked domains · sudo" }, { name: "robots", content: "noindex, nofollow" }];
12+
13+export async function loader({ request, context }: Route.LoaderArgs) {
14+ requireStaff(context);
15+ const domains = await settle(identity.blockedDomains());
16+ const done = new URL(request.url).searchParams.get("done");
17+ return {
18+ domains: domains.ok ? domains.value : [],
19+ error: domains.ok ? null : domains.error,
20+ done: done === "blocked" ? "Blocked. No new account can sign up with an address there." : done === "unblocked" ? "Unblocked." : null,
21+ };
22+}
23+
24+export async function action({ request, context }: Route.ActionArgs) {
25+ const staff = requireStaff(context);
26+ const form = await request.formData();
27+ const block = text(form, "intent") === "block";
28+ const reason = block ? parseReason(text(form, "reason")) : { ok: true as const, value: "" };
29+ if (!reason.ok) return data({ error: reason.error }, { status: 422 });
30+ const result = await identity.blockDomain(text(form, "domain"), block, reason.value, staff.email);
31+ if (!result.ok) return data({ error: result.error.message }, { status: 422 });
32+ throw redirect(`/blocked-domains?done=${block ? "blocked" : "unblocked"}`);
33+}
34+
35+export default function BlockedDomains({ loaderData, actionData }: Route.ComponentProps) {
36+ const { domains, error, done } = loaderData;
37+ return (
38+ <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10">
39+ <PageHeader
40+ title="Blocked domains"
41+ description="Email domains no new account may sign up with, with a password or GitHub, and that no account may add. Each domain's subdomains are blocked too. Accounts already there are not touched: suspend them one at a time from their page."
42+ />
43+ <div className="mt-5 space-y-3 empty:hidden">
44+ {done && <Notice tone="ok">{done}</Notice>}
45+ {error && <Notice tone="warn">Identity did not answer: {error}</Notice>}
46+ {actionData && "error" in actionData && <Notice tone="error">{actionData.error}</Notice>}
47+ </div>
48+ <Section id="block" title="Block a domain" className="mt-6">
49+ <Form method="post" className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto] sm:items-end">
50+ <Field label="Domain">
51+ <Input name="domain" required maxLength={253} placeholder="spam.example" spellCheck={false} autoCapitalize="none" />
52+ </Field>
53+ <Field label="Why">
54+ <Input name="reason" required maxLength={500} placeholder="e.g. Throwaway addresses used for sign-up abuse" />
55+ </Field>
56+ <Button type="submit" name="intent" value="block" variant="danger">
57+ Block
58+ </Button>
59+ </Form>
60+ </Section>
61+ <Section id="list" title="Blocked" className="mt-6">
62+ {domains.length === 0 ? (
63+ <EmptyState title="None blocked">Every domain can sign up.</EmptyState>
64+ ) : (
65+ <ul className="space-y-2">
66+ {domains.map((domain) => (
67+ <li key={domain.domain} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-line bg-surface px-3 py-2 text-sm">
68+ <span className="min-w-0">
69+ <span className="font-mono">{domain.domain}</span>
70+ <span className="block text-xs text-faint">
71+ {domain.reason} · {domain.blockedBy} · <When at={domain.createdAt} />
72+ </span>
73+ </span>
74+ <Form method="post">
75+ <input type="hidden" name="domain" value={domain.domain} />
76+ <Button type="submit" name="intent" value="unblock" variant="quiet">
77+ Unblock
78+ </Button>
79+ </Form>
80+ </li>
81+ ))}
82+ </ul>
83+ )}
84+ </Section>
85+ </main>
86+ );
87+}
+38−0
2222 import { accountsAdmin, identity } from "~/lib/services.server";
2323 import { settle } from "~/lib/settle";
2424 import { requireStaff } from "~/lib/staff";
25+import { SUSPEND_ACCOUNT_ABOUT, parseReason } from "~/lib/suspension";
2526
2627 export const meta: Route.MetaFunction = ({ params }) => [
2728 { title: `${params.username} · sudo` },
5152 "deleted-with-workspaces": "Deleted the account and the workspaces it alone owned.",
5253 restored: "Restored the account.",
5354 "workspace-purged": `Purged ${slug}.`,
55+ suspended: "Suspended the account. It was signed out everywhere.",
56+ lifted: "Lifted the suspension. The account can sign in again.",
5457 };
5558 return {
5659 user,
100103 if (!result.ok) return data({ error: result.error.message, workspace: id }, { status: 422 });
101104 throw redirect(`/users/${encodeURIComponent(params.username)}?done=workspace-purged&slug=${encodeURIComponent(slug)}`);
102105 }
106+ if (intent === "suspend" || intent === "lift") {
107+ const suspend = intent === "suspend";
108+ const reason = suspend ? parseReason(text(form, "reason")) : { ok: true as const, value: "" };
109+ if (!reason.ok) return data({ error: reason.error, account: true }, { status: 422 });
110+ const result = await identity.suspendUser(params.username, suspend, reason.value, staff.email);
111+ if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 });
112+ throw back(suspend ? "suspended" : "lifted");
113+ }
103114 if (intent === "restore-account") {
104115 const result = await accountsAdmin.restoreAccount(text(form, "id"), staff.email);
105116 if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 });
143154 <span className="flex flex-wrap items-center gap-2">
144155 {user.username}
145156 {user.deleted && <Badge tone="danger">Deleted</Badge>}
157+ {user.suspended && <Badge tone="danger">Suspended</Badge>}
146158 {user.deletion.protected && <Badge tone="info">Protected</Badge>}
147159 </span>
148160 }
209221 </ul>
210222 </Section>
211223
224+ {!user.deleted && (
225+ <Section id="suspend" title="Suspension" description={SUSPEND_ACCOUNT_ABOUT} className="mt-6">
226+ {user.suspended ? (
227+ <Form method="post" className="space-y-3">
228+ <Notice tone="error">
229+ Suspended <When at={user.suspended.at} /> by {user.suspended.by}: {user.suspended.reason}
230+ </Notice>
231+ {accountError && <Notice tone="error">{accountError}</Notice>}
232+ <Button type="submit" name="intent" value="lift" variant="quiet">
233+ Lift the suspension
234+ </Button>
235+ </Form>
236+ ) : (
237+ <Form method="post" className="space-y-3">
238+ <Field label="Why" hint="Kept with the suspension and in the audit log.">
239+ <Input name="reason" required maxLength={500} placeholder="e.g. Scripted sign-ups for free compute" />
240+ </Field>
241+ {accountError && <Notice tone="error">{accountError}</Notice>}
242+ <Button type="submit" name="intent" value="suspend" variant="danger">
243+ Suspend {user.username}
244+ </Button>
245+ </Form>
246+ )}
247+ </Section>
248+ )}
249+
212250 <Section id="log" title="Security log" description="What happened to the account's addresses and password, newest first." className="mt-6">
213251 {user.log.length === 0 ? (
214252 <EmptyState title="Nothing yet" />
+234−0
1+import { ArrowLeft } from "lucide-react";
2+import { Form, Link, data, redirect } from "react-router";
3+
4+import type { AdminRunner, IngestToken } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/workspace-abuse";
7+import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, When } from "~/components/ui";
8+import { parseSlug, text } from "~/lib/forms";
9+import { admin, identity, runnersAdmin, telemetry, workspaceProjects } from "~/lib/services.server";
10+import { settle } from "~/lib/settle";
11+import { requireStaff } from "~/lib/staff";
12+import { SUSPEND_WORKSPACE_ABOUT, isSuspensionHold, parseReason, suspensionHold } from "~/lib/suspension";
13+
14+export const meta: Route.MetaFunction = ({ params }) => [
15+ { title: `Suspend and clean up · ${params.slug} · sudo` },
16+ { name: "robots", content: "noindex, nofollow" },
17+];
18+
19+function 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). */
26+async 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+
37+export 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([
41+ settle(identity.workspace(slug)),
42+ settle(runnersAdmin.list(slug)),
43+ settle(telemetryTokens(slug)),
44+ ]);
45+ if (detail.ok && !detail.value) throw data(`There is no workspace called ${slug}.`, { status: 404 });
46+ const done = new URL(request.url).searchParams.get("done");
47+ const DONE: Record<string, string> = {
48+ suspended: `Suspended ${slug}, and put a hold on its compute.`,
49+ lifted: `Lifted the suspension of ${slug}, and the hold it put on.`,
50+ runners: "Removed the runners. What they were running failed; their credentials stopped at once.",
51+ tokens: "Revoked the tokens. Anything sending with them is refused from now on.",
52+ };
53+ return {
54+ slug,
55+ suspended: detail.ok ? (detail.value?.suspended ?? null) : null,
56+ runners: runners.ok ? runners.value : ([] as AdminRunner[]),
57+ runnersError: runners.ok ? null : runners.error,
58+ tokens: tokens.ok ? tokens.value : [],
59+ tokensError: tokens.ok ? null : tokens.error,
60+ error: detail.ok ? null : detail.error,
61+ done: done ? (DONE[done] ?? null) : null,
62+ };
63+}
64+
65+/** Puts the suspension's hold on, or takes it off, keeping every other allowance. */
66+async function setHold(slug: string, hold: string | null, staff: string, note: string): Promise<string | null> {
67+ const account = await admin.account(slug);
68+ if (!account.ok) return account.error.message;
69+ const { allowances, id, kind } = account.value.summary.account;
70+ if (kind === "enterprise") return null;
71+ const current = allowances?.hold ?? null;
72+ // Lifting takes off only a hold a suspension put there.
73+ if (hold === null && !isSuspensionHold(current)) return null;
74+ const set = await admin.setAllowances(id, { plan: false, ossRepoMicros: null, trialMicros: null, ...allowances, hold }, note, staff);
75+ return set.ok ? null : set.error.message;
76+}
77+
78+export async function action({ params, request, context }: Route.ActionArgs) {
79+ const staff = requireStaff(context);
80+ const slug = slugOf(params.slug);
81+ const form = await request.formData();
82+ const intent = text(form, "intent");
83+ const back = (done: string) => redirect(`/workspaces/${encodeURIComponent(slug)}/abuse?done=${done}`);
84+ if (intent === "suspend") {
85+ const reason = parseReason(text(form, "reason"));
86+ if (!reason.ok) return data({ error: reason.error, section: "suspend" }, { status: 422 });
87+ const result = await identity.suspendWorkspace(slug, true, reason.value, staff.email);
88+ if (!result.ok) return data({ error: result.error.message, section: "suspend" }, { status: 422 });
89+ const held = await setHold(slug, suspensionHold(reason.value), staff.email, `Suspended: ${reason.value}`);
90+ if (held) return data({ error: `Suspended, but the hold on its compute was not set: ${held}`, section: "suspend" }, { status: 422 });
91+ throw back("suspended");
92+ }
93+ if (intent === "lift") {
94+ const result = await identity.suspendWorkspace(slug, false, "", staff.email);
95+ if (!result.ok) return data({ error: result.error.message, section: "suspend" }, { status: 422 });
96+ const held = await setHold(slug, null, staff.email, "Suspension lifted");
97+ if (held) return data({ error: `Lifted, but the hold on its compute was not taken off: ${held}`, section: "suspend" }, { status: 422 });
98+ throw back("lifted");
99+ }
100+ if (intent === "remove-runners") {
101+ const id = text(form, "id") || null;
102+ const result = await runnersAdmin.remove(slug, id, staff.email);
103+ if (!result.ok) return data({ error: result.error.message, section: "runners" }, { status: 422 });
104+ throw back("runners");
105+ }
106+ if (intent === "revoke-tokens") {
107+ const project = text(form, "project");
108+ const only = text(form, "id");
109+ const tokens = await telemetry.tokens({ project_id: project });
110+ for (const token of tokens.filter((token) => !only || token.id === only)) {
111+ await telemetry.revokeToken({ project_id: project, id: token.id });
112+ }
113+ console.log(`sudo: ${staff.email} revoked ingest tokens of ${slug}'s project ${project}`);
114+ throw back("tokens");
115+ }
116+ return data({ error: "Unknown action.", section: "top" }, { status: 400 });
117+}
118+
119+export default function WorkspaceAbuse({ loaderData, actionData }: Route.ComponentProps) {
120+ const { slug, suspended, runners, runnersError, tokens, tokensError, error, done } = loaderData;
121+ const errorFor = (section: string) => (actionData && "section" in actionData && actionData.section === section ? actionData.error : null);
122+ return (
123+ <main className="mx-auto max-w-4xl px-4 py-8 sm:py-10">
124+ <Link to={`/workspaces/${slug}`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
125+ <ArrowLeft size={14} /> {slug}
126+ </Link>
127+ <PageHeader title="Suspend and clean up" description={`What staff can stop in ${slug} after abuse: the workspace itself, its self-hosted runners, and its projects' telemetry tokens.`} />
128+ <div className="mt-5 space-y-3 empty:hidden">
129+ {done && <Notice tone="ok">{done}</Notice>}
130+ {error && <Notice tone="warn">Identity did not answer: {error}</Notice>}
131+ </div>
132+
133+ <Section id="suspend" title="Suspension" description={SUSPEND_WORKSPACE_ABOUT} className="mt-6">
134+ {suspended ? (
135+ <Form method="post" className="space-y-3">
136+ <Notice tone="error">
137+ Suspended <When at={suspended.at} /> by {suspended.by}: {suspended.reason}
138+ </Notice>
139+ {errorFor("suspend") && <Notice tone="error">{errorFor("suspend")}</Notice>}
140+ <Button type="submit" name="intent" value="lift" variant="quiet">
141+ Lift the suspension
142+ </Button>
143+ </Form>
144+ ) : (
145+ <Form method="post" className="space-y-3">
146+ <Field label="Why" hint="Kept with the suspension and in the audit log.">
147+ <Input name="reason" required maxLength={500} placeholder="e.g. Mining on self-hosted runners" />
148+ </Field>
149+ {errorFor("suspend") && <Notice tone="error">{errorFor("suspend")}</Notice>}
150+ <Button type="submit" name="intent" value="suspend" variant="danger">
151+ Suspend {slug}
152+ </Button>
153+ </Form>
154+ )}
155+ </Section>
156+
157+ <Section id="runners" title="Self-hosted runners" 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." className="mt-6">
158+ {runnersError && <Notice tone="warn">Actions did not answer: {runnersError}</Notice>}
159+ {errorFor("runners") && <Notice tone="error">{errorFor("runners")}</Notice>}
160+ {runners.length === 0 ? (
161+ <EmptyState title="No runners">{slug} has no self-hosted runners.</EmptyState>
162+ ) : (
163+ <div className="space-y-2">
164+ {runners.map((runner) => (
165+ <Form method="post" key={runner.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-line bg-surface px-3 py-2 text-sm">
166+ <span className="min-w-0">
167+ <span className="font-mono">{runner.name}</span>
168+ {runner.repo && <span className="text-muted"> · {runner.repo}</span>}
169+ <span className="block text-xs text-faint">
170+ {runner.os} · {runner.version} · last seen {runner.lastSeenAt ? <When at={runner.lastSeenAt} /> : "never"}
171+ {runner.createdBy ? ` · added by ${runner.createdBy}` : ""}
172+ </span>
173+ </span>
174+ <span className="flex items-center gap-2">
175+ {runner.busy && <Badge tone="warn">Running a job</Badge>}
176+ <input type="hidden" name="id" value={runner.id} />
177+ <Button type="submit" name="intent" value="remove-runners" variant="danger">
178+ Remove
179+ </Button>
180+ </span>
181+ </Form>
182+ ))}
183+ <Form method="post">
184+ <Button type="submit" name="intent" value="remove-runners" variant="danger">
185+ Remove all {runners.length} and end registration tokens
186+ </Button>
187+ </Form>
188+ </div>
189+ )}
190+ </Section>
191+
192+ <Section id="telemetry" title="Telemetry ingest tokens" 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." className="mt-6">
193+ {tokensError && <Notice tone="warn">Projects or telemetry did not answer: {tokensError}</Notice>}
194+ {tokens.every((row) => row.tokens.length === 0) ? (
195+ <EmptyState title="No ingest tokens">No project in {slug} has one.</EmptyState>
196+ ) : (
197+ <div className="space-y-4">
198+ {tokens
199+ .filter((row) => row.tokens.length > 0)
200+ .map((row) => (
201+ <div key={row.project.id} className="rounded-lg border border-line bg-surface p-3 text-sm">
202+ <div className="flex flex-wrap items-center justify-between gap-2">
203+ <span className="font-medium">{row.project.name}</span>
204+ <Form method="post">
205+ <input type="hidden" name="project" value={row.project.id} />
206+ <Button type="submit" name="intent" value="revoke-tokens" variant="danger">
207+ Revoke all {row.tokens.length}
208+ </Button>
209+ </Form>
210+ </div>
211+ <ul className="mt-2 space-y-1">
212+ {row.tokens.map((token) => (
213+ <li key={token.id} className="flex flex-wrap items-center justify-between gap-2">
214+ <span>
215+ {token.name} <span className="font-mono text-xs text-faint">{token.hint}</span>
216+ </span>
217+ <Form method="post">
218+ <input type="hidden" name="project" value={row.project.id} />
219+ <input type="hidden" name="id" value={token.id} />
220+ <Button type="submit" name="intent" value="revoke-tokens" variant="quiet">
221+ Revoke
222+ </Button>
223+ </Form>
224+ </li>
225+ ))}
226+ </ul>
227+ </div>
228+ ))}
229+ </div>
230+ )}
231+ </Section>
232+ </main>
233+ );
234+}
+4−0
220220 {person?.description && <p className="mt-1 text-sm text-muted">{person.description}</p>}
221221 <div className="mt-2 flex flex-wrap gap-1.5">
222222 {person?.protected && <Badge tone="info">Protected: can never be deleted</Badge>}
223+ {person?.suspended && <Badge tone="danger">Suspended</Badge>}
224+ <Link to={`/workspaces/${slug}/abuse`} className="self-center text-xs text-accent hover:underline">
225+ Suspend and clean up
226+ </Link>
223227 {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>}
224228 <TermsBadge terms={terms} />
225229 {terms.kind !== "standard" && terms.note && <span className="self-center text-xs text-muted">({terms.note})</span>}
+6−0
66 BILLING: ServiceBinding;
77 IDENTITY: ServiceBinding;
88 EVENTS: ServiceBinding;
9+ /** Actions: a workspace's self-hosted runners (admin_runners, admin_remove_runners). */
10+ ACTIONS: ServiceBinding;
11+ /** Projects: a workspace's projects, read as the workspace. */
12+ PROJECTS: ServiceBinding;
13+ /** Telemetry: projects' ingest tokens, to see and revoke. */
14+ TELEMETRY: ServiceBinding;
915 /** apps/status's `StatusAdmin` entrypoint: see `StatusAdminApi`. */
1016 STATUS: Fetcher;
1117 /** services/models's `Discovery` entrypoint: see `ModelDiscoveryApi`. */
+7−1
2727 // "Check for new models" on Agents & models: the model proxy's
2828 // `Discovery` entrypoint lists each provider's models now and records
2929 // them with billing's catalogue. Only bindings reach it.
30− { "binding": "MODELS", "service": "g1t-models", "entrypoint": "Discovery" }
30+ { "binding": "MODELS", "service": "g1t-models", "entrypoint": "Discovery" },
31+ // Suspending a workspace and cleaning up after abuse: its self-hosted
32+ // runners (actions' admin_runners and admin_remove_runners), its
33+ // projects, and their telemetry ingest tokens.
34+ { "binding": "ACTIONS", "service": "g1t-actions" },
35+ { "binding": "PROJECTS", "service": "g1t-projects" },
36+ { "binding": "TELEMETRY", "service": "g1t-telemetry" }
3137 ],
3238 "vars": {
3339 // The Zero Trust team domain, such as `g1t.cloudflareaccess.com`.
+3−0
517517 /// "Joined through <label>".
518518 #[serde(default)]
519519 pub joined_through: Option<crate::identity::SharedInviteSource>,
520+ /// Set while staff have it suspended.
521+ #[serde(default)]
522+ pub suspended: Option<crate::identity::Suspension>,
520523 }
521524
522525 /// `admin_remove_email`: staff remove an address from an account, such as
+60−0
969969 /// It can never be deleted ([`protected_names`]).
970970 #[serde(default)]
971971 pub protected: bool,
972+ /// Set while staff have it suspended.
973+ #[serde(default)]
974+ pub suspended: Option<Suspension>,
972975 }
973976
974977 /// A member of a workspace, as staff see them.
15081511 pub staff: String,
15091512 }
15101513
1514+// --- Suspension and blocked email domains, staff only ---
1515+//
1516+// See services/identity/src/suspension.rs. A suspended account cannot sign
1517+// in, and its sessions and tokens stop working at once; a suspended
1518+// workspace's own tokens stop working, and sudo also puts a hold on its
1519+// compute (billing), which stops its runs, deploys, new self-hosted
1520+// runners and telemetry. Both are undone the same way. A blocked email
1521+// domain makes no new accounts and confirms no new addresses.
1522+
1523+/// Why and since when staff suspended an account or a workspace.
1524+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1525+#[serde(rename_all = "camelCase")]
1526+pub struct Suspension {
1527+ pub reason: String,
1528+ /// RFC 3339.
1529+ pub at: String,
1530+ /// The staff member, by email.
1531+ pub by: String,
1532+}
1533+
1534+/// `admin_suspend_user` (`name` a username) and `admin_suspend_workspace`
1535+/// (`name` a slug): `suspend` true suspends with `reason` (required),
1536+/// false lifts it. Returns `Outcome<Option<Suspension>>`: what stands now.
1537+#[derive(Debug, Serialize, Deserialize)]
1538+pub struct AdminSuspendArgs {
1539+ pub name: String,
1540+ pub suspend: bool,
1541+ #[serde(default)]
1542+ pub reason: String,
1543+ pub staff: String,
1544+}
1545+
1546+/// An email domain no new account may sign up with.
1547+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1548+#[serde(rename_all = "camelCase")]
1549+pub struct BlockedDomain {
1550+ /// Lowercased, such as `spam.example`. Its subdomains are blocked too.
1551+ pub domain: String,
1552+ pub reason: String,
1553+ pub blocked_by: String,
1554+ /// RFC 3339.
1555+ pub created_at: String,
1556+}
1557+
1558+/// `admin_blocked_domains` takes `{}` and returns `Vec<BlockedDomain>`.
1559+///
1560+/// `admin_block_domain`: `block` true blocks `domain` with `reason`,
1561+/// false unblocks it. Returns `Outcome<Vec<BlockedDomain>>`, the list now.
1562+#[derive(Debug, Serialize, Deserialize)]
1563+pub struct AdminBlockDomainArgs {
1564+ pub domain: String,
1565+ pub block: bool,
1566+ #[serde(default)]
1567+ pub reason: String,
1568+ pub staff: String,
1569+}
1570+
15111571 // --- Shared invite links ---
15121572 //
15131573 // Before registration opened, staff handed groups shared invite links.
+40−0
797797 format!("Waiting for a self-hosted runner with labels {}.", wanted.describe())
798798 }
799799
800+// --- Staff only (sudo.g1t.sh) ---
801+
802+/// `admin_runners`: a workspace's self-hosted runners, its own and its
803+/// repositories', for staff. Returns `Vec<AdminRunner>`.
804+#[derive(Debug, Serialize, Deserialize)]
805+pub struct AdminRunnersArgs {
806+ pub workspace: String,
807+}
808+
809+/// A self-hosted runner as staff see it.
810+#[derive(Clone, Debug, Serialize, Deserialize)]
811+#[serde(rename_all = "camelCase")]
812+pub struct AdminRunner {
813+ pub id: String,
814+ pub name: String,
815+ /// `workspace/repo` for a repository's own runner.
816+ pub repo: Option<String>,
817+ pub os: String,
818+ pub version: String,
819+ /// Running something now.
820+ pub busy: bool,
821+ /// RFC 3339.
822+ pub last_seen_at: Option<String>,
823+ /// RFC 3339.
824+ pub created_at: String,
825+ pub created_by: Option<String>,
826+}
827+
828+/// `admin_remove_runners`: staff remove one of a workspace's runners
829+/// (`id`), or every one (`id` null), and end the registration tokens it
830+/// has out. What they were running fails; their credentials stop at once.
831+/// Returns `Outcome<u32>`, how many were removed.
832+#[derive(Debug, Serialize, Deserialize)]
833+pub struct AdminRemoveRunnersArgs {
834+ pub workspace: String,
835+ #[serde(default)]
836+ pub id: Option<String>,
837+ pub staff: String,
838+}
839+
800840 #[cfg(test)]
801841 mod tests {
802842 use super::*;
+3−1
44 */
55 import type { AccountDeletion, DeletedAccount } from "./account-deletion";
66 import type { ServiceBinding } from "./clients";
7−import type { User } from "./identity";
7+import type { Suspension, User } from "./identity";
88 import type { Result } from "./result";
99
1010 /** The most addresses one account may have, confirmed or not. */
127127 deleted: DeletedAccount | null;
128128 /** The shared invite link it was made with, if it was: sudo shows "Joined through <label>". */
129129 joinedThrough: { id: string; label: string } | null;
130+ /** Set while staff have it suspended. */
131+ suspended?: Suspension | null;
130132 };
131133
132134 /** Where an account's two-factor authentication stands. */
+14−1
1111 import type { PullDetail, WorkApi } from "./work";
1212 import type { Result } from "./result";
1313 import type { RunnerComputerApi } from "./runner";
14−import type { RunnersApi } from "./runners";
14+import type { RunnersAdminApi, RunnersApi } from "./runners";
1515
1616 /** A service binding, as far as these clients need it. */
1717 export type ServiceBinding = {
332332 workspace: (slug) => call("admin_workspace", { slug }),
333333 invites: (query) => call("admin_invites", { query: query ?? null }),
334334 revokeInvite: (id, staff) => call("admin_revoke_invite", { id, staff }),
335+ suspendUser: (name, suspend, reason, staff) => call("admin_suspend_user", { name, suspend, reason, staff }),
336+ suspendWorkspace: (name, suspend, reason, staff) => call("admin_suspend_workspace", { name, suspend, reason, staff }),
337+ blockedDomains: () => call("admin_blocked_domains", {}),
338+ blockDomain: (domain, block, reason, staff) => call("admin_block_domain", { domain, block, reason, staff }),
335339 deletedWorkspaces: () => call("admin_deleted_workspaces", {}),
336340 restoreWorkspace: (workspaceId, staff) => call("admin_restore_workspace", { workspaceId, staff }),
337341 purgeWorkspace: (workspaceId, staff, confirm) => call("admin_purge_workspace", { workspaceId, staff, confirm }),
831835 }
832836
833837
838+/** Staff-only self-hosted runners, on the actions service: only sudo calls it. */
839+export function runnersAdminClient(service: ServiceBinding): RunnersAdminApi {
840+ const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
841+ return {
842+ list: (workspace) => call("admin_runners", { workspace }),
843+ remove: (workspace, id, staff) => call("admin_remove_runners", { workspace, id, staff }),
844+ };
845+}
846+
834847 /** Self-hosted runners, kept by the actions service. */
835848 export function runnersClient(service: ServiceBinding): RunnersApi {
836849 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
+21−0
212212 members: AdminMember[];
213213 /** It can never be deleted, by anyone (identity's `PROTECTED_WORKSPACES`). */
214214 protected: boolean;
215+ /** Set while staff have it suspended. */
216+ suspended?: Suspension | null;
215217 };
216218
217219 /** The most workspaces one `workspaces` call returns. */
325327 */
326328 export type SharedInviteSource = { id: string; label: string };
327329
330+/** Why and since when staff suspended an account or a workspace. */
331+export type Suspension = { reason: string; at: string; by: string };
332+
333+/** An email domain no new account may sign up with; its subdomains too. */
334+export type BlockedDomain = { domain: string; reason: string; blockedBy: string; createdAt: string };
335+
328336 /** The most rows one staff listing of invites returns. */
329337 export const ADMIN_INVITES_LIMIT = 500;
330338
344352 invites(query?: string | null): Promise<Invite[]>;
345353 revokeInvite(id: string, staff: string): Promise<Result<Invite>>;
346354
355+ /**
356+ * Suspends an account (by username): it cannot sign in, and its sessions
357+ * and tokens stop at once. `suspend: false` lifts it. A reason is required
358+ * to suspend.
359+ */
360+ suspendUser(username: string, suspend: boolean, reason: string, staff: string): Promise<Result<Suspension | null>>;
361+ /** Suspends a workspace (by slug): its own tokens stop. Sudo also holds its compute in billing. */
362+ suspendWorkspace(slug: string, suspend: boolean, reason: string, staff: string): Promise<Result<Suspension | null>>;
363+ /** Email domains no new account may sign up with. */
364+ blockedDomains(): Promise<BlockedDomain[]>;
365+ /** Blocks (or with `block: false` unblocks) an email domain; returns the list now. */
366+ blockDomain(domain: string, block: boolean, reason: string, staff: string): Promise<Result<BlockedDomain[]>>;
367+
347368 /** Workspaces owners deleted that are not purged yet, newest first. */
348369 deletedWorkspaces(): Promise<DeletedWorkspace[]>;
349370 /**
+22−0
119119 /** A job that has waited ten minutes or more with no matching runner online. */
120120 export type StuckJob = { id: string; name: string; runId: string; repo: string; labels: string; queuedAt: string };
121121
122+/** A self-hosted runner as staff see it. Mirrors `AdminRunner` in crates/contracts. */
123+export type AdminRunner = {
124+ id: string;
125+ name: string;
126+ /** `workspace/repo` for a repository's own runner. */
127+ repo: string | null;
128+ os: string;
129+ version: string;
130+ /** Running something now. */
131+ busy: boolean;
132+ lastSeenAt: string | null;
133+ createdAt: string;
134+ createdBy: string | null;
135+};
136+
137+/** Staff-only: a workspace's runners, and removing them (sudo). */
138+export interface RunnersAdminApi {
139+ list(workspace: string): Promise<AdminRunner[]>;
140+ /** Removes one runner, or with `id` null every one, and the workspace's registration tokens. How many went. */
141+ remove(workspace: string, id: string | null, staff: string): Promise<Result<number>>;
142+}
143+
122144 export interface RunnersApi {
123145 /** The viewer's workspaces' jobs stuck waiting for a self-hosted runner, for Mission control. */
124146 stuck(viewer: User): Promise<StuckJob[]>;
+3−0
311311 "runners" => reply(&service.runners(args(body)?).await?),
312312 "create_registration_token" => reply(&service.create_registration_token(args(body)?).await?),
313313 "remove_runner" => reply(&service.remove_runner(args(body)?).await?),
314+ // Staff only, from sudo: a workspace's runners, and removing them.
315+ "admin_runners" => reply(&service.admin_runners(args(body)?).await?),
316+ "admin_remove_runners" => reply(&service.admin_remove_runners(args(body)?).await?),
314317 "runner_groups" => reply(&service.runner_groups(args(body)?).await?),
315318 "set_runner_group" => reply(&service.set_runner_group(args(body)?).await?),
316319 "delete_runner_group" => reply(&service.delete_runner_group(args(body)?).await?),
+49−0
516516 Ok(Outcome::Ok(true))
517517 }
518518
519+ /// `admin_runners`: staff see a workspace's runners.
520+ pub async fn admin_runners(&self, a: g1t_contracts::runners::AdminRunnersArgs) -> Result<Vec<g1t_contracts::runners::AdminRunner>> {
521+ let workspace = a.workspace.trim().to_lowercase();
522+ Ok(self
523+ .runner_rows(&workspace)
524+ .await?
525+ .into_iter()
526+ .map(|row| g1t_contracts::runners::AdminRunner {
527+ busy: row.work_id.is_some(),
528+ id: row.id,
529+ name: row.name,
530+ repo: row.repo,
531+ os: row.os,
532+ version: row.version,
533+ last_seen_at: row.last_seen_at,
534+ created_at: row.created_at,
535+ created_by: row.created_by,
536+ })
537+ .collect())
538+ }
539+
540+ /// `admin_remove_runners`: staff remove one runner or all of a
541+ /// workspace's, and end its registration tokens.
542+ pub async fn admin_remove_runners(&self, a: g1t_contracts::runners::AdminRemoveRunnersArgs) -> Result<Outcome<u32>> {
543+ let staff = a.staff.trim();
544+ if staff.is_empty() {
545+ return Ok(fail(FailureCode::Forbidden, "Say which staff member is doing it."));
546+ }
547+ let workspace = a.workspace.trim().to_lowercase();
548+ let rows: Vec<RunnerRow> = self
549+ .runner_rows(&workspace)
550+ .await?
551+ .into_iter()
552+ .filter(|row| a.id.as_deref().is_none_or(|id| id == row.id))
553+ .collect();
554+ if a.id.is_some() && rows.is_empty() {
555+ return Ok(fail(FailureCode::NotFound, "There is no such runner."));
556+ }
557+ let why = format!("g1t staff removed the runner from {workspace}.");
558+ for row in &rows {
559+ self.forget_runner(row, &why).await?;
560+ }
561+ if a.id.is_none() {
562+ self.db.prepare("DELETE FROM runner_registrations WHERE workspace = ?").bind(&[workspace.as_str().into()])?.run().await?;
563+ }
564+ worker::console_log!("actions: staff {staff} removed {} of {workspace}'s runners", rows.len());
565+ Ok(Outcome::Ok(u32::try_from(rows.len()).unwrap_or(u32::MAX)))
566+ }
567+
519568 /// Removes a runner: what it was running fails, and its credential
520569 /// stops working at once.
521570 async fn forget_runner(&self, row: &RunnerRow, why: &str) -> Result<()> {
+16−0
1+-- Staff suspend an account or a workspace, and block email domains from
2+-- signing up (src/suspension.rs). Lifting a suspension clears its columns;
3+-- every change is in the staff audit log.
4+ALTER TABLE users ADD COLUMN suspended_at TEXT;
5+ALTER TABLE users ADD COLUMN suspended_reason TEXT;
6+ALTER TABLE users ADD COLUMN suspended_by TEXT;
7+ALTER TABLE workspaces ADD COLUMN suspended_at TEXT;
8+ALTER TABLE workspaces ADD COLUMN suspended_reason TEXT;
9+ALTER TABLE workspaces ADD COLUMN suspended_by TEXT;
10+
11+CREATE TABLE IF NOT EXISTS blocked_domains (
12+ domain TEXT PRIMARY KEY,
13+ reason TEXT NOT NULL,
14+ blocked_by TEXT NOT NULL,
15+ created_at TEXT NOT NULL
16+);
+2−0
201201 })
202202 .collect();
203203 let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
204+ let suspended = self.workspace_suspension(&row.slug).await?;
204205 Ok(Some(AdminWorkspaceDetail {
205206 protected,
207+ suspended,
206208 slug: row.slug,
207209 name: row.name,
208210 description: row.description,
+4−0
303303 if parse_noreply(&email).is_some() || email.ends_with("@users.g1t.sh") {
304304 return Ok(Outcome::fail(FailureCode::Invalid, "That is a g1t noreply address; add an address you receive mail at."));
305305 }
306+ if self.email_blocked(&email).await? {
307+ return Ok(Outcome::fail(FailureCode::Forbidden, crate::suspension::BLOCKED));
308+ }
306309 if let Some(refusal) = self.proof(&a.user.id, &a.reauth).await?.refusal() {
307310 return Ok(refusal);
308311 }
11441147 deletion,
11451148 deleted,
11461149 joined_through: self.shared_source(user_id).await?,
1150+ suspended: self.user_suspension(user_id).await?,
11471151 }))
11481152 }
11491153
+3−0
579579 return Ok(Outcome::fail(FailureCode::Invalid, crate::USERNAME_RULES));
580580 };
581581 let username = chosen.canonical.as_str();
582+ if self.email_blocked(email).await? {
583+ return Ok(Outcome::fail(FailureCode::Forbidden, crate::suspension::BLOCKED));
584+ }
582585 // Made where every account is made, so the invite is checked and
583586 // spent in one place, with registration's rules (invites.rs).
584587 let user = match self
+21−0
2929 mod security;
3030 mod shared_invites;
3131 mod sounds;
32+mod suspension;
3233 mod teams;
3334 mod throttle;
3435 mod token_reach;
146147 let Some(mut user) = user else {
147148 return Ok(None);
148149 };
150+ // A suspended account is nobody, however it is presented
151+ // (suspension.rs): its sessions, tokens, keys and password.
152+ if self.user_suspension(&user.id).await?.is_some() {
153+ return Ok(None);
154+ }
149155 let memberships = self.memberships_and_policies(&user.id).await?;
150156 // Roles on single repositories, under the same policy (access.rs).
151157 let grants = self.grants_of(&user.id).await?;
406412 if !self.sign_up_allowed(a.client.as_deref()).await? {
407413 return Ok(Identity::too_many_sign_ups());
408414 }
415+ if self.email_blocked(&email).await? {
416+ return Ok(Outcome::fail(FailureCode::Forbidden, suspension::BLOCKED));
417+ }
409418 let taken = self
410419 .db
411420 // Usernames and workspaces share one namespace, so that a name
463472 Ok(user) => user,
464473 Err(message) => return Ok(Outcome::fail(FailureCode::Unauthenticated, message)),
465474 };
475+ if self.user_suspension(&user.id).await?.is_some() {
476+ return Ok(Outcome::fail(FailureCode::Forbidden, suspension::SUSPENDED));
477+ }
466478 let user = self.with_workspaces(Some(user)).await?.unwrap_or_default();
467479 self.start_session(user).await
468480 }
489501 if !self.account_live(&user.id).await? {
490502 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Incorrect username or password."));
491503 }
504+ // Nor a suspended one, however it was proved (suspension.rs).
505+ if self.user_suspension(&user.id).await?.is_some() {
506+ return Ok(Outcome::fail(FailureCode::Forbidden, suspension::SUSPENDED));
507+ }
492508 let session_token = crypto::random_hex(32);
493509 self.db
494510 .prepare(format!(
10921108 "admin_workspace" => reply(&identity.admin_workspace(args(body)?).await?),
10931109 "admin_invites" => reply(&identity.admin_invites(args(body)?).await?),
10941110 "admin_revoke_invite" => reply(&identity.admin_revoke_invite(args(body)?).await?),
1111+ // Suspending accounts and workspaces, blocking email domains; see suspension.rs.
1112+ "admin_suspend_user" => reply(&identity.admin_suspend_user(args(body)?).await?),
1113+ "admin_suspend_workspace" => reply(&identity.admin_suspend_workspace(args(body)?).await?),
1114+ "admin_blocked_domains" => reply(&identity.admin_blocked_domains().await?),
1115+ "admin_block_domain" => reply(&identity.admin_block_domain(args(body)?).await?),
10951116 // Deleted workspaces, restored or purged by staff; see deletion.rs.
10961117 "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?),
10971118 "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?),
+240−0
1+//! Staff suspending an account or a workspace, and blocking email domains
2+//! from signing up (sudo.g1t.sh).
3+//!
4+//! - A suspended **account** cannot sign in, with a password or GitHub:
5+//! it is told it is suspended. Every way of being it (sessions, access
6+//! tokens, SSH keys, git passwords) finds nobody while it is, and its
7+//! sessions are ended when it is suspended. Lifting it lets it sign in
8+//! again; tokens and keys work again as they were.
9+//! - A suspended **workspace**'s own tokens find nobody while it is. Sudo
10+//! also puts a hold on its compute in billing, which stops its runs,
11+//! deploys, new self-hosted runners and telemetry.
12+//! - A **blocked domain** (and its subdomains) makes no new account, with
13+//! a password or GitHub, and is not added to an existing one. Accounts
14+//! already there are not touched: suspend them one at a time.
15+//!
16+//! Every change is in sudo's audit log, with the staff member.
17+
18+use g1t_contracts::identity::{AdminBlockDomainArgs, AdminSuspendArgs, BlockedDomain, Suspension};
19+use g1t_contracts::time::rfc3339;
20+use g1t_contracts::{FailureCode, Outcome};
21+use g1t_kit::now_ms;
22+use serde::Deserialize;
23+use worker::Result;
24+
25+use crate::Identity;
26+
27+/// What a suspended account is told when it signs in.
28+pub const SUSPENDED: &str = "This account is suspended. If you think this is a mistake, write to hey@flagon.io from its address.";
29+/// What signing up with a blocked domain is told.
30+pub const BLOCKED: &str = "Accounts cannot be made with addresses at this domain. Use another address.";
31+
32+/// A domain as staff type it: lowercased, without `@` or a trailing dot,
33+/// with at least one dot. None when it is not one.
34+pub fn normalize_domain(input: &str) -> Option<String> {
35+ let domain = input.trim().trim_start_matches('@').trim_end_matches('.').to_ascii_lowercase();
36+ let well_formed = domain.len() <= 253
37+ && domain.contains('.')
38+ && domain.split('.').all(|label| {
39+ !label.is_empty() && label.len() <= 63 && !label.starts_with('-') && !label.ends_with('-') && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
40+ });
41+ well_formed.then_some(domain)
42+}
43+
44+/// The domains a blocked list would match for `email`: its own and each
45+/// parent, so blocking `example.com` blocks `mail.example.com` too.
46+pub fn domains_of(email: &str) -> Vec<String> {
47+ let email = email.trim().to_ascii_lowercase();
48+ let Some((_, domain)) = email.rsplit_once('@') else {
49+ return Vec::new();
50+ };
51+ let labels: Vec<&str> = domain.split('.').collect();
52+ (0..labels.len().saturating_sub(1)).map(|at| labels[at..].join(".")).collect()
53+}
54+
55+#[derive(Deserialize)]
56+struct SuspensionRow {
57+ suspended_at: Option<String>,
58+ suspended_reason: Option<String>,
59+ suspended_by: Option<String>,
60+}
61+
62+impl SuspensionRow {
63+ fn shown(self) -> Option<Suspension> {
64+ Some(Suspension {
65+ at: self.suspended_at?,
66+ reason: self.suspended_reason.unwrap_or_default(),
67+ by: self.suspended_by.unwrap_or_default(),
68+ })
69+ }
70+}
71+
72+#[derive(Deserialize)]
73+struct DomainRow {
74+ domain: String,
75+ reason: String,
76+ blocked_by: String,
77+ created_at: String,
78+}
79+
80+impl Identity {
81+ /// The account's suspension, if it has one.
82+ pub(crate) async fn user_suspension(&self, user_id: &str) -> Result<Option<Suspension>> {
83+ Ok(self
84+ .db
85+ .prepare("SELECT suspended_at, suspended_reason, suspended_by FROM users WHERE id = ?")
86+ .bind(&[user_id.into()])?
87+ .first::<SuspensionRow>(None)
88+ .await?
89+ .and_then(SuspensionRow::shown))
90+ }
91+
92+ /// The workspace's suspension, if it has one, by slug.
93+ pub(crate) async fn workspace_suspension(&self, slug: &str) -> Result<Option<Suspension>> {
94+ Ok(self
95+ .db
96+ .prepare("SELECT suspended_at, suspended_reason, suspended_by FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
97+ .bind(&[slug.trim().to_lowercase().into()])?
98+ .first::<SuspensionRow>(None)
99+ .await?
100+ .and_then(SuspensionRow::shown))
101+ }
102+
103+ /// Whether an address is at a blocked domain.
104+ pub(crate) async fn email_blocked(&self, email: &str) -> Result<bool> {
105+ let domains = domains_of(email);
106+ if domains.is_empty() {
107+ return Ok(false);
108+ }
109+ let marks = vec!["?"; domains.len()].join(", ");
110+ let binds: Vec<worker::wasm_bindgen::JsValue> = domains.iter().map(|d| d.as_str().into()).collect();
111+ Ok(self
112+ .db
113+ .prepare(format!("SELECT domain FROM blocked_domains WHERE domain IN ({marks}) LIMIT 1"))
114+ .bind(&binds)?
115+ .first::<serde_json::Value>(None)
116+ .await?
117+ .is_some())
118+ }
119+
120+ pub async fn admin_suspend_user(&self, a: AdminSuspendArgs) -> Result<Outcome<Option<Suspension>>> {
121+ let username = a.name.trim().trim_start_matches('@').to_lowercase();
122+ self.suspend("users", "username", &username, a, "account").await
123+ }
124+
125+ pub async fn admin_suspend_workspace(&self, a: AdminSuspendArgs) -> Result<Outcome<Option<Suspension>>> {
126+ let slug = a.name.trim().to_lowercase();
127+ self.suspend("workspaces", "slug", &slug, a, "workspace").await
128+ }
129+
130+ async fn suspend(&self, table: &str, column: &str, name: &str, a: AdminSuspendArgs, what: &str) -> Result<Outcome<Option<Suspension>>> {
131+ let staff = a.staff.trim();
132+ if staff.is_empty() {
133+ return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is doing it."));
134+ }
135+ let reason = a.reason.trim();
136+ if a.suspend && reason.is_empty() {
137+ return Ok(Outcome::fail(FailureCode::Invalid, format!("Say why the {what} is suspended. It is kept with the suspension.")));
138+ }
139+ #[derive(Deserialize)]
140+ struct Id {
141+ id: String,
142+ }
143+ let Some(Id { id }) = self
144+ .db
145+ .prepare(format!("SELECT id FROM {table} WHERE {column} = ? AND deleted_at IS NULL"))
146+ .bind(&[name.into()])?
147+ .first::<Id>(None)
148+ .await?
149+ else {
150+ return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no {what} called {name}.")));
151+ };
152+ if a.suspend {
153+ self.db
154+ .prepare(format!("UPDATE {table} SET suspended_at = ?, suspended_reason = ?, suspended_by = ? WHERE id = ?"))
155+ .bind(&[rfc3339(now_ms()).into(), reason.into(), staff.into(), id.as_str().into()])?
156+ .run()
157+ .await?;
158+ if table == "users" {
159+ // Signed out everywhere at once.
160+ self.db.prepare("DELETE FROM sessions WHERE user_id = ?").bind(&[id.as_str().into()])?.run().await?;
161+ }
162+ } else {
163+ self.db
164+ .prepare(format!("UPDATE {table} SET suspended_at = NULL, suspended_reason = NULL, suspended_by = NULL WHERE id = ?"))
165+ .bind(&[id.as_str().into()])?
166+ .run()
167+ .await?;
168+ }
169+ let (action, detail) = if a.suspend {
170+ (format!("{what}_suspended"), format!("Suspended {name}: {reason}"))
171+ } else {
172+ (format!("{what}_unsuspended"), format!("Lifted the suspension of {name}"))
173+ };
174+ self.record_for_staff(name, &action, &detail, staff).await;
175+ let now = if table == "users" { self.user_suspension(&id).await? } else { self.workspace_suspension(name).await? };
176+ Ok(Outcome::Ok(now))
177+ }
178+
179+ pub async fn admin_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
180+ Ok(self
181+ .db
182+ .prepare("SELECT domain, reason, blocked_by, created_at FROM blocked_domains ORDER BY domain")
183+ .all()
184+ .await?
185+ .results::<DomainRow>()?
186+ .into_iter()
187+ .map(|row| BlockedDomain { domain: row.domain, reason: row.reason, blocked_by: row.blocked_by, created_at: row.created_at })
188+ .collect())
189+ }
190+
191+ pub async fn admin_block_domain(&self, a: AdminBlockDomainArgs) -> Result<Outcome<Vec<BlockedDomain>>> {
192+ let staff = a.staff.trim();
193+ if staff.is_empty() {
194+ return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is doing it."));
195+ }
196+ let Some(domain) = normalize_domain(&a.domain) else {
197+ return Ok(Outcome::fail(FailureCode::Invalid, "Enter a domain, such as spam.example."));
198+ };
199+ if a.block {
200+ let reason = a.reason.trim();
201+ if reason.is_empty() {
202+ return Ok(Outcome::fail(FailureCode::Invalid, "Say why the domain is blocked."));
203+ }
204+ self.db
205+ .prepare(
206+ "INSERT INTO blocked_domains (domain, reason, blocked_by, created_at) VALUES (?, ?, ?, ?)
207+ ON CONFLICT (domain) DO UPDATE SET reason = excluded.reason, blocked_by = excluded.blocked_by",
208+ )
209+ .bind(&[domain.as_str().into(), reason.into(), staff.into(), rfc3339(now_ms()).into()])?
210+ .run()
211+ .await?;
212+ self.record_for_staff(&domain, "email_domain_blocked", &format!("Blocked sign-ups from {domain}: {reason}"), staff).await;
213+ } else {
214+ self.db.prepare("DELETE FROM blocked_domains WHERE domain = ?").bind(&[domain.as_str().into()])?.run().await?;
215+ self.record_for_staff(&domain, "email_domain_unblocked", &format!("Unblocked sign-ups from {domain}"), staff).await;
216+ }
217+ Ok(Outcome::Ok(self.admin_blocked_domains().await?))
218+ }
219+}
220+
221+#[cfg(test)]
222+mod tests {
223+ use super::*;
224+
225+ #[test]
226+ fn domains_are_read_as_staff_type_them() {
227+ assert_eq!(normalize_domain(" @Spam.Example. ").as_deref(), Some("spam.example"));
228+ assert_eq!(normalize_domain("mail.spam.example").as_deref(), Some("mail.spam.example"));
229+ for bad in ["", "localhost", "spam..example", "-spam.example", "spam example.com", "a@b.com"] {
230+ assert_eq!(normalize_domain(bad), None, "{bad}");
231+ }
232+ }
233+
234+ #[test]
235+ fn a_blocked_domain_blocks_its_subdomains_too() {
236+ assert_eq!(domains_of("Ada@Mail.Spam.Example"), ["mail.spam.example", "spam.example"]);
237+ assert_eq!(domains_of("ada@example.com"), ["example.com"]);
238+ assert!(domains_of("not-an-address").is_empty());
239+ }
240+}
+3−2
253253 pub(crate) async fn workspace_principal(&self, workspace_id: &str) -> Result<Viewer> {
254254 let workspace = self
255255 .db
256− // A deleted workspace's tokens are refused until it is restored.
257− .prepare("SELECT id, slug FROM workspaces WHERE id = ? AND deleted_at IS NULL")
256+ // A deleted workspace's tokens are refused until it is restored,
257+ // and a suspended one's until staff lift it (suspension.rs).
258+ .prepare("SELECT id, slug FROM workspaces WHERE id = ? AND deleted_at IS NULL AND suspended_at IS NULL")
258259 .bind(&[workspace_id.into()])?
259260 .first::<WorkspaceRef>(None)
260261 .await?;
+9−7
5454
5555 /**
5656 * The day's share a project may send, or null: only for a project whose
57− * workspace (as the site last told its store) is on the free plan. A
58− * project whose workspace is not known yet, or a billing that does not
59− * answer, gets none, so nobody's telemetry stops on g1t's account.
57+ * workspace (as the site last told its store) is on the free plan, and 0
58+ * for one g1t staff put a hold on (or suspended). A project whose
59+ * workspace is not known yet, or a billing that does not answer, gets
60+ * none, so nobody's telemetry stops on g1t's account.
6061 */
6162 async function capFor(env: Env, projectId: string): Promise<number | null> {
6263 const cached = caps.get(projectId);
6364 if (cached && Date.now() - cached.at < CAP_TTL_MS) return cached.cap;
6465 let cap: number | null = null;
6566 const daily = dailyCap(freeMonthlyBytes(env.FREE_TELEMETRY_BYTES_PER_MONTH));
66− if (daily && env.BILLING) {
67+ if (env.BILLING) {
6768 try {
6869 const workspace = await store(env, projectId).workspaceOf();
6970 if (workspace) {
7071 const { billingClient } = await import("@g1t/contracts");
71− const free = await billingClient(env.BILLING as never).freeWorkspaces([workspace]);
72− if (free.map((slug) => slug.toLowerCase()).includes(workspace)) cap = daily;
72+ const standing = await billingClient(env.BILLING as never).entitlements(workspace);
73+ if (standing.held) cap = 0;
74+ else if (standing.plan === "free" && daily) cap = daily;
7375 }
7476 } catch (error) {
7577 console.error("could not tell whether the project's workspace is free", projectId, error);
189191 }
190192 const cap = await capFor(env, project);
191193 const done = await store(env, project).ingest(read.batch, [], token, cap);
192− if (!done.accepted && done.reason === "quota" && cap) return refused(429, overCapMessage(cap));
194+ if (!done.accepted && done.reason === "quota" && cap != null) return refused(429, overCapMessage(cap));
193195 if (!done.accepted) return refused(401, "That token is not one of the project's, or it was revoked.");
194196 return accepted(read.protobuf);
195197 }
+3−0
1818 assert.equal(overCap(90, 11, 100), true);
1919 assert.equal(overCap(1e12, 1e12, null), false);
2020 assert.match(overCapMessage(35_791_395), /its 34 MB for today/);
21+ // A held workspace's projects take nothing.
22+ assert.equal(overCap(0, 1, 0), true);
23+ assert.match(overCapMessage(0), /put a hold on/);
2124 });
2225
2326 test("a row counts as what is kept of it", () => {
+7−3
2525 return monthly > 0 ? Math.ceil(monthly / DAYS_PER_MONTH) : null;
2626 }
2727
28−/** Whether `adding` more bytes today would go past `cap`. */
28+/**
29+ * Whether `adding` more bytes today would go past `cap`. A cap of 0 takes
30+ * nothing: a workspace g1t staff put a hold on (or suspended).
31+ */
2932 export function overCap(today: number, adding: number, cap: number | null | undefined): boolean {
30− return cap != null && cap > 0 && today + adding > cap;
33+ return cap != null && cap >= 0 && today + adding > cap;
3134 }
3235
3336 /** The size of a row as it is kept: its JSON. */
3538 return JSON.stringify(value)?.length ?? 0;
3639 }
3740
38−/** What OTLP past the day's share is told. */
41+/** What OTLP past the day's share is told; with a cap of 0, that the workspace is held. */
3942 export function overCapMessage(cap: number): string {
43+ if (cap === 0) return "g1t staff put a hold on this project's workspace, so its telemetry is not taken. Write to hey@flagon.io if you think this is a mistake.";
4044 const mb = Math.round(cap / (1024 * 1024));
4145 return `This project has sent its ${mb} MB for today, a workspace on the free plan's daily share of its monthly observability. Send less, or start the g1t plan; more is taken from 00:00 UTC.`;
4246 }

Comments

Sign in to comment.