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.
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 1 | //! Who owns and belongs to a workspace, and what its members may do. See |
| 2 | //! `g1t_contracts::members` for the rules. | |
| 3 | //! | |
| 4 | //! - **Owners.** `update_member` makes a member an owner or an owner a | |
| 5 | //! member, and gives or takes away billing manager and security manager; | |
| 6 | //! `transfer_ownership` hands the workspace over in one step; | |
| 7 | //! `leave_workspace` is anyone leaving. None of them leaves a workspace | |
| 8 | //! without an owner. | |
| 9 | //! - **Member privileges.** `set_member_privileges` stores them as JSON in | |
| 10 | //! `workspaces.member_privileges`; every membership identity resolves | |
| 11 | //! carries them (workspaces.rs), and the repos service enforces them. | |
| 12 | //! - **Two-factor requirement.** `set_two_factor_requirement` sets | |
| 13 | //! `workspaces.require_two_factor`, which security.rs enforces. | |
| 14 | //! - **Creators.** `grant_creator` gives whoever creates a repository the | |
| 15 | //! Admin role on it, and [`Identity::backfill_creator_grants`] did the | |
| 16 | //! same, once, for repositories made before. | |
| 17 | //! | |
| 18 | //! Every change is recorded in the workspace's audit log. | |
| 19 | ||
| 20 | use g1t_contracts::audit::Surface; | |
| 21 | use g1t_contracts::identity::Member; | |
| 22 | use g1t_contracts::members::*; | |
| 23 | use g1t_contracts::repos::{AllIdsArgs, CreatorPage}; | |
| 24 | use g1t_contracts::time::rfc3339; | |
| 25 | use g1t_contracts::{FailureCode, Outcome, Role, User}; | |
| 26 | use g1t_kit::now_ms; | |
| 27 | use serde::Deserialize; | |
| 28 | use worker::Result; | |
| 29 | use worker::wasm_bindgen::JsValue; | |
| 30 | ||
| 31 | use crate::Identity; | |
| 32 | use crate::security::is_person; | |
| 33 | ||
| 34 | const OWNERS_ONLY: &str = "Only an owner can change a workspace's members."; | |
| 35 | const CONFIRM_FIRST: &str = "Confirm your email address before changing the workspace's members."; | |
| 36 | const NOT_A_MEMBER: &str = "That person is not a member of this workspace."; | |
| 37 | ||
| 38 | /// A member's row, as the rules here need it. | |
| 39 | #[derive(Deserialize)] | |
| 40 | pub(crate) struct MemberRow { | |
| 41 | pub user_id: String, | |
| 42 | pub username: String, | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 43 | #[serde(default)] |
| 44 | pub display_username: Option<String>, | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 45 | pub role: Role, |
| 46 | #[serde(default)] | |
| 47 | pub name: Option<String>, | |
| 48 | #[serde(default)] | |
| 49 | pub avatar: Option<String>, | |
| 50 | #[serde(default)] | |
| 51 | pub billing_manager: u8, | |
| 52 | #[serde(default)] | |
| 53 | pub security_manager: u8, | |
| 54 | } | |
| 55 | ||
| 56 | impl MemberRow { | |
| 57 | pub fn org_roles(&self) -> Vec<OrgRole> { | |
| 58 | let mut roles = Vec::new(); | |
| 59 | if self.billing_manager != 0 { | |
| 60 | roles.push(OrgRole::BillingManager); | |
| 61 | } | |
| 62 | if self.security_manager != 0 { | |
| 63 | roles.push(OrgRole::SecurityManager); | |
| 64 | } | |
| 65 | roles | |
| 66 | } | |
| 67 | ||
| 68 | pub fn member(&self, two_factor: Option<bool>) -> Member { | |
| 69 | Member { | |
| 70 | username: self.username.clone(), | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 71 | display_username: self |
| 72 | .display_username | |
| 73 | .clone() | |
| 74 | .filter(|display| display.eq_ignore_ascii_case(&self.username) && *display != self.username), | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 75 | role: self.role, |
| 76 | org_roles: self.org_roles(), | |
| 77 | two_factor, | |
| 78 | name: self.name.clone(), | |
| 79 | avatar: self.avatar.clone(), | |
| 80 | } | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | /// How a role change reads in the audit log. | |
| 85 | fn role_words(role: Role) -> &'static str { | |
| 86 | match role { | |
| 87 | Role::Owner => "owner", | |
| 88 | Role::Member => "member", | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | impl Identity { | |
| 93 | /// The workspace's id, if `actor` is a verified person who owns it; the | |
| 94 | /// refusal otherwise. | |
| 95 | async fn owners_workspace(&self, actor: &User, slug: &str) -> Result<Outcome<String>> { | |
| 96 | if !is_person(actor) || actor.role_in(slug) != Some(Role::Owner) { | |
| 97 | return Ok(Outcome::fail(FailureCode::Forbidden, OWNERS_ONLY)); | |
| 98 | } | |
| 99 | if !actor.verified { | |
| 100 | return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST)); | |
| 101 | } | |
| 102 | Ok(match self.workspace_id_of(slug).await? { | |
| 103 | Some(id) => Outcome::Ok(id), | |
| 104 | None => Outcome::fail(FailureCode::NotFound, "Workspace not found."), | |
| 105 | }) | |
| 106 | } | |
| 107 | ||
| 108 | /// One member of a workspace, by username. | |
| 109 | pub(crate) async fn member_row(&self, workspace_id: &str, username: &str) -> Result<Option<MemberRow>> { | |
| 110 | self.db | |
| 111 | .prepare( | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 112 | "SELECT m.user_id, u.username, u.display_username, m.role, u.display_name AS name, u.avatar, |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 113 | m.billing_manager, m.security_manager |
| 114 | FROM workspace_members m JOIN users u ON u.id = m.user_id | |
| 115 | WHERE m.workspace_id = ? AND u.username = ?", | |
| 116 | ) | |
| 117 | .bind(&[workspace_id.into(), username.trim().trim_start_matches('@').to_lowercase().into()])? | |
| 118 | .first::<MemberRow>(None) | |
| 119 | .await | |
| 120 | } | |
| 121 | ||
| 122 | /// How many owners a workspace has. | |
| 123 | pub(crate) async fn owner_count(&self, workspace_id: &str) -> Result<usize> { | |
| 124 | #[derive(Deserialize)] | |
| 125 | struct Count { | |
| 126 | owners: u32, | |
| 127 | } | |
| 128 | Ok(self | |
| 129 | .db | |
| 130 | .prepare("SELECT count(*) AS owners FROM workspace_members WHERE workspace_id = ? AND role = 'owner'") | |
| 131 | .bind(&[workspace_id.into()])? | |
| 132 | .first::<Count>(None) | |
| 133 | .await? | |
| 134 | .map_or(0, |count| count.owners as usize)) | |
| 135 | } | |
| 136 | ||
| 137 | /// `update_member`: see [`UpdateMemberArgs`]. | |
| 138 | pub async fn update_member(&self, a: UpdateMemberArgs) -> Result<Outcome<Member>> { | |
| 139 | let slug = a.slug.trim().to_lowercase(); | |
| 140 | let workspace_id = match self.owners_workspace(&a.actor, &slug).await? { | |
| 141 | Outcome::Ok(id) => id, | |
| 142 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 143 | }; | |
| 144 | let Some(row) = self.member_row(&workspace_id, &a.username).await? else { | |
| 145 | return Ok(Outcome::fail(FailureCode::NotFound, NOT_A_MEMBER)); | |
| 146 | }; | |
| 147 | if a.role.is_none() && a.org_roles.is_none() { | |
| 148 | return Ok(Outcome::fail(FailureCode::Invalid, "Give role or org_roles to change.")); | |
| 149 | } | |
| 150 | let surface = a.surface.unwrap_or(Surface::Web); | |
| 151 | let role = a.role.unwrap_or(row.role); | |
| 152 | if row.role == Role::Owner | |
| 153 | && role == Role::Member | |
| 154 | && let Some(why) = last_owner_refusal(self.owner_count(&workspace_id).await?, true) | |
| 155 | { | |
| 156 | return Ok(Outcome::fail(FailureCode::Conflict, why)); | |
| 157 | } | |
| 158 | let mut org_roles = a.org_roles.clone().unwrap_or_else(|| row.org_roles()); | |
| 159 | org_roles.sort(); | |
| 160 | org_roles.dedup(); | |
| 161 | let billing = org_roles.contains(&OrgRole::BillingManager); | |
| 162 | let security = org_roles.contains(&OrgRole::SecurityManager); | |
| 163 | self.db | |
| 164 | .prepare( | |
| 165 | "UPDATE workspace_members SET role = ?, billing_manager = ?, security_manager = ? | |
| 166 | WHERE workspace_id = ? AND user_id = ?", | |
| 167 | ) | |
| 168 | .bind(&[ | |
| 169 | role_words(role).into(), | |
| 170 | (billing as u8).into(), | |
| 171 | (security as u8).into(), | |
| 172 | workspace_id.as_str().into(), | |
| 173 | row.user_id.as_str().into(), | |
| 174 | ])? | |
| 175 | .run() | |
| 176 | .await?; | |
| 177 | if role != row.role { | |
| 178 | self.audit_workspace( | |
| 179 | &a.actor, | |
| 180 | "member.role_changed", | |
| 181 | &slug, | |
| 182 | surface, | |
| 183 | format!("Changed {}'s role from {} to {}", row.username, role_words(row.role), role_words(role)), | |
| 184 | ) | |
| 185 | .await; | |
| 186 | } | |
| 187 | let before = row.org_roles(); | |
| 188 | for changed in OrgRole::ALL { | |
| 189 | let (had, has) = (before.contains(&changed), org_roles.contains(&changed)); | |
| 190 | if had != has { | |
| 191 | self.audit_workspace( | |
| 192 | &a.actor, | |
| 193 | if has { "member.org_role_added" } else { "member.org_role_removed" }, | |
| 194 | &slug, | |
| 195 | surface, | |
| 196 | if has { | |
| 197 | format!("Made {} a {}", row.username, changed.label().to_lowercase()) | |
| 198 | } else { | |
| 199 | format!("Took {} off as {}", row.username, changed.label().to_lowercase()) | |
| 200 | }, | |
| 201 | ) | |
| 202 | .await; | |
| 203 | } | |
| 204 | } | |
| 205 | let updated = MemberRow { role, billing_manager: billing as u8, security_manager: security as u8, ..row }; | |
| 206 | Ok(Outcome::Ok(updated.member(None))) | |
| 207 | } | |
| 208 | ||
| 209 | /// `transfer_ownership`: see [`TransferOwnershipArgs`]. | |
| 210 | pub async fn transfer_ownership(&self, a: TransferOwnershipArgs) -> Result<Outcome<bool>> { | |
| 211 | let slug = a.slug.trim().to_lowercase(); | |
| 212 | let workspace_id = match self.owners_workspace(&a.actor, &slug).await? { | |
| 213 | Outcome::Ok(id) => id, | |
| 214 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 215 | }; | |
| 216 | let Some(row) = self.member_row(&workspace_id, &a.username).await? else { | |
| 217 | return Ok(Outcome::fail(FailureCode::NotFound, NOT_A_MEMBER)); | |
| 218 | }; | |
| 219 | if row.user_id == a.actor.id { | |
| 220 | return Ok(Outcome::fail(FailureCode::Invalid, "Choose another member to hand the workspace to.")); | |
| 221 | } | |
| 222 | // The new owner must be able to use the workspace: its policy holds | |
| 223 | // for owners too. | |
| 224 | if let Some(why) = self.policy_refusal(&row.user_id, &slug).await? { | |
| 225 | return Ok(Outcome::fail(FailureCode::Conflict, format!("{} cannot own {slug} yet: {why}", row.username))); | |
| 226 | } | |
| 227 | self.db | |
| 228 | .batch(vec![ | |
| 229 | self.db | |
| 230 | .prepare("UPDATE workspace_members SET role = 'owner' WHERE workspace_id = ? AND user_id = ?") | |
| 231 | .bind(&[workspace_id.as_str().into(), row.user_id.as_str().into()])?, | |
| 232 | self.db | |
| 233 | .prepare("UPDATE workspace_members SET role = 'member' WHERE workspace_id = ? AND user_id = ?") | |
| 234 | .bind(&[workspace_id.as_str().into(), a.actor.id.as_str().into()])?, | |
| 235 | ]) | |
| 236 | .await?; | |
| 237 | self.audit_workspace( | |
| 238 | &a.actor, | |
| 239 | "workspace.ownership_transferred", | |
| 240 | &slug, | |
| 241 | a.surface.unwrap_or(Surface::Web), | |
| 242 | format!("Handed {slug} to {}: they are an owner, and {} a member", row.username, a.actor.username), | |
| 243 | ) | |
| 244 | .await; | |
| 245 | Ok(Outcome::Ok(true)) | |
| 246 | } | |
| 247 | ||
| 248 | /// `leave_workspace`: see [`LeaveWorkspaceArgs`]. | |
| 249 | pub async fn leave_workspace(&self, a: LeaveWorkspaceArgs) -> Result<Outcome<bool>> { | |
| 250 | let slug = a.slug.trim().to_lowercase(); | |
| 251 | if !is_person(&a.user) { | |
| 252 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can leave a workspace, signed in as themselves.")); | |
| 253 | } | |
| 254 | let Some(workspace_id) = self.workspace_id_of(&slug).await? else { | |
| 255 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); | |
| 256 | }; | |
| 257 | // Read from the table, not the resolved user: someone held out by | |
| 258 | // the workspace's policy can still leave it. | |
| 259 | let Some(row) = self.member_row(&workspace_id, &a.user.username).await? else { | |
| 260 | return Ok(Outcome::fail(FailureCode::NotFound, "You are not a member of this workspace.")); | |
| 261 | }; | |
| 262 | if row.role == Role::Owner | |
| 263 | && let Some(why) = last_owner_refusal(self.owner_count(&workspace_id).await?, true) | |
| 264 | { | |
| 265 | return Ok(Outcome::fail(FailureCode::Conflict, why)); | |
| 266 | } | |
| 267 | self.drop_member(&workspace_id, &row.user_id).await?; | |
| 268 | self.audit_workspace(&a.user, "member.left", &slug, a.surface.unwrap_or(Surface::Web), format!("{} left {slug}", row.username)) | |
| 269 | .await; | |
| 270 | Ok(Outcome::Ok(true)) | |
| 271 | } | |
| 272 | ||
| 273 | /// Takes a person out of a workspace and every way into it: their roles | |
| 274 | /// on its repositories (access.rs) and their place in its teams | |
| 275 | /// (teams.rs). To keep them on a repository, add them to it again as an | |
| 276 | /// outside collaborator. | |
| 277 | pub(crate) async fn drop_member(&self, workspace_id: &str, user_id: &str) -> Result<()> { | |
| 278 | self.db | |
| 279 | .batch(vec![ | |
| 280 | self.db | |
| 281 | .prepare("DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?") | |
| 282 | .bind(&[workspace_id.into(), user_id.into()])?, | |
| 283 | self.db | |
| 284 | .prepare( | |
| 285 | "DELETE FROM repo_grants | |
| 286 | WHERE workspace_id = ? AND principal_kind = 'user' AND principal_id = ?", | |
| 287 | ) | |
| 288 | .bind(&[workspace_id.into(), user_id.into()])?, | |
| 289 | self.db | |
| 290 | .prepare( | |
| 291 | "DELETE FROM team_members | |
| 292 | WHERE team_id IN (SELECT id FROM teams WHERE workspace_id = ?) AND user_id = ?", | |
| 293 | ) | |
| 294 | .bind(&[workspace_id.into(), user_id.into()])?, | |
| People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how. | 295 | // No one reports to them here any more, and they lead no team. |
| 296 | self.db | |
| 297 | .prepare("UPDATE workspace_members SET manager_id = NULL WHERE workspace_id = ? AND manager_id = ?") | |
| 298 | .bind(&[workspace_id.into(), user_id.into()])?, | |
| 299 | self.db | |
| 300 | .prepare("UPDATE teams SET lead_kind = NULL, lead_id = NULL WHERE workspace_id = ? AND lead_kind = 'user' AND lead_id = ?") | |
| 301 | .bind(&[workspace_id.into(), user_id.into()])?, | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 302 | ]) |
| 303 | .await?; | |
| 304 | Ok(()) | |
| 305 | } | |
| 306 | ||
| 307 | /// A workspace's member privileges, by its id. | |
| 308 | pub(crate) async fn privileges_of(&self, workspace_id: &str) -> Result<MemberPrivileges> { | |
| 309 | #[derive(Deserialize)] | |
| 310 | struct Row { | |
| 311 | member_privileges: Option<String>, | |
| 312 | } | |
| 313 | Ok(self | |
| 314 | .db | |
| 315 | .prepare("SELECT member_privileges FROM workspaces WHERE id = ?") | |
| 316 | .bind(&[workspace_id.into()])? | |
| 317 | .first::<Row>(None) | |
| 318 | .await? | |
| 319 | .map(|row| MemberPrivileges::from_stored(row.member_privileges.as_deref())) | |
| 320 | .unwrap_or_default()) | |
| 321 | } | |
| 322 | ||
| 323 | /// `set_member_privileges`: see [`SetMemberPrivilegesArgs`]. | |
| 324 | pub async fn set_member_privileges(&self, a: SetMemberPrivilegesArgs) -> Result<Outcome<MemberPrivileges>> { | |
| 325 | let slug = a.slug.trim().to_lowercase(); | |
| 326 | let workspace_id = match self.owners_workspace(&a.actor, &slug).await? { | |
| 327 | Outcome::Ok(id) => id, | |
| 328 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 329 | }; | |
| 330 | let before = self.privileges_of(&workspace_id).await?; | |
| 331 | let after = a.privileges.apply(before); | |
| 332 | if after == before { | |
| 333 | return Ok(Outcome::Ok(before)); | |
| 334 | } | |
| 335 | let stored = serde_json::to_string(&after).unwrap_or_default(); | |
| 336 | self.db | |
| 337 | .prepare("UPDATE workspaces SET member_privileges = ? WHERE id = ?") | |
| 338 | .bind(&[stored.into(), workspace_id.as_str().into()])? | |
| 339 | .run() | |
| 340 | .await?; | |
| 341 | for ((name, was), (_, now)) in before.listed().into_iter().zip(after.listed()) { | |
| 342 | if was != now { | |
| 343 | self.audit_workspace( | |
| 344 | &a.actor, | |
| 345 | "workspace.member_privileges_changed", | |
| 346 | &slug, | |
| 347 | a.surface.unwrap_or(Surface::Web), | |
| 348 | format!("Turned {} {}", if now { "on" } else { "off" }, MemberPrivileges::describe(name)), | |
| 349 | ) | |
| 350 | .await; | |
| 351 | } | |
| 352 | } | |
| 353 | self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await; | |
| 354 | Ok(Outcome::Ok(after)) | |
| 355 | } | |
| 356 | ||
| 357 | /// `set_two_factor_requirement`: see [`SetTwoFactorRequirementArgs`]. | |
| 358 | pub async fn set_two_factor_requirement(&self, a: SetTwoFactorRequirementArgs) -> Result<Outcome<bool>> { | |
| 359 | let slug = a.slug.trim().to_lowercase(); | |
| 360 | let workspace_id = match self.owners_workspace(&a.actor, &slug).await? { | |
| 361 | Outcome::Ok(id) => id, | |
| 362 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 363 | }; | |
| 364 | if a.required && !self.two_factor_enabled(&a.actor.id).await? { | |
| 365 | return Ok(Outcome::fail( | |
| 366 | FailureCode::Conflict, | |
| 367 | "Turn on two-factor authentication for your own account before requiring it of everyone.", | |
| 368 | )); | |
| 369 | } | |
| 370 | let before = self.workspace_policy(&slug).await?.require_two_factor; | |
| 371 | if before == a.required { | |
| 372 | return Ok(Outcome::Ok(before)); | |
| 373 | } | |
| 374 | self.db | |
| 375 | .prepare("UPDATE workspaces SET require_two_factor = ? WHERE id = ?") | |
| 376 | .bind(&[(a.required as u8).into(), workspace_id.as_str().into()])? | |
| 377 | .run() | |
| 378 | .await?; | |
| 379 | self.audit_workspace( | |
| 380 | &a.actor, | |
| 381 | if a.required { "workspace.two_factor_required" } else { "workspace.two_factor_not_required" }, | |
| 382 | &slug, | |
| 383 | a.surface.unwrap_or(Surface::Web), | |
| 384 | if a.required { | |
| 385 | "Required two-factor authentication of members and outside collaborators".to_owned() | |
| 386 | } else { | |
| 387 | "Stopped requiring two-factor authentication".to_owned() | |
| 388 | }, | |
| 389 | ) | |
| 390 | .await; | |
| 391 | self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await; | |
| 392 | Ok(Outcome::Ok(a.required)) | |
| 393 | } | |
| 394 | ||
| 395 | /// `grant_creator`: see [`GrantCreatorArgs`]. | |
| 396 | pub async fn grant_creator(&self, a: GrantCreatorArgs) -> Result<bool> { | |
| 397 | let Some(workspace_id) = self.workspace_id_of(&a.namespace.to_lowercase()).await? else { | |
| 398 | return Ok(false); | |
| 399 | }; | |
| 400 | if !self.is_member_of(&workspace_id, &a.user_id).await? { | |
| 401 | return Ok(false); | |
| 402 | } | |
| 403 | self.insert_creator_grant(&a.repo_id, &workspace_id, &a.name, &a.user_id).await?; | |
| 404 | Ok(true) | |
| 405 | } | |
| 406 | ||
| 407 | async fn insert_creator_grant(&self, repo_id: &str, workspace_id: &str, name: &str, user_id: &str) -> Result<()> { | |
| 408 | let now = rfc3339(now_ms()); | |
| 409 | // Never lowers a role someone already gave them. | |
| 410 | self.db | |
| 411 | .prepare( | |
| 412 | "INSERT INTO repo_grants | |
| 413 | (repo_id, principal_kind, principal_id, workspace_id, repo_name, role, granted_by, created_at, updated_at) | |
| 414 | VALUES (?1, 'user', ?2, ?3, ?4, 'admin', NULL, ?5, ?5) | |
| 415 | ON CONFLICT (repo_id, principal_kind, principal_id) | |
| 416 | DO UPDATE SET role = 'admin', updated_at = excluded.updated_at", | |
| 417 | ) | |
| 418 | .bind(&[repo_id.into(), user_id.into(), workspace_id.into(), name.into(), now.as_str().into()])? | |
| 419 | .run() | |
| 420 | .await?; | |
| 421 | Ok(()) | |
| 422 | } | |
| 423 | ||
| 424 | /// Once, a page per run of the scheduled handler: the person who | |
| 425 | /// created each repository made before creators were given Admin gets | |
| 426 | /// it, if they are still a member of its workspace and lack it. | |
| 427 | pub async fn backfill_creator_grants(&self) -> Result<()> { | |
| 428 | #[derive(Deserialize)] | |
| 429 | struct Job { | |
| 430 | cursor: Option<String>, | |
| 431 | done_at: Option<String>, | |
| 432 | } | |
| 433 | let Some(job) = self | |
| 434 | .db | |
| 435 | .prepare("SELECT cursor, done_at FROM identity_jobs WHERE name = 'creator_grants'") | |
| 436 | .first::<Job>(None) | |
| 437 | .await? | |
| 438 | else { | |
| 439 | return Ok(()); | |
| 440 | }; | |
| 441 | if job.done_at.is_some() { | |
| 442 | return Ok(()); | |
| 443 | } | |
| 444 | let page: CreatorPage = g1t_kit::call( | |
| 445 | &self.env.service("REPOS")?, | |
| 446 | "repo_creators", | |
| 447 | &AllIdsArgs { after: job.cursor.clone(), limit: 200 }, | |
| 448 | ) | |
| 449 | .await?; | |
| 450 | #[derive(Deserialize)] | |
| 451 | struct Standing { | |
| 452 | workspace_id: String, | |
| 453 | role: String, | |
| 454 | base_permission: String, | |
| 455 | direct: Option<String>, | |
| 456 | } | |
| 457 | for repo in &page.repos { | |
| 458 | let standing = self | |
| 459 | .db | |
| 460 | .prepare( | |
| 461 | "SELECT w.id AS workspace_id, m.role, w.base_permission, | |
| 462 | (SELECT g.role FROM repo_grants g WHERE g.repo_id = ?1 AND g.principal_kind = 'user' AND g.principal_id = ?2) AS direct | |
| 463 | FROM workspaces w JOIN workspace_members m ON m.workspace_id = w.id AND m.user_id = ?2 | |
| 464 | WHERE w.slug = ?3 AND w.deleted_at IS NULL", | |
| 465 | ) | |
| 466 | .bind(&[repo.id.as_str().into(), repo.owner_id.as_str().into(), repo.namespace.to_lowercase().into()])? | |
| 467 | .first::<Standing>(None) | |
| 468 | .await?; | |
| 469 | let Some(standing) = standing else { continue }; | |
| 470 | let has_admin = standing.role == "owner" || standing.base_permission == "admin" || standing.direct.as_deref() == Some("admin"); | |
| 471 | if !has_admin { | |
| 472 | self.insert_creator_grant(&repo.id, &standing.workspace_id, &repo.name, &repo.owner_id).await?; | |
| 473 | } | |
| 474 | } | |
| 475 | let (cursor, done): (JsValue, JsValue) = match &page.next { | |
| 476 | Some(next) => (next.as_str().into(), JsValue::NULL), | |
| 477 | None => (job.cursor.as_deref().map_or(JsValue::NULL, Into::into), rfc3339(now_ms()).into()), | |
| 478 | }; | |
| 479 | self.db | |
| 480 | .prepare("UPDATE identity_jobs SET cursor = ?, done_at = ? WHERE name = 'creator_grants'") | |
| 481 | .bind(&[cursor, done])? | |
| 482 | .run() | |
| 483 | .await?; | |
| 484 | Ok(()) | |
| 485 | } | |
| 486 | } | |
| 487 | ||
| 488 | #[cfg(test)] | |
| 489 | mod tests { | |
| 490 | use super::*; | |
| 491 | ||
| 492 | fn row(billing: u8, security: u8) -> MemberRow { | |
| 493 | MemberRow { | |
| 494 | user_id: "usr_1".into(), | |
| 495 | username: "ada".into(), | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 496 | display_username: Some("Ada".into()), |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 497 | role: Role::Member, |
| 498 | name: None, | |
| 499 | avatar: None, | |
| 500 | billing_manager: billing, | |
| 501 | security_manager: security, | |
| 502 | } | |
| 503 | } | |
| 504 | ||
| 505 | #[test] | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 506 | fn a_member_keeps_their_usernames_chosen_case() { |
| 507 | assert_eq!(row(0, 0).member(None).display_username.as_deref(), Some("Ada")); | |
| 508 | let plain = MemberRow { display_username: Some("ada".into()), ..row(0, 0) }; | |
| 509 | assert_eq!(plain.member(None).display_username, None); | |
| 510 | } | |
| 511 | ||
| 512 | #[test] | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 513 | fn a_rows_flags_are_its_org_roles() { |
| 514 | assert!(row(0, 0).org_roles().is_empty()); | |
| 515 | assert_eq!(row(1, 0).org_roles(), vec![OrgRole::BillingManager]); | |
| 516 | assert_eq!(row(1, 1).org_roles(), vec![OrgRole::BillingManager, OrgRole::SecurityManager]); | |
| 517 | let member = row(0, 1).member(Some(true)); | |
| 518 | assert_eq!(member.org_roles, vec![OrgRole::SecurityManager]); | |
| 519 | assert_eq!(member.two_factor, Some(true)); | |
| 520 | } | |
| 521 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.