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