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