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
297 linesCodeBlameRaw
1//! A workspace's people: the directory of its members and teams, and each
2//! member's place in it (a title and what they own). See
3//! `g1t_contracts::people`. Agents live in the agents service; teams name
4//! them by id.
5
6use g1t_contracts::audit::Surface;
7use g1t_contracts::people::*;
8use g1t_contracts::teams::{TeamRef, TeamRole};
9use g1t_contracts::{FailureCode, Outcome, Role};
10use serde::Deserialize;
11use worker::Result;
12use worker::wasm_bindgen::JsValue;
13
14use crate::Identity;
15use crate::teams::{TeamRow, budget_of, channel_of, may_see, role_of};
16
17const MEMBERS_ONLY: &str = "Only members can see who is in a workspace.";
18const NO_SUCH_MEMBER: &str = "There is no member with that username.";
19
20/// What someone owns, from its stored JSON; nothing when it is missing or
21/// not a list of words.
22pub fn owns_from(json: Option<&str>) -> Vec<String> {
23 json.and_then(|json| serde_json::from_str::<Vec<String>>(json).ok())
24 .map(|owns| clean_owns(&owns))
25 .unwrap_or_default()
26}
27
28/// One member's row, as the directory reads it.
29#[derive(Deserialize)]
30struct PersonRow {
31 user_id: String,
32 username: String,
33 #[serde(default)]
34 display_username: Option<String>,
35 #[serde(default)]
36 name: Option<String>,
37 #[serde(default)]
38 avatar: Option<String>,
39 #[serde(default)]
40 bio: Option<String>,
41 #[serde(default)]
42 location: Option<String>,
43 #[serde(default)]
44 pronouns: Option<String>,
45 #[serde(default)]
46 timezone: Option<String>,
47 role: String,
48 #[serde(default)]
49 title: Option<String>,
50 #[serde(default)]
51 owns: Option<String>,
52 joined_at: String,
53}
54
55impl PersonRow {
56 fn person(self) -> DirectoryPerson {
57 let blank = |value: Option<String>| value.filter(|value| !value.trim().is_empty());
58 DirectoryPerson {
59 user_id: self.user_id,
60 display_username: self.display_username.filter(|shown| *shown != self.username),
61 username: self.username,
62 name: blank(self.name),
63 avatar: self.avatar,
64 bio: blank(self.bio),
65 location: blank(self.location),
66 pronouns: blank(self.pronouns),
67 timezone: blank(self.timezone),
68 role: if self.role == "owner" { Role::Owner } else { Role::Member },
69 title: self.title.as_deref().and_then(clean_title),
70 owns: owns_from(self.owns.as_deref()),
71 joined_at: self.joined_at,
72 }
73 }
74}
75
76/// Every member of a workspace (by slug, `?1`), with `filter` added.
77fn people_sql(filter: &str) -> String {
78 format!(
79 "SELECT u.id AS user_id, u.username, u.display_username, u.display_name AS name, u.avatar, u.bio, u.location,
80 u.pronouns, u.timezone, wm.role, wm.title, wm.owns, wm.created_at AS joined_at
81 FROM workspace_members wm
82 JOIN users u ON u.id = wm.user_id AND u.deleted_at IS NULL
83 JOIN workspaces w ON w.id = wm.workspace_id AND w.deleted_at IS NULL
84 WHERE w.slug = ?1 {filter}
85 ORDER BY lower(coalesce(u.display_name, u.username)) LIMIT 5000"
86 )
87}
88
89#[derive(Deserialize)]
90struct TeamPersonRow {
91 team_id: String,
92 username: String,
93 role: String,
94}
95
96#[derive(Deserialize)]
97struct TeamAgentRow {
98 team_id: String,
99 agent_id: String,
100}
101
102#[derive(Deserialize)]
103struct BaseRow {
104 #[serde(default)]
105 base_permission: Option<String>,
106}
107
108impl Identity {
109 pub async fn people_directory(&self, a: PeopleArgs) -> Result<Outcome<PeopleDirectory>> {
110 let workspace = a.workspace.trim().to_lowercase();
111 let Some(viewer) = a.viewer.as_ref() else {
112 return Ok(Outcome::fail(FailureCode::Forbidden, MEMBERS_ONLY));
113 };
114 let Some(role) = role_of(viewer, &workspace) else {
115 return Ok(Outcome::fail(FailureCode::Forbidden, MEMBERS_ONLY));
116 };
117 let owner = role == Role::Owner;
118 let slug = JsValue::from(workspace.as_str());
119 let mut found = self
120 .db
121 .batch(vec![
122 self.db.prepare(people_sql("")).bind(std::slice::from_ref(&slug))?,
123 self.db
124 .prepare(
125 "SELECT tm.team_id, u.username, tm.role FROM team_members tm
126 JOIN teams t ON t.id = tm.team_id
127 JOIN workspaces w ON w.id = t.workspace_id
128 JOIN users u ON u.id = tm.user_id AND u.deleted_at IS NULL
129 WHERE w.slug = ?1 ORDER BY tm.role DESC, u.username LIMIT 20000",
130 )
131 .bind(std::slice::from_ref(&slug))?,
132 self.db
133 .prepare(
134 "SELECT ta.team_id, ta.agent_id FROM team_agents ta
135 JOIN teams t ON t.id = ta.team_id
136 JOIN workspaces w ON w.id = t.workspace_id
137 WHERE w.slug = ?1 ORDER BY ta.created_at LIMIT 20000",
138 )
139 .bind(std::slice::from_ref(&slug))?,
140 self.db
141 .prepare("SELECT base_permission FROM workspaces WHERE slug = ?1 AND deleted_at IS NULL")
142 .bind(std::slice::from_ref(&slug))?,
143 ])
144 .await?
145 .into_iter();
146 let mut next = || found.next().ok_or_else(|| worker::Error::RustError("a statement of the batch did not answer".into()));
147 let people = next()?.results::<PersonRow>()?;
148 let team_people = next()?.results::<TeamPersonRow>()?;
149 let team_agents = next()?.results::<TeamAgentRow>()?;
150 let base = next()?.results::<BaseRow>()?;
151 let rows = self.workspace_team_rows(&workspace, &viewer.id).await?;
152 let teams = rows
153 .iter()
154 .filter(|row| may_see(row.visibility(), owner, row.viewer_role.is_some()))
155 .map(|row| DirectoryTeam {
156 slug: row.slug.clone(),
157 name: row.name.clone(),
158 description: row.description.clone(),
159 visibility: row.visibility(),
160 parent: match (&row.parent_slug, &row.parent_name) {
161 (Some(slug), Some(name)) => Some(TeamRef {
162 slug: slug.clone(),
163 name: name.clone(),
164 }),
165 _ => None,
166 },
167 lead: row.lead(),
168 channel: channel_of(row.channel_id.as_deref(), row.channel_name.as_deref()),
169 budget_micros: budget_of(row.budget_micros),
170 people: team_people
171 .iter()
172 .filter(|person| person.team_id == row.id)
173 .map(|person| TeamPersonRef {
174 username: person.username.clone(),
175 role: TeamRole::parse(&person.role).unwrap_or(TeamRole::Member),
176 })
177 .collect(),
178 agent_ids: team_agents.iter().filter(|agent| agent.team_id == row.id).map(|agent| agent.agent_id.clone()).collect(),
179 repos_count: row.repos_count,
180 })
181 .collect();
182 Ok(Outcome::Ok(PeopleDirectory {
183 people: people.into_iter().map(PersonRow::person).collect(),
184 teams,
185 base_permission: base
186 .into_iter()
187 .next()
188 .and_then(|row| row.base_permission)
189 .filter(|base| !base.trim().is_empty())
190 .unwrap_or_else(|| "write".to_owned()),
191 can_manage: owner,
192 }))
193 }
194
195 pub async fn set_member_profile(&self, a: SetMemberProfileArgs) -> Result<Outcome<DirectoryPerson>> {
196 let workspace = a.workspace.trim().to_lowercase();
197 if !crate::security::is_person(&a.actor) {
198 return Ok(Outcome::fail(
199 FailureCode::Forbidden,
200 "Only a person can change a profile, signed in as themselves.",
201 ));
202 }
203 let Some(role) = role_of(&a.actor, &workspace) else {
204 return Ok(Outcome::fail(FailureCode::Forbidden, MEMBERS_ONLY));
205 };
206 if !a.actor.verified {
207 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first."));
208 }
209 let owner = role == Role::Owner;
210 let Some(workspace_id) = self.workspace_id_of(&workspace).await? else {
211 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
212 };
213 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
214 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER));
215 };
216 if !self.is_member_of(&workspace_id, &user_id).await? {
217 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER));
218 }
219 let is_self = a.actor.id == user_id;
220 let mut sets: Vec<String> = Vec::new();
221 let mut binds: Vec<JsValue> = Vec::new();
222 let mut changed: Vec<&str> = Vec::new();
223 let refused = |what: &str| {
224 Ok(Outcome::fail(
225 FailureCode::Forbidden,
226 format!("Only owners of {workspace} can change {what} for someone else."),
227 ))
228 };
229 if let Some(title) = &a.title {
230 if !may_edit_profile(ProfileField::Title, is_self, owner) {
231 return refused("a title");
232 }
233 binds.push(clean_title(title).map_or(JsValue::NULL, JsValue::from));
234 sets.push(format!("title = ?{}", binds.len()));
235 changed.push("title");
236 }
237 if let Some(owns) = &a.owns {
238 if !may_edit_profile(ProfileField::Owns, is_self, owner) {
239 return refused("what someone owns");
240 }
241 let owns = clean_owns(owns);
242 binds.push(if owns.is_empty() { JsValue::NULL } else { serde_json::to_string(&owns)?.into() });
243 sets.push(format!("owns = ?{}", binds.len()));
244 changed.push("what they own");
245 }
246 if !sets.is_empty() {
247 binds.push(JsValue::from(workspace_id.clone()));
248 binds.push(JsValue::from(user_id.clone()));
249 self.db
250 .prepare(format!(
251 "UPDATE workspace_members SET {} WHERE workspace_id = ?{} AND user_id = ?{}",
252 sets.join(", "),
253 binds.len() - 1,
254 binds.len()
255 ))
256 .bind(&binds)?
257 .run()
258 .await?;
259 self.audit_workspace(
260 &a.actor,
261 "member.profile_edited",
262 &workspace,
263 a.surface.unwrap_or(Surface::Web),
264 format!("Changed {} for {username}", changed.join(", ")),
265 )
266 .await;
267 }
268 let person = self
269 .db
270 .prepare(people_sql("AND u.id = ?2"))
271 .bind(&[workspace.as_str().into(), user_id.as_str().into()])?
272 .first::<PersonRow>(None)
273 .await?;
274 Ok(match person {
275 Some(person) => Outcome::Ok(person.person()),
276 None => Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER),
277 })
278 }
279
280 /// Every team of a workspace, as `viewer_id` stands in each.
281 async fn workspace_team_rows(&self, workspace: &str, viewer_id: &str) -> Result<Vec<TeamRow>> {
282 self.team_rows("WHERE w.slug = ?2 ORDER BY lower(t.name)", &[viewer_id.into(), workspace.into()])
283 .await
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn what_someone_owns_reads_back_from_storage() {
293 assert_eq!(owns_from(Some(r#"["storefront"," Storefront ","releases"]"#)), vec!["storefront", "releases"]);
294 assert!(owns_from(Some("not json")).is_empty());
295 assert!(owns_from(None).is_empty());
296 }
297}