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