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