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