Skip to content
1,747 linesCodeBlameRaw
1//! Teams: groups of a workspace's members (see `g1t_contracts::teams`).
2//!
3//! Kept beside the workspaces they belong to: `teams` and `team_members`,
4//! and a team's roles on repositories as rows of `repo_grants` whose
5//! principal is the team. Nothing here decides access on a request:
6//! [`Identity::grants_of`] (access.rs) folds a person's teams' roles into
7//! the grants every resolved user carries.
8//!
9//! **Who may do what.**
10//!
11//! | | Who |
12//! | --- | --- |
13//! | See a visible team | Every member of the workspace |
14//! | See a secret team | Its own people and the workspace's owners |
15//! | Create a team | Any member, or owners only when the workspace says so (`TeamCreation`); they become its maintainer. Under a parent: an owner, or a maintainer of the parent |
16//! | Change a team, its people and settings, or delete it | Owners, and the team's maintainers |
17//! | Move a team under another | Owners, or maintainers of both |
18//! | Give a team a role on a repository | Admin on the repository |
19//! | Take a team's role away | Admin on the repository, owners, and the team's maintainers |
20//! | Leave a team | Anyone in it |
21//!
22//! Changes are for people only, never an agent's or a workspace's token,
23//! and need a confirmed email address. Each is published as a `team.*`
24//! event and recorded in the workspace's audit log.
25
26use std::collections::{HashMap, HashSet};
27
28use g1t_contracts::access::{RepoRef, RepoRole, granted};
29use g1t_contracts::audit::Surface;
30use g1t_contracts::codeowners::{Owner, OwnerCheck};
31use g1t_contracts::events::TeamChanged;
32use g1t_contracts::teams::*;
33use g1t_contracts::time::rfc3339;
34use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, Viewer, new_id};
35use g1t_kit::now_ms;
36use serde::Deserialize;
37use worker::Result;
38use worker::wasm_bindgen::JsValue;
39
40use crate::Identity;
41use crate::access::Named;
42
43const PEOPLE_ONLY: &str = "Only a person can change a team, signed in as themselves; never an agent's or a workspace's token.";
44const CONFIRM_FIRST: &str = "Confirm your email address before changing a team.";
45const NOT_FOUND: &str = "Team not found.";
46const OWNERS_CREATE: &str = "Only owners can create teams in this workspace. Ask an owner to create one, or to let members create them in the workspace's settings.";
47
48/// A [`TeamCreation`] as the audit log says it.
49fn creation_words(setting: TeamCreation) -> &'static str {
50 match setting {
51 TeamCreation::Members => "any member",
52 TeamCreation::Owners => "owners only",
53 }
54}
55const NO_SUCH_USER: &str = "There is no account with that username.";
56const LIST_LIMIT: u32 = 500;
57
58fn opt(value: Option<&str>) -> JsValue {
59 value.map_or(JsValue::NULL, JsValue::from)
60}
61
62/// A team's row, with what lists show of it and the viewer's place in it.
63#[derive(Clone, Debug, Default, Deserialize)]
64pub(crate) struct TeamRow {
65 pub id: String,
66 pub workspace_id: String,
67 pub workspace: String,
68 pub slug: String,
69 pub name: String,
70 #[serde(default)]
71 pub description: Option<String>,
72 pub visibility: String,
73 #[serde(default)]
74 pub parent_id: Option<String>,
75 #[serde(default)]
76 pub parent_slug: Option<String>,
77 #[serde(default)]
78 pub parent_name: Option<String>,
79 pub notify: u8,
80 #[serde(default)]
81 pub review_assignment: Option<String>,
82 pub members_count: u32,
83 pub repos_count: u32,
84 pub child_teams_count: u32,
85 /// The viewer's role in it, if they are in it.
86 #[serde(default)]
87 pub viewer_role: Option<String>,
88 /// Its lead: `user` and the id, and who they are. (`agent` is from before.)
89 #[serde(default)]
90 pub lead_kind: Option<String>,
91 #[serde(default)]
92 pub lead_id: Option<String>,
93 #[serde(default)]
94 pub lead_username: Option<String>,
95 #[serde(default)]
96 pub lead_name: Option<String>,
97 #[serde(default)]
98 pub lead_avatar: Option<String>,
99 #[serde(default)]
100 pub channel_id: Option<String>,
101 #[serde(default)]
102 pub channel_name: Option<String>,
103 pub created_at: String,
104 pub updated_at: String,
105}
106
107/// A team's lead from its columns: a person still on record.
108pub(crate) fn lead_of(
109 kind: Option<&str>,
110 id: Option<&str>,
111 username: Option<&str>,
112 name: Option<&str>,
113 avatar: Option<&str>,
114) -> Option<TeamLead> {
115 // A lead is a person. One recorded as an agent, from before, is none.
116 match (kind?, id?) {
117 ("user", _) => Some(TeamLead::User {
118 username: username?.to_owned(),
119 name: name.map(str::to_owned),
120 avatar: avatar.map(str::to_owned),
121 }),
122 _ => None,
123 }
124}
125
126/// A channel from its columns, when it has both.
127pub(crate) fn channel_of(id: Option<&str>, name: Option<&str>) -> Option<TeamChannel> {
128 match (id, name) {
129 (Some(id), Some(name)) if !id.is_empty() => Some(TeamChannel {
130 id: id.to_owned(),
131 name: name.to_owned(),
132 }),
133 _ => None,
134 }
135}
136
137impl TeamRow {
138 pub fn visibility(&self) -> TeamVisibility {
139 TeamVisibility::parse(&self.visibility).unwrap_or_default()
140 }
141
142 pub fn review(&self) -> ReviewAssignment {
143 self.review_assignment
144 .as_deref()
145 .and_then(|json| serde_json::from_str::<ReviewAssignment>(json).ok())
146 .unwrap_or_default()
147 }
148
149 fn viewer_role(&self) -> Option<TeamRole> {
150 self.viewer_role.as_deref().and_then(TeamRole::parse)
151 }
152
153 pub(crate) fn lead(&self) -> Option<TeamLead> {
154 lead_of(
155 self.lead_kind.as_deref(),
156 self.lead_id.as_deref(),
157 self.lead_username.as_deref(),
158 self.lead_name.as_deref(),
159 self.lead_avatar.as_deref(),
160 )
161 }
162
163 fn shown(&self, owner: bool) -> Team {
164 let viewer_role = self.viewer_role();
165 Team {
166 id: self.id.clone(),
167 workspace: self.workspace.clone(),
168 slug: self.slug.clone(),
169 name: self.name.clone(),
170 description: self.description.clone(),
171 visibility: self.visibility(),
172 parent: match (&self.parent_slug, &self.parent_name) {
173 (Some(slug), Some(name)) => Some(TeamRef {
174 slug: slug.clone(),
175 name: name.clone(),
176 }),
177 _ => None,
178 },
179 notify: self.notify != 0,
180 review_assignment: self.review(),
181 members_count: self.members_count,
182 repos_count: self.repos_count,
183 child_teams_count: self.child_teams_count,
184 lead: self.lead(),
185 channel: channel_of(self.channel_id.as_deref(), self.channel_name.as_deref()),
186 viewer_role,
187 can_manage: may_manage(owner, viewer_role),
188 created_at: self.created_at.clone(),
189 updated_at: self.updated_at.clone(),
190 }
191 }
192
193 fn event(&self) -> TeamChanged {
194 TeamChanged {
195 workspace: self.workspace.clone(),
196 team_id: self.id.clone(),
197 team: self.slug.clone(),
198 name: self.name.clone(),
199 visibility: Some(self.visibility()),
200 parent: self.parent_slug.clone(),
201 ..TeamChanged::default()
202 }
203 }
204}
205
206/// Every column of [`TeamRow`]; binds the viewer's id as `?1`.
207const TEAM_COLUMNS: &str = "t.id, t.workspace_id, w.slug AS workspace, t.slug, t.name, t.description, t.visibility,
208 t.parent_id, p.slug AS parent_slug, p.name AS parent_name, t.notify, t.review_assignment,
209 (SELECT count(*) FROM team_members tm WHERE tm.team_id = t.id) AS members_count,
210 (SELECT count(*) FROM repo_grants g WHERE g.principal_kind = 'team' AND g.principal_id = t.id) AS repos_count,
211 (SELECT count(*) FROM teams c WHERE c.parent_id = t.id) AS child_teams_count,
212 (SELECT role FROM team_members me WHERE me.team_id = t.id AND me.user_id = ?1) AS viewer_role,
213 t.lead_kind, t.lead_id, lu.username AS lead_username, lu.display_name AS lead_name, lu.avatar AS lead_avatar,
214 t.channel_id, t.channel_name,
215 t.created_at, t.updated_at
216 FROM teams t
217 JOIN workspaces w ON w.id = t.workspace_id AND w.deleted_at IS NULL
218 LEFT JOIN teams p ON p.id = t.parent_id
219 LEFT JOIN users lu ON t.lead_kind = 'user' AND lu.id = t.lead_id AND lu.deleted_at IS NULL";
220
221// --- The rules, apart from the database ---------------------------------------
222
223/// Whether someone sees a team: everyone in the workspace sees a visible
224/// one; a secret one, its own people and the owners.
225pub fn may_see(visibility: TeamVisibility, owner: bool, in_team: bool) -> bool {
226 match visibility {
227 TeamVisibility::Visible => true,
228 TeamVisibility::Secret => owner || in_team,
229 }
230}
231
232/// Whether someone may change a team: owners, and its maintainers.
233pub fn may_manage(owner: bool, role: Option<TeamRole>) -> bool {
234 owner || role == Some(TeamRole::Maintainer)
235}
236
237/// Why a team cannot go under `parent`, or `None` if it can. `chain` is
238/// the parent and its ancestors, nearest first; `below` how many levels
239/// of teams sit under the team (0 for none).
240pub fn nesting_problem(
241 team_id: &str,
242 team_visibility: TeamVisibility,
243 parent_id: &str,
244 parent_visibility: TeamVisibility,
245 chain: &[String],
246 below: usize,
247) -> Option<&'static str> {
248 if team_id == parent_id || chain.iter().any(|id| id == team_id) {
249 return Some("A team cannot go under itself or one of its own child teams.");
250 }
251 if team_visibility == TeamVisibility::Secret || parent_visibility == TeamVisibility::Secret {
252 return Some("Secret teams cannot be nested. Make both teams visible first.");
253 }
254 // The parent's chain, this team, and what is under it.
255 if chain.len() + 1 + below > MAX_DEPTH {
256 return Some("Teams can nest at most 8 levels deep.");
257 }
258 None
259}
260
261/// The workspace role the viewer has, counting g1t acting in it, and a
262/// workspace's own token an owner gave Admin, as owners. Any other
263/// workspace token is a member.
264pub(crate) fn role_of(viewer: &User, workspace: &str) -> Option<Role> {
265 if matches!(viewer.kind, PrincipalKind::Workspace | PrincipalKind::System) && viewer.is_member(workspace) {
266 if viewer.kind == PrincipalKind::Workspace && viewer.token.as_deref().is_some_and(|token| !token.admin) {
267 return Some(Role::Member);
268 }
269 return Some(Role::Owner);
270 }
271 viewer.role_in(workspace)
272}
273
274/// One person, as a team lists them.
275#[derive(Deserialize)]
276struct MemberRow {
277 id: String,
278 username: String,
279 #[serde(default)]
280 name: Option<String>,
281 #[serde(default)]
282 avatar: Option<String>,
283 role: String,
284 /// The team they are in directly.
285 team_id: String,
286 team_slug: String,
287}
288
289#[derive(Deserialize)]
290struct IdRow {
291 id: String,
292}
293
294#[derive(Deserialize)]
295struct RoleRow {
296 role: String,
297}
298
299impl Identity {
300 pub(crate) async fn team_rows(&self, filter: &str, binds: &[JsValue]) -> Result<Vec<TeamRow>> {
301 self.db
302 .prepare(format!("SELECT {TEAM_COLUMNS} {filter} LIMIT {LIST_LIMIT}"))
303 .bind(binds)?
304 .all()
305 .await?
306 .results::<TeamRow>()
307 }
308
309 /// One team of a workspace by slug, with the viewer's place in it.
310 pub(crate) async fn team_row(&self, workspace: &str, slug: &str, viewer_id: Option<&str>) -> Result<Option<TeamRow>> {
311 Ok(self
312 .team_rows(
313 "WHERE w.slug = ?2 AND t.slug = ?3",
314 &[opt(viewer_id), workspace.to_lowercase().into(), slug.trim().trim_start_matches('@').to_lowercase().into()],
315 )
316 .await?
317 .into_iter()
318 .next())
319 }
320
321 /// The team, if `viewer` may see it: a member of its workspace, and
322 /// for a secret team, in it or an owner. Missing otherwise.
323 async fn seen_team(&self, viewer: &Viewer, workspace: &str, slug: &str) -> Result<Outcome<(TeamRow, bool)>> {
324 let workspace = workspace.trim().to_lowercase();
325 let Some(viewer) = viewer.as_ref() else {
326 return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND));
327 };
328 let Some(role) = role_of(viewer, &workspace) else {
329 return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND));
330 };
331 let owner = role == Role::Owner;
332 let Some(row) = self.team_row(&workspace, slug, Some(&viewer.id)).await? else {
333 return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND));
334 };
335 if !may_see(row.visibility(), owner, row.viewer_role.is_some()) {
336 return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND));
337 }
338 Ok(Outcome::Ok((row, owner)))
339 }
340
341 /// The team, if `actor` may change it.
342 async fn managed_team(&self, actor: &User, workspace: &str, slug: &str) -> Result<Outcome<(TeamRow, bool)>> {
343 if !crate::security::is_person(actor) {
344 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
345 }
346 let (row, owner) = match self.seen_team(&Some(actor.clone()), workspace, slug).await? {
347 Outcome::Ok(found) => found,
348 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
349 };
350 if !may_manage(owner, row.viewer_role()) {
351 return Ok(Outcome::fail(
352 FailureCode::Forbidden,
353 format!("Only owners of {} and maintainers of {} can change it.", row.workspace, row.name),
354 ));
355 }
356 if !actor.verified {
357 return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST));
358 }
359 Ok(Outcome::Ok((row, owner)))
360 }
361
362 pub async fn list_teams(&self, a: ListTeamsArgs) -> Result<Outcome<Vec<Team>>> {
363 let workspace = a.workspace.trim().to_lowercase();
364 let Some(role) = a.viewer.as_ref().and_then(|viewer| role_of(viewer, &workspace)) else {
365 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's teams."));
366 };
367 let owner = role == Role::Owner;
368 let viewer_id = a.viewer.as_ref().map(|viewer| viewer.id.as_str());
369 let query = a.query.as_deref().map(str::trim).filter(|query| !query.is_empty());
370 let rows = match query {
371 Some(query) => {
372 let like = format!("%{}%", query.to_lowercase().replace(['%', '_'], ""));
373 self.team_rows(
374 "WHERE w.slug = ?2 AND (lower(t.name) LIKE ?3 OR t.slug LIKE ?3)
375 ORDER BY viewer_role IS NULL, lower(t.name)",
376 &[opt(viewer_id), workspace.as_str().into(), like.into()],
377 )
378 .await?
379 }
380 None => {
381 self.team_rows(
382 "WHERE w.slug = ?2 ORDER BY viewer_role IS NULL, lower(t.name)",
383 &[opt(viewer_id), workspace.as_str().into()],
384 )
385 .await?
386 }
387 };
388 Ok(Outcome::Ok(
389 rows.iter()
390 .filter(|row| may_see(row.visibility(), owner, row.viewer_role.is_some()))
391 .map(|row| row.shown(owner))
392 .collect(),
393 ))
394 }
395
396 pub async fn get_team(&self, a: TeamArgs) -> Result<Outcome<Team>> {
397 Ok(match self.seen_team(&a.viewer, &a.workspace, &a.team).await? {
398 Outcome::Ok((row, owner)) => Outcome::Ok(row.shown(owner)),
399 Outcome::Fail(failure) => Outcome::Fail(failure),
400 })
401 }
402
403 pub async fn child_teams(&self, a: TeamArgs) -> Result<Outcome<Vec<Team>>> {
404 let (row, owner) = match self.seen_team(&a.viewer, &a.workspace, &a.team).await? {
405 Outcome::Ok(found) => found,
406 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
407 };
408 let viewer_id = a.viewer.as_ref().map(|viewer| viewer.id.as_str());
409 let rows = self
410 .team_rows("WHERE t.parent_id = ?2 ORDER BY lower(t.name)", &[opt(viewer_id), row.id.as_str().into()])
411 .await?;
412 Ok(Outcome::Ok(
413 rows.iter()
414 .filter(|row| may_see(row.visibility(), owner, row.viewer_role.is_some()))
415 .map(|row| row.shown(owner))
416 .collect(),
417 ))
418 }
419
420 pub async fn user_teams(&self, a: UserTeamsArgs) -> Result<Outcome<Vec<Team>>> {
421 let workspace = a.workspace.trim().to_lowercase();
422 let Some(role) = a.viewer.as_ref().and_then(|viewer| role_of(viewer, &workspace)) else {
423 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's teams."));
424 };
425 let owner = role == Role::Owner;
426 let viewer_id = a.viewer.as_ref().map(|viewer| viewer.id.as_str());
427 let rows = self
428 .team_rows(
429 "WHERE w.slug = ?2 AND t.id IN (
430 SELECT tm.team_id FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE u.username = ?3
431 ) ORDER BY lower(t.name)",
432 &[opt(viewer_id), workspace.as_str().into(), a.username.trim().trim_start_matches('@').to_lowercase().into()],
433 )
434 .await?;
435 Ok(Outcome::Ok(
436 rows.iter()
437 .filter(|row| may_see(row.visibility(), owner, row.viewer_role.is_some()))
438 .map(|row| row.shown(owner))
439 .collect(),
440 ))
441 }
442
443 /// The parent and its ancestors, nearest first, by id.
444 async fn chain_of(&self, team_id: &str) -> Result<Vec<String>> {
445 Ok(self
446 .db
447 .prepare(
448 "WITH RECURSIVE up(id, depth) AS (
449 SELECT ?1, 0
450 UNION ALL
451 SELECT t.parent_id, up.depth + 1 FROM teams t JOIN up ON t.id = up.id
452 WHERE t.parent_id IS NOT NULL AND up.depth < 20
453 )
454 SELECT id FROM up ORDER BY depth",
455 )
456 .bind(&[team_id.into()])?
457 .all()
458 .await?
459 .results::<IdRow>()?
460 .into_iter()
461 .map(|row| row.id)
462 .collect())
463 }
464
465 /// How many levels of teams sit under a team.
466 async fn depth_below(&self, team_id: &str) -> Result<usize> {
467 #[derive(Deserialize)]
468 struct Depth {
469 depth: Option<u32>,
470 }
471 let row = self
472 .db
473 .prepare(
474 "WITH RECURSIVE down(id, depth) AS (
475 SELECT ?1, 0
476 UNION ALL
477 SELECT t.id, down.depth + 1 FROM teams t JOIN down ON t.parent_id = down.id WHERE down.depth < 20
478 )
479 SELECT max(depth) AS depth FROM down",
480 )
481 .bind(&[team_id.into()])?
482 .first::<Depth>(None)
483 .await?;
484 Ok(row.and_then(|row| row.depth).unwrap_or(0) as usize)
485 }
486
487 /// Whether `parent` can take `row` (or a new team, with `row` None)
488 /// under it, as `actor`: the parent exists, the actor may manage it,
489 /// and the nesting is allowed. Returns the parent.
490 async fn parent_for(
491 &self,
492 actor: &User,
493 owner: bool,
494 workspace: &str,
495 parent: &str,
496 team: Option<(&str, TeamVisibility)>,
497 visibility: TeamVisibility,
498 ) -> Result<Outcome<TeamRow>> {
499 let Some(parent) = self.team_row(workspace, parent, Some(&actor.id)).await? else {
500 return Ok(Outcome::fail(FailureCode::NotFound, "There is no team with that slug to go under."));
501 };
502 if !may_see(parent.visibility(), owner, parent.viewer_role.is_some()) {
503 return Ok(Outcome::fail(FailureCode::NotFound, "There is no team with that slug to go under."));
504 }
505 if !may_manage(owner, parent.viewer_role()) {
506 return Ok(Outcome::fail(
507 FailureCode::Forbidden,
508 format!("Only owners and maintainers of {} can put a team under it.", parent.name),
509 ));
510 }
511 let chain = self.chain_of(&parent.id).await?;
512 let (team_id, below) = match team {
513 Some((id, _)) => (id.to_owned(), self.depth_below(id).await?),
514 None => (String::new(), 0),
515 };
516 let team_visibility = team.map_or(visibility, |(_, _)| visibility);
517 if let Some(why) = nesting_problem(&team_id, team_visibility, &parent.id, parent.visibility(), &chain[..], below) {
518 return Ok(Outcome::fail(FailureCode::Invalid, why));
519 }
520 Ok(Outcome::Ok(parent))
521 }
522
523 /// Who may create a workspace's teams; the default when there is no
524 /// such workspace.
525 pub(crate) async fn team_creation_of(&self, slug: &str) -> Result<TeamCreation> {
526 #[derive(Deserialize)]
527 struct Row {
528 #[serde(default)]
529 team_creation: Option<String>,
530 }
531 let row = self
532 .db
533 .prepare("SELECT team_creation FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
534 .bind(&[slug.into()])?
535 .first::<Row>(None)
536 .await?;
537 Ok(row
538 .and_then(|row| row.team_creation)
539 .as_deref()
540 .and_then(TeamCreation::parse)
541 .unwrap_or_default())
542 }
543
544 /// `set_team_creation`: who may create the workspace's teams. Owners
545 /// only, as a person with a confirmed email address. Teams already made
546 /// stay as they are.
547 pub async fn set_team_creation(&self, a: SetTeamCreationArgs) -> Result<Outcome<TeamCreation>> {
548 let slug = a.slug.trim().to_lowercase();
549 if !crate::security::is_person(&a.actor) || a.actor.role_in(&slug) != Some(Role::Owner) {
550 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can change who may create teams."));
551 }
552 if !a.actor.verified {
553 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address before changing the workspace's settings."));
554 }
555 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
556 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
557 };
558 let previous = self.team_creation_of(&slug).await?;
559 if previous == a.team_creation {
560 return Ok(Outcome::Ok(previous));
561 }
562 let stored = match a.team_creation {
563 TeamCreation::Members => JsValue::NULL,
564 other => other.as_str().into(),
565 };
566 self.db
567 .prepare("UPDATE workspaces SET team_creation = ? WHERE id = ?")
568 .bind(&[stored, workspace_id.as_str().into()])?
569 .run()
570 .await?;
571 self.audit_workspace(
572 &a.actor,
573 "workspace.team_creation_changed",
574 &slug,
575 a.surface.unwrap_or(Surface::Web),
576 format!(
577 "Changed who may create teams from {} to {}",
578 creation_words(previous),
579 creation_words(a.team_creation)
580 ),
581 )
582 .await;
583 // Members' resolved users carry the setting, for the site's menus.
584 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
585 Ok(Outcome::Ok(a.team_creation))
586 }
587
588 pub async fn create_team(&self, a: CreateTeamArgs) -> Result<Outcome<Team>> {
589 let workspace = a.workspace.trim().to_lowercase();
590 if !crate::security::is_person(&a.actor) {
591 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
592 }
593 let Some(role) = a.actor.role_in(&workspace) else {
594 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members of a workspace can create its teams."));
595 };
596 if !a.actor.verified {
597 return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST));
598 }
599 // A workspace can keep creating teams to its owners.
600 if !self.team_creation_of(&workspace).await?.allows(role) {
601 return Ok(Outcome::fail(FailureCode::Forbidden, OWNERS_CREATE));
602 }
603 let owner = role == Role::Owner;
604 let name: String = a.name.trim().chars().take(MAX_NAME_LENGTH).collect();
605 if name.is_empty() {
606 return Ok(Outcome::fail(FailureCode::Invalid, "Give the team a name."));
607 }
608 let slug = match a.slug.as_deref().map(str::trim).filter(|slug| !slug.is_empty()) {
609 Some(slug) if is_valid_slug(&slug.to_lowercase()) => slug.to_lowercase(),
610 Some(_) => {
611 return Ok(Outcome::fail(
612 FailureCode::Invalid,
613 "Team slugs use lowercase letters, digits and single hyphens, up to 60 characters.",
614 ));
615 }
616 None => match slug_of(&name) {
617 Some(slug) => slug,
618 None => return Ok(Outcome::fail(FailureCode::Invalid, "Use letters or digits in the team's name.")),
619 },
620 };
621 let Some(workspace_id) = self.workspace_id_of(&workspace).await? else {
622 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
623 };
624 if self.team_row(&workspace, &slug, None).await?.is_some() {
625 return Ok(Outcome::fail(FailureCode::Conflict, format!("{workspace} already has a team called @{workspace}/{slug}.")));
626 }
627 #[derive(Deserialize)]
628 struct Count {
629 n: u32,
630 }
631 let count = self
632 .db
633 .prepare("SELECT count(*) AS n FROM teams WHERE workspace_id = ?")
634 .bind(&[workspace_id.as_str().into()])?
635 .first::<Count>(None)
636 .await?
637 .map_or(0, |row| row.n);
638 if count >= MAX_TEAMS {
639 return Ok(Outcome::fail(FailureCode::Conflict, format!("A workspace can have at most {MAX_TEAMS} teams.")));
640 }
641 let visibility = a.visibility.unwrap_or_default();
642 let parent = match a.parent.as_deref().map(str::trim).filter(|parent| !parent.is_empty()) {
643 Some(parent) => match self.parent_for(&a.actor, owner, &workspace, parent, None, visibility).await? {
644 Outcome::Ok(parent) => Some(parent),
645 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
646 },
647 None => None,
648 };
649 // The people to add first: members of the workspace only.
650 let mut people: Vec<(String, String)> = Vec::new();
651 for username in &a.members {
652 let Some((id, name)) = self.person_by_username(username).await? else {
653 return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no account named {}.", username.trim())));
654 };
655 if !self.is_member_of(&workspace_id, &id).await? {
656 return Ok(Outcome::fail(
657 FailureCode::Invalid,
658 format!("{name} is not a member of {workspace}. Add them to the workspace first."),
659 ));
660 }
661 if id != a.actor.id && !people.iter().any(|(had, _)| *had == id) {
662 people.push((id, name));
663 }
664 }
665 let now = now_ms();
666 let id = new_id("team", now);
667 let at = rfc3339(now);
668 let description = a
669 .description
670 .as_deref()
671 .map(|text| text.trim().chars().take(MAX_DESCRIPTION_LENGTH).collect::<String>())
672 .filter(|text| !text.is_empty());
673 let mut statements = vec![
674 self.db
675 .prepare(
676 "INSERT INTO teams (id, workspace_id, slug, name, description, visibility, parent_id, notify, review_assignment, created_by, created_at, updated_at)
677 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)",
678 )
679 .bind(&[
680 id.as_str().into(),
681 workspace_id.as_str().into(),
682 slug.as_str().into(),
683 name.as_str().into(),
684 opt(description.as_deref()),
685 visibility.as_str().into(),
686 opt(parent.as_ref().map(|parent| parent.id.as_str())),
687 u8::from(a.notify.unwrap_or(true)).into(),
688 serde_json::to_string(&ReviewAssignment::default())?.into(),
689 a.actor.id.as_str().into(),
690 at.as_str().into(),
691 ])?,
692 // Whoever creates it maintains it.
693 self.db
694 .prepare("INSERT INTO team_members (team_id, user_id, role, created_at) VALUES (?, ?, 'maintainer', ?)")
695 .bind(&[id.as_str().into(), a.actor.id.as_str().into(), at.as_str().into()])?,
696 ];
697 for (user_id, _) in &people {
698 statements.push(
699 self.db
700 .prepare("INSERT OR IGNORE INTO team_members (team_id, user_id, role, created_at) VALUES (?, ?, 'member', ?)")
701 .bind(&[id.as_str().into(), user_id.as_str().into(), at.as_str().into()])?,
702 );
703 }
704 self.db.batch(statements).await?;
705 let Some(row) = self.team_row(&workspace, &slug, Some(&a.actor.id)).await? else {
706 return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND));
707 };
708 let surface = a.surface.unwrap_or(Surface::Web);
709 self.team_event("team.created", &a.actor, row.event(), surface, format!("Created the team {}", row.name))
710 .await;
711 for (username, role) in std::iter::once((a.actor.username.clone(), TeamRole::Maintainer))
712 .chain(people.into_iter().map(|(_, name)| (name, TeamRole::Member)))
713 {
714 self.team_event(
715 "team.member_added",
716 &a.actor,
717 TeamChanged {
718 username: Some(username.clone()),
719 role: Some(role),
720 ..row.event()
721 },
722 surface,
723 format!("Added {username} to {} as a {}", row.name, role.as_str()),
724 )
725 .await;
726 }
727 Ok(Outcome::Ok(row.shown(owner)))
728 }
729
730 pub async fn update_team(&self, a: UpdateTeamArgs) -> Result<Outcome<Team>> {
731 let (row, owner) = match self.managed_team(&a.actor, &a.workspace, &a.team).await? {
732 Outcome::Ok(found) => found,
733 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
734 };
735 let mut changes: Vec<&'static str> = Vec::new();
736 let mut sets: Vec<String> = Vec::new();
737 let mut binds: Vec<JsValue> = Vec::new();
738 let mut set = |column: &str, value: JsValue, binds: &mut Vec<JsValue>| {
739 binds.push(value);
740 sets.push(format!("{column} = ?{}", binds.len()));
741 };
742 if let Some(name) = &a.name {
743 let name: String = name.trim().chars().take(MAX_NAME_LENGTH).collect();
744 if name.is_empty() {
745 return Ok(Outcome::fail(FailureCode::Invalid, "Give the team a name."));
746 }
747 if name != row.name {
748 set("name", name.into(), &mut binds);
749 changes.push("name");
750 }
751 }
752 let mut slug = row.slug.clone();
753 if let Some(wanted) = a.slug.as_deref().map(|slug| slug.trim().to_lowercase()).filter(|slug| *slug != row.slug) {
754 if !is_valid_slug(&wanted) {
755 return Ok(Outcome::fail(
756 FailureCode::Invalid,
757 "Team slugs use lowercase letters, digits and single hyphens, up to 60 characters.",
758 ));
759 }
760 if self.team_row(&row.workspace, &wanted, None).await?.is_some() {
761 return Ok(Outcome::fail(
762 FailureCode::Conflict,
763 format!("{0} already has a team called @{0}/{wanted}.", row.workspace),
764 ));
765 }
766 set("slug", wanted.as_str().into(), &mut binds);
767 changes.push("slug");
768 slug = wanted;
769 }
770 if let Some(description) = &a.description {
771 let description: String = description.trim().chars().take(MAX_DESCRIPTION_LENGTH).collect();
772 if Some(&description) != row.description.as_ref() {
773 set(
774 "description",
775 if description.is_empty() { JsValue::NULL } else { description.into() },
776 &mut binds,
777 );
778 changes.push("description");
779 }
780 }
781 let visibility = a.visibility.unwrap_or(row.visibility());
782 let parent_slug = a.parent.as_deref().map(str::trim);
783 let parent_changes = parent_slug.is_some_and(|parent| Some(parent.to_lowercase()) != row.parent_slug);
784 if visibility != row.visibility() {
785 if visibility == TeamVisibility::Secret {
786 let has_parent = if parent_changes { parent_slug.is_some_and(|parent| !parent.is_empty()) } else { row.parent_id.is_some() };
787 if has_parent || row.child_teams_count > 0 {
788 return Ok(Outcome::fail(
789 FailureCode::Invalid,
790 "Secret teams cannot be nested. Take it out from under its parent and move its child teams first.",
791 ));
792 }
793 }
794 set("visibility", visibility.as_str().into(), &mut binds);
795 changes.push("visibility");
796 }
797 if parent_changes {
798 match parent_slug.filter(|parent| !parent.is_empty()) {
799 Some(parent) => {
800 let parent = match self
801 .parent_for(&a.actor, owner, &row.workspace, parent, Some((&row.id, visibility)), visibility)
802 .await?
803 {
804 Outcome::Ok(parent) => parent,
805 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
806 };
807 set("parent_id", parent.id.as_str().into(), &mut binds);
808 }
809 None => set("parent_id", JsValue::NULL, &mut binds),
810 }
811 changes.push("parent");
812 }
813 if let Some(notify) = a.notify
814 && notify != (row.notify != 0)
815 {
816 set("notify", u8::from(notify).into(), &mut binds);
817 changes.push("notify");
818 }
819 if let Some(review) = a.review_assignment {
820 let review = review.bounded();
821 if review != row.review() {
822 set("review_assignment", serde_json::to_string(&review)?.into(), &mut binds);
823 changes.push("review_assignment");
824 }
825 }
826 if let Some(lead) = a.lead.as_deref().map(str::trim) {
827 let (kind, id): (JsValue, JsValue) = if lead.is_empty() {
828 (JsValue::NULL, JsValue::NULL)
829 } else {
830 match parse_lead(lead) {
831 Some(LeadInput::User(username)) => {
832 let Some((user_id, username)) = self.person_by_username(&username).await? else {
833 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
834 };
835 if self.team_role_of(&row.id, &user_id).await?.is_none() {
836 return Ok(Outcome::fail(
837 FailureCode::Invalid,
838 format!("{username} is not on {}. Add them to the team first.", row.name),
839 ));
840 }
841 ("user".into(), user_id.into())
842 }
843 None => {
844 return Ok(Outcome::fail(FailureCode::Invalid, "A team's lead is a person on it: name them as @username."));
845 }
846 }
847 };
848 let same = kind.as_string() == row.lead_kind && id.as_string() == row.lead_id;
849 if !same {
850 set("lead_kind", kind, &mut binds);
851 set("lead_id", id, &mut binds);
852 changes.push("lead");
853 }
854 }
855 if let Some(channel_id) = a.channel_id.as_deref().map(str::trim) {
856 let name = a
857 .channel_name
858 .as_deref()
859 .map(|name| name.trim().trim_start_matches('#').chars().take(80).collect::<String>())
860 .unwrap_or_default();
861 if !channel_id.is_empty() && name.is_empty() {
862 return Ok(Outcome::fail(FailureCode::Invalid, "Give the channel's name with its id."));
863 }
864 if Some(channel_id) != row.channel_id.as_deref() || (!channel_id.is_empty() && Some(name.as_str()) != row.channel_name.as_deref()) {
865 if channel_id.is_empty() {
866 set("channel_id", JsValue::NULL, &mut binds);
867 set("channel_name", JsValue::NULL, &mut binds);
868 } else {
869 set("channel_id", channel_id.into(), &mut binds);
870 set("channel_name", name.into(), &mut binds);
871 }
872 changes.push("channel");
873 }
874 }
875 if !changes.is_empty() {
876 set("updated_at", rfc3339(now_ms()).into(), &mut binds);
877 binds.push(row.id.as_str().into());
878 self.db
879 .prepare(format!("UPDATE teams SET {} WHERE id = ?{}", sets.join(", "), binds.len()))
880 .bind(&binds)?
881 .run()
882 .await?;
883 }
884 let Some(updated) = self.team_row(&row.workspace, &slug, Some(&a.actor.id)).await? else {
885 return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND));
886 };
887 if !changes.is_empty() {
888 self.team_event(
889 "team.edited",
890 &a.actor,
891 TeamChanged {
892 changes: changes.iter().map(|change| (*change).to_owned()).collect(),
893 ..updated.event()
894 },
895 a.surface.unwrap_or(Surface::Web),
896 format!("Changed the team {}: {}", updated.name, changes.join(", ").replace('_', " ")),
897 )
898 .await;
899 }
900 Ok(Outcome::Ok(updated.shown(owner)))
901 }
902
903 pub async fn delete_team(&self, a: DeleteTeamArgs) -> Result<Outcome<bool>> {
904 let (row, _) = match self.managed_team(&a.actor, &a.workspace, &a.team).await? {
905 Outcome::Ok(found) => found,
906 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
907 };
908 let id = JsValue::from(row.id.as_str());
909 self.db
910 .batch(vec![
911 // Its child teams move up to its parent.
912 self.db
913 .prepare("UPDATE teams SET parent_id = ?1 WHERE parent_id = ?2")
914 .bind(&[opt(row.parent_id.as_deref()), id.clone()])?,
915 self.db.prepare("DELETE FROM team_members WHERE team_id = ?").bind(std::slice::from_ref(&id))?,
916 self.db.prepare("DELETE FROM team_agents WHERE team_id = ?").bind(std::slice::from_ref(&id))?,
917 self.db
918 .prepare("DELETE FROM repo_grants WHERE principal_kind = 'team' AND principal_id = ?")
919 .bind(std::slice::from_ref(&id))?,
920 self.db.prepare("DELETE FROM teams WHERE id = ?").bind(&[id])?,
921 ])
922 .await?;
923 self.team_event(
924 "team.deleted",
925 &a.actor,
926 row.event(),
927 a.surface.unwrap_or(Surface::Web),
928 format!("Deleted the team {}", row.name),
929 )
930 .await;
931 Ok(Outcome::Ok(true))
932 }
933
934 /// The people of a team, its own first; with `children`, also the
935 /// people of its child teams who are not its own, each with the child
936 /// team they are in.
937 async fn member_rows(&self, team_id: &str, children: bool) -> Result<Vec<MemberRow>> {
938 let reach = if children {
939 "WITH RECURSIVE down(id) AS (SELECT ?1 UNION SELECT t.id FROM teams t JOIN down ON t.parent_id = down.id)"
940 } else {
941 "WITH down(id) AS (SELECT ?1)"
942 };
943 self.db
944 .prepare(format!(
945 "{reach}
946 SELECT u.id, u.username, u.display_name AS name, u.avatar, tm.role, tm.team_id, t.slug AS team_slug
947 FROM down JOIN team_members tm ON tm.team_id = down.id
948 JOIN teams t ON t.id = tm.team_id
949 JOIN users u ON u.id = tm.user_id
950 ORDER BY tm.team_id != ?1, u.username, t.slug LIMIT 2000"
951 ))
952 .bind(&[team_id.into()])?
953 .all()
954 .await?
955 .results::<MemberRow>()
956 }
957
958 pub async fn team_members(&self, a: TeamArgs) -> Result<Outcome<Vec<TeamMember>>> {
959 let (row, _) = match self.seen_team(&a.viewer, &a.workspace, &a.team).await? {
960 Outcome::Ok(found) => found,
961 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
962 };
963 let mut seen: HashSet<String> = HashSet::new();
964 let mut members: Vec<TeamMember> = Vec::new();
965 for person in self.member_rows(&row.id, a.include_child_teams).await? {
966 if !seen.insert(person.id.clone()) {
967 continue;
968 }
969 let own = person.team_id == row.id;
970 members.push(TeamMember {
971 username: person.username,
972 name: person.name,
973 avatar: person.avatar,
974 role: if own { TeamRole::parse(&person.role).unwrap_or(TeamRole::Member) } else { TeamRole::Member },
975 via: (!own).then_some(person.team_slug),
976 });
977 }
978 // Maintainers first, then by name; child teams' people after.
979 members.sort_by(|a, b| {
980 a.via
981 .is_some()
982 .cmp(&b.via.is_some())
983 .then_with(|| b.role.cmp(&a.role))
984 .then_with(|| a.username.cmp(&b.username))
985 });
986 Ok(Outcome::Ok(members))
987 }
988
989 async fn team_role_of(&self, team_id: &str, user_id: &str) -> Result<Option<TeamRole>> {
990 Ok(self
991 .db
992 .prepare("SELECT role FROM team_members WHERE team_id = ? AND user_id = ?")
993 .bind(&[team_id.into(), user_id.into()])?
994 .first::<RoleRow>(None)
995 .await?
996 .and_then(|row| TeamRole::parse(&row.role)))
997 }
998
999 pub async fn set_team_member(&self, a: SetTeamMemberArgs) -> Result<Outcome<TeamMember>> {
1000 let (row, _) = match self.managed_team(&a.actor, &a.workspace, &a.team).await? {
1001 Outcome::Ok(found) => found,
1002 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1003 };
1004 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
1005 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
1006 };
1007 if !self.is_member_of(&row.workspace_id, &user_id).await? {
1008 return Ok(Outcome::fail(
1009 FailureCode::Invalid,
1010 format!("{username} is not a member of {}. Add them to the workspace first.", row.workspace),
1011 ));
1012 }
1013 let previous = self.team_role_of(&row.id, &user_id).await?;
1014 let now = rfc3339(now_ms());
1015 self.db
1016 .prepare(
1017 "INSERT INTO team_members (team_id, user_id, role, created_at) VALUES (?1, ?2, ?3, ?4)
1018 ON CONFLICT (team_id, user_id) DO UPDATE SET role = excluded.role",
1019 )
1020 .bind(&[row.id.as_str().into(), user_id.as_str().into(), a.role.as_str().into(), now.as_str().into()])?
1021 .run()
1022 .await?;
1023 let surface = a.surface.unwrap_or(Surface::Web);
1024 match previous {
1025 None => {
1026 self.team_event(
1027 "team.member_added",
1028 &a.actor,
1029 TeamChanged {
1030 username: Some(username.clone()),
1031 role: Some(a.role),
1032 ..row.event()
1033 },
1034 surface,
1035 format!("Added {username} to {} as a {}", row.name, a.role.as_str()),
1036 )
1037 .await;
1038 }
1039 Some(previous) if previous != a.role => {
1040 self.team_event(
1041 "team.member_role_changed",
1042 &a.actor,
1043 TeamChanged {
1044 username: Some(username.clone()),
1045 role: Some(a.role),
1046 previous_role: Some(previous),
1047 ..row.event()
1048 },
1049 surface,
1050 format!("Made {username} a {} of {}", a.role.as_str(), row.name),
1051 )
1052 .await;
1053 }
1054 Some(_) => {}
1055 }
1056 #[derive(Deserialize)]
1057 struct Person {
1058 #[serde(default)]
1059 name: Option<String>,
1060 #[serde(default)]
1061 avatar: Option<String>,
1062 }
1063 let person = self
1064 .db
1065 .prepare("SELECT display_name AS name, avatar FROM users WHERE id = ?")
1066 .bind(&[user_id.as_str().into()])?
1067 .first::<Person>(None)
1068 .await?;
1069 Ok(Outcome::Ok(TeamMember {
1070 username,
1071 name: person.as_ref().and_then(|person| person.name.clone()),
1072 avatar: person.and_then(|person| person.avatar),
1073 role: a.role,
1074 via: None,
1075 }))
1076 }
1077
1078 pub async fn remove_team_member(&self, a: RemoveTeamMemberArgs) -> Result<Outcome<bool>> {
1079 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
1080 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
1081 };
1082 // Anyone may leave a team; otherwise, owners and maintainers.
1083 let leaving = crate::security::is_person(&a.actor) && a.actor.id == user_id;
1084 let row = if leaving {
1085 match self.seen_team(&Some(a.actor.clone()), &a.workspace, &a.team).await? {
1086 Outcome::Ok((row, _)) => row,
1087 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1088 }
1089 } else {
1090 match self.managed_team(&a.actor, &a.workspace, &a.team).await? {
1091 Outcome::Ok((row, _)) => row,
1092 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1093 }
1094 };
1095 let Some(previous) = self.team_role_of(&row.id, &user_id).await? else {
1096 return Ok(Outcome::fail(FailureCode::NotFound, format!("{username} is not in {}.", row.name)));
1097 };
1098 self.db
1099 .batch(vec![
1100 self.db
1101 .prepare("DELETE FROM team_members WHERE team_id = ? AND user_id = ?")
1102 .bind(&[row.id.as_str().into(), user_id.as_str().into()])?,
1103 // Someone off the team no longer leads it.
1104 self.db
1105 .prepare("UPDATE teams SET lead_kind = NULL, lead_id = NULL WHERE id = ? AND lead_kind = 'user' AND lead_id = ?")
1106 .bind(&[row.id.as_str().into(), user_id.as_str().into()])?,
1107 ])
1108 .await?;
1109 self.team_event(
1110 "team.member_removed",
1111 &a.actor,
1112 TeamChanged {
1113 username: Some(username.clone()),
1114 previous_role: Some(previous),
1115 ..row.event()
1116 },
1117 a.surface.unwrap_or(Surface::Web),
1118 format!("Removed {username} from {}", row.name),
1119 )
1120 .await;
1121 Ok(Outcome::Ok(true))
1122 }
1123
1124 pub async fn team_repos(&self, a: TeamArgs) -> Result<Outcome<Vec<TeamRepo>>> {
1125 let (row, owner) = match self.seen_team(&a.viewer, &a.workspace, &a.team).await? {
1126 Outcome::Ok(found) => found,
1127 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1128 };
1129 #[derive(Deserialize)]
1130 struct Row {
1131 repo_id: String,
1132 repo_name: String,
1133 workspace: String,
1134 role: String,
1135 depth: u32,
1136 team: String,
1137 }
1138 let rows = self
1139 .db
1140 .prepare(format!(
1141 "WITH RECURSIVE up(id, depth) AS (
1142 SELECT ?1, 0
1143 UNION ALL
1144 SELECT t.parent_id, up.depth + 1 FROM teams t JOIN up ON t.id = up.id
1145 WHERE t.parent_id IS NOT NULL AND up.depth < 20
1146 )
1147 SELECT g.repo_id, g.repo_name, w.slug AS workspace, g.role, up.depth, t.slug AS team
1148 FROM up JOIN repo_grants g ON g.principal_kind = 'team' AND g.principal_id = up.id
1149 JOIN teams t ON t.id = up.id
1150 JOIN workspaces w ON w.id = g.workspace_id AND w.deleted_at IS NULL
1151 ORDER BY g.repo_name, up.depth LIMIT {LIST_LIMIT}"
1152 ))
1153 .bind(&[row.id.as_str().into()])?
1154 .all()
1155 .await?
1156 .results::<Row>()?;
1157 let found = rows.into_iter().filter_map(|row| {
1158 Some((row.repo_id, format!("{}/{}", row.workspace, row.repo_name), RepoRole::parse(&row.role)?, row.depth, row.team))
1159 });
1160 let mut repos = fold_team_repos(found);
1161 // Only what the viewer can see: owners everything; others what
1162 // they have a role on.
1163 if !owner && let Some(viewer) = a.viewer.as_ref() {
1164 repos.retain(|repo| {
1165 granted(
1166 viewer,
1167 RepoRef {
1168 id: &repo.repo_id,
1169 namespace: &row.workspace,
1170 private: true,
1171 },
1172 )
1173 .is_some()
1174 });
1175 }
1176 Ok(Outcome::Ok(repos))
1177 }
1178
1179 /// The repository at `path`, in the team's workspace, if `actor` may
1180 /// manage who has access to it.
1181 async fn team_repo_target(&self, actor: &User, row: &TeamRow, path: &g1t_contracts::repos::RepoPath) -> Result<Outcome<crate::access::Target>> {
1182 let target = match self.manageable(actor, path).await? {
1183 Outcome::Ok(target) => target,
1184 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1185 };
1186 if target.workspace_id != row.workspace_id {
1187 return Ok(Outcome::fail(
1188 FailureCode::Invalid,
1189 format!("A team has roles only on its own workspace's repositories, and {}/{} is not in {}.", target.repo.namespace, target.repo.name, row.workspace),
1190 ));
1191 }
1192 Ok(Outcome::Ok(target))
1193 }
1194
1195 async fn team_grant(&self, repo_id: &str, team_id: &str) -> Result<Option<RepoRole>> {
1196 Ok(self
1197 .db
1198 .prepare("SELECT role FROM repo_grants WHERE repo_id = ? AND principal_kind = 'team' AND principal_id = ?")
1199 .bind(&[repo_id.into(), team_id.into()])?
1200 .first::<RoleRow>(None)
1201 .await?
1202 .and_then(|row| RepoRole::parse(&row.role)))
1203 }
1204
1205 pub async fn set_team_repo(&self, a: SetTeamRepoArgs) -> Result<Outcome<TeamRepo>> {
1206 if !crate::security::is_person(&a.actor) {
1207 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
1208 }
1209 let Some(row) = (match self.seen_team(&Some(a.actor.clone()), &a.workspace, &a.team).await? {
1210 Outcome::Ok((row, _)) => Some(row),
1211 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1212 }) else {
1213 return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND));
1214 };
1215 let target = match self.team_repo_target(&a.actor, &row, &a.repo).await? {
1216 Outcome::Ok(target) => target,
1217 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1218 };
1219 let repo = &target.repo;
1220 let previous = self.team_grant(&repo.id, &row.id).await?;
1221 let now = rfc3339(now_ms());
1222 self.db
1223 .prepare(
1224 "INSERT INTO repo_grants
1225 (repo_id, principal_kind, principal_id, workspace_id, repo_name, role, granted_by, created_at, updated_at)
1226 VALUES (?1, 'team', ?2, ?3, ?4, ?5, ?6, ?7, ?7)
1227 ON CONFLICT (repo_id, principal_kind, principal_id)
1228 DO UPDATE SET role = excluded.role, updated_at = excluded.updated_at",
1229 )
1230 .bind(&[
1231 repo.id.as_str().into(),
1232 row.id.as_str().into(),
1233 target.workspace_id.as_str().into(),
1234 repo.name.to_lowercase().into(),
1235 a.role.as_str().into(),
1236 a.actor.id.as_str().into(),
1237 now.as_str().into(),
1238 ])?
1239 .run()
1240 .await?;
1241 if previous != Some(a.role) {
1242 let kind = if previous.is_none() { "team.repo_added" } else { "team.repo_role_changed" };
1243 let message = match previous {
1244 None => format!("Gave the team {} the {} role", row.name, a.role.label()),
1245 Some(previous) => format!("Changed the team {}'s role from {} to {}", row.name, previous.label(), a.role.label()),
1246 };
1247 self.team_repo_event(kind, &a.actor, &row, repo.into(), Some(a.role), previous, a.surface.unwrap_or(Surface::Web), message)
1248 .await;
1249 }
1250 Ok(Outcome::Ok(TeamRepo {
1251 repo: format!("{}/{}", repo.namespace, repo.name),
1252 repo_id: repo.id.clone(),
1253 role: a.role,
1254 inherited_from: None,
1255 }))
1256 }
1257
1258 pub async fn remove_team_repo(&self, a: RemoveTeamRepoArgs) -> Result<Outcome<bool>> {
1259 if !crate::security::is_person(&a.actor) {
1260 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
1261 }
1262 let (row, owner) = match self.seen_team(&Some(a.actor.clone()), &a.workspace, &a.team).await? {
1263 Outcome::Ok(found) => found,
1264 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1265 };
1266 // An owner or maintainer may take a role from their team; anyone
1267 // else needs Admin on the repository.
1268 let repo = if may_manage(owner, row.viewer_role()) {
1269 if !a.actor.verified {
1270 return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST));
1271 }
1272 match self.repo_for(&a.repo, &Some(a.actor.clone())).await? {
1273 Some(repo) => repo,
1274 None => return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found.")),
1275 }
1276 } else {
1277 match self.team_repo_target(&a.actor, &row, &a.repo).await? {
1278 Outcome::Ok(target) => target.repo,
1279 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1280 }
1281 };
1282 let Some(previous) = self.team_grant(&repo.id, &row.id).await? else {
1283 return Ok(Outcome::fail(
1284 FailureCode::NotFound,
1285 format!("{} has no role of its own on {}/{}.", row.name, repo.namespace, repo.name),
1286 ));
1287 };
1288 self.db
1289 .prepare("DELETE FROM repo_grants WHERE repo_id = ? AND principal_kind = 'team' AND principal_id = ?")
1290 .bind(&[repo.id.as_str().into(), row.id.as_str().into()])?
1291 .run()
1292 .await?;
1293 self.team_repo_event(
1294 "team.repo_removed",
1295 &a.actor,
1296 &row,
1297 (&repo).into(),
1298 None,
1299 Some(previous),
1300 a.surface.unwrap_or(Surface::Web),
1301 format!("Removed the team {}'s {} role", row.name, previous.label()),
1302 )
1303 .await;
1304 Ok(Outcome::Ok(true))
1305 }
1306
1307 pub async fn team_memberships(&self, a: TeamMembershipsArgs) -> Result<Outcome<Vec<MemberTeams>>> {
1308 let workspace = a.workspace.trim().to_lowercase();
1309 let Some(viewer) = a.viewer.as_ref() else {
1310 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's teams."));
1311 };
1312 let Some(role) = role_of(viewer, &workspace) else {
1313 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's teams."));
1314 };
1315 #[derive(Deserialize)]
1316 struct Row {
1317 username: String,
1318 slug: String,
1319 name: String,
1320 visibility: String,
1321 /// Whether the viewer is in the team.
1322 mine: u8,
1323 }
1324 let rows = self
1325 .db
1326 .prepare(
1327 "SELECT u.username, t.slug, t.name, t.visibility,
1328 EXISTS (SELECT 1 FROM team_members me WHERE me.team_id = t.id AND me.user_id = ?2) AS mine
1329 FROM teams t
1330 JOIN workspaces w ON w.id = t.workspace_id AND w.deleted_at IS NULL
1331 JOIN team_members tm ON tm.team_id = t.id
1332 JOIN users u ON u.id = tm.user_id
1333 WHERE w.slug = ?1
1334 ORDER BY u.username, lower(t.name) LIMIT 5000",
1335 )
1336 .bind(&[workspace.as_str().into(), viewer.id.as_str().into()])?
1337 .all()
1338 .await?
1339 .results::<Row>()?;
1340 let owner = role == Role::Owner;
1341 let mut people: Vec<MemberTeams> = Vec::new();
1342 for row in rows {
1343 let visibility = TeamVisibility::parse(&row.visibility).unwrap_or_default();
1344 if !may_see(visibility, owner, row.mine != 0) {
1345 continue;
1346 }
1347 let team = TeamRef {
1348 slug: row.slug,
1349 name: row.name,
1350 };
1351 match people.last_mut().filter(|person| person.username == row.username) {
1352 Some(person) => person.teams.push(team),
1353 None => people.push(MemberTeams {
1354 username: row.username,
1355 teams: vec![team],
1356 }),
1357 }
1358 }
1359 Ok(Outcome::Ok(people))
1360 }
1361
1362 // --- For other services ---
1363
1364 /// Everyone in a team: its own people, and its child teams' people who
1365 /// are not its own.
1366 async fn people_of(&self, team_id: &str) -> Result<(Vec<TeamPerson>, Vec<TeamPerson>)> {
1367 let mut own = Vec::new();
1368 let mut children = Vec::new();
1369 let mut seen: HashSet<String> = HashSet::new();
1370 for row in self.member_rows(team_id, true).await? {
1371 if !seen.insert(row.id.clone()) {
1372 continue;
1373 }
1374 let person = TeamPerson {
1375 id: row.id,
1376 username: row.username,
1377 };
1378 if row.team_id == team_id {
1379 own.push(person);
1380 } else {
1381 children.push(person);
1382 }
1383 }
1384 Ok((own, children))
1385 }
1386
1387 /// A team's role on a repository: its own, or one inherited from a
1388 /// parent, the highest.
1389 async fn team_role_on(&self, team_id: &str, repo_id: &str) -> Result<Option<RepoRole>> {
1390 Ok(self
1391 .db
1392 .prepare(
1393 "WITH RECURSIVE up(id, depth) AS (
1394 SELECT ?1, 0
1395 UNION ALL
1396 SELECT t.parent_id, up.depth + 1 FROM teams t JOIN up ON t.id = up.id
1397 WHERE t.parent_id IS NOT NULL AND up.depth < 20
1398 )
1399 SELECT g.role FROM up JOIN repo_grants g ON g.principal_kind = 'team' AND g.principal_id = up.id
1400 WHERE g.repo_id = ?2",
1401 )
1402 .bind(&[team_id.into(), repo_id.into()])?
1403 .all()
1404 .await?
1405 .results::<RoleRow>()?
1406 .into_iter()
1407 .filter_map(|row| RepoRole::parse(&row.role))
1408 .max())
1409 }
1410
1411 /// Whether `user_id` may see a team: a member of its workspace, and for
1412 /// a secret team, in it or an owner.
1413 async fn sees(&self, row: &TeamRow, team: &ResolvedTeam, user_id: &str) -> Result<bool> {
1414 let role = self
1415 .db
1416 .prepare("SELECT role FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
1417 .bind(&[row.workspace_id.as_str().into(), user_id.into()])?
1418 .first::<RoleRow>(None)
1419 .await?;
1420 let Some(role) = role else {
1421 return Ok(false);
1422 };
1423 let in_team = team.everyone().any(|person| person.id == user_id);
1424 Ok(may_see(row.visibility(), role.role == "owner", in_team))
1425 }
1426
1427 async fn resolved(&self, row: &TeamRow, repo_id: Option<&str>) -> Result<ResolvedTeam> {
1428 let (members, child_members) = self.people_of(&row.id).await?;
1429 let repo_role = match repo_id {
1430 Some(repo_id) => self.team_role_on(&row.id, repo_id).await?,
1431 None => None,
1432 };
1433 Ok(ResolvedTeam {
1434 id: row.id.clone(),
1435 workspace: row.workspace.clone(),
1436 slug: row.slug.clone(),
1437 name: row.name.clone(),
1438 visibility: row.visibility(),
1439 notify: row.notify != 0,
1440 review_assignment: row.review(),
1441 members,
1442 child_members,
1443 repo_role,
1444 asker_sees: false,
1445 })
1446 }
1447
1448 pub async fn resolve_teams(&self, a: ResolveTeamsArgs) -> Result<Vec<ResolvedTeam>> {
1449 let mut found: Vec<ResolvedTeam> = Vec::new();
1450 for name in a.teams.iter().take(50) {
1451 let name = name.trim().trim_start_matches('@');
1452 let Some((workspace, slug)) = name.split_once('/') else {
1453 continue;
1454 };
1455 let Some(row) = self.team_row(workspace, slug, None).await? else {
1456 continue;
1457 };
1458 if found.iter().any(|team| team.id == row.id) {
1459 continue;
1460 }
1461 let mut team = self.resolved(&row, a.repo_id.as_deref()).await?;
1462 if let Some(asker) = a.asker.as_deref() {
1463 team.asker_sees = self.sees(&row, &team, asker).await?;
1464 }
1465 found.push(team);
1466 }
1467 Ok(found)
1468 }
1469
1470 /// Someone's effective role on a repository of a workspace: owner,
1471 /// base permission, a direct grant or a team's.
1472 async fn person_role(&self, repo_id: &str, workspace_id: &str, user_id: &str) -> Result<Option<RepoRole>> {
1473 #[derive(Deserialize)]
1474 struct Row {
1475 #[serde(default)]
1476 workspace_role: Option<String>,
1477 #[serde(default)]
1478 direct: Option<String>,
1479 }
1480 let row = self
1481 .db
1482 .prepare(
1483 "SELECT m.role AS workspace_role, g.role AS direct FROM users u
1484 LEFT JOIN workspace_members m ON m.user_id = u.id AND m.workspace_id = ?2
1485 LEFT JOIN repo_grants g ON g.principal_kind = 'user' AND g.principal_id = u.id AND g.repo_id = ?1
1486 WHERE u.id = ?3",
1487 )
1488 .bind(&[repo_id.into(), workspace_id.into(), user_id.into()])?
1489 .first::<Row>(None)
1490 .await?;
1491 let Some(row) = row else {
1492 return Ok(None);
1493 };
1494 let base = self.base_of(workspace_id).await?;
1495 let teams = self.team_roles_on(repo_id, Some(user_id)).await?;
1496 let workspace_role = row.workspace_role.as_deref();
1497 Ok(crate::access::effective(
1498 workspace_role == Some("owner"),
1499 workspace_role.and(base.role()),
1500 row.direct.as_deref().and_then(RepoRole::parse),
1501 teams.get(user_id).map(|(role, _)| *role),
1502 )
1503 .map(|(role, _)| role))
1504 }
1505
1506 pub async fn resolve_owners(&self, a: ResolveOwnersArgs) -> Result<Vec<ResolvedOwner>> {
1507 let workspace = a.workspace.trim().to_lowercase();
1508 let Some(workspace_id) = self.workspace_id_of(&workspace).await? else {
1509 return Ok(Vec::new());
1510 };
1511 let mut cache: HashMap<Owner, ResolvedOwner> = HashMap::new();
1512 let mut out = Vec::new();
1513 for owner in a.owners.iter().take(2000) {
1514 if let Some(done) = cache.get(owner) {
1515 out.push(done.clone());
1516 continue;
1517 }
1518 let resolved = self.resolve_owner(&a.repo_id, &workspace, &workspace_id, owner).await?;
1519 cache.insert(owner.clone(), resolved.clone());
1520 out.push(resolved);
1521 }
1522 Ok(out)
1523 }
1524
1525 async fn resolve_owner(&self, repo_id: &str, workspace: &str, workspace_id: &str, owner: &Owner) -> Result<ResolvedOwner> {
1526 let answer = |check: OwnerCheck, members: Vec<String>, team: Option<String>| ResolvedOwner {
1527 owner: owner.clone(),
1528 check,
1529 members,
1530 team,
1531 };
1532 let person = |id: Option<(String, String)>| async move {
1533 let Some((id, username)) = id else {
1534 return Ok::<_, worker::Error>(None);
1535 };
1536 let writes = self
1537 .person_role(repo_id, workspace_id, &id)
1538 .await?
1539 .is_some_and(|role| role >= RepoRole::Write);
1540 Ok(Some((username, writes)))
1541 };
1542 Ok(match owner {
1543 Owner::User { username } => match person(self.person_by_username(username).await?).await? {
1544 None => answer(OwnerCheck::UnknownUser, Vec::new(), None),
1545 Some((username, true)) => answer(OwnerCheck::Ok, vec![username], None),
1546 Some((username, false)) => answer(OwnerCheck::NoWriteAccess, vec![username], None),
1547 },
1548 Owner::Email { email } => {
1549 let id = match self.user_with_verified_email(email).await? {
1550 Some(id) => self
1551 .find_public_user("SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?", &id)
1552 .await?
1553 .map(|user| (user.id, user.username)),
1554 None => None,
1555 };
1556 match person(id).await? {
1557 None => answer(OwnerCheck::UnknownEmail, Vec::new(), None),
1558 Some((username, true)) => answer(OwnerCheck::Ok, vec![username], None),
1559 Some((username, false)) => answer(OwnerCheck::NoWriteAccess, vec![username], None),
1560 }
1561 }
1562 Owner::Team { workspace: org, slug } => {
1563 // A team of another g1t workspace never has a role here;
1564 // an `org` that is not a g1t workspace means this one.
1565 let home = if org.eq_ignore_ascii_case(workspace) || self.workspace_id_of(org).await?.is_none() {
1566 workspace.to_owned()
1567 } else {
1568 org.to_lowercase()
1569 };
1570 match self.team_row(&home, slug, None).await? {
1571 None => answer(OwnerCheck::UnknownTeam, Vec::new(), None),
1572 Some(row) => {
1573 let team = self.resolved(&row, Some(repo_id)).await?;
1574 let members = team.everyone().map(|person| person.username.clone()).collect();
1575 let check = if home == workspace && team.repo_role.is_some_and(|role| role >= RepoRole::Write) {
1576 OwnerCheck::Ok
1577 } else {
1578 OwnerCheck::TeamNoAccess
1579 };
1580 answer(check, members, Some(format!("{}/{}", row.workspace, row.slug)))
1581 }
1582 }
1583 }
1584 })
1585 }
1586
1587 // --- Telling others ---
1588
1589 /// Publishes a change to a team and records it in the workspace's
1590 /// audit log.
1591 async fn team_event(&self, kind: &'static str, actor: &User, data: TeamChanged, surface: Surface, message: String) {
1592 let workspace = data.workspace.clone();
1593 self.announce(kind, Some(&actor.id), data).await;
1594 self.audit_workspace(actor, kind, &workspace, surface, message).await;
1595 }
1596
1597 #[allow(clippy::too_many_arguments)]
1598 async fn team_repo_event(
1599 &self,
1600 kind: &'static str,
1601 actor: &User,
1602 row: &TeamRow,
1603 repo: Named<'_>,
1604 role: Option<RepoRole>,
1605 previous: Option<RepoRole>,
1606 surface: Surface,
1607 message: String,
1608 ) {
1609 let data = TeamChanged {
1610 repo_id: Some(repo.id.to_owned()),
1611 repo: Some(format!("{}/{}", repo.namespace, repo.name)),
1612 repo_role: role,
1613 previous_repo_role: previous,
1614 ..row.event()
1615 };
1616 self.publish_repo(kind, repo.id, &actor.id, data).await;
1617 self.audit(actor, kind, repo, surface, message).await;
1618 }
1619}
1620
1621/// A team's repositories from its own grants and its ancestors' (`depth`
1622/// 0 for its own): each repository once, at the highest role, named by
1623/// the ancestor it comes from when that is higher than its own.
1624pub fn fold_team_repos(rows: impl IntoIterator<Item = (String, String, RepoRole, u32, String)>) -> Vec<TeamRepo> {
1625 let mut repos: Vec<(TeamRepo, u32)> = Vec::new();
1626 for (repo_id, repo, role, depth, team) in rows {
1627 let inherited_from = (depth > 0).then_some(team);
1628 match repos.iter_mut().find(|(had, _)| had.repo_id == repo_id) {
1629 Some((had, had_depth)) => {
1630 if role > had.role || (role == had.role && depth < *had_depth) {
1631 had.role = role;
1632 had.inherited_from = inherited_from;
1633 *had_depth = depth;
1634 }
1635 }
1636 None => repos.push((
1637 TeamRepo {
1638 repo,
1639 repo_id,
1640 role,
1641 inherited_from,
1642 },
1643 depth,
1644 )),
1645 }
1646 }
1647 let mut repos: Vec<TeamRepo> = repos.into_iter().map(|(repo, _)| repo).collect();
1648 repos.sort_by(|a, b| a.repo.cmp(&b.repo));
1649 repos
1650}
1651
1652#[cfg(test)]
1653mod tests {
1654 use super::*;
1655
1656 #[test]
1657 fn secret_teams_are_seen_by_their_people_and_owners() {
1658 assert!(may_see(TeamVisibility::Visible, false, false));
1659 assert!(!may_see(TeamVisibility::Secret, false, false));
1660 assert!(may_see(TeamVisibility::Secret, false, true));
1661 assert!(may_see(TeamVisibility::Secret, true, false));
1662 }
1663
1664 #[test]
1665 fn owners_and_maintainers_manage_a_team() {
1666 assert!(may_manage(true, None));
1667 assert!(may_manage(false, Some(TeamRole::Maintainer)));
1668 assert!(!may_manage(false, Some(TeamRole::Member)));
1669 assert!(!may_manage(false, None));
1670 }
1671
1672 #[test]
1673 fn nesting_refuses_cycles_secret_teams_and_depth() {
1674 use TeamVisibility::*;
1675 let chain = |ids: &[&str]| ids.iter().map(|id| (*id).to_owned()).collect::<Vec<_>>();
1676 assert_eq!(nesting_problem("t1", Visible, "t2", Visible, &chain(&["t2"]), 0), None);
1677 // Under itself, or under its own child.
1678 assert!(nesting_problem("t1", Visible, "t1", Visible, &chain(&["t1"]), 0).is_some());
1679 assert!(nesting_problem("t1", Visible, "t3", Visible, &chain(&["t3", "t2", "t1"]), 2).is_some());
1680 // Secret on either side.
1681 assert!(nesting_problem("t1", Secret, "t2", Visible, &chain(&["t2"]), 0).is_some());
1682 assert!(nesting_problem("t1", Visible, "t2", Secret, &chain(&["t2"]), 0).is_some());
1683 // Eight levels at most: a chain of 6, the team, and one below.
1684 let deep = chain(&["a", "b", "c", "d", "e", "f"]);
1685 assert_eq!(nesting_problem("t1", Visible, "a", Visible, &deep, 1), None);
1686 assert!(nesting_problem("t1", Visible, "a", Visible, &deep, 2).is_some());
1687 // A new team has no id yet and nothing below.
1688 assert_eq!(nesting_problem("", Visible, "a", Visible, &chain(&["a"]), 0), None);
1689 }
1690
1691 #[test]
1692 fn a_teams_repositories_are_its_own_and_its_parents_at_the_highest_role() {
1693 let repos = fold_team_repos([
1694 ("rep_1".to_owned(), "acme/api".to_owned(), RepoRole::Write, 0, "backend".to_owned()),
1695 ("rep_1".to_owned(), "acme/api".to_owned(), RepoRole::Read, 1, "engineering".to_owned()),
1696 ("rep_2".to_owned(), "acme/web".to_owned(), RepoRole::Triage, 0, "backend".to_owned()),
1697 ("rep_2".to_owned(), "acme/web".to_owned(), RepoRole::Maintain, 2, "everyone".to_owned()),
1698 ("rep_3".to_owned(), "acme/docs".to_owned(), RepoRole::Read, 1, "engineering".to_owned()),
1699 ("rep_4".to_owned(), "acme/cli".to_owned(), RepoRole::Write, 1, "engineering".to_owned()),
1700 ("rep_4".to_owned(), "acme/cli".to_owned(), RepoRole::Write, 0, "backend".to_owned()),
1701 ]);
1702 let shown: Vec<(&str, RepoRole, Option<&str>)> =
1703 repos.iter().map(|repo| (repo.repo.as_str(), repo.role, repo.inherited_from.as_deref())).collect();
1704 assert_eq!(
1705 shown,
1706 vec![
1707 ("acme/api", RepoRole::Write, None),
1708 // Its own role is a tie with the parent's: its own.
1709 ("acme/cli", RepoRole::Write, None),
1710 ("acme/docs", RepoRole::Read, Some("engineering")),
1711 ("acme/web", RepoRole::Maintain, Some("everyone")),
1712 ]
1713 );
1714 }
1715
1716 #[test]
1717 fn a_teams_lead_and_channel_read_from_their_columns() {
1718 // An agent recorded as lead, from before, leads nothing.
1719 assert_eq!(lead_of(Some("agent"), Some("agt_1"), None, None, None), None);
1720 assert_eq!(
1721 lead_of(Some("user"), Some("usr_1"), Some("priya"), Some("Priya Shah"), None),
1722 Some(TeamLead::User { username: "priya".into(), name: Some("Priya Shah".into()), avatar: None })
1723 );
1724 // A person whose account is gone leads nothing.
1725 assert_eq!(lead_of(Some("user"), Some("usr_1"), None, None, None), None);
1726 assert_eq!(lead_of(None, None, None, None, None), None);
1727 assert_eq!(channel_of(Some("chn_1"), Some("sales")).map(|c| c.name), Some("sales".into()));
1728 assert_eq!(channel_of(Some(""), Some("sales")), None);
1729 }
1730
1731 #[test]
1732 fn a_workspace_token_sees_teams_as_an_owner() {
1733 let token = User {
1734 id: "wsp_1".into(),
1735 username: "acme".into(),
1736 kind: PrincipalKind::Workspace,
1737 workspaces: vec![g1t_contracts::Membership::member("acme")],
1738 ..User::default()
1739 };
1740 assert_eq!(role_of(&token, "acme"), Some(Role::Owner), "the workspace itself, with no token: a service");
1741 assert_eq!(role_of(&token, "globex"), None);
1742 let mut writer = User { token: Some(Box::new(g1t_contracts::scopes::TokenAccess::full())), ..token.clone() };
1743 assert_eq!(role_of(&writer, "acme"), Some(Role::Member), "a workspace token is a member unless given Admin");
1744 writer.token.as_mut().unwrap().admin = true;
1745 assert_eq!(role_of(&writer, "acme"), Some(Role::Owner));
1746 }
1747}