g1t/services/identity/src/workspaces.rs

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