Skip to content

g1t/services/identity/src/workspaces.rs

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