flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/identity/src/workspaces.rs

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