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