g1t/services/identity/src/admin.rs

257 lines9,227 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1//! Staff-only views of workspaces, for sudo.g1t.sh.
2//!
3//! These methods take no viewer and check no membership. Only sudo calls
4//! them, over its service binding, once it has verified a Cloudflare Access
5//! sign-in against its staff list; nothing a customer can reach forwards to
6//! them. They read, and never change, anything.
7//!
8//! Every workspace is listed: there is one kind, and a person's own space
9//! is simply a workspace with one member. A user with no workspace has
10//! nothing to list, as nothing can exist outside one.
11
12use std::collections::HashMap;
13
14use g1t_contracts::Role;
15use g1t_contracts::identity::*;
16use serde::Deserialize;
17use worker::Result;
18
19use crate::Identity;
20
21#[derive(Deserialize)]
22struct ListRow {
23 id: String,
24 slug: String,
25 name: String,
26 created_at: String,
27 member_count: u32,
28}
29
30#[derive(Deserialize)]
31struct OwnerRow {
32 workspace_id: String,
33 username: String,
34 email: Option<String>,
35}
36
37#[derive(Deserialize)]
38struct DetailRow {
39 id: String,
40 slug: String,
41 name: String,
42 description: Option<String>,
43 created_at: String,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member44 #[serde(default)]
45 protected: u8,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace46}
47
48#[derive(Deserialize)]
49struct MemberRow {
50 username: String,
51 email: Option<String>,
52 role: Role,
53 joined: String,
54}
55
56/// A `LIKE` pattern matching `query` anywhere, lowercased, with `%`, `_`
57/// and the escape character itself taken literally. None for a blank query.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look58pub(crate) fn like_pattern(query: Option<&str>) -> Option<String> {
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace59 let query = query.map(str::trim).filter(|q| !q.is_empty())?;
60 let mut pattern = String::from("%");
61 for c in query.to_lowercase().chars().take(100) {
62 if matches!(c, '%' | '_' | '\\') {
63 pattern.push('\\');
64 }
65 pattern.push(c);
66 }
67 pattern.push('%');
68 Some(pattern)
69}
70
71/// The workspaces, in their order, each with its owners.
72fn with_owners(rows: Vec<ListRow>, owners: Vec<OwnerRow>) -> Vec<AdminWorkspace> {
73 let mut by_workspace: HashMap<String, Vec<AdminOwner>> = HashMap::new();
74 for owner in owners {
75 by_workspace.entry(owner.workspace_id).or_default().push(AdminOwner {
76 username: owner.username,
77 email: owner.email,
78 });
79 }
80 rows.into_iter()
81 .map(|row| AdminWorkspace {
82 owners: by_workspace.remove(&row.id).unwrap_or_default(),
83 slug: row.slug,
84 name: row.name,
85 created_at: row.created_at,
86 member_count: row.member_count,
87 })
88 .collect()
89}
90
91impl Identity {
92 /// The workspaces staff can see, newest first. Staff only: see the
93 /// module's note.
94 /// Emails each owner of the workspace with a confirmed address. Only
95 /// other services call this; the link must stay on g1t.sh, so a notice
96 /// can never point owners anywhere else.
97 pub async fn notify_owners(&self, a: NotifyOwnersArgs) -> Result<u32> {
98 if !a.link.starts_with("https://g1t.sh/") {
99 return Ok(0);
100 }
101 #[derive(Deserialize)]
102 struct Owner {
103 email: Option<String>,
104 }
105 let owners = self
106 .db
107 .prepare(
108 "SELECT u.email FROM workspace_members m
109 JOIN users u ON u.id = m.user_id
110 JOIN workspaces w ON w.id = m.workspace_id
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member111 WHERE w.slug = ? AND w.deleted_at IS NULL AND m.role = 'owner' AND u.email_verified_at IS NOT NULL",
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace112 )
113 .bind(&[a.workspace.to_lowercase().into()])?
114 .all()
115 .await?
116 .results::<Owner>()?;
117 let mut sent = 0;
118 for email in owners.into_iter().filter_map(|owner| owner.email) {
119 match crate::email::send_link(&self.env, &email, &a.subject, &a.intro, &a.action, &a.link, &a.footer).await {
120 Ok(()) => sent += 1,
121 Err(error) => worker::console_error!("could not email an owner of {}: {error}", a.workspace),
122 }
123 }
124 Ok(sent)
125 }
126
127 pub async fn admin_workspaces(&self, a: AdminWorkspacesArgs) -> Result<Vec<AdminWorkspace>> {
128 let pattern = like_pattern(a.query.as_deref());
129 // The same filter picks the workspaces and, below, their owners.
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member130 // Deleted ones are listed apart (`admin_deleted_workspaces`).
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace131 let filter = if pattern.is_some() {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member132 "WHERE w.deleted_at IS NULL AND (w.slug LIKE ?1 ESCAPE '\\' OR lower(w.name) LIKE ?1 ESCAPE '\\'
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace133 OR EXISTS (SELECT 1 FROM workspace_members om JOIN users ou ON ou.id = om.user_id
134 WHERE om.workspace_id = w.id AND om.role = 'owner'
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member135 AND (lower(ou.username) LIKE ?1 ESCAPE '\\' OR lower(ou.email) LIKE ?1 ESCAPE '\\')))"
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace136 } else {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member137 "WHERE w.deleted_at IS NULL"
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace138 };
139 let chosen = format!(
140 "SELECT w.id FROM workspaces w {filter}
141 ORDER BY w.created_at DESC, w.slug LIMIT {ADMIN_WORKSPACES_LIMIT}"
142 );
143 let binds: Vec<worker::wasm_bindgen::JsValue> = pattern.into_iter().map(Into::into).collect();
144 let rows = self
145 .db
146 .prepare(format!(
147 "SELECT w.id, w.slug, w.name, w.created_at,
148 (SELECT count(*) FROM workspace_members m WHERE m.workspace_id = w.id) AS member_count
149 FROM workspaces w WHERE w.id IN ({chosen})
150 ORDER BY w.created_at DESC, w.slug"
151 ))
152 .bind(&binds)?
153 .all()
154 .await?
155 .results::<ListRow>()?;
156 let owners = self
157 .db
158 .prepare(format!(
159 "SELECT m.workspace_id, u.username, u.email FROM workspace_members m
160 JOIN users u ON u.id = m.user_id
161 WHERE m.role = 'owner' AND m.workspace_id IN ({chosen})
162 ORDER BY m.created_at, u.username"
163 ))
164 .bind(&binds)?
165 .all()
166 .await?
167 .results::<OwnerRow>()?;
168 Ok(with_owners(rows, owners))
169 }
170
171 /// One workspace with every member, or None. Staff only: see the
172 /// module's note.
173 pub async fn admin_workspace(&self, a: SlugArgs) -> Result<Option<AdminWorkspaceDetail>> {
174 let Some(row) = self
175 .db
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member176 .prepare("SELECT id, slug, name, description, created_at, protected FROM workspaces WHERE slug = ?")
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace177 .bind(&[a.slug.trim().to_lowercase().into()])?
178 .first::<DetailRow>(None)
179 .await?
180 else {
181 return Ok(None);
182 };
183 let members = self
184 .db
185 .prepare(
186 "SELECT u.username, u.email, m.role, m.created_at AS joined FROM workspace_members m
187 JOIN users u ON u.id = m.user_id
188 WHERE m.workspace_id = ?
189 ORDER BY m.role DESC, u.username",
190 )
191 .bind(&[row.id.as_str().into()])?
192 .all()
193 .await?
194 .results::<MemberRow>()?
195 .into_iter()
196 .map(|m| AdminMember {
197 username: m.username,
198 email: m.email,
199 role: m.role,
200 joined: m.joined,
201 })
202 .collect();
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member203 let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace204 Ok(Some(AdminWorkspaceDetail {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member205 protected,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace206 slug: row.slug,
207 name: row.name,
208 description: row.description,
209 created_at: row.created_at,
210 members,
211 }))
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn a_blank_query_matches_everything() {
221 assert_eq!(like_pattern(None), None);
222 assert_eq!(like_pattern(Some(" ")), None);
223 }
224
225 #[test]
226 fn a_query_is_matched_literally_anywhere() {
227 assert_eq!(like_pattern(Some(" Acme ")).as_deref(), Some("%acme%"));
228 assert_eq!(like_pattern(Some("50%_off\\")).as_deref(), Some("%50\\%\\_off\\\\%"));
229 }
230
231 #[test]
232 fn owners_go_to_their_workspaces_in_order() {
233 let row = |id: &str, slug: &str| ListRow {
234 id: id.into(),
235 slug: slug.into(),
236 name: slug.into(),
237 created_at: "2026-10-04T00:00:00Z".into(),
238 member_count: 2,
239 };
240 let owner = |workspace_id: &str, username: &str| OwnerRow {
241 workspace_id: workspace_id.into(),
242 username: username.into(),
243 email: Some(format!("{username}@example.com")),
244 };
245 let listed = with_owners(
246 vec![row("wsp_2", "newer"), row("wsp_1", "older"), row("wsp_3", "orphan")],
247 vec![owner("wsp_1", "ada"), owner("wsp_2", "bob"), owner("wsp_1", "cy")],
248 );
249 let slugs: Vec<_> = listed.iter().map(|w| w.slug.as_str()).collect();
250 assert_eq!(slugs, ["newer", "older", "orphan"]);
251 let owners = |i: usize| listed[i].owners.iter().map(|o| o.username.as_str()).collect::<Vec<_>>();
252 assert_eq!(owners(0), ["bob"]);
253 assert_eq!(owners(1), ["ada", "cy"]);
254 assert!(owners(2).is_empty());
255 assert_eq!(listed[1].owners[0].email.as_deref(), Some("ada@example.com"));
256 }
257}