Skip to content

g1t/services/identity/src/teams.rs

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