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.
| 54 | 54 | still opens and says so in those sections. A protected workspace (one | |
| 55 | 55 | nobody can ever delete: identity's `PROTECTED_WORKSPACES`, and | |
| 56 | 56 | 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. | |
| 57 | 66 | - **Deleted workspaces** (`/workspaces/deleted`, linked from Workspaces): | |
| 58 | 67 | workspaces their owners deleted, newest first (`admin_deleted_workspaces`), | |
| 59 | 68 | each with who deleted it and when, when it is purged, what went with it | |
| ⋯ | |||
| 114 | 123 | first: once the account is purged its page is gone (they stay on | |
| 115 | 124 | Deleted workspaces). To undo it all, restore the account first, then | |
| 116 | 125 | 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. | |
| 117 | 134 | - **Deleted accounts** (`/users/deleted`, linked from Workspaces): | |
| 118 | 135 | accounts deleted by the person or by staff, newest first | |
| 119 | 136 | (`admin_deleted_accounts`), each with who deleted it (the person, or the | |
| 40 | 40 | ||
| 41 | 41 | test("the built pages are not marked soon", () => { | |
| 42 | 42 | 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"]); | |
| 44 | 44 | }); | |
| 45 | 45 | ||
| 46 | 46 | test("every soon page says what it will do, why, and what it will have", () => { |
| 231 | 231 | about: "Sandboxes stopped for looking like mining, and disputes and declines.", | |
| 232 | 232 | }, | |
| 233 | 233 | { | |
| 234 | + | label: "Blocked domains", | |
| 235 | + | to: "/blocked-domains", | |
| 236 | + | icon: "abuse", | |
| 237 | + | about: "Email domains no new account may sign up with.", | |
| 238 | + | }, | |
| 239 | + | { | |
| 234 | 240 | label: "Incidents", | |
| 235 | 241 | to: "/incidents", | |
| 236 | 242 | icon: "incidents", |
| 8 | 8 | billingClient, | |
| 9 | 9 | eventsClient, | |
| 10 | 10 | identityAdminClient, | |
| 11 | + | projectsClient, | |
| 12 | + | runnersAdminClient, | |
| 13 | + | telemetryClient, | |
| 14 | + | type User, | |
| 11 | 15 | } from "@g1t/contracts"; | |
| 12 | 16 | ||
| 13 | 17 | /** Staff-only billing, on the billing service. */ | |
| ⋯ | |||
| 34 | 38 | /** "Check for new models": the model proxy's `Discovery` entrypoint (services/models). */ | |
| 35 | 39 | export const modelDiscovery = env.MODELS as unknown as ModelDiscoveryApi; | |
| 36 | 40 | ||
| 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 | + | ||
| 37 | 53 | /** STAFF_EMAILS, for suggesting staff in the incident roles. */ | |
| 38 | 54 | export const staffEmails = (): string => env.STAFF_EMAILS ?? ""; | |
| 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 | + | }); |
| 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."; |
| 7 | 7 | route("workspaces", "routes/workspaces.tsx"), | |
| 8 | 8 | route("workspaces/deleted", "routes/deleted-workspaces.tsx"), | |
| 9 | 9 | route("workspaces/:slug", "routes/workspace.tsx"), | |
| 10 | + | route("workspaces/:slug/abuse", "routes/workspace-abuse.tsx"), | |
| 10 | 11 | route("users/deleted", "routes/deleted-accounts.tsx"), | |
| 11 | 12 | route("users/:username", "routes/user.tsx"), | |
| 12 | 13 | route("enterprises", "routes/enterprises.tsx"), | |
| ⋯ | |||
| 22 | 23 | route("costs/bill", "routes/costs-bill.tsx"), | |
| 23 | 24 | route("agents", "routes/agents.tsx"), | |
| 24 | 25 | route("abuse", "routes/abuse.tsx"), | |
| 26 | + | route("blocked-domains", "routes/blocked-domains.tsx"), | |
| 25 | 27 | route("invoices", "routes/invoices.tsx"), | |
| 26 | 28 | route("credits", "routes/credits.tsx"), | |
| 27 | 29 | route("stripe", "routes/stripe.tsx"), | |
| 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 | + | } |
| 22 | 22 | import { accountsAdmin, identity } from "~/lib/services.server"; | |
| 23 | 23 | import { settle } from "~/lib/settle"; | |
| 24 | 24 | import { requireStaff } from "~/lib/staff"; | |
| 25 | + | import { SUSPEND_ACCOUNT_ABOUT, parseReason } from "~/lib/suspension"; | |
| 25 | 26 | ||
| 26 | 27 | export const meta: Route.MetaFunction = ({ params }) => [ | |
| 27 | 28 | { title: `${params.username} · sudo` }, | |
| ⋯ | |||
| 51 | 52 | "deleted-with-workspaces": "Deleted the account and the workspaces it alone owned.", | |
| 52 | 53 | restored: "Restored the account.", | |
| 53 | 54 | "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.", | |
| 54 | 57 | }; | |
| 55 | 58 | return { | |
| 56 | 59 | user, | |
| ⋯ | |||
| 100 | 103 | if (!result.ok) return data({ error: result.error.message, workspace: id }, { status: 422 }); | |
| 101 | 104 | throw redirect(`/users/${encodeURIComponent(params.username)}?done=workspace-purged&slug=${encodeURIComponent(slug)}`); | |
| 102 | 105 | } | |
| 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 | + | } | |
| 103 | 114 | if (intent === "restore-account") { | |
| 104 | 115 | const result = await accountsAdmin.restoreAccount(text(form, "id"), staff.email); | |
| 105 | 116 | if (!result.ok) return data({ error: result.error.message, account: true }, { status: 422 }); | |
| ⋯ | |||
| 143 | 154 | <span className="flex flex-wrap items-center gap-2"> | |
| 144 | 155 | {user.username} | |
| 145 | 156 | {user.deleted && <Badge tone="danger">Deleted</Badge>} | |
| 157 | + | {user.suspended && <Badge tone="danger">Suspended</Badge>} | |
| 146 | 158 | {user.deletion.protected && <Badge tone="info">Protected</Badge>} | |
| 147 | 159 | </span> | |
| 148 | 160 | } | |
| ⋯ | |||
| 209 | 221 | </ul> | |
| 210 | 222 | </Section> | |
| 211 | 223 | ||
| 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 | + | ||
| 212 | 250 | <Section id="log" title="Security log" description="What happened to the account's addresses and password, newest first." className="mt-6"> | |
| 213 | 251 | {user.log.length === 0 ? ( | |
| 214 | 252 | <EmptyState title="Nothing yet" /> | |
| 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 | + | } |
| 220 | 220 | {person?.description && <p className="mt-1 text-sm text-muted">{person.description}</p>} | |
| 221 | 221 | <div className="mt-2 flex flex-wrap gap-1.5"> | |
| 222 | 222 | {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> | |
| 223 | 227 | {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>} | |
| 224 | 228 | <TermsBadge terms={terms} /> | |
| 225 | 229 | {terms.kind !== "standard" && terms.note && <span className="self-center text-xs text-muted">({terms.note})</span>} |
| 6 | 6 | BILLING: ServiceBinding; | |
| 7 | 7 | IDENTITY: ServiceBinding; | |
| 8 | 8 | 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; | |
| 9 | 15 | /** apps/status's `StatusAdmin` entrypoint: see `StatusAdminApi`. */ | |
| 10 | 16 | STATUS: Fetcher; | |
| 11 | 17 | /** services/models's `Discovery` entrypoint: see `ModelDiscoveryApi`. */ |
| 27 | 27 | // "Check for new models" on Agents & models: the model proxy's | |
| 28 | 28 | // `Discovery` entrypoint lists each provider's models now and records | |
| 29 | 29 | // 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" } | |
| 31 | 37 | ], | |
| 32 | 38 | "vars": { | |
| 33 | 39 | // The Zero Trust team domain, such as `g1t.cloudflareaccess.com`. |
| 517 | 517 | /// "Joined through <label>". | |
| 518 | 518 | #[serde(default)] | |
| 519 | 519 | pub joined_through: Option<crate::identity::SharedInviteSource>, | |
| 520 | + | /// Set while staff have it suspended. | |
| 521 | + | #[serde(default)] | |
| 522 | + | pub suspended: Option<crate::identity::Suspension>, | |
| 520 | 523 | } | |
| 521 | 524 | ||
| 522 | 525 | /// `admin_remove_email`: staff remove an address from an account, such as |
| 969 | 969 | /// It can never be deleted ([`protected_names`]). | |
| 970 | 970 | #[serde(default)] | |
| 971 | 971 | pub protected: bool, | |
| 972 | + | /// Set while staff have it suspended. | |
| 973 | + | #[serde(default)] | |
| 974 | + | pub suspended: Option<Suspension>, | |
| 972 | 975 | } | |
| 973 | 976 | ||
| 974 | 977 | /// A member of a workspace, as staff see them. | |
| ⋯ | |||
| 1508 | 1511 | pub staff: String, | |
| 1509 | 1512 | } | |
| 1510 | 1513 | ||
| 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 | + | ||
| 1511 | 1571 | // --- Shared invite links --- | |
| 1512 | 1572 | // | |
| 1513 | 1573 | // Before registration opened, staff handed groups shared invite links. | |
| 797 | 797 | format!("Waiting for a self-hosted runner with labels {}.", wanted.describe()) | |
| 798 | 798 | } | |
| 799 | 799 | ||
| 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 | + | ||
| 800 | 840 | #[cfg(test)] | |
| 801 | 841 | mod tests { | |
| 802 | 842 | use super::*; |
| 4 | 4 | */ | |
| 5 | 5 | import type { AccountDeletion, DeletedAccount } from "./account-deletion"; | |
| 6 | 6 | import type { ServiceBinding } from "./clients"; | |
| 7 | − | import type { User } from "./identity"; | |
| 7 | + | import type { Suspension, User } from "./identity"; | |
| 8 | 8 | import type { Result } from "./result"; | |
| 9 | 9 | ||
| 10 | 10 | /** The most addresses one account may have, confirmed or not. */ | |
| ⋯ | |||
| 127 | 127 | deleted: DeletedAccount | null; | |
| 128 | 128 | /** The shared invite link it was made with, if it was: sudo shows "Joined through <label>". */ | |
| 129 | 129 | joinedThrough: { id: string; label: string } | null; | |
| 130 | + | /** Set while staff have it suspended. */ | |
| 131 | + | suspended?: Suspension | null; | |
| 130 | 132 | }; | |
| 131 | 133 | ||
| 132 | 134 | /** Where an account's two-factor authentication stands. */ | |
| 11 | 11 | import type { PullDetail, WorkApi } from "./work"; | |
| 12 | 12 | import type { Result } from "./result"; | |
| 13 | 13 | import type { RunnerComputerApi } from "./runner"; | |
| 14 | − | import type { RunnersApi } from "./runners"; | |
| 14 | + | import type { RunnersAdminApi, RunnersApi } from "./runners"; | |
| 15 | 15 | ||
| 16 | 16 | /** A service binding, as far as these clients need it. */ | |
| 17 | 17 | export type ServiceBinding = { | |
| ⋯ | |||
| 332 | 332 | workspace: (slug) => call("admin_workspace", { slug }), | |
| 333 | 333 | invites: (query) => call("admin_invites", { query: query ?? null }), | |
| 334 | 334 | 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 }), | |
| 335 | 339 | deletedWorkspaces: () => call("admin_deleted_workspaces", {}), | |
| 336 | 340 | restoreWorkspace: (workspaceId, staff) => call("admin_restore_workspace", { workspaceId, staff }), | |
| 337 | 341 | purgeWorkspace: (workspaceId, staff, confirm) => call("admin_purge_workspace", { workspaceId, staff, confirm }), | |
| ⋯ | |||
| 831 | 835 | } | |
| 832 | 836 | ||
| 833 | 837 | ||
| 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 | + | ||
| 834 | 847 | /** Self-hosted runners, kept by the actions service. */ | |
| 835 | 848 | export function runnersClient(service: ServiceBinding): RunnersApi { | |
| 836 | 849 | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); | |
| 212 | 212 | members: AdminMember[]; | |
| 213 | 213 | /** It can never be deleted, by anyone (identity's `PROTECTED_WORKSPACES`). */ | |
| 214 | 214 | protected: boolean; | |
| 215 | + | /** Set while staff have it suspended. */ | |
| 216 | + | suspended?: Suspension | null; | |
| 215 | 217 | }; | |
| 216 | 218 | ||
| 217 | 219 | /** The most workspaces one `workspaces` call returns. */ | |
| ⋯ | |||
| 325 | 327 | */ | |
| 326 | 328 | export type SharedInviteSource = { id: string; label: string }; | |
| 327 | 329 | ||
| 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 | + | ||
| 328 | 336 | /** The most rows one staff listing of invites returns. */ | |
| 329 | 337 | export const ADMIN_INVITES_LIMIT = 500; | |
| 330 | 338 | ||
| ⋯ | |||
| 344 | 352 | invites(query?: string | null): Promise<Invite[]>; | |
| 345 | 353 | revokeInvite(id: string, staff: string): Promise<Result<Invite>>; | |
| 346 | 354 | ||
| 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 | + | ||
| 347 | 368 | /** Workspaces owners deleted that are not purged yet, newest first. */ | |
| 348 | 369 | deletedWorkspaces(): Promise<DeletedWorkspace[]>; | |
| 349 | 370 | /** | |
| 119 | 119 | /** A job that has waited ten minutes or more with no matching runner online. */ | |
| 120 | 120 | export type StuckJob = { id: string; name: string; runId: string; repo: string; labels: string; queuedAt: string }; | |
| 121 | 121 | ||
| 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 | + | ||
| 122 | 144 | export interface RunnersApi { | |
| 123 | 145 | /** The viewer's workspaces' jobs stuck waiting for a self-hosted runner, for Mission control. */ | |
| 124 | 146 | stuck(viewer: User): Promise<StuckJob[]>; |
| 311 | 311 | "runners" => reply(&service.runners(args(body)?).await?), | |
| 312 | 312 | "create_registration_token" => reply(&service.create_registration_token(args(body)?).await?), | |
| 313 | 313 | "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?), | |
| 314 | 317 | "runner_groups" => reply(&service.runner_groups(args(body)?).await?), | |
| 315 | 318 | "set_runner_group" => reply(&service.set_runner_group(args(body)?).await?), | |
| 316 | 319 | "delete_runner_group" => reply(&service.delete_runner_group(args(body)?).await?), |
| 516 | 516 | Ok(Outcome::Ok(true)) | |
| 517 | 517 | } | |
| 518 | 518 | ||
| 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 | + | ||
| 519 | 568 | /// Removes a runner: what it was running fails, and its credential | |
| 520 | 569 | /// stops working at once. | |
| 521 | 570 | async fn forget_runner(&self, row: &RunnerRow, why: &str) -> Result<()> { |
| 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 | + | ); |
| 201 | 201 | }) | |
| 202 | 202 | .collect(); | |
| 203 | 203 | let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?; | |
| 204 | + | let suspended = self.workspace_suspension(&row.slug).await?; | |
| 204 | 205 | Ok(Some(AdminWorkspaceDetail { | |
| 205 | 206 | protected, | |
| 207 | + | suspended, | |
| 206 | 208 | slug: row.slug, | |
| 207 | 209 | name: row.name, | |
| 208 | 210 | description: row.description, |
| 303 | 303 | if parse_noreply(&email).is_some() || email.ends_with("@users.g1t.sh") { | |
| 304 | 304 | return Ok(Outcome::fail(FailureCode::Invalid, "That is a g1t noreply address; add an address you receive mail at.")); | |
| 305 | 305 | } | |
| 306 | + | if self.email_blocked(&email).await? { | |
| 307 | + | return Ok(Outcome::fail(FailureCode::Forbidden, crate::suspension::BLOCKED)); | |
| 308 | + | } | |
| 306 | 309 | if let Some(refusal) = self.proof(&a.user.id, &a.reauth).await?.refusal() { | |
| 307 | 310 | return Ok(refusal); | |
| 308 | 311 | } | |
| ⋯ | |||
| 1144 | 1147 | deletion, | |
| 1145 | 1148 | deleted, | |
| 1146 | 1149 | joined_through: self.shared_source(user_id).await?, | |
| 1150 | + | suspended: self.user_suspension(user_id).await?, | |
| 1147 | 1151 | })) | |
| 1148 | 1152 | } | |
| 1149 | 1153 | ||
| 579 | 579 | return Ok(Outcome::fail(FailureCode::Invalid, crate::USERNAME_RULES)); | |
| 580 | 580 | }; | |
| 581 | 581 | let username = chosen.canonical.as_str(); | |
| 582 | + | if self.email_blocked(email).await? { | |
| 583 | + | return Ok(Outcome::fail(FailureCode::Forbidden, crate::suspension::BLOCKED)); | |
| 584 | + | } | |
| 582 | 585 | // Made where every account is made, so the invite is checked and | |
| 583 | 586 | // spent in one place, with registration's rules (invites.rs). | |
| 584 | 587 | let user = match self |
| 29 | 29 | mod security; | |
| 30 | 30 | mod shared_invites; | |
| 31 | 31 | mod sounds; | |
| 32 | + | mod suspension; | |
| 32 | 33 | mod teams; | |
| 33 | 34 | mod throttle; | |
| 34 | 35 | mod token_reach; | |
| ⋯ | |||
| 146 | 147 | let Some(mut user) = user else { | |
| 147 | 148 | return Ok(None); | |
| 148 | 149 | }; | |
| 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 | + | } | |
| 149 | 155 | let memberships = self.memberships_and_policies(&user.id).await?; | |
| 150 | 156 | // Roles on single repositories, under the same policy (access.rs). | |
| 151 | 157 | let grants = self.grants_of(&user.id).await?; | |
| ⋯ | |||
| 406 | 412 | if !self.sign_up_allowed(a.client.as_deref()).await? { | |
| 407 | 413 | return Ok(Identity::too_many_sign_ups()); | |
| 408 | 414 | } | |
| 415 | + | if self.email_blocked(&email).await? { | |
| 416 | + | return Ok(Outcome::fail(FailureCode::Forbidden, suspension::BLOCKED)); | |
| 417 | + | } | |
| 409 | 418 | let taken = self | |
| 410 | 419 | .db | |
| 411 | 420 | // Usernames and workspaces share one namespace, so that a name | |
| ⋯ | |||
| 463 | 472 | Ok(user) => user, | |
| 464 | 473 | Err(message) => return Ok(Outcome::fail(FailureCode::Unauthenticated, message)), | |
| 465 | 474 | }; | |
| 475 | + | if self.user_suspension(&user.id).await?.is_some() { | |
| 476 | + | return Ok(Outcome::fail(FailureCode::Forbidden, suspension::SUSPENDED)); | |
| 477 | + | } | |
| 466 | 478 | let user = self.with_workspaces(Some(user)).await?.unwrap_or_default(); | |
| 467 | 479 | self.start_session(user).await | |
| 468 | 480 | } | |
| ⋯ | |||
| 489 | 501 | if !self.account_live(&user.id).await? { | |
| 490 | 502 | return Ok(Outcome::fail(FailureCode::Unauthenticated, "Incorrect username or password.")); | |
| 491 | 503 | } | |
| 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 | + | } | |
| 492 | 508 | let session_token = crypto::random_hex(32); | |
| 493 | 509 | self.db | |
| 494 | 510 | .prepare(format!( | |
| ⋯ | |||
| 1092 | 1108 | "admin_workspace" => reply(&identity.admin_workspace(args(body)?).await?), | |
| 1093 | 1109 | "admin_invites" => reply(&identity.admin_invites(args(body)?).await?), | |
| 1094 | 1110 | "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?), | |
| 1095 | 1116 | // Deleted workspaces, restored or purged by staff; see deletion.rs. | |
| 1096 | 1117 | "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?), | |
| 1097 | 1118 | "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?), | |
| 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 | + | } |
| 253 | 253 | pub(crate) async fn workspace_principal(&self, workspace_id: &str) -> Result<Viewer> { | |
| 254 | 254 | let workspace = self | |
| 255 | 255 | .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") | |
| 258 | 259 | .bind(&[workspace_id.into()])? | |
| 259 | 260 | .first::<WorkspaceRef>(None) | |
| 260 | 261 | .await?; |
| 54 | 54 | ||
| 55 | 55 | /** | |
| 56 | 56 | * 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. | |
| 60 | 61 | */ | |
| 61 | 62 | async function capFor(env: Env, projectId: string): Promise<number | null> { | |
| 62 | 63 | const cached = caps.get(projectId); | |
| 63 | 64 | if (cached && Date.now() - cached.at < CAP_TTL_MS) return cached.cap; | |
| 64 | 65 | let cap: number | null = null; | |
| 65 | 66 | const daily = dailyCap(freeMonthlyBytes(env.FREE_TELEMETRY_BYTES_PER_MONTH)); | |
| 66 | − | if (daily && env.BILLING) { | |
| 67 | + | if (env.BILLING) { | |
| 67 | 68 | try { | |
| 68 | 69 | const workspace = await store(env, projectId).workspaceOf(); | |
| 69 | 70 | if (workspace) { | |
| 70 | 71 | 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; | |
| 73 | 75 | } | |
| 74 | 76 | } catch (error) { | |
| 75 | 77 | console.error("could not tell whether the project's workspace is free", projectId, error); | |
| ⋯ | |||
| 189 | 191 | } | |
| 190 | 192 | const cap = await capFor(env, project); | |
| 191 | 193 | 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)); | |
| 193 | 195 | if (!done.accepted) return refused(401, "That token is not one of the project's, or it was revoked."); | |
| 194 | 196 | return accepted(read.protobuf); | |
| 195 | 197 | } | |
| 18 | 18 | assert.equal(overCap(90, 11, 100), true); | |
| 19 | 19 | assert.equal(overCap(1e12, 1e12, null), false); | |
| 20 | 20 | 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/); | |
| 21 | 24 | }); | |
| 22 | 25 | ||
| 23 | 26 | test("a row counts as what is kept of it", () => { |
| 25 | 25 | return monthly > 0 ? Math.ceil(monthly / DAYS_PER_MONTH) : null; | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 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 | + | */ | |
| 29 | 32 | 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; | |
| 31 | 34 | } | |
| 32 | 35 | ||
| 33 | 36 | /** The size of a row as it is kept: its JSON. */ | |
| ⋯ | |||
| 35 | 38 | return JSON.stringify(value)?.length ?? 0; | |
| 36 | 39 | } | |
| 37 | 40 | ||
| 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. */ | |
| 39 | 42 | 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."; | |
| 40 | 44 | const mb = Math.round(cap / (1024 * 1024)); | |
| 41 | 45 | 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.`; | |
| 42 | 46 | } | |
Comments
Sign in to comment.