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.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1 | //! Deleting a workspace. |
| 2 | //! | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 3 | //! Only an owner can, only a person, typing the slug to confirm, and only |
| 4 | //! once billing can settle it (`close_workspace`: nothing owed that cannot | |
| 5 | //! be charged now, no failed invoice, no prepaid credit left). Some | |
| 6 | //! workspaces can never be deleted, by anyone: those in | |
| 7 | //! `PROTECTED_WORKSPACES`, Flagon's whatever that says | |
| 8 | //! (`g1t_contracts::identity::protected_names`), and any whose row is | |
| 9 | //! marked protected, which a rename of a protected workspace sets so the | |
| 10 | //! protection follows it. | |
| 11 | //! | |
| 12 | //! Everything in it goes in that one step, softly. The row gets | |
| 13 | //! `deleted_at`, `deleted_by` and `purge_after` ([`WORKSPACE_RESTORE_DAYS`] | |
| 14 | //! on), and every read that resolves a workspace leaves it out: nobody's | |
| 15 | //! memberships list it, its tokens are refused, its pages are not found. | |
| 16 | //! Its members and tokens are kept as they were, and its slug stays held by | |
| 17 | //! its row. `workspace.deleting` tells every service to put away what it | |
| 18 | //! keeps for it: repos deletes its repositories softly, marked as gone with | |
| 19 | //! it, deployments pauses its apps, projects and search hide it. | |
| 20 | //! | |
| 21 | //! Until `purge_after`, g1t's staff can restore it from sudo: the columns | |
| 22 | //! are cleared, `workspace.restored` undoes exactly what the deletion did, | |
| 23 | //! and it is back with its members and tokens. A malicious or mistaken | |
| 24 | //! deletion is undone this way, through support. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 25 | //! |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 26 | //! The purge, by the scheduled sweep once `purge_after` passes or by staff |
| 27 | //! from sudo, is what deleting used to do at once: the row, memberships, | |
| 28 | //! tokens and old-slug redirects go, the slug is kept in | |
| 29 | //! `deleted_workspaces` so it is never given to another workspace or | |
| 30 | //! account, and `workspace.deleted` tells services to drop what they keep. | |
| 31 | //! Billing's ledger and invoices, and the audit log, keep its history under | |
| 32 | //! its slug. The one exception to the slug is the person whose username it | |
| 33 | //! is: usernames and workspaces share one namespace, so the name is theirs | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 34 | //! anyway, and they may make a workspace of it again (it starts empty). |
| 35 | //! | |
| 36 | //! A person keeps their account whatever workspaces they lose: an account | |
| 37 | //! with no workspace, or with only other people's, works as any other. | |
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 38 | //! |
| 39 | //! g1t's staff delete a workspace only together with the account that is | |
| 40 | //! its only owner (account_deletion.rs, `with_sole_workspaces`), through | |
| 41 | //! [`Identity::staff_delete_workspace`]: the same steps, the same refusals | |
| 42 | //! (protected, billing that cannot settle), with the staff member as | |
| 43 | //! `deleted_by` and a line in sudo's audit log with the reason. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 44 | |
| 45 | use g1t_contracts::audit::{ | |
| 46 | AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface, | |
| 47 | }; | |
| 48 | use g1t_contracts::billing::CloseWorkspaceArgs; | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 49 | use g1t_contracts::events::{WorkspaceDeleted, WorkspaceDeleting, WorkspaceRestored}; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 50 | use g1t_contracts::identity::*; |
| 51 | use g1t_contracts::repos::NamespaceCountArgs; | |
| 52 | use g1t_contracts::time::rfc3339; | |
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 53 | use g1t_contracts::account_deletion::WorkspaceDeletedWith; |
| 54 | use g1t_contracts::{FailureCode, Membership, Outcome, PrincipalKind, Role, User, new_id}; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 55 | use g1t_kit::now_ms; |
| 56 | use serde::Deserialize; | |
| 57 | use serde_json::json; | |
| 58 | use worker::Result; | |
| 59 | ||
| 60 | use crate::Identity; | |
| 61 | ||
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 62 | type Refusal = (FailureCode, String); |
| 63 | ||
| 64 | /// How many workspaces one sweep purges. | |
| 65 | const PURGES_PER_SWEEP: u32 = 25; | |
| 66 | ||
| 67 | /// Who may delete, decided from the request and whether the workspace is | |
| 68 | /// protected: `Ok`, or why not. A protected workspace is refused to | |
| 69 | /// everyone, owners included. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 70 | pub fn may_delete( |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 71 | protected: bool, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 72 | person: bool, |
| 73 | verified: bool, | |
| 74 | role: Option<Role>, | |
| 75 | slug: &str, | |
| 76 | confirm: Option<&str>, | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 77 | ) -> std::result::Result<(), Refusal> { |
| 78 | if protected { | |
| 79 | return Err((FailureCode::Forbidden, protected_refusal(slug))); | |
| 80 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 81 | if !person || role != Some(Role::Owner) { |
| 82 | return Err(( | |
| 83 | FailureCode::Forbidden, | |
| 84 | "Only an owner can delete a workspace.".into(), | |
| 85 | )); | |
| 86 | } | |
| 87 | if !verified { | |
| 88 | return Err(( | |
| 89 | FailureCode::Forbidden, | |
| 90 | "Confirm your email address before deleting a workspace.".into(), | |
| 91 | )); | |
| 92 | } | |
| 93 | if let Some(typed) = confirm | |
| 94 | && typed.trim().to_lowercase() != slug | |
| 95 | { | |
| 96 | return Err(( | |
| 97 | FailureCode::Invalid, | |
| 98 | format!("Type {slug} to confirm."), | |
| 99 | )); | |
| 100 | } | |
| 101 | Ok(()) | |
| 102 | } | |
| 103 | ||
| 104 | /// Whether `slug`, once a deleted workspace's, may be taken by the person | |
| 105 | /// whose username is `username`: only when it is that very name. | |
| 106 | pub fn may_reclaim(slug: &str, username: &str) -> bool { | |
| 107 | slug.eq_ignore_ascii_case(username) | |
| 108 | } | |
| 109 | ||
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 110 | /// When a workspace deleted at `now_ms` is purged. |
| 111 | pub fn purge_after(now_ms: u64) -> String { | |
| 112 | rfc3339(now_ms + WORKSPACE_RESTORE_DAYS * 86_400_000) | |
| 113 | } | |
| 114 | ||
| 115 | /// Whether a workspace to be purged at `purge_after` can still be restored | |
| 116 | /// at `now` (both RFC 3339, which compare as text). Once it cannot, the | |
| 117 | /// next sweep purges it. | |
| 118 | pub fn restorable(purge_after: &str, now: &str) -> bool { | |
| 119 | now < purge_after | |
| 120 | } | |
| 121 | ||
| 122 | /// Whether the workspace `id`, now at `slug`, is protected: its row says | |
| 123 | /// so (`flagged`), or `names` (from [`protected_names`]) holds its id, its | |
| 124 | /// slug or a slug it was renamed from (`old_slugs`). | |
| 125 | pub fn is_protected(names: &[String], id: &str, slug: &str, flagged: bool, old_slugs: &[String]) -> bool { | |
| 126 | let named = |name: &str| names.iter().any(|protected| protected.eq_ignore_ascii_case(name)); | |
| 127 | flagged || named(id) || named(slug) || old_slugs.iter().any(|old| named(old)) | |
| 128 | } | |
| 129 | ||
| 130 | /// Whether staff may restore a deleted workspace, now `now`. | |
| 131 | pub fn may_restore(slug: &str, purge_after: &str, now: &str) -> std::result::Result<(), Refusal> { | |
| 132 | if !restorable(purge_after, now) { | |
| 133 | return Err(( | |
| 134 | FailureCode::Conflict, | |
| 135 | format!("{slug} is being purged and can no longer be restored."), | |
| 136 | )); | |
| 137 | } | |
| 138 | Ok(()) | |
| 139 | } | |
| 140 | ||
| 141 | /// Whether a deleted workspace may be purged, by staff (`confirm` is what | |
| 142 | /// they typed) or by the sweep (`None`). Never a protected one. | |
| 143 | pub fn may_purge(protected: bool, slug: &str, confirm: Option<&str>) -> std::result::Result<(), Refusal> { | |
| 144 | if protected { | |
| 145 | return Err((FailureCode::Forbidden, protected_refusal(slug))); | |
| 146 | } | |
| 147 | if let Some(typed) = confirm | |
| 148 | && typed.trim().to_lowercase() != slug | |
| 149 | { | |
| 150 | return Err((FailureCode::Invalid, format!("Type {slug} to confirm."))); | |
| 151 | } | |
| 152 | Ok(()) | |
| 153 | } | |
| 154 | ||
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 155 | /// Who billing's `close_workspace` sees when staff delete a workspace with |
| 156 | /// the account that is its only owner: the account, as owner of `slug` | |
| 157 | /// (billing closes only for an owner), named by the staff member so | |
| 158 | /// billing's record says who closed it. | |
| 159 | pub fn staff_billing_actor(owner_id: &str, staff: &str, slug: &str) -> User { | |
| 160 | User { | |
| 161 | id: owner_id.to_owned(), | |
| 162 | username: staff.to_owned(), | |
| 163 | kind: PrincipalKind::User, | |
| 164 | verified: true, | |
| 165 | workspaces: vec![Membership { role: Role::Owner, ..Membership::member(slug) }], | |
| 166 | ..User::default() | |
| 167 | } | |
| 168 | } | |
| 169 | ||
| 170 | /// Who deletes a workspace, for its row, its audit log and the event. | |
| 171 | struct Deleter<'a> { | |
| 172 | /// Who billing closes it for: an owner. | |
| 173 | billing: &'a User, | |
| 174 | /// `deleted_by` on its row: the owner's id, or the staff member. | |
| 175 | deleted_by: &'a str, | |
| 176 | /// `by` on `workspace.deleting`: the owner's username, or the staff | |
| 177 | /// member. | |
| 178 | by: &'a str, | |
| 179 | audit: AuditActor, | |
| 180 | surface: Surface, | |
| 181 | /// The audit log's rule: `owner` or `staff`. | |
| 182 | rule: &'static str, | |
| 183 | /// What the audit log says, given when it can be restored until. | |
| 184 | message: Box<dyn Fn(&str) -> String + 'a>, | |
| 185 | /// The event's actor. | |
| 186 | actor_id: Option<&'a str>, | |
| 187 | } | |
| 188 | ||
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 189 | /// What `deleted_went` holds: what went with the workspace. |
| 190 | fn went_json(went: &WorkspaceDeletion) -> String { | |
| 191 | json!({ | |
| 192 | "repositories": went.repositories, | |
| 193 | "projects": went.projects, | |
| 194 | "members": went.members, | |
| 195 | }) | |
| 196 | .to_string() | |
| 197 | } | |
| 198 | ||
| 199 | fn went_of(stored: Option<&str>) -> WorkspaceDeletion { | |
| 200 | stored | |
| 201 | .and_then(|text| serde_json::from_str::<WorkspaceDeletion>(text).ok()) | |
| 202 | .unwrap_or_default() | |
| 203 | } | |
| 204 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 205 | #[derive(Deserialize)] |
| 206 | struct Target { | |
| 207 | id: String, | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 208 | #[serde(default)] |
| 209 | protected: u8, | |
| 210 | } | |
| 211 | ||
| 212 | /// A deleted workspace's row. | |
| 213 | #[derive(Deserialize)] | |
| 214 | struct DeletedRow { | |
| 215 | id: String, | |
| 216 | slug: String, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 217 | name: String, |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 218 | deleted_at: String, |
| 219 | #[serde(default)] | |
| 220 | deleted_by: Option<String>, | |
| 221 | purge_after: String, | |
| 222 | #[serde(default)] | |
| 223 | deleted_went: Option<String>, | |
| 224 | #[serde(default)] | |
| 225 | protected: u8, | |
| 226 | /// The deleter's username, when their account is still there. | |
| 227 | #[serde(default)] | |
| 228 | deleter: Option<String>, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 229 | } |
| 230 | ||
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 231 | const DELETED_COLUMNS: &str = "w.id, w.slug, w.name, w.deleted_at, w.deleted_by, w.purge_after, |
| 232 | w.deleted_went, w.protected, u.username AS deleter | |
| 233 | FROM workspaces w LEFT JOIN users u ON u.id = w.deleted_by | |
| 234 | WHERE w.deleted_at IS NOT NULL"; | |
| 235 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 236 | impl Identity { |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 237 | /// Whether `slug` belonged to a workspace that was deleted and purged, |
| 238 | /// or is the username of an account that was (account_deletion.rs): | |
| 239 | /// neither is ever given to anyone again. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 240 | pub async fn slug_deleted(&self, slug: &str) -> Result<bool> { |
| 241 | Ok(self | |
| 242 | .db | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 243 | .prepare( |
| 244 | "SELECT 1 AS held FROM deleted_workspaces WHERE slug = ?1 | |
| 245 | UNION ALL SELECT 1 FROM deleted_users WHERE username = ?1", | |
| 246 | ) | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 247 | .bind(&[slug.to_lowercase().into()])? |
| 248 | .first::<serde_json::Value>(None) | |
| 249 | .await? | |
| 250 | .is_some()) | |
| 251 | } | |
| 252 | ||
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 253 | /// Whether a workspace holds `slug`, deleted or not: one deleted and |
| 254 | /// not yet purged keeps it for a restore. | |
| 255 | pub async fn slug_in_use(&self, slug: &str) -> Result<bool> { | |
| 256 | Ok(self | |
| 257 | .db | |
| 258 | .prepare("SELECT 1 AS held FROM workspaces WHERE slug = ?") | |
| 259 | .bind(&[slug.to_lowercase().into()])? | |
| 260 | .first::<serde_json::Value>(None) | |
| 261 | .await? | |
| 262 | .is_some()) | |
| 263 | } | |
| 264 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 265 | /// A workspace made again under a deleted slug is a workspace again. |
| 266 | pub async fn forget_deleted(&self, slug: &str) -> Result<()> { | |
| 267 | self.db | |
| 268 | .prepare("DELETE FROM deleted_workspaces WHERE slug = ?") | |
| 269 | .bind(&[slug.into()])? | |
| 270 | .run() | |
| 271 | .await?; | |
| 272 | Ok(()) | |
| 273 | } | |
| 274 | ||
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 275 | /// `PROTECTED_WORKSPACES`, with Flagon's whatever it says. |
| 276 | fn protected_names(&self) -> Vec<String> { | |
| 277 | let configured = self.env.var("PROTECTED_WORKSPACES").ok().map(|v| v.to_string()); | |
| 278 | protected_names(configured.as_deref()) | |
| 279 | } | |
| 280 | ||
| 281 | /// Whether the workspace `id`, now at `slug`, can never be deleted. | |
| 282 | /// Asked each time, of its row, the variable and the slugs it was | |
| 283 | /// renamed from, so a rename cannot take the protection away. | |
| 284 | pub(crate) async fn is_protected(&self, id: &str, slug: &str, flagged: bool) -> Result<bool> { | |
| 285 | let names = self.protected_names(); | |
| 286 | if is_protected(&names, id, slug, flagged, &[]) { | |
| 287 | return Ok(true); | |
| 288 | } | |
| 289 | #[derive(Deserialize)] | |
| 290 | struct Old { | |
| 291 | old_slug: String, | |
| 292 | } | |
| 293 | let old: Vec<String> = self | |
| 294 | .db | |
| 295 | .prepare("SELECT old_slug FROM workspace_redirects WHERE workspace_id = ?") | |
| 296 | .bind(&[id.into()])? | |
| 297 | .all() | |
| 298 | .await? | |
| 299 | .results::<Old>()? | |
| 300 | .into_iter() | |
| 301 | .map(|row| row.old_slug) | |
| 302 | .collect(); | |
| 303 | Ok(is_protected(&names, id, slug, flagged, &old)) | |
| 304 | } | |
| 305 | ||
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 306 | /// Why billing could not close `slug` now for `actor` (an owner), or |
| 307 | /// `None` when it could. Changes nothing. | |
| 308 | pub(crate) async fn billing_refusal(&self, actor: &User, slug: &str) -> Result<Option<String>> { | |
| 309 | let closing: Outcome<bool> = g1t_kit::call( | |
| 310 | &self.env.service("BILLING")?, | |
| 311 | "close_workspace", | |
| 312 | &CloseWorkspaceArgs { | |
| 313 | actor: actor.clone(), | |
| 314 | workspace: slug.to_owned(), | |
| 315 | dry_run: true, | |
| 316 | }, | |
| 317 | ) | |
| 318 | .await?; | |
| 319 | Ok(match closing { | |
| 320 | Outcome::Ok(_) => None, | |
| 321 | Outcome::Fail(failure) => Some(failure.message), | |
| 322 | }) | |
| 323 | } | |
| 324 | ||
| 325 | /// What would go with the workspace, and whether billing can close it | |
| 326 | /// for `actor`. | |
| 327 | async fn deletion_facts(&self, actor: &User, slug: &str, target: &Target) -> Result<WorkspaceDeletion> { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 328 | let repositories: u32 = g1t_kit::call( |
| 329 | &self.env.service("REPOS")?, | |
| 330 | "namespace_count", | |
| 331 | &NamespaceCountArgs { | |
| 332 | namespace: slug.to_owned(), | |
| 333 | }, | |
| 334 | ) | |
| 335 | .await?; | |
| 336 | let projects: u32 = g1t_kit::call( | |
| 337 | &self.env.service("PROJECTS")?, | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 338 | "count", |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 339 | &json!({ "workspace": slug }), |
| 340 | ) | |
| 341 | .await?; | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 342 | #[derive(Deserialize)] |
| 343 | struct Count { | |
| 344 | n: u32, | |
| 345 | } | |
| 346 | let members = self | |
| 347 | .db | |
| 348 | .prepare("SELECT count(*) AS n FROM workspace_members WHERE workspace_id = ?") | |
| 349 | .bind(&[target.id.as_str().into()])? | |
| 350 | .first::<Count>(None) | |
| 351 | .await? | |
| 352 | .map_or(0, |count| count.n); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 353 | Ok(WorkspaceDeletion { |
| 354 | repositories, | |
| 355 | projects, | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 356 | members, |
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 357 | billing: self.billing_refusal(actor, slug).await?, |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 358 | protected: self.is_protected(&target.id, slug, target.protected != 0).await?, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 359 | }) |
| 360 | } | |
| 361 | ||
| 362 | /// The workspace, if the actor may delete it. | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 363 | async fn deletable(&self, a: &DeleteWorkspaceArgs, confirm: bool) -> Result<Outcome<(Target, bool)>> { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 364 | let slug = a.slug.trim().to_lowercase(); |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 365 | let Some(target) = self |
| 366 | .db | |
| 367 | .prepare("SELECT id, protected FROM workspaces WHERE slug = ? AND deleted_at IS NULL") | |
| 368 | .bind(&[slug.as_str().into()])? | |
| 369 | .first::<Target>(None) | |
| 370 | .await? | |
| 371 | else { | |
| 372 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); | |
| 373 | }; | |
| 374 | let protected = self.is_protected(&target.id, &slug, target.protected != 0).await?; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 375 | if let Err((code, message)) = may_delete( |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 376 | // Only the actual deletion is refused for it; the check says |
| 377 | // so in `protected`, for the page to show. | |
| 378 | protected && confirm, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 379 | a.actor.kind == PrincipalKind::User, |
| 380 | a.actor.verified, | |
| 381 | a.actor.role_in(&slug), | |
| 382 | &slug, | |
| 383 | confirm.then_some(a.confirm.as_str()), | |
| 384 | ) { | |
| 385 | return Ok(Outcome::fail(code, message)); | |
| 386 | } | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 387 | Ok(Outcome::Ok((target, protected))) |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 388 | } |
| 389 | ||
| 390 | pub async fn check_workspace_deletion(&self, a: DeleteWorkspaceArgs) -> Result<Outcome<WorkspaceDeletion>> { | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 391 | let target = match self.deletable(&a, false).await? { |
| 392 | Outcome::Ok((target, _)) => target, | |
| 393 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 394 | }; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 395 | let slug = a.slug.trim().to_lowercase(); |
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 396 | Ok(Outcome::Ok(self.deletion_facts(&a.actor, &slug, &target).await?)) |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 397 | } |
| 398 | ||
| 399 | pub async fn delete_workspace(&self, a: DeleteWorkspaceArgs) -> Result<Outcome<bool>> { | |
| 400 | let workspace = match self.deletable(&a, true).await? { | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 401 | Outcome::Ok((workspace, _)) => workspace, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 402 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 403 | }; | |
| 404 | let slug = a.slug.trim().to_lowercase(); | |
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 405 | let deleter = Deleter { |
| 406 | billing: &a.actor, | |
| 407 | deleted_by: &a.actor.id, | |
| 408 | by: &a.actor.username, | |
| 409 | audit: AuditActor::of(&a.actor), | |
| 410 | surface: a.surface.unwrap_or(Surface::Web), | |
| 411 | rule: "owner", | |
| 412 | message: Box::new(|purge| format!("Deleted {slug}; restorable by g1t's staff until {purge}")), | |
| 413 | actor_id: Some(&a.actor.id), | |
| 414 | }; | |
| 415 | self.soft_delete_workspace(&workspace, &slug, &deleter).await | |
| 416 | } | |
| 417 | ||
| 418 | /// g1t's staff delete a live workspace that `owner_id` (`owner`) is the | |
| 419 | /// only owner of, as part of deleting that account (account_deletion.rs): | |
| 420 | /// exactly as its owner would, refused the same way, with the staff | |
| 421 | /// member as `deleted_by` and in sudo's audit log with `reason`. | |
| 422 | pub(crate) async fn staff_delete_workspace( | |
| 423 | &self, | |
| 424 | slug: &str, | |
| 425 | owner_id: &str, | |
| 426 | owner: &str, | |
| 427 | staff: &str, | |
| 428 | reason: &str, | |
| 429 | ) -> Result<Outcome<WorkspaceDeletedWith>> { | |
| 430 | let slug = slug.trim().to_lowercase(); | |
| 431 | let Some(workspace) = self | |
| 432 | .db | |
| 433 | .prepare("SELECT id, protected FROM workspaces WHERE slug = ? AND deleted_at IS NULL") | |
| 434 | .bind(&[slug.as_str().into()])? | |
| 435 | .first::<Target>(None) | |
| 436 | .await? | |
| 437 | else { | |
| 438 | return Ok(Outcome::fail(FailureCode::NotFound, format!("{slug} is not a live workspace any more."))); | |
| 439 | }; | |
| 440 | let billing = staff_billing_actor(owner_id, staff, &slug); | |
| 441 | let deleter = Deleter { | |
| 442 | billing: &billing, | |
| 443 | deleted_by: staff, | |
| 444 | by: staff, | |
| 445 | // The workspace's members read its audit log: it says g1t's | |
| 446 | // staff did it, and sudo's log says who and why. | |
| 447 | audit: AuditActor::system(), | |
| 448 | surface: Surface::Web, | |
| 449 | rule: "staff", | |
| 450 | message: Box::new(|purge| { | |
| 451 | format!("Deleted {slug} by g1t's staff, with the account {owner} that was its only owner; restorable by g1t's staff until {purge}") | |
| 452 | }), | |
| 453 | actor_id: None, | |
| 454 | }; | |
| 455 | let id = workspace.id.clone(); | |
| 456 | match self.soft_delete_workspace(&workspace, &slug, &deleter).await? { | |
| 457 | Outcome::Ok(_) => {} | |
| 458 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 459 | } | |
| 460 | self.record_for_staff( | |
| 461 | &slug, | |
| 462 | "workspace_deleted", | |
| 463 | &format!("Deleted {slug} with the account {owner}, its only owner: {reason}"), | |
| 464 | staff, | |
| 465 | ) | |
| 466 | .await; | |
| 467 | Ok(Outcome::Ok(WorkspaceDeletedWith { workspace_id: id, slug: slug.clone() })) | |
| 468 | } | |
| 469 | ||
| 470 | /// Deletes a live workspace softly, as every deletion does: refused if | |
| 471 | /// it is protected or billing cannot settle it; billing closes it for | |
| 472 | /// real first; then its row is marked, its audit log says so and | |
| 473 | /// `workspace.deleting` tells every service. | |
| 474 | async fn soft_delete_workspace(&self, workspace: &Target, slug: &str, deleter: &Deleter<'_>) -> Result<Outcome<bool>> { | |
| 475 | let went = self.deletion_facts(deleter.billing, slug, workspace).await?; | |
| 476 | if let Some(reason) = went.reason(slug) { | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 477 | let code = if went.protected { FailureCode::Forbidden } else { FailureCode::PaymentRequired }; |
| 478 | return Ok(Outcome::fail(code, reason)); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 479 | } |
| 480 | // Money first: if billing cannot settle it after all (a card | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 481 | // declined a moment ago), nothing is deleted. Its plan ends now, so |
| 482 | // nothing more is charged while it waits to be purged. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 483 | let closed: Outcome<bool> = g1t_kit::call( |
| 484 | &self.env.service("BILLING")?, | |
| 485 | "close_workspace", | |
| 486 | &CloseWorkspaceArgs { | |
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 487 | actor: deleter.billing.clone(), |
| 488 | workspace: slug.to_owned(), | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 489 | dry_run: false, |
| 490 | }, | |
| 491 | ) | |
| 492 | .await?; | |
| 493 | if let Outcome::Fail(failure) = closed { | |
| 494 | return Ok(Outcome::Fail(failure)); | |
| 495 | } | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 496 | let now = now_ms(); |
| 497 | let purge = purge_after(now); | |
| 498 | self.db | |
| 499 | .prepare( | |
| 500 | "UPDATE workspaces SET deleted_at = ?, deleted_by = ?, purge_after = ?, deleted_went = ? | |
| 501 | WHERE id = ? AND deleted_at IS NULL", | |
| 502 | ) | |
| 503 | .bind(&[ | |
| 504 | rfc3339(now).into(), | |
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 505 | deleter.deleted_by.into(), |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 506 | purge.as_str().into(), |
| 507 | went_json(&went).into(), | |
| 508 | workspace.id.as_str().into(), | |
| 509 | ])? | |
| 510 | .run() | |
| 511 | .await?; | |
| 512 | self.record_on_workspace( | |
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 513 | slug, |
| 514 | deleter.audit.clone(), | |
| 515 | deleter.surface, | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 516 | "workspace.deleted", |
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 517 | deleter.rule, |
| 518 | (deleter.message)(&purge), | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 519 | ) |
| 520 | .await; | |
| 521 | self.announce( | |
| 522 | "workspace.deleting", | |
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 523 | deleter.actor_id, |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 524 | WorkspaceDeleting { |
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 525 | workspace_id: workspace.id.clone(), |
| 526 | slug: slug.to_owned(), | |
| 527 | by: deleter.by.to_owned(), | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 528 | purge_after: purge, |
| 529 | }, | |
| 530 | ) | |
| 531 | .await; | |
| 532 | Ok(Outcome::Ok(true)) | |
| 533 | } | |
| 534 | ||
| 535 | /// Deleted workspaces not purged yet, newest first. Staff only. | |
| 536 | pub async fn admin_deleted_workspaces(&self) -> Result<Vec<DeletedWorkspace>> { | |
| 537 | let rows = self | |
| 538 | .db | |
| 539 | .prepare(format!("SELECT {DELETED_COLUMNS} ORDER BY w.deleted_at DESC LIMIT 500")) | |
| 540 | .all() | |
| 541 | .await? | |
| 542 | .results::<DeletedRow>()?; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 543 | let now = rfc3339(now_ms()); |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 544 | let mut listed = Vec::with_capacity(rows.len()); |
| 545 | for row in rows { | |
| 546 | let mut went = went_of(row.deleted_went.as_deref()); | |
| 547 | went.protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?; | |
| 548 | listed.push(DeletedWorkspace { | |
| 549 | restorable: restorable(&row.purge_after, &now), | |
| 550 | workspace_id: row.id, | |
| 551 | slug: row.slug, | |
| 552 | name: row.name, | |
| 553 | deleted_at: row.deleted_at, | |
| 554 | deleted_by: row.deleter.or(row.deleted_by).unwrap_or_default(), | |
| 555 | purge_after: row.purge_after, | |
| 556 | went, | |
| 557 | }); | |
| 558 | } | |
| 559 | Ok(listed) | |
| 560 | } | |
| 561 | ||
| 562 | async fn deleted_row(&self, id: &str) -> Result<Option<DeletedRow>> { | |
| 563 | self.db | |
| 564 | .prepare(format!("SELECT {DELETED_COLUMNS} AND w.id = ?")) | |
| 565 | .bind(&[id.into()])? | |
| 566 | .first::<DeletedRow>(None) | |
| 567 | .await | |
| 568 | } | |
| 569 | ||
| 570 | /// Staff bring a deleted workspace back within its window, with its | |
| 571 | /// members and tokens; `workspace.restored` brings back what went with | |
| 572 | /// it. Staff only. | |
| 573 | pub async fn admin_restore_workspace(&self, a: AdminDeletedWorkspaceArgs) -> Result<Outcome<bool>> { | |
| 574 | let staff = a.staff.trim(); | |
| 575 | if staff.is_empty() { | |
| 576 | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is restoring it.")); | |
| 577 | } | |
| 578 | let Some(row) = self.deleted_row(&a.workspace_id).await? else { | |
| 579 | return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted workspace with that id.")); | |
| 580 | }; | |
| 581 | if let Err((code, message)) = may_restore(&row.slug, &row.purge_after, &rfc3339(now_ms())) { | |
| 582 | return Ok(Outcome::fail(code, message)); | |
| 583 | } | |
| 584 | self.db | |
| 585 | .prepare( | |
| 586 | "UPDATE workspaces SET deleted_at = NULL, deleted_by = NULL, purge_after = NULL, deleted_went = NULL | |
| 587 | WHERE id = ? AND deleted_at IS NOT NULL", | |
| 588 | ) | |
| 589 | .bind(&[row.id.as_str().into()])? | |
| 590 | .run() | |
| 591 | .await?; | |
| 592 | let message = format!( | |
| 593 | "Restored by g1t's staff; deleted by {} at {}", | |
| 594 | row.deleter.as_deref().or(row.deleted_by.as_deref()).unwrap_or("an owner"), | |
| 595 | row.deleted_at | |
| 596 | ); | |
| 597 | self.record_on_workspace(&row.slug, AuditActor::system(), Surface::Web, "workspace.restored", "staff", message) | |
| 598 | .await; | |
| 599 | self.record_for_staff(&row.slug, "workspace_restored", &format!("Restored {}", row.slug), staff) | |
| 600 | .await; | |
| 601 | self.announce( | |
| 602 | "workspace.restored", | |
| 603 | None, | |
| 604 | WorkspaceRestored { | |
| 605 | workspace_id: row.id, | |
| 606 | slug: row.slug, | |
| 607 | }, | |
| 608 | ) | |
| 609 | .await; | |
| 610 | Ok(Outcome::Ok(true)) | |
| 611 | } | |
| 612 | ||
| 613 | /// Staff purge a deleted workspace now rather than at `purge_after`. | |
| 614 | /// Staff only. | |
| 615 | pub async fn admin_purge_workspace(&self, a: AdminDeletedWorkspaceArgs) -> Result<Outcome<bool>> { | |
| 616 | let staff = a.staff.trim(); | |
| 617 | if staff.is_empty() { | |
| 618 | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is purging it.")); | |
| 619 | } | |
| 620 | let Some(row) = self.deleted_row(&a.workspace_id).await? else { | |
| 621 | return Ok(Outcome::fail(FailureCode::NotFound, "There is no deleted workspace with that id.")); | |
| 622 | }; | |
| 623 | let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?; | |
| 624 | if let Err((code, message)) = may_purge(protected, &row.slug, Some(&a.confirm)) { | |
| 625 | return Ok(Outcome::fail(code, message)); | |
| 626 | } | |
| 627 | self.purge(&row).await?; | |
| 628 | self.record_on_workspace( | |
| 629 | &row.slug, | |
| 630 | AuditActor::system(), | |
| 631 | Surface::Web, | |
| 632 | "workspace.purged", | |
| 633 | "staff", | |
| 634 | "Purged by g1t's staff before its restore window ended".to_owned(), | |
| 635 | ) | |
| 636 | .await; | |
| 637 | self.record_for_staff(&row.slug, "workspace_purged", &format!("Purged {} now", row.slug), staff) | |
| 638 | .await; | |
| 639 | Ok(Outcome::Ok(true)) | |
| 640 | } | |
| 641 | ||
| 642 | /// The sweep: purges deleted workspaces whose restore window has | |
| 643 | /// passed. A protected one is never purged, however it came to be | |
| 644 | /// deleted. | |
| 645 | pub async fn purge_due_workspaces(&self) -> Result<u32> { | |
| 646 | let due = self | |
| 647 | .db | |
| 648 | .prepare(format!( | |
| 649 | "SELECT {DELETED_COLUMNS} AND w.purge_after <= ? ORDER BY w.purge_after LIMIT {PURGES_PER_SWEEP}" | |
| 650 | )) | |
| 651 | .bind(&[rfc3339(now_ms()).into()])? | |
| 652 | .all() | |
| 653 | .await? | |
| 654 | .results::<DeletedRow>()?; | |
| 655 | let mut purged = 0; | |
| 656 | for row in due { | |
| 657 | let protected = self.is_protected(&row.id, &row.slug, row.protected != 0).await?; | |
| 658 | if let Err((_, why)) = may_purge(protected, &row.slug, None) { | |
| 659 | worker::console_error!("{} not purged: {why}", row.slug); | |
| 660 | continue; | |
| 661 | } | |
| 662 | match self.purge(&row).await { | |
| 663 | Ok(()) => { | |
| 664 | purged += 1; | |
| 665 | self.record_on_workspace( | |
| 666 | &row.slug, | |
| 667 | AuditActor::system(), | |
| 668 | Surface::Web, | |
| 669 | "workspace.purged", | |
| 670 | "schedule", | |
| 671 | format!("Purged {WORKSPACE_RESTORE_DAYS} days after it was deleted"), | |
| 672 | ) | |
| 673 | .await; | |
| 674 | } | |
| 675 | Err(error) => worker::console_error!("{} not purged: {error}", row.slug), | |
| 676 | } | |
| 677 | } | |
| 678 | Ok(purged) | |
| 679 | } | |
| 680 | ||
| 681 | /// Removes a deleted workspace for good, as deleting one always did: | |
| 682 | /// its row, memberships, tokens and redirects go, its slugs are kept in | |
| 683 | /// `deleted_workspaces`, and `workspace.deleted` tells services to drop | |
| 684 | /// what they keep for it. | |
| 685 | async fn purge(&self, row: &DeletedRow) -> Result<()> { | |
| 686 | let id = row.id.as_str(); | |
| 687 | let by = row.deleted_by.as_deref().unwrap_or_default(); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 688 | self.db |
| 689 | .batch(vec![ | |
| 690 | self.db | |
| 691 | .prepare( | |
| 692 | "INSERT OR REPLACE INTO deleted_workspaces (slug, workspace_id, name, deleted_by, deleted_at) | |
| 693 | VALUES (?, ?, ?, ?, ?)", | |
| 694 | ) | |
| 695 | .bind(&[ | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 696 | row.slug.as_str().into(), |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 697 | id.into(), |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 698 | row.name.as_str().into(), |
| 699 | by.into(), | |
| 700 | row.deleted_at.as_str().into(), | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 701 | ])?, |
| 702 | // Slugs it was renamed from, still redirecting, are kept | |
| 703 | // the same way. | |
| 704 | self.db | |
| 705 | .prepare( | |
| 706 | "INSERT OR IGNORE INTO deleted_workspaces (slug, workspace_id, name, deleted_by, deleted_at) | |
| 707 | SELECT old_slug, workspace_id, ?, ?, ? FROM workspace_redirects WHERE workspace_id = ?", | |
| 708 | ) | |
| 709 | .bind(&[ | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 710 | row.name.as_str().into(), |
| 711 | by.into(), | |
| 712 | row.deleted_at.as_str().into(), | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 713 | id.into(), |
| 714 | ])?, | |
| 715 | self.db | |
| 716 | .prepare("DELETE FROM access_tokens WHERE workspace_id = ?") | |
| 717 | .bind(&[id.into()])?, | |
| 718 | self.db | |
| 719 | .prepare("DELETE FROM workspace_members WHERE workspace_id = ?") | |
| 720 | .bind(&[id.into()])?, | |
| The apps you pin to your dock are kept with your account, per workspace and in your order, so the dock is the same on every device: identity keeps them in dock_pins and answers dock_pins and set_dock_pins, the dock reads them with the rest of the page, pins kept only on this device carry over with your first change, this device's copy still draws the dock when identity can't be reached, a pin that can't be saved says so, and they go when you or the workspace do; the workspaces guide says how. | 721 | // Each member's dock pins in it (dock.rs). |
| 722 | self.db | |
| 723 | .prepare("DELETE FROM dock_pins WHERE workspace_id = ?") | |
| 724 | .bind(&[id.into()])?, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 725 | // Its teams, their people and the roles they gave (teams.rs). |
| 726 | self.db | |
| 727 | .prepare("DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE workspace_id = ?)") | |
| 728 | .bind(&[id.into()])?, | |
| 729 | self.db | |
| 730 | .prepare("DELETE FROM repo_grants WHERE principal_kind = 'team' AND principal_id IN (SELECT id FROM teams WHERE workspace_id = ?)") | |
| 731 | .bind(&[id.into()])?, | |
| 732 | self.db | |
| 733 | .prepare("DELETE FROM teams WHERE workspace_id = ?") | |
| 734 | .bind(&[id.into()])?, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 735 | self.db |
| 736 | .prepare("DELETE FROM workspace_redirects WHERE workspace_id = ?") | |
| 737 | .bind(&[id.into()])?, | |
| Merge branch 'worktree-agent-a8385d293d42c913a' | 738 | // Aliases staff pointed at it lead nowhere now (aliases.rs). |
| 739 | self.db | |
| 740 | .prepare("DELETE FROM workspace_aliases WHERE workspace_id = ?") | |
| 741 | .bind(&[id.into()])?, | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 742 | // Only while it is still deleted: a restore a moment ago wins. |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 743 | self.db |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 744 | .prepare("DELETE FROM workspaces WHERE id = ? AND deleted_at IS NOT NULL") |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 745 | .bind(&[id.into()])?, |
| 746 | ]) | |
| 747 | .await?; | |
| 748 | self.announce( | |
| 749 | "workspace.deleted", | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 750 | None, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 751 | WorkspaceDeleted { |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 752 | workspace_id: row.id.clone(), |
| 753 | slug: row.slug.clone(), | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 754 | }, |
| 755 | ) | |
| 756 | .await; | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 757 | Ok(()) |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 758 | } |
| 759 | ||
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 760 | /// An entry in the workspace's audit log, which outlives it. |
| Merge branch 'worktree-agent-a8385d293d42c913a' | 761 | pub(crate) async fn record_on_workspace( |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 762 | &self, |
| 763 | slug: &str, | |
| 764 | actor: AuditActor, | |
| 765 | surface: Surface, | |
| 766 | action: &str, | |
| 767 | rule: &str, | |
| 768 | message: String, | |
| 769 | ) { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 770 | let Ok(events) = self.env.service("EVENTS") else { |
| 771 | return; | |
| 772 | }; | |
| 773 | let entry = NewAuditEntry { | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 774 | actor, |
| 775 | action: action.to_owned(), | |
| 776 | surface, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 777 | target: AuditTarget { |
| 778 | workspace: slug.to_owned(), | |
| 779 | ..AuditTarget::default() | |
| 780 | }, | |
| 781 | outcome: AuditOutcome::Allowed, | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 782 | rule: rule.to_owned(), |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 783 | result: Some("ok".to_owned()), |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 784 | message: Some(message), |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 785 | request_id: new_id("req", now_ms()), |
| 786 | }; | |
| 787 | let recorded: Result<u32> = g1t_kit::call( | |
| 788 | &events, | |
| 789 | "audit_record", | |
| 790 | &RecordAuditArgs { | |
| 791 | entries: vec![entry], | |
| 792 | }, | |
| 793 | ) | |
| 794 | .await; | |
| 795 | if let Err(error) = recorded { | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 796 | worker::console_error!("{action} of {slug} not recorded: {error}"); |
| 797 | } | |
| 798 | } | |
| 799 | ||
| 800 | /// A line in sudo's audit log (billing keeps it), naming the staff | |
| 801 | /// member. | |
| Merge branch 'worktree-agent-a8385d293d42c913a' | 802 | pub(crate) async fn record_for_staff(&self, slug: &str, action: &str, detail: &str, staff: &str) { |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 803 | let Ok(billing) = self.env.service("BILLING") else { |
| 804 | return; | |
| 805 | }; | |
| 806 | let recorded: Result<bool> = g1t_kit::call( | |
| 807 | &billing, | |
| 808 | "admin_log", | |
| 809 | &json!({ "workspace": slug, "action": action, "detail": detail, "by": staff }), | |
| 810 | ) | |
| 811 | .await; | |
| 812 | if let Err(error) = recorded { | |
| 813 | worker::console_error!("{action} of {slug} by {staff} not recorded for sudo: {error}"); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 814 | } |
| 815 | } | |
| 816 | ||
| 817 | /// `transfer_repo_scopes`: agents at work on a transferred repository | |
| 818 | /// keep their scope, which names it by path. | |
| 819 | pub async fn transfer_repo_scopes(&self, a: TransferRepoScopesArgs) -> Result<bool> { | |
| 820 | self.db | |
| 821 | .prepare( | |
| 822 | "UPDATE access_tokens | |
| 823 | SET agent_scope = json_set(agent_scope, '$.repo.namespace', ?1, '$.repo.name', ?2) | |
| 824 | WHERE agent_scope IS NOT NULL | |
| 825 | AND json_extract(agent_scope, '$.repo.namespace') = ?3 | |
| 826 | AND json_extract(agent_scope, '$.repo.name') = ?4", | |
| 827 | ) | |
| 828 | .bind(&[ | |
| 829 | a.to.namespace.as_str().into(), | |
| 830 | a.to.name.as_str().into(), | |
| 831 | a.from.namespace.as_str().into(), | |
| 832 | a.from.name.as_str().into(), | |
| 833 | ])? | |
| 834 | .run() | |
| 835 | .await?; | |
| 836 | // Who has access to it follows it too (access.rs). | |
| 837 | self.move_repo_access(&a.from, &a.to).await?; | |
| 838 | Ok(true) | |
| 839 | } | |
| 840 | } | |
| 841 | ||
| 842 | #[cfg(test)] | |
| 843 | mod tests { | |
| 844 | use super::*; | |
| 845 | ||
| 846 | #[test] | |
| 847 | fn only_a_verified_owner_who_types_the_name() { | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 848 | assert!(may_delete(false, true, true, Some(Role::Owner), "acme", Some(" Acme ")).is_ok()); |
| 849 | assert!(may_delete(false, true, true, Some(Role::Owner), "acme", None).is_ok()); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 850 | assert_eq!( |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 851 | may_delete(false, true, true, Some(Role::Member), "acme", Some("acme")).unwrap_err().0, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 852 | FailureCode::Forbidden |
| 853 | ); | |
| 854 | assert_eq!( | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 855 | may_delete(false, false, true, Some(Role::Owner), "acme", Some("acme")).unwrap_err().0, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 856 | FailureCode::Forbidden |
| 857 | ); | |
| 858 | assert_eq!( | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 859 | may_delete(false, true, false, Some(Role::Owner), "acme", Some("acme")).unwrap_err().0, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 860 | FailureCode::Forbidden |
| 861 | ); | |
| 862 | assert_eq!( | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 863 | may_delete(false, true, true, Some(Role::Owner), "acme", Some("acme-inc")).unwrap_err().0, |
| 864 | FailureCode::Invalid | |
| 865 | ); | |
| 866 | // Nothing typed is no confirmation. | |
| 867 | assert_eq!( | |
| 868 | may_delete(false, true, true, Some(Role::Owner), "acme", Some("")).unwrap_err().0, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 869 | FailureCode::Invalid |
| 870 | ); | |
| 871 | } | |
| 872 | ||
| 873 | #[test] | |
| Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member | 874 | fn a_protected_workspace_is_refused_to_every_caller() { |
| 875 | let refused = |person: bool, verified: bool, role: Option<Role>| { | |
| 876 | may_delete(true, person, verified, role, "flagon-io", Some("flagon-io")).unwrap_err() | |
| 877 | }; | |
| 878 | // An owner who types the name, a workspace's token, a member, anyone. | |
| 879 | for (person, verified, role) in [ | |
| 880 | (true, true, Some(Role::Owner)), | |
| 881 | (false, true, Some(Role::Owner)), | |
| 882 | (true, true, Some(Role::Member)), | |
| 883 | (true, false, None), | |
| 884 | ] { | |
| 885 | let (code, message) = refused(person, verified, role); | |
| 886 | assert_eq!(code, FailureCode::Forbidden); | |
| 887 | assert_eq!(message, "flagon-io is protected and can never be deleted."); | |
| 888 | } | |
| 889 | // Neither the sweep nor staff purge it, typed or not. | |
| 890 | assert_eq!(may_purge(true, "flagon-io", None).unwrap_err().0, FailureCode::Forbidden); | |
| 891 | assert_eq!(may_purge(true, "flagon-io", Some("flagon-io")).unwrap_err().0, FailureCode::Forbidden); | |
| 892 | } | |
| 893 | ||
| 894 | #[test] | |
| 895 | fn protection_follows_the_workspace_through_a_rename() { | |
| 896 | let names = protected_names(Some("")); | |
| 897 | assert!(is_protected(&names, "wsp_1", "flagon-io", false, &[])); | |
| 898 | assert!(is_protected(&names, "wsp_1", "FLAGON-IO", false, &[])); | |
| 899 | // Renamed away: its old slug, or the mark on its row, still holds. | |
| 900 | assert!(is_protected(&names, "wsp_1", "flagon", false, &["flagon-io".into()])); | |
| 901 | assert!(is_protected(&names, "wsp_1", "flagon", true, &[])); | |
| 902 | // Named by id in the variable. | |
| 903 | assert!(is_protected(&protected_names(Some("wsp_9")), "wsp_9", "anything", false, &[])); | |
| 904 | assert!(!is_protected(&names, "wsp_2", "acme", false, &["acme-old".into()])); | |
| 905 | } | |
| 906 | ||
| 907 | #[test] | |
| 908 | fn staff_purge_only_with_the_name_typed() { | |
| 909 | assert!(may_purge(false, "acme", None).is_ok()); | |
| 910 | assert!(may_purge(false, "acme", Some(" ACME ")).is_ok()); | |
| 911 | assert_eq!(may_purge(false, "acme", Some("")).unwrap_err().0, FailureCode::Invalid); | |
| 912 | assert_eq!(may_purge(false, "acme", Some("acme-inc")).unwrap_err().0, FailureCode::Invalid); | |
| 913 | } | |
| 914 | ||
| 915 | #[test] | |
| 916 | fn a_deleted_workspace_is_restorable_for_thirty_days_then_due() { | |
| 917 | let deleted = 1_790_000_000_000; | |
| 918 | let purge = purge_after(deleted); | |
| 919 | assert_eq!(purge, rfc3339(deleted + 30 * 86_400_000)); | |
| 920 | assert!(restorable(&purge, &rfc3339(deleted))); | |
| 921 | assert!(restorable(&purge, &rfc3339(deleted + 29 * 86_400_000))); | |
| 922 | assert!(!restorable(&purge, &purge)); | |
| 923 | assert!(!restorable(&purge, &rfc3339(deleted + 31 * 86_400_000))); | |
| 924 | assert!(may_restore("acme", &purge, &rfc3339(deleted + 86_400_000)).is_ok()); | |
| 925 | assert_eq!( | |
| 926 | may_restore("acme", &purge, &rfc3339(deleted + 30 * 86_400_000)).unwrap_err(), | |
| 927 | (FailureCode::Conflict, "acme is being purged and can no longer be restored.".to_owned()) | |
| 928 | ); | |
| 929 | } | |
| 930 | ||
| 931 | #[test] | |
| 932 | fn what_went_is_kept_and_read_back() { | |
| 933 | let went = WorkspaceDeletion { | |
| 934 | repositories: 4, | |
| 935 | projects: 2, | |
| 936 | members: 3, | |
| 937 | billing: None, | |
| 938 | protected: false, | |
| 939 | }; | |
| 940 | assert_eq!(went_of(Some(&went_json(&went))), went); | |
| 941 | assert_eq!(went_of(None), WorkspaceDeletion::default()); | |
| 942 | assert_eq!(went_of(Some("not json")), WorkspaceDeletion::default()); | |
| 943 | } | |
| 944 | ||
| 945 | #[test] | |
| Merge sudo: delete an account with the workspaces it alone owns, purge each | 946 | fn billing_sees_staff_as_the_owner_closing_it_named_by_the_staff_member() { |
| 947 | let actor = staff_billing_actor("usr_ada", "staff@g1t.sh", "ada"); | |
| 948 | assert_eq!(actor.role_in("ada"), Some(Role::Owner)); | |
| 949 | assert_eq!(actor.role_in("globex"), None); | |
| 950 | assert_eq!(actor.username, "staff@g1t.sh"); | |
| 951 | assert_eq!(actor.id, "usr_ada"); | |
| 952 | assert_eq!(actor.kind, PrincipalKind::User); | |
| 953 | } | |
| 954 | ||
| 955 | #[test] | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 956 | fn a_deleted_slug_is_reclaimed_only_by_its_namesake() { |
| 957 | assert!(may_reclaim("syntaqx", "syntaqx")); | |
| 958 | assert!(may_reclaim("syntaqx", "Syntaqx")); | |
| 959 | assert!(!may_reclaim("flagon-io", "syntaqx")); | |
| 960 | } | |
| 961 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.