Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

575 linesCodeBlameRaw
1//! Workspaces and their members.
2//!
3//! A workspace owns repositories and is the first segment of their URLs.
4//! There is one kind: a person's own space and a company's differ only in
5//! how many members they have. Nothing can be created outside one.
6
7use g1t_contracts::access::BasePermission;
8use g1t_contracts::audit::Surface;
9use g1t_contracts::events::{JoinedHow, MemberJoined};
10use g1t_contracts::identity::*;
11use g1t_contracts::members::{LeaveWorkspaceArgs, last_owner_refusal};
12use g1t_contracts::teams::TeamCreation;
13use g1t_contracts::time::rfc3339;
14use g1t_contracts::{
15 FailureCode, MemberPrivileges, Membership, OrgRole, Outcome, PrincipalKind, Role, User, claimable_namespace, new_id,
16};
17use g1t_kit::now_ms;
18use serde::Deserialize;
19use worker::Result;
20
21use crate::Identity;
22
23/// Enough for a person and their teams; stops one account claiming names in
24/// bulk.
25const MAX_WORKSPACES_PER_USER: usize = 10;
26
27const MAX_NAME_LENGTH: usize = 80;
28const MAX_DESCRIPTION_LENGTH: usize = 160;
29
30const WORKSPACE_COLUMNS: &str = "workspaces.id, workspaces.slug, workspaces.name,
31 workspaces.description, workspaces.avatar, workspaces.created_at, workspaces.base_permission, workspaces.team_creation,
32 workspaces.member_privileges, workspaces.require_two_factor,
33 (SELECT count(*) FROM workspace_members
34 WHERE workspace_members.workspace_id = workspaces.id) AS member_count";
35
36#[derive(Deserialize)]
37struct WorkspaceRow {
38 id: String,
39 slug: String,
40 name: String,
41 description: Option<String>,
42 avatar: Option<String>,
43 created_at: String,
44 member_count: u32,
45 #[serde(default)]
46 base_permission: Option<String>,
47 #[serde(default)]
48 team_creation: Option<String>,
49 #[serde(default)]
50 member_privileges: Option<String>,
51 #[serde(default)]
52 require_two_factor: Option<u8>,
53}
54
55impl From<WorkspaceRow> for Workspace {
56 fn from(row: WorkspaceRow) -> Self {
57 Workspace {
58 id: row.id,
59 slug: row.slug,
60 name: row.name,
61 description: row.description,
62 created_at: row.created_at,
63 member_count: row.member_count,
64 avatar: row.avatar,
65 base_permission: row
66 .base_permission
67 .as_deref()
68 .and_then(BasePermission::parse)
69 .unwrap_or_default(),
70 team_creation: row
71 .team_creation
72 .as_deref()
73 .and_then(TeamCreation::parse)
74 .unwrap_or_default(),
75 privileges: MemberPrivileges::from_stored(row.member_privileges.as_deref()),
76 two_factor_requirement_enabled: row.require_two_factor.unwrap_or(0) != 0,
77 }
78 }
79}
80
81/// The name a new account's own workspace takes: its username, when that
82/// can name a workspace at all (g1t's own names cannot).
83pub(crate) fn own_workspace_slug(username: &str) -> Option<String> {
84 claimable_namespace(username)
85}
86
87/// One of a person's memberships, as stored.
88#[derive(Deserialize)]
89struct MembershipRow {
90 slug: String,
91 role: Role,
92 name: Option<String>,
93 avatar: Option<String>,
94 base_permission: Option<String>,
95 team_creation: Option<String>,
96 member_privileges: Option<String>,
97 #[serde(default)]
98 billing_manager: u8,
99 #[serde(default)]
100 security_manager: u8,
101 #[serde(default)]
102 require_two_factor: u8,
103 #[serde(default)]
104 require_sso: u8,
105}
106
107/// A membership, with what its workspace requires of its members.
108pub struct Policed {
109 pub membership: Membership,
110 pub require_two_factor: bool,
111 pub require_sso: bool,
112}
113
114impl Identity {
115 /// The workspaces a user belongs to, attached to every user resolved
116 /// from credentials, with what the site needs to show each one, what
117 /// members get on its repositories (access.rs), the roles the person
118 /// holds besides member (members.rs), and its member privileges.
119 pub async fn memberships(&self, user_id: &str) -> Result<Vec<Membership>> {
120 Ok(self.memberships_and_policies(user_id).await?.into_iter().map(|policed| policed.membership).collect())
121 }
122
123 /// The same, each with what its workspace requires: two-factor
124 /// authentication (security.rs), single sign-on (sso.rs).
125 pub async fn memberships_and_policies(&self, user_id: &str) -> Result<Vec<Policed>> {
126 let rows = self
127 .db
128 .prepare(
129 "SELECT workspaces.slug, workspace_members.role, workspaces.name,
130 workspaces.avatar, workspaces.base_permission, workspaces.team_creation,
131 workspaces.member_privileges, workspaces.require_two_factor, workspaces.require_sso,
132 workspace_members.billing_manager, workspace_members.security_manager
133 FROM workspace_members
134 JOIN workspaces ON workspaces.id = workspace_members.workspace_id
135 WHERE workspace_members.user_id = ? AND workspaces.deleted_at IS NULL
136 ORDER BY workspaces.slug",
137 )
138 .bind(&[user_id.into()])?
139 .all()
140 .await?
141 .results::<MembershipRow>()?;
142 Ok(rows
143 .into_iter()
144 .map(|row| {
145 let mut org_roles = Vec::new();
146 if row.billing_manager != 0 {
147 org_roles.push(OrgRole::BillingManager);
148 }
149 if row.security_manager != 0 {
150 org_roles.push(OrgRole::SecurityManager);
151 }
152 let membership = Membership {
153 slug: row.slug,
154 role: row.role,
155 name: row.name,
156 avatar: row.avatar,
157 base_permission: row.base_permission.as_deref().and_then(BasePermission::parse),
158 team_creation: row.team_creation.as_deref().and_then(TeamCreation::parse),
159 org_roles,
160 privileges: Some(MemberPrivileges::from_stored(row.member_privileges.as_deref())),
161 };
162 Policed { membership, require_two_factor: row.require_two_factor != 0, require_sso: row.require_sso != 0 }
163 })
164 .collect())
165 }
166
167 pub async fn create_workspace(&self, a: CreateWorkspaceArgs) -> Result<Outcome<Workspace>> {
168 if a.user.kind != PrincipalKind::User {
169 return Ok(Outcome::fail(
170 FailureCode::Forbidden,
171 "A workspace's access token cannot create workspaces. Sign in as a person.",
172 ));
173 }
174 if !a.user.verified {
175 return Ok(Outcome::fail(
176 FailureCode::Forbidden,
177 "Confirm your email address before creating a workspace.",
178 ));
179 }
180 let Some(slug) = claimable_namespace(&a.slug) else {
181 return Ok(Outcome::fail(
182 FailureCode::Invalid,
183 "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters, and cannot be a reserved word.",
184 ));
185 };
186 if self.memberships(&a.user.id).await?.len() >= MAX_WORKSPACES_PER_USER {
187 return Ok(Outcome::fail(
188 FailureCode::Conflict,
189 "You belong to the maximum number of workspaces.",
190 ));
191 }
192 // One free workspace per person (paid.rs): a new one starts free.
193 if let Some(refused) = self.second_free_workspace(&a.user.id).await? {
194 return Ok(refused);
195 }
196 // Usernames and workspaces share one namespace: a person's username
197 // is theirs to use for a workspace, and nobody else's.
198 let someone_elses_username = self
199 .db
200 .prepare("SELECT id FROM users WHERE username = ? AND id != ?")
201 .bind(&[slug.as_str().into(), a.user.id.as_str().into()])?
202 .first::<serde_json::Value>(None)
203 .await?
204 .is_some();
205 if someone_elses_username
206 // A deleted workspace still holds its slug until it is purged.
207 || self.slug_in_use(&slug).await?
208 // A renamed workspace's old slug stays reserved for it a while.
209 || self.slug_held(&slug).await?
210 // A deleted workspace's slug is never given to anyone else; the
211 // person whose username it is may use it again.
212 || (self.slug_deleted(&slug).await?
213 && !crate::deletion::may_reclaim(&slug, &a.user.username))
214 {
215 return Ok(Outcome::fail(
216 FailureCode::Conflict,
217 "That workspace name is taken.",
218 ));
219 }
220 let name = match a.name.trim() {
221 "" => slug.clone(),
222 name => name.chars().take(MAX_NAME_LENGTH).collect(),
223 };
224 Ok(Outcome::Ok(self.insert_workspace(&a.user, slug, name).await?))
225 }
226
227 /// Tells other services a person became a member of a workspace
228 /// (`workspace.member_joined`, with `how` saying by which door): the
229 /// agents service's @g1t welcomes them, once. Best effort, after the
230 /// membership is written; a failure is logged.
231 pub(crate) async fn announce_member_joined(&self, workspace_id: &str, slug: &str, user: &User, role: Role, how: JoinedHow) {
232 self.announce("workspace.member_joined", Some(&user.id), member_joined(workspace_id, slug, user, role, how))
233 .await;
234 }
235
236 /// A new account's own workspace, named for its username, on the free
237 /// plan as every new workspace is: so nobody is left without one. Made
238 /// only when the username is free to use as a workspace's name (it
239 /// usually is: usernames and workspaces share one namespace). None
240 /// when it is not, and the site asks the person to make one.
241 pub(crate) async fn create_own_workspace(&self, user: &User) -> Result<Option<Workspace>> {
242 let Some(slug) = own_workspace_slug(&user.username) else {
243 return Ok(None);
244 };
245 if self.slug_in_use(&slug).await?
246 || self.slug_held(&slug).await?
247 || (self.slug_deleted(&slug).await? && !crate::deletion::may_reclaim(&slug, &user.username))
248 {
249 return Ok(None);
250 }
251 let made = self.insert_workspace(user, slug.clone(), slug).await;
252 match made {
253 Ok(workspace) => Ok(Some(workspace)),
254 // Taken a moment ago: the person makes one themselves.
255 Err(error) if error.to_string().contains("UNIQUE") => Ok(None),
256 Err(error) => Err(error),
257 }
258 }
259
260 /// Makes a workspace `user` owns, once every check has passed, and
261 /// says so (`workspace.member_joined` as `created`: its maker gets
262 /// no welcome).
263 async fn insert_workspace(&self, user: &User, slug: String, name: String) -> Result<Workspace> {
264 let user_id = user.id.as_str();
265 let now = now_ms();
266 let workspace = Workspace {
267 id: new_id("wsp", now),
268 name,
269 description: None,
270 slug,
271 created_at: rfc3339(now),
272 member_count: 1,
273 avatar: None,
274 // Read, as on GitHub: an owner widens it on People.
275 base_permission: BasePermission::FOR_NEW_WORKSPACES,
276 team_creation: TeamCreation::default(),
277 privileges: MemberPrivileges::default(),
278 two_factor_requirement_enabled: false,
279 };
280 self.db
281 .batch(vec![
282 self.db
283 .prepare(
284 "INSERT INTO workspaces (id, slug, name, created_by, created_at, base_permission)
285 VALUES (?, ?, ?, ?, ?, ?)",
286 )
287 .bind(&[
288 workspace.id.as_str().into(),
289 workspace.slug.as_str().into(),
290 workspace.name.as_str().into(),
291 user_id.into(),
292 workspace.created_at.as_str().into(),
293 workspace.base_permission.as_str().into(),
294 ])?,
295 self.db
296 .prepare(
297 "INSERT INTO workspace_members (workspace_id, user_id, role, created_at)
298 VALUES (?, ?, 'owner', ?)",
299 )
300 .bind(&[
301 workspace.id.as_str().into(),
302 user_id.into(),
303 workspace.created_at.as_str().into(),
304 ])?,
305 ])
306 .await?;
307 self.forget_deleted(&workspace.slug).await?;
308 self.announce_member_joined(&workspace.id, &workspace.slug, user, Role::Owner, JoinedHow::Created).await;
309 Ok(workspace)
310 }
311
312 pub async fn get_workspace(&self, a: SlugArgs) -> Result<Option<Workspace>> {
313 Ok(self
314 .db
315 .prepare(format!(
316 "SELECT {WORKSPACE_COLUMNS} FROM workspaces WHERE slug = ? AND deleted_at IS NULL"
317 ))
318 .bind(&[a.slug.to_lowercase().into()])?
319 .first::<WorkspaceRow>(None)
320 .await?
321 .map(Workspace::from))
322 }
323
324 pub async fn update_workspace(&self, a: UpdateWorkspaceArgs) -> Result<Outcome<Workspace>> {
325 let slug = a.slug.to_lowercase();
326 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
327 return Ok(Outcome::fail(
328 FailureCode::Forbidden,
329 "Only an owner can change a workspace's details.",
330 ));
331 }
332 let name: String = match a.name.trim() {
333 "" => slug.clone(),
334 name => name.chars().take(MAX_NAME_LENGTH).collect(),
335 };
336 let description: String = a
337 .description
338 .trim()
339 .chars()
340 .take(MAX_DESCRIPTION_LENGTH)
341 .collect();
342 self.db
343 .prepare("UPDATE workspaces SET name = ?, description = ? WHERE slug = ?")
344 .bind(&[
345 name.into(),
346 if description.is_empty() {
347 worker::wasm_bindgen::JsValue::NULL
348 } else {
349 description.into()
350 },
351 slug.as_str().into(),
352 ])?
353 .run()
354 .await?;
355 Ok(match self.get_workspace(SlugArgs { slug }).await? {
356 Some(workspace) => Outcome::Ok(workspace),
357 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
358 })
359 }
360
361 pub async fn list_members(&self, a: ListMembersArgs) -> Result<Outcome<Vec<Member>>> {
362 let slug = a.slug.to_lowercase();
363 let Some(viewer) = a.viewer.filter(|viewer| viewer.is_member(&slug)) else {
364 return Ok(Outcome::fail(
365 FailureCode::Forbidden,
366 "Only members can see who is in a workspace.",
367 ));
368 };
369 // Owners see who has two-factor authentication on, as they need to
370 // before requiring it.
371 let owner = viewer.role_in(&slug) == Some(Role::Owner);
372 #[derive(Deserialize)]
373 struct Row {
374 #[serde(flatten)]
375 member: crate::members::MemberRow,
376 #[serde(default)]
377 two_factor: u8,
378 }
379 let rows = self
380 .db
381 .prepare(
382 "SELECT workspace_members.user_id, users.username, users.display_username, workspace_members.role, users.display_name AS name, users.avatar,
383 workspace_members.billing_manager, workspace_members.security_manager,
384 EXISTS (SELECT 1 FROM two_factor WHERE two_factor.user_id = users.id AND two_factor.enabled_at IS NOT NULL) AS two_factor
385 FROM workspace_members
386 JOIN users ON users.id = workspace_members.user_id
387 JOIN workspaces ON workspaces.id = workspace_members.workspace_id
388 WHERE workspaces.slug = ? AND workspaces.deleted_at IS NULL
389 ORDER BY workspace_members.role DESC, users.username",
390 )
391 .bind(&[slug.into()])?
392 .all()
393 .await?
394 .results::<Row>()?;
395 Ok(Outcome::Ok(
396 rows.into_iter()
397 .map(|row| row.member.member(owner.then_some(row.two_factor != 0)))
398 .collect(),
399 ))
400 }
401
402 /// The ids needed to change a workspace's members, if `actor` owns it
403 /// and `username` exists.
404 async fn member_target(&self, a: &MemberArgs) -> Result<Outcome<(String, User)>> {
405 let slug = a.slug.to_lowercase();
406 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
407 return Ok(Outcome::fail(
408 FailureCode::Forbidden,
409 "Only an owner can change a workspace's members.",
410 ));
411 }
412 let Some(workspace) = self.get_workspace(SlugArgs { slug }).await? else {
413 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
414 };
415 let Some(user) = self
416 .find_public_user(
417 "SELECT id, username, email_verified_at IS NOT NULL AS verified
418 FROM users WHERE username = ? AND deleted_at IS NULL",
419 &a.username.trim().to_lowercase(),
420 )
421 .await?
422 else {
423 return Ok(Outcome::fail(
424 FailureCode::NotFound,
425 "There is no account with that username.",
426 ));
427 };
428 Ok(Outcome::Ok((workspace.id, user)))
429 }
430
431 pub async fn add_member(&self, a: MemberArgs) -> Result<Outcome<bool>> {
432 let (workspace_id, user) = match self.member_target(&a).await? {
433 Outcome::Ok(target) => target,
434 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
435 };
436 // A person is never added without saying yes: they get a workspace
437 // invitation to accept or decline (invites/invitations.rs). Only
438 // g1t's own agent is added at once.
439 if !crate::paid::is_g1t(&user.username) {
440 let invited = self
441 .invite_member(InviteMemberArgs {
442 actor: a.actor,
443 slug: a.slug,
444 email: String::new(),
445 username: Some(user.username),
446 role: None,
447 message: None,
448 surface: a.surface,
449 })
450 .await?;
451 return Ok(match invited {
452 Outcome::Ok(_) => Outcome::Ok(true),
453 Outcome::Fail(failure) => Outcome::Fail(failure),
454 });
455 }
456 // What the workspace asks of its members (security.rs); nothing yet.
457 if let Some(why) = self.policy_refusal(&user.id, &a.slug.to_lowercase()).await? {
458 return Ok(Outcome::fail(FailureCode::Forbidden, why));
459 }
460 // A free workspace adds no one until it starts the plan (paid.rs);
461 // g1t's agent is never someone added.
462 if !crate::paid::is_g1t(&user.username)
463 && let Some(refused) = self.free_workspace_refusal(&a.slug).await?
464 {
465 return Ok(refused);
466 }
467 let added = self
468 .db
469 .prepare(
470 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
471 VALUES (?, ?, 'member', ?) RETURNING user_id",
472 )
473 .bind(&[
474 workspace_id.into(),
475 user.id.as_str().into(),
476 rfc3339(now_ms()).into(),
477 ])?
478 .first::<serde_json::Value>(None)
479 .await?
480 .is_some();
481 if added {
482 self.audit_workspace(
483 &a.actor,
484 "member.added",
485 &a.slug.to_lowercase(),
486 a.surface.unwrap_or(Surface::Web),
487 format!("Added {} as a member", user.username),
488 )
489 .await;
490 }
491 Ok(Outcome::Ok(true))
492 }
493
494 pub async fn remove_member(&self, a: MemberArgs) -> Result<Outcome<bool>> {
495 // Removing yourself is leaving, which anyone may do.
496 if a.username.trim().trim_start_matches('@').eq_ignore_ascii_case(&a.actor.username) {
497 return self
498 .leave_workspace(LeaveWorkspaceArgs { user: a.actor, slug: a.slug, surface: a.surface })
499 .await;
500 }
501 let (workspace_id, user) = match self.member_target(&a).await? {
502 Outcome::Ok(target) => target,
503 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
504 };
505 let slug = a.slug.to_lowercase();
506 let Some(row) = self.member_row(&workspace_id, &user.username).await? else {
507 return Ok(Outcome::Ok(true));
508 };
509 if row.role == Role::Owner
510 && let Some(why) = last_owner_refusal(self.owner_count(&workspace_id).await?, true)
511 {
512 return Ok(Outcome::fail(FailureCode::Conflict, why));
513 }
514 self.drop_member(&workspace_id, &user.id).await?;
515 self.audit_workspace(
516 &a.actor,
517 "member.removed",
518 &slug,
519 a.surface.unwrap_or(Surface::Web),
520 format!("Removed {} from the workspace", user.username),
521 )
522 .await;
523 Ok(Outcome::Ok(true))
524 }
525}
526
527/// The `workspace.member_joined` event for `user` becoming a member of the
528/// workspace `slug` with `role`, by the door `how`.
529pub(crate) fn member_joined(workspace_id: &str, slug: &str, user: &User, role: Role, how: JoinedHow) -> MemberJoined {
530 MemberJoined {
531 workspace_id: workspace_id.to_owned(),
532 workspace: slug.to_lowercase(),
533 user_id: user.id.clone(),
534 username: user.username.clone(),
535 role,
536 how,
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 #[test]
545 fn a_member_joining_names_the_person_the_workspace_their_role_and_the_door() {
546 let ada = User {
547 id: "usr_ada".into(),
548 username: "ada".into(),
549 ..User::default()
550 };
551 let joined = member_joined("wsp_1", "Acme", &ada, Role::Member, JoinedHow::Invitation);
552 assert_eq!(joined.workspace_id, "wsp_1");
553 assert_eq!(joined.workspace, "acme", "the slug as every service keys it");
554 assert_eq!(joined.user_id, "usr_ada");
555 assert_eq!(joined.username, "ada");
556 assert_eq!(joined.role, Role::Member);
557 assert_eq!(joined.how, JoinedHow::Invitation);
558 // The maker of a workspace is its first owner, and marked so: no welcome for them.
559 let made = member_joined("wsp_2", "ada", &ada, Role::Owner, JoinedHow::Created);
560 assert_eq!((made.role, made.how), (Role::Owner, JoinedHow::Created));
561 let data = serde_json::to_value(&made).unwrap();
562 assert_eq!(data["how"], "created");
563 assert_eq!(data["role"], "owner");
564 assert_eq!(data["workspaceId"], "wsp_2");
565 }
566
567 #[test]
568 fn no_workspace_is_created_with_g1ts_names() {
569 // What create_workspace takes the slug through, whatever its case.
570 for slug in ["g1t", "G1T", " g1t-agent ", "G1T-Agent"] {
571 assert_eq!(claimable_namespace(slug), None, "{slug}");
572 }
573 assert_eq!(claimable_namespace("Acme").as_deref(), Some("acme"));
574 }
575}