flagon-io/g1t

public

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/services/identity/src/admin.rs

252 lines8,913 bytesCodeBlame
1//! 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,
44}
45
46#[derive(Deserialize)]
47struct MemberRow {
48 username: String,
49 email: Option<String>,
50 role: Role,
51 joined: String,
52}
53
54/// A `LIKE` pattern matching `query` anywhere, lowercased, with `%`, `_`
55/// and the escape character itself taken literally. None for a blank query.
56pub(crate) fn like_pattern(query: Option<&str>) -> Option<String> {
57 let query = query.map(str::trim).filter(|q| !q.is_empty())?;
58 let mut pattern = String::from("%");
59 for c in query.to_lowercase().chars().take(100) {
60 if matches!(c, '%' | '_' | '\\') {
61 pattern.push('\\');
62 }
63 pattern.push(c);
64 }
65 pattern.push('%');
66 Some(pattern)
67}
68
69/// The workspaces, in their order, each with its owners.
70fn with_owners(rows: Vec<ListRow>, owners: Vec<OwnerRow>) -> Vec<AdminWorkspace> {
71 let mut by_workspace: HashMap<String, Vec<AdminOwner>> = HashMap::new();
72 for owner in owners {
73 by_workspace.entry(owner.workspace_id).or_default().push(AdminOwner {
74 username: owner.username,
75 email: owner.email,
76 });
77 }
78 rows.into_iter()
79 .map(|row| AdminWorkspace {
80 owners: by_workspace.remove(&row.id).unwrap_or_default(),
81 slug: row.slug,
82 name: row.name,
83 created_at: row.created_at,
84 member_count: row.member_count,
85 })
86 .collect()
87}
88
89impl Identity {
90 /// The workspaces staff can see, newest first. Staff only: see the
91 /// module's note.
92 /// Emails each owner of the workspace with a confirmed address. Only
93 /// other services call this; the link must stay on g1t.sh, so a notice
94 /// can never point owners anywhere else.
95 pub async fn notify_owners(&self, a: NotifyOwnersArgs) -> Result<u32> {
96 if !a.link.starts_with("https://g1t.sh/") {
97 return Ok(0);
98 }
99 #[derive(Deserialize)]
100 struct Owner {
101 email: Option<String>,
102 }
103 let owners = self
104 .db
105 .prepare(
106 "SELECT u.email FROM workspace_members m
107 JOIN users u ON u.id = m.user_id
108 JOIN workspaces w ON w.id = m.workspace_id
109 WHERE w.slug = ? AND m.role = 'owner' AND u.email_verified_at IS NOT NULL",
110 )
111 .bind(&[a.workspace.to_lowercase().into()])?
112 .all()
113 .await?
114 .results::<Owner>()?;
115 let mut sent = 0;
116 for email in owners.into_iter().filter_map(|owner| owner.email) {
117 match crate::email::send_link(&self.env, &email, &a.subject, &a.intro, &a.action, &a.link, &a.footer).await {
118 Ok(()) => sent += 1,
119 Err(error) => worker::console_error!("could not email an owner of {}: {error}", a.workspace),
120 }
121 }
122 Ok(sent)
123 }
124
125 pub async fn admin_workspaces(&self, a: AdminWorkspacesArgs) -> Result<Vec<AdminWorkspace>> {
126 let pattern = like_pattern(a.query.as_deref());
127 // The same filter picks the workspaces and, below, their owners.
128 let filter = if pattern.is_some() {
129 "WHERE w.slug LIKE ?1 ESCAPE '\\' OR lower(w.name) LIKE ?1 ESCAPE '\\'
130 OR EXISTS (SELECT 1 FROM workspace_members om JOIN users ou ON ou.id = om.user_id
131 WHERE om.workspace_id = w.id AND om.role = 'owner'
132 AND (lower(ou.username) LIKE ?1 ESCAPE '\\' OR lower(ou.email) LIKE ?1 ESCAPE '\\'))"
133 } else {
134 ""
135 };
136 let chosen = format!(
137 "SELECT w.id FROM workspaces w {filter}
138 ORDER BY w.created_at DESC, w.slug LIMIT {ADMIN_WORKSPACES_LIMIT}"
139 );
140 let binds: Vec<worker::wasm_bindgen::JsValue> = pattern.into_iter().map(Into::into).collect();
141 let rows = self
142 .db
143 .prepare(format!(
144 "SELECT w.id, w.slug, w.name, w.created_at,
145 (SELECT count(*) FROM workspace_members m WHERE m.workspace_id = w.id) AS member_count
146 FROM workspaces w WHERE w.id IN ({chosen})
147 ORDER BY w.created_at DESC, w.slug"
148 ))
149 .bind(&binds)?
150 .all()
151 .await?
152 .results::<ListRow>()?;
153 let owners = self
154 .db
155 .prepare(format!(
156 "SELECT m.workspace_id, u.username, u.email FROM workspace_members m
157 JOIN users u ON u.id = m.user_id
158 WHERE m.role = 'owner' AND m.workspace_id IN ({chosen})
159 ORDER BY m.created_at, u.username"
160 ))
161 .bind(&binds)?
162 .all()
163 .await?
164 .results::<OwnerRow>()?;
165 Ok(with_owners(rows, owners))
166 }
167
168 /// One workspace with every member, or None. Staff only: see the
169 /// module's note.
170 pub async fn admin_workspace(&self, a: SlugArgs) -> Result<Option<AdminWorkspaceDetail>> {
171 let Some(row) = self
172 .db
173 .prepare("SELECT id, slug, name, description, created_at FROM workspaces WHERE slug = ?")
174 .bind(&[a.slug.trim().to_lowercase().into()])?
175 .first::<DetailRow>(None)
176 .await?
177 else {
178 return Ok(None);
179 };
180 let members = self
181 .db
182 .prepare(
183 "SELECT u.username, u.email, m.role, m.created_at AS joined FROM workspace_members m
184 JOIN users u ON u.id = m.user_id
185 WHERE m.workspace_id = ?
186 ORDER BY m.role DESC, u.username",
187 )
188 .bind(&[row.id.as_str().into()])?
189 .all()
190 .await?
191 .results::<MemberRow>()?
192 .into_iter()
193 .map(|m| AdminMember {
194 username: m.username,
195 email: m.email,
196 role: m.role,
197 joined: m.joined,
198 })
199 .collect();
200 Ok(Some(AdminWorkspaceDetail {
201 slug: row.slug,
202 name: row.name,
203 description: row.description,
204 created_at: row.created_at,
205 members,
206 }))
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn a_blank_query_matches_everything() {
216 assert_eq!(like_pattern(None), None);
217 assert_eq!(like_pattern(Some(" ")), None);
218 }
219
220 #[test]
221 fn a_query_is_matched_literally_anywhere() {
222 assert_eq!(like_pattern(Some(" Acme ")).as_deref(), Some("%acme%"));
223 assert_eq!(like_pattern(Some("50%_off\\")).as_deref(), Some("%50\\%\\_off\\\\%"));
224 }
225
226 #[test]
227 fn owners_go_to_their_workspaces_in_order() {
228 let row = |id: &str, slug: &str| ListRow {
229 id: id.into(),
230 slug: slug.into(),
231 name: slug.into(),
232 created_at: "2026-10-04T00:00:00Z".into(),
233 member_count: 2,
234 };
235 let owner = |workspace_id: &str, username: &str| OwnerRow {
236 workspace_id: workspace_id.into(),
237 username: username.into(),
238 email: Some(format!("{username}@example.com")),
239 };
240 let listed = with_owners(
241 vec![row("wsp_2", "newer"), row("wsp_1", "older"), row("wsp_3", "orphan")],
242 vec![owner("wsp_1", "ada"), owner("wsp_2", "bob"), owner("wsp_1", "cy")],
243 );
244 let slugs: Vec<_> = listed.iter().map(|w| w.slug.as_str()).collect();
245 assert_eq!(slugs, ["newer", "older", "orphan"]);
246 let owners = |i: usize| listed[i].owners.iter().map(|o| o.username.as_str()).collect::<Vec<_>>();
247 assert_eq!(owners(0), ["bob"]);
248 assert_eq!(owners(1), ["ada", "cy"]);
249 assert!(owners(2).is_empty());
250 assert_eq!(listed[1].owners[0].email.as_deref(), Some("ada@example.com"));
251 }
252}