pr_01m47d24b0e6n91zwymwxg0vpx/services/identity/src/workspaces.rs

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