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
240 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 hey@flagon.io 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#[derive(Deserialize)]
73struct DomainRow {
74 domain: String,
75 reason: String,
76 blocked_by: String,
77 created_at: String,
78}
79
80impl 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)]
222mod 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}