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/deletion.rs

344 lines12,610 bytesCodeBlame
1//! Deleting a workspace.
2//!
3//! Only an owner can, only a person, and only once the workspace is empty
4//! and settled: no repositories (transfer them first), no projects of its
5//! own, and billing able to close it (`close_workspace`: nothing owed that
6//! cannot be charged now, no failed invoice, no prepaid credit left).
7//!
8//! The workspace's row, its memberships, its access tokens and its
9//! old-slug redirects go. Billing's ledger and invoices, and the audit log,
10//! keep its history under its slug. The slug itself is kept in
11//! `deleted_workspaces`, so it is never given to another workspace or
12//! account: old links keep meaning what they meant, and nobody can squat
13//! the name. The one exception is the person whose username the slug is:
14//! usernames and workspaces share one namespace, so the name is theirs
15//! anyway, and they may make a workspace of it again (it starts empty).
16//!
17//! A person keeps their account whatever workspaces they lose: an account
18//! with no workspace, or with only other people's, works as any other.
19//!
20//! The deletion publishes `workspace.deleted`; services drop what they
21//! keep for the workspace alone.
22
23use g1t_contracts::audit::{
24 AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface,
25};
26use g1t_contracts::billing::CloseWorkspaceArgs;
27use g1t_contracts::events::WorkspaceDeleted;
28use g1t_contracts::identity::*;
29use g1t_contracts::repos::NamespaceCountArgs;
30use g1t_contracts::time::rfc3339;
31use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, new_id};
32use g1t_kit::now_ms;
33use serde::Deserialize;
34use serde_json::json;
35use worker::Result;
36
37use crate::Identity;
38
39/// Who may delete, decided from the request alone: `Ok`, or why not.
40pub fn may_delete(
41 person: bool,
42 verified: bool,
43 role: Option<Role>,
44 slug: &str,
45 confirm: Option<&str>,
46) -> std::result::Result<(), (FailureCode, String)> {
47 if !person || role != Some(Role::Owner) {
48 return Err((
49 FailureCode::Forbidden,
50 "Only an owner can delete a workspace.".into(),
51 ));
52 }
53 if !verified {
54 return Err((
55 FailureCode::Forbidden,
56 "Confirm your email address before deleting a workspace.".into(),
57 ));
58 }
59 if let Some(typed) = confirm
60 && typed.trim().to_lowercase() != slug
61 {
62 return Err((
63 FailureCode::Invalid,
64 format!("Type {slug} to confirm."),
65 ));
66 }
67 Ok(())
68}
69
70/// Whether `slug`, once a deleted workspace's, may be taken by the person
71/// whose username is `username`: only when it is that very name.
72pub fn may_reclaim(slug: &str, username: &str) -> bool {
73 slug.eq_ignore_ascii_case(username)
74}
75
76#[derive(Deserialize)]
77struct Target {
78 id: String,
79 name: String,
80}
81
82impl Identity {
83 /// Whether `slug` belonged to a workspace that was deleted.
84 pub async fn slug_deleted(&self, slug: &str) -> Result<bool> {
85 Ok(self
86 .db
87 .prepare("SELECT 1 AS held FROM deleted_workspaces WHERE slug = ?")
88 .bind(&[slug.to_lowercase().into()])?
89 .first::<serde_json::Value>(None)
90 .await?
91 .is_some())
92 }
93
94 /// A workspace made again under a deleted slug is a workspace again.
95 pub async fn forget_deleted(&self, slug: &str) -> Result<()> {
96 self.db
97 .prepare("DELETE FROM deleted_workspaces WHERE slug = ?")
98 .bind(&[slug.into()])?
99 .run()
100 .await?;
101 Ok(())
102 }
103
104 /// What stands in the way: repositories, projects of its own, billing.
105 async fn blockers(&self, a: &DeleteWorkspaceArgs, slug: &str) -> Result<WorkspaceDeletion> {
106 let repositories: u32 = g1t_kit::call(
107 &self.env.service("REPOS")?,
108 "namespace_count",
109 &NamespaceCountArgs {
110 namespace: slug.to_owned(),
111 },
112 )
113 .await?;
114 let projects: u32 = g1t_kit::call(
115 &self.env.service("PROJECTS")?,
116 "held",
117 &json!({ "workspace": slug }),
118 )
119 .await?;
120 let closing: Outcome<bool> = g1t_kit::call(
121 &self.env.service("BILLING")?,
122 "close_workspace",
123 &CloseWorkspaceArgs {
124 actor: a.actor.clone(),
125 workspace: slug.to_owned(),
126 dry_run: true,
127 },
128 )
129 .await?;
130 Ok(WorkspaceDeletion {
131 repositories,
132 projects,
133 billing: match closing {
134 Outcome::Ok(_) => None,
135 Outcome::Fail(failure) => Some(failure.message),
136 },
137 })
138 }
139
140 /// The workspace, if the actor may delete it.
141 async fn deletable(&self, a: &DeleteWorkspaceArgs, confirm: bool) -> Result<Outcome<Target>> {
142 let slug = a.slug.trim().to_lowercase();
143 if let Err((code, message)) = may_delete(
144 a.actor.kind == PrincipalKind::User,
145 a.actor.verified,
146 a.actor.role_in(&slug),
147 &slug,
148 confirm.then_some(a.confirm.as_str()),
149 ) {
150 return Ok(Outcome::fail(code, message));
151 }
152 Ok(
153 match self
154 .db
155 .prepare("SELECT id, name FROM workspaces WHERE slug = ?")
156 .bind(&[slug.as_str().into()])?
157 .first::<Target>(None)
158 .await?
159 {
160 Some(target) => Outcome::Ok(target),
161 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
162 },
163 )
164 }
165
166 pub async fn check_workspace_deletion(&self, a: DeleteWorkspaceArgs) -> Result<Outcome<WorkspaceDeletion>> {
167 if let Outcome::Fail(failure) = self.deletable(&a, false).await? {
168 return Ok(Outcome::Fail(failure));
169 }
170 let slug = a.slug.trim().to_lowercase();
171 Ok(Outcome::Ok(self.blockers(&a, &slug).await?))
172 }
173
174 pub async fn delete_workspace(&self, a: DeleteWorkspaceArgs) -> Result<Outcome<bool>> {
175 let workspace = match self.deletable(&a, true).await? {
176 Outcome::Ok(workspace) => workspace,
177 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
178 };
179 let slug = a.slug.trim().to_lowercase();
180 let blockers = self.blockers(&a, &slug).await?;
181 if let Some(reason) = blockers.reason(&slug) {
182 return Ok(Outcome::fail(FailureCode::Conflict, reason));
183 }
184 // Money first: if billing cannot settle it after all (a card
185 // declined a moment ago), nothing is deleted.
186 let closed: Outcome<bool> = g1t_kit::call(
187 &self.env.service("BILLING")?,
188 "close_workspace",
189 &CloseWorkspaceArgs {
190 actor: a.actor.clone(),
191 workspace: slug.clone(),
192 dry_run: false,
193 },
194 )
195 .await?;
196 if let Outcome::Fail(failure) = closed {
197 return Ok(Outcome::Fail(failure));
198 }
199 let now = rfc3339(now_ms());
200 let id = workspace.id.as_str();
201 self.db
202 .batch(vec![
203 self.db
204 .prepare(
205 "INSERT OR REPLACE INTO deleted_workspaces (slug, workspace_id, name, deleted_by, deleted_at)
206 VALUES (?, ?, ?, ?, ?)",
207 )
208 .bind(&[
209 slug.as_str().into(),
210 id.into(),
211 workspace.name.as_str().into(),
212 a.actor.id.as_str().into(),
213 now.as_str().into(),
214 ])?,
215 // Slugs it was renamed from, still redirecting, are kept
216 // the same way.
217 self.db
218 .prepare(
219 "INSERT OR IGNORE INTO deleted_workspaces (slug, workspace_id, name, deleted_by, deleted_at)
220 SELECT old_slug, workspace_id, ?, ?, ? FROM workspace_redirects WHERE workspace_id = ?",
221 )
222 .bind(&[
223 workspace.name.as_str().into(),
224 a.actor.id.as_str().into(),
225 now.as_str().into(),
226 id.into(),
227 ])?,
228 self.db
229 .prepare("DELETE FROM access_tokens WHERE workspace_id = ?")
230 .bind(&[id.into()])?,
231 self.db
232 .prepare("DELETE FROM workspace_members WHERE workspace_id = ?")
233 .bind(&[id.into()])?,
234 self.db
235 .prepare("DELETE FROM workspace_redirects WHERE workspace_id = ?")
236 .bind(&[id.into()])?,
237 self.db
238 .prepare("DELETE FROM workspaces WHERE id = ?")
239 .bind(&[id.into()])?,
240 ])
241 .await?;
242 self.record_deletion(&a, &slug).await;
243 self.announce(
244 "workspace.deleted",
245 Some(&a.actor.id),
246 WorkspaceDeleted {
247 workspace_id: workspace.id,
248 slug,
249 },
250 )
251 .await;
252 Ok(Outcome::Ok(true))
253 }
254
255 /// The last entry in the workspace's audit log, which outlives it.
256 async fn record_deletion(&self, a: &DeleteWorkspaceArgs, slug: &str) {
257 let Ok(events) = self.env.service("EVENTS") else {
258 return;
259 };
260 let entry = NewAuditEntry {
261 actor: AuditActor::of(&a.actor),
262 action: "workspace.deleted".to_owned(),
263 surface: a.surface.unwrap_or(Surface::Web),
264 target: AuditTarget {
265 workspace: slug.to_owned(),
266 ..AuditTarget::default()
267 },
268 outcome: AuditOutcome::Allowed,
269 rule: "owner".to_owned(),
270 result: Some("ok".to_owned()),
271 message: Some(format!("Deleted {slug}")),
272 request_id: new_id("req", now_ms()),
273 };
274 let recorded: Result<u32> = g1t_kit::call(
275 &events,
276 "audit_record",
277 &RecordAuditArgs {
278 entries: vec![entry],
279 },
280 )
281 .await;
282 if let Err(error) = recorded {
283 worker::console_error!("deletion of {slug} not recorded: {error}");
284 }
285 }
286
287 /// `transfer_repo_scopes`: agents at work on a transferred repository
288 /// keep their scope, which names it by path.
289 pub async fn transfer_repo_scopes(&self, a: TransferRepoScopesArgs) -> Result<bool> {
290 self.db
291 .prepare(
292 "UPDATE access_tokens
293 SET agent_scope = json_set(agent_scope, '$.repo.namespace', ?1, '$.repo.name', ?2)
294 WHERE agent_scope IS NOT NULL
295 AND json_extract(agent_scope, '$.repo.namespace') = ?3
296 AND json_extract(agent_scope, '$.repo.name') = ?4",
297 )
298 .bind(&[
299 a.to.namespace.as_str().into(),
300 a.to.name.as_str().into(),
301 a.from.namespace.as_str().into(),
302 a.from.name.as_str().into(),
303 ])?
304 .run()
305 .await?;
306 // Who has access to it follows it too (access.rs).
307 self.move_repo_access(&a.from, &a.to).await?;
308 Ok(true)
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn only_a_verified_owner_who_types_the_name() {
318 assert!(may_delete(true, true, Some(Role::Owner), "acme", Some(" Acme ")).is_ok());
319 assert!(may_delete(true, true, Some(Role::Owner), "acme", None).is_ok());
320 assert_eq!(
321 may_delete(true, true, Some(Role::Member), "acme", Some("acme")).unwrap_err().0,
322 FailureCode::Forbidden
323 );
324 assert_eq!(
325 may_delete(false, true, Some(Role::Owner), "acme", Some("acme")).unwrap_err().0,
326 FailureCode::Forbidden
327 );
328 assert_eq!(
329 may_delete(true, false, Some(Role::Owner), "acme", Some("acme")).unwrap_err().0,
330 FailureCode::Forbidden
331 );
332 assert_eq!(
333 may_delete(true, true, Some(Role::Owner), "acme", Some("acme-inc")).unwrap_err().0,
334 FailureCode::Invalid
335 );
336 }
337
338 #[test]
339 fn a_deleted_slug_is_reclaimed_only_by_its_namesake() {
340 assert!(may_reclaim("syntaqx", "syntaqx"));
341 assert!(may_reclaim("syntaqx", "Syntaqx"));
342 assert!(!may_reclaim("flagon-io", "syntaqx"));
343 }
344}