Skip to content
382 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::identity::*;
9use g1t_contracts::time::rfc3339;
10use g1t_contracts::{
11 FailureCode, Membership, Outcome, PrincipalKind, Role, User, claimable_namespace, new_id,
12};
13use g1t_kit::now_ms;
14use serde::Deserialize;
15use worker::Result;
16
17use crate::Identity;
18
19/// Enough for a person and their teams; stops one account claiming names in
20/// bulk.
21const MAX_WORKSPACES_PER_USER: usize = 10;
22
23const MAX_NAME_LENGTH: usize = 80;
24const MAX_DESCRIPTION_LENGTH: usize = 160;
25
26const WORKSPACE_COLUMNS: &str = "workspaces.id, workspaces.slug, workspaces.name,
27 workspaces.description, workspaces.avatar, workspaces.created_at, workspaces.base_permission,
28 (SELECT count(*) FROM workspace_members
29 WHERE workspace_members.workspace_id = workspaces.id) AS member_count";
30
31#[derive(Deserialize)]
32struct WorkspaceRow {
33 id: String,
34 slug: String,
35 name: String,
36 description: Option<String>,
37 avatar: Option<String>,
38 created_at: String,
39 member_count: u32,
40 #[serde(default)]
41 base_permission: Option<String>,
42}
43
44impl From<WorkspaceRow> for Workspace {
45 fn from(row: WorkspaceRow) -> Self {
46 Workspace {
47 id: row.id,
48 slug: row.slug,
49 name: row.name,
50 description: row.description,
51 created_at: row.created_at,
52 member_count: row.member_count,
53 avatar: row.avatar,
54 base_permission: row
55 .base_permission
56 .as_deref()
57 .and_then(BasePermission::parse)
58 .unwrap_or_default(),
59 }
60 }
61}
62
63#[derive(Deserialize)]
64struct MemberRow {
65 username: String,
66 role: Role,
67 #[serde(default)]
68 name: Option<String>,
69 #[serde(default)]
70 avatar: Option<String>,
71}
72
73impl Identity {
74 /// The workspaces a user belongs to, attached to every user resolved
75 /// from credentials, with what the site needs to show each one and
76 /// what members get on its repositories (access.rs).
77 pub async fn memberships(&self, user_id: &str) -> Result<Vec<Membership>> {
78 self.db
79 .prepare(
80 "SELECT workspaces.slug, workspace_members.role, workspaces.name,
81 workspaces.avatar, workspaces.base_permission
82 FROM workspace_members
83 JOIN workspaces ON workspaces.id = workspace_members.workspace_id
84 WHERE workspace_members.user_id = ? AND workspaces.deleted_at IS NULL
85 ORDER BY workspaces.slug",
86 )
87 .bind(&[user_id.into()])?
88 .all()
89 .await?
90 .results::<Membership>()
91 }
92
93 pub async fn create_workspace(&self, a: CreateWorkspaceArgs) -> Result<Outcome<Workspace>> {
94 if a.user.kind != PrincipalKind::User {
95 return Ok(Outcome::fail(
96 FailureCode::Forbidden,
97 "A workspace's access token cannot create workspaces. Sign in as a person.",
98 ));
99 }
100 if !a.user.verified {
101 return Ok(Outcome::fail(
102 FailureCode::Forbidden,
103 "Confirm your email address before creating a workspace.",
104 ));
105 }
106 let Some(slug) = claimable_namespace(&a.slug) else {
107 return Ok(Outcome::fail(
108 FailureCode::Invalid,
109 "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters, and cannot be a reserved word.",
110 ));
111 };
112 if self.memberships(&a.user.id).await?.len() >= MAX_WORKSPACES_PER_USER {
113 return Ok(Outcome::fail(
114 FailureCode::Conflict,
115 "You belong to the maximum number of workspaces.",
116 ));
117 }
118 // One free workspace per person (paid.rs): a new one starts free.
119 if let Some(refused) = self.second_free_workspace(&a.user.id).await? {
120 return Ok(refused);
121 }
122 // Usernames and workspaces share one namespace: a person's username
123 // is theirs to use for a workspace, and nobody else's.
124 let someone_elses_username = self
125 .db
126 .prepare("SELECT id FROM users WHERE username = ? AND id != ?")
127 .bind(&[slug.as_str().into(), a.user.id.as_str().into()])?
128 .first::<serde_json::Value>(None)
129 .await?
130 .is_some();
131 if someone_elses_username
132 // A deleted workspace still holds its slug until it is purged.
133 || self.slug_in_use(&slug).await?
134 // A renamed workspace's old slug stays reserved for it a while.
135 || self.slug_held(&slug).await?
136 // A deleted workspace's slug is never given to anyone else; the
137 // person whose username it is may use it again.
138 || (self.slug_deleted(&slug).await?
139 && !crate::deletion::may_reclaim(&slug, &a.user.username))
140 {
141 return Ok(Outcome::fail(
142 FailureCode::Conflict,
143 "That workspace name is taken.",
144 ));
145 }
146 let now = now_ms();
147 let workspace = Workspace {
148 id: new_id("wsp", now),
149 name: match a.name.trim() {
150 "" => slug.clone(),
151 name => name.chars().take(MAX_NAME_LENGTH).collect(),
152 },
153 description: None,
154 slug,
155 created_at: rfc3339(now),
156 member_count: 1,
157 avatar: None,
158 base_permission: BasePermission::default(),
159 };
160 self.db
161 .batch(vec![
162 self.db
163 .prepare(
164 "INSERT INTO workspaces (id, slug, name, created_by, created_at)
165 VALUES (?, ?, ?, ?, ?)",
166 )
167 .bind(&[
168 workspace.id.as_str().into(),
169 workspace.slug.as_str().into(),
170 workspace.name.as_str().into(),
171 a.user.id.as_str().into(),
172 workspace.created_at.as_str().into(),
173 ])?,
174 self.db
175 .prepare(
176 "INSERT INTO workspace_members (workspace_id, user_id, role, created_at)
177 VALUES (?, ?, 'owner', ?)",
178 )
179 .bind(&[
180 workspace.id.as_str().into(),
181 a.user.id.as_str().into(),
182 workspace.created_at.as_str().into(),
183 ])?,
184 ])
185 .await?;
186 self.forget_deleted(&workspace.slug).await?;
187 Ok(Outcome::Ok(workspace))
188 }
189
190 pub async fn get_workspace(&self, a: SlugArgs) -> Result<Option<Workspace>> {
191 Ok(self
192 .db
193 .prepare(format!(
194 "SELECT {WORKSPACE_COLUMNS} FROM workspaces WHERE slug = ? AND deleted_at IS NULL"
195 ))
196 .bind(&[a.slug.to_lowercase().into()])?
197 .first::<WorkspaceRow>(None)
198 .await?
199 .map(Workspace::from))
200 }
201
202 pub async fn update_workspace(&self, a: UpdateWorkspaceArgs) -> Result<Outcome<Workspace>> {
203 let slug = a.slug.to_lowercase();
204 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
205 return Ok(Outcome::fail(
206 FailureCode::Forbidden,
207 "Only an owner can change a workspace's details.",
208 ));
209 }
210 let name: String = match a.name.trim() {
211 "" => slug.clone(),
212 name => name.chars().take(MAX_NAME_LENGTH).collect(),
213 };
214 let description: String = a
215 .description
216 .trim()
217 .chars()
218 .take(MAX_DESCRIPTION_LENGTH)
219 .collect();
220 self.db
221 .prepare("UPDATE workspaces SET name = ?, description = ? WHERE slug = ?")
222 .bind(&[
223 name.into(),
224 if description.is_empty() {
225 worker::wasm_bindgen::JsValue::NULL
226 } else {
227 description.into()
228 },
229 slug.as_str().into(),
230 ])?
231 .run()
232 .await?;
233 Ok(match self.get_workspace(SlugArgs { slug }).await? {
234 Some(workspace) => Outcome::Ok(workspace),
235 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
236 })
237 }
238
239 pub async fn list_members(&self, a: ListMembersArgs) -> Result<Outcome<Vec<Member>>> {
240 let slug = a.slug.to_lowercase();
241 if !a.viewer.is_some_and(|viewer| viewer.is_member(&slug)) {
242 return Ok(Outcome::fail(
243 FailureCode::Forbidden,
244 "Only members can see who is in a workspace.",
245 ));
246 }
247 let rows = self
248 .db
249 .prepare(
250 "SELECT users.username, workspace_members.role, users.display_name AS name, users.avatar FROM workspace_members
251 JOIN users ON users.id = workspace_members.user_id
252 JOIN workspaces ON workspaces.id = workspace_members.workspace_id
253 WHERE workspaces.slug = ? AND workspaces.deleted_at IS NULL
254 ORDER BY workspace_members.role DESC, users.username",
255 )
256 .bind(&[slug.into()])?
257 .all()
258 .await?
259 .results::<MemberRow>()?;
260 Ok(Outcome::Ok(
261 rows.into_iter()
262 .map(|row| Member {
263 username: row.username,
264 role: row.role,
265 name: row.name,
266 avatar: row.avatar,
267 })
268 .collect(),
269 ))
270 }
271
272 /// The ids needed to change a workspace's members, if `actor` owns it
273 /// and `username` exists.
274 async fn member_target(&self, a: &MemberArgs) -> Result<Outcome<(String, User)>> {
275 let slug = a.slug.to_lowercase();
276 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
277 return Ok(Outcome::fail(
278 FailureCode::Forbidden,
279 "Only an owner can change a workspace's members.",
280 ));
281 }
282 let Some(workspace) = self.get_workspace(SlugArgs { slug }).await? else {
283 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
284 };
285 let Some(user) = self
286 .find_public_user(
287 "SELECT id, username, email_verified_at IS NOT NULL AS verified
288 FROM users WHERE username = ?",
289 &a.username.trim().to_lowercase(),
290 )
291 .await?
292 else {
293 return Ok(Outcome::fail(
294 FailureCode::NotFound,
295 "There is no account with that username.",
296 ));
297 };
298 Ok(Outcome::Ok((workspace.id, user)))
299 }
300
301 pub async fn add_member(&self, a: MemberArgs) -> Result<Outcome<bool>> {
302 let (workspace_id, user) = match self.member_target(&a).await? {
303 Outcome::Ok(target) => target,
304 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
305 };
306 // What the workspace asks of its members (security.rs); nothing yet.
307 if let Some(why) = self.policy_refusal(&user.id, &a.slug.to_lowercase()).await? {
308 return Ok(Outcome::fail(FailureCode::Forbidden, why));
309 }
310 // A free workspace adds no one until it starts the plan (paid.rs);
311 // g1t's agent is never someone added.
312 if !crate::paid::is_g1t(&user.username)
313 && let Some(refused) = self.free_workspace_refusal(&a.slug).await?
314 {
315 return Ok(refused);
316 }
317 self.db
318 .prepare(
319 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
320 VALUES (?, ?, 'member', ?)",
321 )
322 .bind(&[
323 workspace_id.into(),
324 user.id.into(),
325 rfc3339(now_ms()).into(),
326 ])?
327 .run()
328 .await?;
329 Ok(Outcome::Ok(true))
330 }
331
332 pub async fn remove_member(&self, a: MemberArgs) -> Result<Outcome<bool>> {
333 let (workspace_id, user) = match self.member_target(&a).await? {
334 Outcome::Ok(target) => target,
335 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
336 };
337 if user.id == a.actor.id {
338 return Ok(Outcome::fail(
339 FailureCode::Conflict,
340 "An owner cannot remove themselves.",
341 ));
342 }
343 // Leaving a workspace takes away every way into it: the person's
344 // roles on its repositories go too (access.rs), and their place in
345 // its teams (teams.rs). To keep someone on a repository, add them
346 // to it again as an outside collaborator.
347 self.db
348 .batch(vec![
349 self.db
350 .prepare("DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
351 .bind(&[workspace_id.as_str().into(), user.id.as_str().into()])?,
352 self.db
353 .prepare(
354 "DELETE FROM repo_grants
355 WHERE workspace_id = ? AND principal_kind = 'user' AND principal_id = ?",
356 )
357 .bind(&[workspace_id.as_str().into(), user.id.as_str().into()])?,
358 self.db
359 .prepare(
360 "DELETE FROM team_members
361 WHERE team_id IN (SELECT id FROM teams WHERE workspace_id = ?) AND user_id = ?",
362 )
363 .bind(&[workspace_id.as_str().into(), user.id.as_str().into()])?,
364 ])
365 .await?;
366 Ok(Outcome::Ok(true))
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn no_workspace_is_created_with_g1ts_names() {
376 // What create_workspace takes the slug through, whatever its case.
377 for slug in ["g1t", "G1T", " g1t-agent ", "G1T-Agent"] {
378 assert_eq!(claimable_namespace(slug), None, "{slug}");
379 }
380 assert_eq!(claimable_namespace("Acme").as_deref(), Some("acme"));
381 }
382}