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