g1t/services/identity/src/workspaces.rs

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