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