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

352 lines13,256 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Workspaces own repositories1//! 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look7use g1t_contracts::access::BasePermission;
Workspaces own repositories8use g1t_contracts::identity::*;
9use g1t_contracts::time::rfc3339;
Agents as a team: lifecycle, merge queue, billing and a new shell10use g1t_contracts::{
11 FailureCode, Membership, Outcome, PrincipalKind, Role, User, is_valid_namespace, new_id,
12};
Workspaces own repositories13use 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
Agents as a team: lifecycle, merge queue, billing and a new shell23const MAX_NAME_LENGTH: usize = 80;
24const MAX_DESCRIPTION_LENGTH: usize = 160;
25
Workspaces own repositories26const WORKSPACE_COLUMNS: &str = "workspaces.id, workspaces.slug, workspaces.name,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look27 workspaces.description, workspaces.avatar, workspaces.created_at, workspaces.base_permission,
Workspaces own repositories28 (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,
Agents as a team: lifecycle, merge queue, billing and a new shell36 description: Option<String>,
Workspace names and icons, and a component kit for every control37 avatar: Option<String>,
Workspaces own repositories38 created_at: String,
39 member_count: u32,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look40 #[serde(default)]
41 base_permission: Option<String>,
Workspaces own repositories42}
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,
Agents as a team: lifecycle, merge queue, billing and a new shell50 description: row.description,
Workspaces own repositories51 created_at: row.created_at,
52 member_count: row.member_count,
Workspace names and icons, and a component kit for every control53 avatar: row.avatar,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look54 base_permission: row
55 .base_permission
56 .as_deref()
57 .and_then(BasePermission::parse)
58 .unwrap_or_default(),
Workspaces own repositories59 }
60 }
61}
62
63#[derive(Deserialize)]
64struct MemberRow {
65 username: String,
66 role: Role,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look67 #[serde(default)]
68 name: Option<String>,
69 #[serde(default)]
70 avatar: Option<String>,
Workspaces own repositories71}
72
73impl Identity {
74 /// The workspaces a user belongs to, attached to every user resolved
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look75 /// from credentials, with what the site needs to show each one and
76 /// what members get on its repositories (access.rs).
Workspaces own repositories77 pub async fn memberships(&self, user_id: &str) -> Result<Vec<Membership>> {
78 self.db
79 .prepare(
Workspace names and icons, and a component kit for every control80 "SELECT workspaces.slug, workspace_members.role, workspaces.name,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look81 workspaces.avatar, workspaces.base_permission
Workspace names and icons, and a component kit for every control82 FROM workspace_members
Workspaces own repositories83 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>> {
Agents as a team: lifecycle, merge queue, billing and a new shell93 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 }
Workspaces own repositories99 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 slug = a.slug.trim().to_lowercase();
106 if !is_valid_namespace(&slug) {
107 return Ok(Outcome::fail(
108 FailureCode::Invalid,
109 "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters.",
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 }
Agents as a team: lifecycle, merge queue, billing and a new shell118 // 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)
Workspaces own repositories125 .await?
Agents as a team: lifecycle, merge queue, billing and a new shell126 .is_some();
127 if someone_elses_username
128 || self
129 .get_workspace(SlugArgs { slug: slug.clone() })
130 .await?
131 .is_some()
Agents and memory, checks and conflicts, profiles, slug renames, custom domains132 // A renamed workspace's old slug stays reserved for it a while.
133 || self.slug_held(&slug).await?
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look134 // A deleted workspace's slug is never given to anyone else; the
135 // person whose username it is may use it again.
136 || (self.slug_deleted(&slug).await?
137 && !crate::deletion::may_reclaim(&slug, &a.user.username))
Workspaces own repositories138 {
139 return Ok(Outcome::fail(
140 FailureCode::Conflict,
141 "That workspace name is taken.",
142 ));
143 }
144 let now = now_ms();
145 let workspace = Workspace {
146 id: new_id("wsp", now),
147 name: match a.name.trim() {
148 "" => slug.clone(),
Agents as a team: lifecycle, merge queue, billing and a new shell149 name => name.chars().take(MAX_NAME_LENGTH).collect(),
Workspaces own repositories150 },
Agents as a team: lifecycle, merge queue, billing and a new shell151 description: None,
Workspaces own repositories152 slug,
153 created_at: rfc3339(now),
154 member_count: 1,
Workspace names and icons, and a component kit for every control155 avatar: None,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look156 base_permission: BasePermission::default(),
Workspaces own repositories157 };
158 self.db
159 .batch(vec![
160 self.db
161 .prepare(
162 "INSERT INTO workspaces (id, slug, name, created_by, created_at)
163 VALUES (?, ?, ?, ?, ?)",
164 )
165 .bind(&[
166 workspace.id.as_str().into(),
167 workspace.slug.as_str().into(),
168 workspace.name.as_str().into(),
169 a.user.id.as_str().into(),
170 workspace.created_at.as_str().into(),
171 ])?,
172 self.db
173 .prepare(
174 "INSERT INTO workspace_members (workspace_id, user_id, role, created_at)
175 VALUES (?, ?, 'owner', ?)",
176 )
177 .bind(&[
178 workspace.id.as_str().into(),
179 a.user.id.as_str().into(),
180 workspace.created_at.as_str().into(),
181 ])?,
182 ])
183 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look184 self.forget_deleted(&workspace.slug).await?;
Workspaces own repositories185 Ok(Outcome::Ok(workspace))
186 }
187
188 pub async fn get_workspace(&self, a: SlugArgs) -> Result<Option<Workspace>> {
189 Ok(self
190 .db
191 .prepare(format!(
192 "SELECT {WORKSPACE_COLUMNS} FROM workspaces WHERE slug = ?"
193 ))
194 .bind(&[a.slug.to_lowercase().into()])?
195 .first::<WorkspaceRow>(None)
196 .await?
197 .map(Workspace::from))
198 }
199
Agents as a team: lifecycle, merge queue, billing and a new shell200 pub async fn update_workspace(&self, a: UpdateWorkspaceArgs) -> Result<Outcome<Workspace>> {
201 let slug = a.slug.to_lowercase();
202 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
203 return Ok(Outcome::fail(
204 FailureCode::Forbidden,
205 "Only an owner can change a workspace's details.",
206 ));
207 }
208 let name: String = match a.name.trim() {
209 "" => slug.clone(),
210 name => name.chars().take(MAX_NAME_LENGTH).collect(),
211 };
212 let description: String = a
213 .description
214 .trim()
215 .chars()
216 .take(MAX_DESCRIPTION_LENGTH)
217 .collect();
218 self.db
219 .prepare("UPDATE workspaces SET name = ?, description = ? WHERE slug = ?")
220 .bind(&[
221 name.into(),
222 if description.is_empty() {
223 worker::wasm_bindgen::JsValue::NULL
224 } else {
225 description.into()
226 },
227 slug.as_str().into(),
228 ])?
229 .run()
230 .await?;
231 Ok(match self.get_workspace(SlugArgs { slug }).await? {
232 Some(workspace) => Outcome::Ok(workspace),
233 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
234 })
235 }
236
Workspaces own repositories237 pub async fn list_members(&self, a: ListMembersArgs) -> Result<Outcome<Vec<Member>>> {
238 let slug = a.slug.to_lowercase();
239 if !a.viewer.is_some_and(|viewer| viewer.is_member(&slug)) {
240 return Ok(Outcome::fail(
241 FailureCode::Forbidden,
242 "Only members can see who is in a workspace.",
243 ));
244 }
245 let rows = self
246 .db
247 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look248 "SELECT users.username, workspace_members.role, users.display_name AS name, users.avatar FROM workspace_members
Workspaces own repositories249 JOIN users ON users.id = workspace_members.user_id
250 JOIN workspaces ON workspaces.id = workspace_members.workspace_id
251 WHERE workspaces.slug = ?
252 ORDER BY workspace_members.role DESC, users.username",
253 )
254 .bind(&[slug.into()])?
255 .all()
256 .await?
257 .results::<MemberRow>()?;
258 Ok(Outcome::Ok(
259 rows.into_iter()
260 .map(|row| Member {
261 username: row.username,
262 role: row.role,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look263 name: row.name,
264 avatar: row.avatar,
Workspaces own repositories265 })
266 .collect(),
267 ))
268 }
269
270 /// The ids needed to change a workspace's members, if `actor` owns it
271 /// and `username` exists.
272 async fn member_target(&self, a: &MemberArgs) -> Result<Outcome<(String, User)>> {
273 let slug = a.slug.to_lowercase();
Agents as a team: lifecycle, merge queue, billing and a new shell274 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
Workspaces own repositories275 return Ok(Outcome::fail(
276 FailureCode::Forbidden,
277 "Only an owner can change a workspace's members.",
278 ));
279 }
280 let Some(workspace) = self.get_workspace(SlugArgs { slug }).await? else {
281 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
282 };
283 let Some(user) = self
284 .find_public_user(
285 "SELECT id, username, email_verified_at IS NOT NULL AS verified
286 FROM users WHERE username = ?",
287 &a.username.trim().to_lowercase(),
288 )
289 .await?
290 else {
291 return Ok(Outcome::fail(
292 FailureCode::NotFound,
293 "There is no account with that username.",
294 ));
295 };
296 Ok(Outcome::Ok((workspace.id, user)))
297 }
298
299 pub async fn add_member(&self, a: MemberArgs) -> Result<Outcome<bool>> {
300 let (workspace_id, user) = match self.member_target(&a).await? {
301 Outcome::Ok(target) => target,
302 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
303 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look304 // What the workspace asks of its members (security.rs); nothing yet.
305 if let Some(why) = self.policy_refusal(&user.id, &a.slug.to_lowercase()).await? {
306 return Ok(Outcome::fail(FailureCode::Forbidden, why));
307 }
Workspaces own repositories308 self.db
309 .prepare(
310 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
311 VALUES (?, ?, 'member', ?)",
312 )
313 .bind(&[
314 workspace_id.into(),
315 user.id.into(),
316 rfc3339(now_ms()).into(),
317 ])?
318 .run()
319 .await?;
320 Ok(Outcome::Ok(true))
321 }
322
323 pub async fn remove_member(&self, a: MemberArgs) -> Result<Outcome<bool>> {
324 let (workspace_id, user) = match self.member_target(&a).await? {
325 Outcome::Ok(target) => target,
326 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
327 };
328 if user.id == a.actor.id {
329 return Ok(Outcome::fail(
330 FailureCode::Conflict,
331 "An owner cannot remove themselves.",
332 ));
333 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look334 // Leaving a workspace takes away every way into it: the person's
335 // roles on its repositories go too (access.rs). To keep someone on
336 // a repository, add them to it again as an outside collaborator.
Workspaces own repositories337 self.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look338 .batch(vec![
339 self.db
340 .prepare("DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
341 .bind(&[workspace_id.as_str().into(), user.id.as_str().into()])?,
342 self.db
343 .prepare(
344 "DELETE FROM repo_grants
345 WHERE workspace_id = ? AND principal_kind = 'user' AND principal_id = ?",
346 )
347 .bind(&[workspace_id.as_str().into(), user.id.as_str().into()])?,
348 ])
Workspaces own repositories349 .await?;
350 Ok(Outcome::Ok(true))
351 }
352}