pr_01m47d15m3e54sn21z27rpy5n9/services/identity/src/workspaces.rs

316 lines11,411 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::identity::*;
8use g1t_contracts::time::rfc3339;
9use g1t_contracts::{
10 FailureCode, Membership, Outcome, PrincipalKind, Role, User, is_valid_namespace, new_id,
11};
12use g1t_kit::now_ms;
13use serde::Deserialize;
14use worker::Result;
15
16use crate::Identity;
17
18/// Enough for a person and their teams; stops one account claiming names in
19/// bulk.
20const MAX_WORKSPACES_PER_USER: usize = 10;
21
22const MAX_NAME_LENGTH: usize = 80;
23const MAX_DESCRIPTION_LENGTH: usize = 160;
24
25const WORKSPACE_COLUMNS: &str = "workspaces.id, workspaces.slug, workspaces.name,
26 workspaces.description, workspaces.avatar, workspaces.created_at,
27 (SELECT count(*) FROM workspace_members
28 WHERE workspace_members.workspace_id = workspaces.id) AS member_count";
29
30#[derive(Deserialize)]
31struct WorkspaceRow {
32 id: String,
33 slug: String,
34 name: String,
35 description: Option<String>,
36 avatar: Option<String>,
37 created_at: String,
38 member_count: u32,
39}
40
41impl From<WorkspaceRow> for Workspace {
42 fn from(row: WorkspaceRow) -> Self {
43 Workspace {
44 id: row.id,
45 slug: row.slug,
46 name: row.name,
47 description: row.description,
48 created_at: row.created_at,
49 member_count: row.member_count,
50 avatar: row.avatar,
51 }
52 }
53}
54
55#[derive(Deserialize)]
56struct MemberRow {
57 username: String,
58 role: Role,
59}
60
61impl Identity {
62 /// The workspaces a user belongs to, attached to every user resolved
63 /// from credentials, with what the site needs to show each one.
64 pub async fn memberships(&self, user_id: &str) -> Result<Vec<Membership>> {
65 self.db
66 .prepare(
67 "SELECT workspaces.slug, workspace_members.role, workspaces.name,
68 workspaces.avatar
69 FROM workspace_members
70 JOIN workspaces ON workspaces.id = workspace_members.workspace_id
71 WHERE workspace_members.user_id = ? ORDER BY workspaces.slug",
72 )
73 .bind(&[user_id.into()])?
74 .all()
75 .await?
76 .results::<Membership>()
77 }
78
79 pub async fn create_workspace(&self, a: CreateWorkspaceArgs) -> Result<Outcome<Workspace>> {
80 if a.user.kind != PrincipalKind::User {
81 return Ok(Outcome::fail(
82 FailureCode::Forbidden,
83 "A workspace's access token cannot create workspaces. Sign in as a person.",
84 ));
85 }
86 if !a.user.verified {
87 return Ok(Outcome::fail(
88 FailureCode::Forbidden,
89 "Confirm your email address before creating a workspace.",
90 ));
91 }
92 let slug = a.slug.trim().to_lowercase();
93 if !is_valid_namespace(&slug) {
94 return Ok(Outcome::fail(
95 FailureCode::Invalid,
96 "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters.",
97 ));
98 }
99 if self.memberships(&a.user.id).await?.len() >= MAX_WORKSPACES_PER_USER {
100 return Ok(Outcome::fail(
101 FailureCode::Conflict,
102 "You belong to the maximum number of workspaces.",
103 ));
104 }
105 // Usernames and workspaces share one namespace: a person's username
106 // is theirs to use for a workspace, and nobody else's.
107 let someone_elses_username = self
108 .db
109 .prepare("SELECT id FROM users WHERE username = ? AND id != ?")
110 .bind(&[slug.as_str().into(), a.user.id.as_str().into()])?
111 .first::<serde_json::Value>(None)
112 .await?
113 .is_some();
114 if someone_elses_username
115 || self
116 .get_workspace(SlugArgs { slug: slug.clone() })
117 .await?
118 .is_some()
119 // A renamed workspace's old slug stays reserved for it a while.
120 || self.slug_held(&slug).await?
121 {
122 return Ok(Outcome::fail(
123 FailureCode::Conflict,
124 "That workspace name is taken.",
125 ));
126 }
127 let now = now_ms();
128 let workspace = Workspace {
129 id: new_id("wsp", now),
130 name: match a.name.trim() {
131 "" => slug.clone(),
132 name => name.chars().take(MAX_NAME_LENGTH).collect(),
133 },
134 description: None,
135 slug,
136 created_at: rfc3339(now),
137 member_count: 1,
138 avatar: None,
139 };
140 self.db
141 .batch(vec![
142 self.db
143 .prepare(
144 "INSERT INTO workspaces (id, slug, name, created_by, created_at)
145 VALUES (?, ?, ?, ?, ?)",
146 )
147 .bind(&[
148 workspace.id.as_str().into(),
149 workspace.slug.as_str().into(),
150 workspace.name.as_str().into(),
151 a.user.id.as_str().into(),
152 workspace.created_at.as_str().into(),
153 ])?,
154 self.db
155 .prepare(
156 "INSERT INTO workspace_members (workspace_id, user_id, role, created_at)
157 VALUES (?, ?, 'owner', ?)",
158 )
159 .bind(&[
160 workspace.id.as_str().into(),
161 a.user.id.as_str().into(),
162 workspace.created_at.as_str().into(),
163 ])?,
164 ])
165 .await?;
166 Ok(Outcome::Ok(workspace))
167 }
168
169 pub async fn get_workspace(&self, a: SlugArgs) -> Result<Option<Workspace>> {
170 Ok(self
171 .db
172 .prepare(format!(
173 "SELECT {WORKSPACE_COLUMNS} FROM workspaces WHERE slug = ?"
174 ))
175 .bind(&[a.slug.to_lowercase().into()])?
176 .first::<WorkspaceRow>(None)
177 .await?
178 .map(Workspace::from))
179 }
180
181 pub async fn update_workspace(&self, a: UpdateWorkspaceArgs) -> Result<Outcome<Workspace>> {
182 let slug = a.slug.to_lowercase();
183 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
184 return Ok(Outcome::fail(
185 FailureCode::Forbidden,
186 "Only an owner can change a workspace's details.",
187 ));
188 }
189 let name: String = match a.name.trim() {
190 "" => slug.clone(),
191 name => name.chars().take(MAX_NAME_LENGTH).collect(),
192 };
193 let description: String = a
194 .description
195 .trim()
196 .chars()
197 .take(MAX_DESCRIPTION_LENGTH)
198 .collect();
199 self.db
200 .prepare("UPDATE workspaces SET name = ?, description = ? WHERE slug = ?")
201 .bind(&[
202 name.into(),
203 if description.is_empty() {
204 worker::wasm_bindgen::JsValue::NULL
205 } else {
206 description.into()
207 },
208 slug.as_str().into(),
209 ])?
210 .run()
211 .await?;
212 Ok(match self.get_workspace(SlugArgs { slug }).await? {
213 Some(workspace) => Outcome::Ok(workspace),
214 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
215 })
216 }
217
218 pub async fn list_members(&self, a: ListMembersArgs) -> Result<Outcome<Vec<Member>>> {
219 let slug = a.slug.to_lowercase();
220 if !a.viewer.is_some_and(|viewer| viewer.is_member(&slug)) {
221 return Ok(Outcome::fail(
222 FailureCode::Forbidden,
223 "Only members can see who is in a workspace.",
224 ));
225 }
226 let rows = self
227 .db
228 .prepare(
229 "SELECT users.username, workspace_members.role FROM workspace_members
230 JOIN users ON users.id = workspace_members.user_id
231 JOIN workspaces ON workspaces.id = workspace_members.workspace_id
232 WHERE workspaces.slug = ?
233 ORDER BY workspace_members.role DESC, users.username",
234 )
235 .bind(&[slug.into()])?
236 .all()
237 .await?
238 .results::<MemberRow>()?;
239 Ok(Outcome::Ok(
240 rows.into_iter()
241 .map(|row| Member {
242 username: row.username,
243 role: row.role,
244 })
245 .collect(),
246 ))
247 }
248
249 /// The ids needed to change a workspace's members, if `actor` owns it
250 /// and `username` exists.
251 async fn member_target(&self, a: &MemberArgs) -> Result<Outcome<(String, User)>> {
252 let slug = a.slug.to_lowercase();
253 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
254 return Ok(Outcome::fail(
255 FailureCode::Forbidden,
256 "Only an owner can change a workspace's members.",
257 ));
258 }
259 let Some(workspace) = self.get_workspace(SlugArgs { slug }).await? else {
260 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
261 };
262 let Some(user) = self
263 .find_public_user(
264 "SELECT id, username, email_verified_at IS NOT NULL AS verified
265 FROM users WHERE username = ?",
266 &a.username.trim().to_lowercase(),
267 )
268 .await?
269 else {
270 return Ok(Outcome::fail(
271 FailureCode::NotFound,
272 "There is no account with that username.",
273 ));
274 };
275 Ok(Outcome::Ok((workspace.id, user)))
276 }
277
278 pub async fn add_member(&self, a: MemberArgs) -> Result<Outcome<bool>> {
279 let (workspace_id, user) = match self.member_target(&a).await? {
280 Outcome::Ok(target) => target,
281 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
282 };
283 self.db
284 .prepare(
285 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
286 VALUES (?, ?, 'member', ?)",
287 )
288 .bind(&[
289 workspace_id.into(),
290 user.id.into(),
291 rfc3339(now_ms()).into(),
292 ])?
293 .run()
294 .await?;
295 Ok(Outcome::Ok(true))
296 }
297
298 pub async fn remove_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 if user.id == a.actor.id {
304 return Ok(Outcome::fail(
305 FailureCode::Conflict,
306 "An owner cannot remove themselves.",
307 ));
308 }
309 self.db
310 .prepare("DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
311 .bind(&[workspace_id.into(), user.id.into()])?
312 .run()
313 .await?;
314 Ok(Outcome::Ok(true))
315 }
316}