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.

246 linesCodeBlameRaw
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
18use g1t_contracts::identity::{AdminBlockDomainArgs, AdminSuspendArgs, BlockedDomain, Suspension};
19use g1t_contracts::time::rfc3339;
20use g1t_contracts::{FailureCode, Outcome};
21use g1t_kit::now_ms;
22use serde::Deserialize;
23use worker::Result;
24
25use crate::Identity;
26
27/// What a suspended account is told when it signs in.
28pub const SUSPENDED: &str = "This account is suspended. If you think this is a mistake, write to support@g1t.sh from its address.";
29/// What signing up with a blocked domain is told.
30pub 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.
34pub 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.
46pub 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)]
56struct SuspensionRow {
57 suspended_at: Option<String>,
58 suspended_reason: Option<String>,
59 suspended_by: Option<String>,
60}
61
62impl 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/// The account's suspension, from what `user_suspension_statement` read.
73pub(crate) fn user_suspension_from(result: &worker::D1Result) -> Result<Option<Suspension>> {
74 Ok(result.results::<SuspensionRow>()?.into_iter().next().and_then(SuspensionRow::shown))
75}
76
77#[derive(Deserialize)]
78struct DomainRow {
79 domain: String,
80 reason: String,
81 blocked_by: String,
82 created_at: String,
83}
84
85impl Identity {
86 /// The account's suspension, if it has one.
87 pub(crate) async fn user_suspension(&self, user_id: &str) -> Result<Option<Suspension>> {
88 user_suspension_from(&self.user_suspension_statement(user_id)?.all().await?)
89 }
90
91 /// What `user_suspension` reads, to batch with other reads.
92 pub(crate) fn user_suspension_statement(&self, user_id: &str) -> Result<worker::D1PreparedStatement> {
93 self.db
94 .prepare("SELECT suspended_at, suspended_reason, suspended_by FROM users WHERE id = ?")
95 .bind(&[user_id.into()])
96 }
97
98 /// The workspace's suspension, if it has one, by slug.
99 pub(crate) async fn workspace_suspension(&self, slug: &str) -> Result<Option<Suspension>> {
100 Ok(self
101 .db
102 .prepare("SELECT suspended_at, suspended_reason, suspended_by FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
103 .bind(&[slug.trim().to_lowercase().into()])?
104 .first::<SuspensionRow>(None)
105 .await?
106 .and_then(SuspensionRow::shown))
107 }
108
109 /// Whether an address is at a blocked domain.
110 pub(crate) async fn email_blocked(&self, email: &str) -> Result<bool> {
111 let domains = domains_of(email);
112 if domains.is_empty() {
113 return Ok(false);
114 }
115 let marks = vec!["?"; domains.len()].join(", ");
116 let binds: Vec<worker::wasm_bindgen::JsValue> = domains.iter().map(|d| d.as_str().into()).collect();
117 Ok(self
118 .db
119 .prepare(format!("SELECT domain FROM blocked_domains WHERE domain IN ({marks}) LIMIT 1"))
120 .bind(&binds)?
121 .first::<serde_json::Value>(None)
122 .await?
123 .is_some())
124 }
125
126 pub async fn admin_suspend_user(&self, a: AdminSuspendArgs) -> Result<Outcome<Option<Suspension>>> {
127 let username = a.name.trim().trim_start_matches('@').to_lowercase();
128 self.suspend("users", "username", &username, a, "account").await
129 }
130
131 pub async fn admin_suspend_workspace(&self, a: AdminSuspendArgs) -> Result<Outcome<Option<Suspension>>> {
132 let slug = a.name.trim().to_lowercase();
133 self.suspend("workspaces", "slug", &slug, a, "workspace").await
134 }
135
136 async fn suspend(&self, table: &str, column: &str, name: &str, a: AdminSuspendArgs, what: &str) -> Result<Outcome<Option<Suspension>>> {
137 let staff = a.staff.trim();
138 if staff.is_empty() {
139 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is doing it."));
140 }
141 let reason = a.reason.trim();
142 if a.suspend && reason.is_empty() {
143 return Ok(Outcome::fail(FailureCode::Invalid, format!("Say why the {what} is suspended. It is kept with the suspension.")));
144 }
145 #[derive(Deserialize)]
146 struct Id {
147 id: String,
148 }
149 let Some(Id { id }) = self
150 .db
151 .prepare(format!("SELECT id FROM {table} WHERE {column} = ? AND deleted_at IS NULL"))
152 .bind(&[name.into()])?
153 .first::<Id>(None)
154 .await?
155 else {
156 return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no {what} called {name}.")));
157 };
158 if a.suspend {
159 self.db
160 .prepare(format!("UPDATE {table} SET suspended_at = ?, suspended_reason = ?, suspended_by = ? WHERE id = ?"))
161 .bind(&[rfc3339(now_ms()).into(), reason.into(), staff.into(), id.as_str().into()])?
162 .run()
163 .await?;
164 if table == "users" {
165 // Signed out everywhere at once.
166 self.db.prepare("DELETE FROM sessions WHERE user_id = ?").bind(&[id.as_str().into()])?.run().await?;
167 }
168 } else {
169 self.db
170 .prepare(format!("UPDATE {table} SET suspended_at = NULL, suspended_reason = NULL, suspended_by = NULL WHERE id = ?"))
171 .bind(&[id.as_str().into()])?
172 .run()
173 .await?;
174 }
175 let (action, detail) = if a.suspend {
176 (format!("{what}_suspended"), format!("Suspended {name}: {reason}"))
177 } else {
178 (format!("{what}_unsuspended"), format!("Lifted the suspension of {name}"))
179 };
180 self.record_for_staff(name, &action, &detail, staff).await;
181 let now = if table == "users" { self.user_suspension(&id).await? } else { self.workspace_suspension(name).await? };
182 Ok(Outcome::Ok(now))
183 }
184
185 pub async fn admin_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
186 Ok(self
187 .db
188 .prepare("SELECT domain, reason, blocked_by, created_at FROM blocked_domains ORDER BY domain")
189 .all()
190 .await?
191 .results::<DomainRow>()?
192 .into_iter()
193 .map(|row| BlockedDomain { domain: row.domain, reason: row.reason, blocked_by: row.blocked_by, created_at: row.created_at })
194 .collect())
195 }
196
197 pub async fn admin_block_domain(&self, a: AdminBlockDomainArgs) -> Result<Outcome<Vec<BlockedDomain>>> {
198 let staff = a.staff.trim();
199 if staff.is_empty() {
200 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is doing it."));
201 }
202 let Some(domain) = normalize_domain(&a.domain) else {
203 return Ok(Outcome::fail(FailureCode::Invalid, "Enter a domain, such as spam.example."));
204 };
205 if a.block {
206 let reason = a.reason.trim();
207 if reason.is_empty() {
208 return Ok(Outcome::fail(FailureCode::Invalid, "Say why the domain is blocked."));
209 }
210 self.db
211 .prepare(
212 "INSERT INTO blocked_domains (domain, reason, blocked_by, created_at) VALUES (?, ?, ?, ?)
213 ON CONFLICT (domain) DO UPDATE SET reason = excluded.reason, blocked_by = excluded.blocked_by",
214 )
215 .bind(&[domain.as_str().into(), reason.into(), staff.into(), rfc3339(now_ms()).into()])?
216 .run()
217 .await?;
218 self.record_for_staff(&domain, "email_domain_blocked", &format!("Blocked sign-ups from {domain}: {reason}"), staff).await;
219 } else {
220 self.db.prepare("DELETE FROM blocked_domains WHERE domain = ?").bind(&[domain.as_str().into()])?.run().await?;
221 self.record_for_staff(&domain, "email_domain_unblocked", &format!("Unblocked sign-ups from {domain}"), staff).await;
222 }
223 Ok(Outcome::Ok(self.admin_blocked_domains().await?))
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn domains_are_read_as_staff_type_them() {
233 assert_eq!(normalize_domain(" @Spam.Example. ").as_deref(), Some("spam.example"));
234 assert_eq!(normalize_domain("mail.spam.example").as_deref(), Some("mail.spam.example"));
235 for bad in ["", "localhost", "spam..example", "-spam.example", "spam example.com", "a@b.com"] {
236 assert_eq!(normalize_domain(bad), None, "{bad}");
237 }
238 }
239
240 #[test]
241 fn a_blocked_domain_blocks_its_subdomains_too() {
242 assert_eq!(domains_of("Ada@Mail.Spam.Example"), ["mail.spam.example", "spam.example"]);
243 assert_eq!(domains_of("ada@example.com"), ["example.com"]);
244 assert!(domains_of("not-an-address").is_empty());
245 }
246}