| 1 | //! Secret alerts beyond the list: every place a secret was found, pushing |
| 2 | //! past push protection with a reason (or asking to, when the workspace |
| 3 | //! delegates bypasses to its owners and the repository's admins), and |
| 4 | //! asking a secret's issuer whether it still works. |
| 5 | |
| 6 | use g1t_contracts::access::{self, Capability}; |
| 7 | use g1t_contracts::security::{AlertActivity, NewSecret, SecretFinding, SecretStatus}; |
| 8 | use g1t_contracts::security_suite::{ |
| 9 | AlertType, BypassArgs, BypassReason, BypassRequest, BypassRequestsArgs, BypassResult, CheckSecretArgs, CheckValidityArgs, |
| 10 | PaidFeature, ReviewBypassArgs, SecretAlertArgs, SecretAlertDetail, SecretValidity, SecurityEvent, |
| 11 | }; |
| 12 | use g1t_contracts::time::rfc3339; |
| 13 | use g1t_contracts::{FailureCode, Outcome, Role, User}; |
| 14 | use g1t_kit::now_ms; |
| 15 | use g1t_scan::secrets::SecretKind; |
| 16 | use worker::Result; |
| 17 | |
| 18 | use crate::Security; |
| 19 | use crate::store::{Activity, RepoRow}; |
| 20 | use crate::suite::link; |
| 21 | |
| 22 | const MAX_COMMENT_CHARS: usize = 500; |
| 23 | /// Validity checks the sweep makes per repository, and how often a secret |
| 24 | /// is asked about again. |
| 25 | const CHECKS_PER_SWEEP: u32 = 10; |
| 26 | const RECHECK_MS: u64 = 7 * 24 * 60 * 60 * 1000; |
| 27 | |
| 28 | fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> { |
| 29 | Outcome::fail(code, message) |
| 30 | } |
| 31 | |
| 32 | /// How a sentence names a secret: its format's label, or its custom |
| 33 | /// pattern's. |
| 34 | pub fn label_of(kind: &str, pattern_name: Option<&str>) -> String { |
| 35 | if kind == g1t_scan::custom::KIND { |
| 36 | return g1t_scan::custom::label(pattern_name.unwrap_or("custom")); |
| 37 | } |
| 38 | SecretKind::parse(kind).map_or("a secret", |kind| kind.label()).to_owned() |
| 39 | } |
| 40 | |
| 41 | /// "An AWS access key in config/prod.env": a secret alert's title. |
| 42 | pub fn title_of(secret: &SecretFinding) -> String { |
| 43 | let mut label = secret.label.clone(); |
| 44 | if let Some(first) = label.get(..1) { |
| 45 | label = format!("{}{}", first.to_uppercase(), &label[1..]); |
| 46 | } |
| 47 | format!("{label} in {}", secret.path) |
| 48 | } |
| 49 | |
| 50 | /// The event that tells of a secret alert. |
| 51 | pub fn secret_event(repo: &RepoRow, secret: &SecretFinding) -> SecurityEvent { |
| 52 | SecurityEvent { |
| 53 | repo_id: repo.repo_id.clone(), |
| 54 | alert_id: secret.id.clone(), |
| 55 | alert_type: AlertType::SecretScanning.as_str().to_owned(), |
| 56 | severity: if secret.test_value.is_some() { "low" } else { "critical" }.to_owned(), |
| 57 | title: title_of(secret), |
| 58 | link: link(repo, &format!("secret-scanning/{}", secret.id)), |
| 59 | path: Some(secret.path.clone()), |
| 60 | line: Some(secret.line), |
| 61 | state: secret.state.as_str().to_owned(), |
| 62 | ..SecurityEvent::default() |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | /// Whether a person reviews bypass requests in this repository: an owner |
| 67 | /// or a security manager of its workspace, or one of its admins. |
| 68 | fn reviews_bypasses(user: &User, repo: &RepoRow) -> bool { |
| 69 | user.manages_security(&repo.namespace) |
| 70 | || access::can(Some(user), access::RepoRef { id: &repo.repo_id, namespace: &repo.namespace, private: true }, Capability::ManageSecurity) |
| 71 | } |
| 72 | |
| 73 | impl Security { |
| 74 | /// Records where new secrets were found and publishes an alert for each |
| 75 | /// one seen for the first time. `fresh` are the fingerprints not known |
| 76 | /// before; `pusher` is who pushed, for a blocked push. |
| 77 | pub(crate) async fn secrets_found(&self, repo: &RepoRow, secrets: &[NewSecret], source: &str, fresh: &[String], pusher: Option<&str>) -> Result<()> { |
| 78 | self.store.note_found(&repo.repo_id, secrets, source).await?; |
| 79 | let known = self.store.known(&repo.repo_id, fresh).await?; |
| 80 | for (_, id, _) in known.iter().take(20) { |
| 81 | if let Some(secret) = self.store.secret(&repo.repo_id, id).await? { |
| 82 | let mut event = secret_event(repo, &secret); |
| 83 | event.pusher = pusher.filter(|_| secret.status == SecretStatus::Blocked).map(str::to_owned); |
| 84 | self.alert_event(AlertType::SecretScanning, "created", repo, event, None).await; |
| 85 | } |
| 86 | } |
| 87 | Ok(()) |
| 88 | } |
| 89 | |
| 90 | pub(crate) async fn secret_alert(&self, a: SecretAlertArgs) -> Result<Outcome<SecretAlertDetail>> { |
| 91 | let repo = match self.member_repo(&a.repo, &a.viewer, crate::SEE_FINDINGS).await? { |
| 92 | Outcome::Ok(repo) => repo, |
| 93 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 94 | }; |
| 95 | let Some(secret) = self.store.secret(&repo.repo_id, &a.id).await? else { |
| 96 | return Ok(fail(FailureCode::NotFound, "No such alert.")); |
| 97 | }; |
| 98 | let activity: Vec<AlertActivity> = |
| 99 | self.store.activity(&repo.repo_id, 500).await?.into_iter().filter(|item| item.alert_id == secret.id).collect(); |
| 100 | let workspace = self.store.workspace_settings(&repo.namespace).await?; |
| 101 | let viewer = a.viewer.as_ref(); |
| 102 | let blocked = secret.status == SecretStatus::Blocked && secret.bypass.is_none(); |
| 103 | let reviewer = viewer.is_some_and(|user| reviews_bypasses(user, &repo)); |
| 104 | let delegated = workspace.delegated_bypass && self.entitled(&repo).await?; |
| 105 | Ok(Outcome::Ok(SecretAlertDetail { |
| 106 | locations: self.store.locations(&secret.id).await?, |
| 107 | requests: self.store.requests_for_secret(&secret.id).await?.iter().map(|row| row.contract()).collect(), |
| 108 | checkable: SecretKind::parse(&secret.kind).is_some_and(|kind| g1t_scan::validity::SUPPORTED.contains(&kind)), |
| 109 | can_bypass: blocked && (!delegated || reviewer), |
| 110 | can_request_bypass: blocked && delegated && !reviewer, |
| 111 | activity, |
| 112 | secret, |
| 113 | })) |
| 114 | } |
| 115 | |
| 116 | pub(crate) async fn bypass(&self, a: BypassArgs) -> Result<Outcome<BypassResult>> { |
| 117 | let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), crate::SEE_FINDINGS).await? { |
| 118 | Outcome::Ok(repo) => repo, |
| 119 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 120 | }; |
| 121 | if !a.actor.verified { |
| 122 | return Ok(fail(FailureCode::Forbidden, "Confirm your email address first.")); |
| 123 | } |
| 124 | let Some(secret) = self.store.secret(&repo.repo_id, &a.id).await? else { |
| 125 | return Ok(fail(FailureCode::NotFound, "No such alert.")); |
| 126 | }; |
| 127 | if secret.status != SecretStatus::Blocked || secret.bypass.is_some() { |
| 128 | return Ok(fail(FailureCode::Conflict, "Only a secret push protection blocked, and not bypassed yet, can be bypassed.")); |
| 129 | } |
| 130 | let comment: String = a.comment.trim().chars().take(MAX_COMMENT_CHARS).collect(); |
| 131 | let comment = (!comment.is_empty()).then_some(comment); |
| 132 | let workspace = self.store.workspace_settings(&repo.namespace).await?; |
| 133 | let delegated = workspace.delegated_bypass && self.entitled(&repo).await?; |
| 134 | if delegated && !reviews_bypasses(&a.actor, &repo) { |
| 135 | // Asked for, not made: an owner or admin decides. |
| 136 | if let Some(pending) = self.store.pending_request(&secret.id, &a.actor.username).await? { |
| 137 | return Ok(Outcome::Ok(BypassResult { secret, request: Some(pending.contract()) })); |
| 138 | } |
| 139 | let id = self |
| 140 | .store |
| 141 | .add_request(&repo.repo_id, &repo.namespace, &secret.id, &a.actor.username, a.reason, comment.as_deref()) |
| 142 | .await?; |
| 143 | self.store |
| 144 | .record(&repo.repo_id, &[Activity { |
| 145 | alert_id: &secret.id, |
| 146 | action: "bypass_requested", |
| 147 | actor: Some(&a.actor.username), |
| 148 | reason: None, |
| 149 | comment: Some(a.reason.label()), |
| 150 | number: None, |
| 151 | }]) |
| 152 | .await?; |
| 153 | let request = self.store.request(&id).await?.map(|row| row.contract()); |
| 154 | let reviewers = self.bypass_reviewers(&repo).await; |
| 155 | let event = SecurityEvent { |
| 156 | request_id: Some(id.clone()), |
| 157 | reason: Some(a.reason.as_str().to_owned()), |
| 158 | notify: reviewers.into_iter().filter(|name| !name.eq_ignore_ascii_case(&a.actor.username)).collect(), |
| 159 | link: format!("/{}/-/security/bypass-requests", repo.namespace), |
| 160 | ..secret_event(&repo, &secret) |
| 161 | }; |
| 162 | self.publish("secret_scanning.bypass_requested", &repo, event, Some(a.actor.id.clone())).await; |
| 163 | self.audit( |
| 164 | &a.actor, |
| 165 | "secret_scanning.bypass_requested", |
| 166 | Some(&repo), |
| 167 | &repo.namespace, |
| 168 | Some(&secret.path), |
| 169 | &format!("Asked to bypass push protection for {} ({})", title_of(&secret), a.reason.label()), |
| 170 | ) |
| 171 | .await; |
| 172 | return Ok(Outcome::Ok(BypassResult { secret, request })); |
| 173 | } |
| 174 | self.apply_bypass(&repo, &secret, a.reason, comment.as_deref(), &a.actor, None).await?; |
| 175 | let secret = self.store.secret(&repo.repo_id, &a.id).await?.unwrap_or(secret); |
| 176 | Ok(Outcome::Ok(BypassResult { secret, request: None })) |
| 177 | } |
| 178 | |
| 179 | /// The people who review bypass requests that the inbox tells: the |
| 180 | /// workspace's owners and security managers. |
| 181 | async fn bypass_reviewers(&self, repo: &RepoRow) -> Vec<String> { |
| 182 | self.members(&repo.namespace) |
| 183 | .await |
| 184 | .into_iter() |
| 185 | .filter(|member| member.role == Role::Owner || member.org_roles.contains(&g1t_contracts::OrgRole::SecurityManager)) |
| 186 | .map(|member| member.username) |
| 187 | .collect() |
| 188 | } |
| 189 | |
| 190 | /// Lets a secret through: recorded on the alert, in its activity and in |
| 191 | /// the audit log. |
| 192 | async fn apply_bypass( |
| 193 | &self, |
| 194 | repo: &RepoRow, |
| 195 | secret: &SecretFinding, |
| 196 | reason: BypassReason, |
| 197 | comment: Option<&str>, |
| 198 | by: &User, |
| 199 | approved_by: Option<&str>, |
| 200 | ) -> Result<()> { |
| 201 | self.store.bypass(&repo.repo_id, &secret.id, reason, comment, &by.username, approved_by).await?; |
| 202 | self.store |
| 203 | .record(&repo.repo_id, &[Activity { |
| 204 | alert_id: &secret.id, |
| 205 | action: "bypassed", |
| 206 | actor: Some(&by.username), |
| 207 | reason: reason.dismissal(), |
| 208 | comment: Some(comment.unwrap_or(reason.label())), |
| 209 | number: None, |
| 210 | }]) |
| 211 | .await?; |
| 212 | self.audit( |
| 213 | by, |
| 214 | "secret_scanning.bypass", |
| 215 | Some(repo), |
| 216 | &repo.namespace, |
| 217 | Some(&secret.path), |
| 218 | &format!( |
| 219 | "Bypassed push protection for {} ({}){}", |
| 220 | title_of(secret), |
| 221 | reason.label(), |
| 222 | approved_by.map(|who| format!(", approved by {who}")).unwrap_or_default() |
| 223 | ), |
| 224 | ) |
| 225 | .await; |
| 226 | if let Some(dismissal) = reason.dismissal() { |
| 227 | let event = SecurityEvent { reason: Some(dismissal.as_str().to_owned()), state: "dismissed".to_owned(), ..secret_event(repo, secret) }; |
| 228 | self.alert_event(AlertType::SecretScanning, "dismissed", repo, event, Some(by.id.clone())).await; |
| 229 | } |
| 230 | Ok(()) |
| 231 | } |
| 232 | |
| 233 | pub(crate) async fn bypass_requests(&self, a: BypassRequestsArgs) -> Result<Outcome<Vec<BypassRequest>>> { |
| 234 | let workspace = a.workspace.to_lowercase(); |
| 235 | let Some(viewer) = a.viewer.as_ref().filter(|user| user.is_member(&workspace)) else { |
| 236 | return Ok(fail(FailureCode::NotFound, "Workspace not found.")); |
| 237 | }; |
| 238 | let repo_id = match &a.repo { |
| 239 | Some(path) => match self.member_repo(path, &a.viewer, crate::SEE_FINDINGS).await? { |
| 240 | Outcome::Ok(repo) => Some(repo.repo_id), |
| 241 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 242 | }, |
| 243 | None => None, |
| 244 | }; |
| 245 | let owner = viewer.manages_security(&workspace); |
| 246 | let mut shown = Vec::new(); |
| 247 | for row in self.store.requests(&workspace, repo_id.as_deref(), a.state.as_deref()).await? { |
| 248 | // Reviewers see every request; anyone else their own. |
| 249 | let reviewer = owner |
| 250 | || access::can(Some(viewer), access::RepoRef { id: &row.repo_id, namespace: &workspace, private: true }, Capability::ManageSecurity); |
| 251 | if reviewer || row.requester.eq_ignore_ascii_case(&viewer.username) { |
| 252 | shown.push(row.contract()); |
| 253 | } |
| 254 | } |
| 255 | Ok(Outcome::Ok(shown)) |
| 256 | } |
| 257 | |
| 258 | pub(crate) async fn review_bypass(&self, a: ReviewBypassArgs) -> Result<Outcome<BypassRequest>> { |
| 259 | let workspace = a.workspace.to_lowercase(); |
| 260 | if !a.actor.is_member(&workspace) { |
| 261 | return Ok(fail(FailureCode::NotFound, "Workspace not found.")); |
| 262 | } |
| 263 | let Some(request) = self.store.request(&a.id).await?.filter(|row| row.namespace == workspace) else { |
| 264 | return Ok(fail(FailureCode::NotFound, "No such request.")); |
| 265 | }; |
| 266 | let Some(repo) = self.store.repo(&request.repo_id).await? else { |
| 267 | return Ok(fail(FailureCode::NotFound, "No such request.")); |
| 268 | }; |
| 269 | if request.state != "pending" { |
| 270 | return Ok(fail(FailureCode::Conflict, format!("This request was {} already.", request.state))); |
| 271 | } |
| 272 | let comment: String = a.comment.trim().chars().take(MAX_COMMENT_CHARS).collect(); |
| 273 | let comment = (!comment.is_empty()).then_some(comment); |
| 274 | let state = match a.decision.as_str() { |
| 275 | "cancel" if request.requester.eq_ignore_ascii_case(&a.actor.username) => "cancelled", |
| 276 | "cancel" => return Ok(fail(FailureCode::Forbidden, "Only whoever asked can cancel a request.")), |
| 277 | "approve" | "deny" if !reviews_bypasses(&a.actor, &repo) => { |
| 278 | return Ok(fail(FailureCode::Forbidden, "Only the workspace's owners and the repository's admins review bypass requests.")); |
| 279 | } |
| 280 | "approve" | "deny" if request.requester.eq_ignore_ascii_case(&a.actor.username) => { |
| 281 | return Ok(fail(FailureCode::Forbidden, "Someone else has to review your own request.")); |
| 282 | } |
| 283 | "approve" => "approved", |
| 284 | "deny" => "denied", |
| 285 | _ => return Ok(fail(FailureCode::Invalid, "The decision is approve, deny or cancel.")), |
| 286 | }; |
| 287 | if !a.actor.verified { |
| 288 | return Ok(fail(FailureCode::Forbidden, "Confirm your email address first.")); |
| 289 | } |
| 290 | if !self.store.review_request(&a.id, state, &a.actor.username, comment.as_deref()).await? { |
| 291 | return Ok(fail(FailureCode::Conflict, "This request was reviewed already.")); |
| 292 | } |
| 293 | let reason = BypassReason::parse(&request.reason).unwrap_or(BypassReason::WillFixLater); |
| 294 | let secret = self.store.secret(&repo.repo_id, &request.secret_id).await?; |
| 295 | if let (Some(secret), "approved") = (&secret, state) { |
| 296 | let requester = User { username: request.requester.clone(), ..User::default() }; |
| 297 | self.apply_bypass(&repo, secret, reason, request.comment.as_deref(), &requester, Some(&a.actor.username)).await?; |
| 298 | } |
| 299 | if let Some(secret) = &secret { |
| 300 | self.store |
| 301 | .record(&repo.repo_id, &[Activity { |
| 302 | alert_id: &secret.id, |
| 303 | action: match state { |
| 304 | "approved" => "bypass_approved", |
| 305 | "denied" => "bypass_denied", |
| 306 | _ => "bypass_cancelled", |
| 307 | }, |
| 308 | actor: Some(&a.actor.username), |
| 309 | reason: None, |
| 310 | comment: comment.as_deref(), |
| 311 | number: None, |
| 312 | }]) |
| 313 | .await?; |
| 314 | if state != "cancelled" { |
| 315 | let event = SecurityEvent { |
| 316 | request_id: Some(request.id.clone()), |
| 317 | state: state.to_owned(), |
| 318 | reason: Some(reason.as_str().to_owned()), |
| 319 | notify: vec![request.requester.clone()], |
| 320 | ..secret_event(&repo, secret) |
| 321 | }; |
| 322 | self.publish("secret_scanning.bypass_reviewed", &repo, event, Some(a.actor.id.clone())).await; |
| 323 | } |
| 324 | } |
| 325 | self.audit( |
| 326 | &a.actor, |
| 327 | "secret_scanning.bypass_reviewed", |
| 328 | Some(&repo), |
| 329 | &workspace, |
| 330 | None, |
| 331 | &format!("Bypass request {} by {} {state}", request.id, request.requester), |
| 332 | ) |
| 333 | .await; |
| 334 | let Some(row) = self.store.request(&a.id).await? else { |
| 335 | return Ok(fail(FailureCode::NotFound, "No such request.")); |
| 336 | }; |
| 337 | Ok(Outcome::Ok(row.contract())) |
| 338 | } |
| 339 | |
| 340 | pub(crate) async fn check_validity(&self, a: CheckValidityArgs) -> Result<Outcome<SecretFinding>> { |
| 341 | let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), crate::SEE_FINDINGS).await? { |
| 342 | Outcome::Ok(repo) => repo, |
| 343 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 344 | }; |
| 345 | if let Some(refusal) = self.gate(&repo, PaidFeature::ValidityChecks).await? { |
| 346 | return Ok(refusal); |
| 347 | } |
| 348 | if !self.store.workspace_settings(&repo.namespace).await?.validity_checks { |
| 349 | return Ok(fail( |
| 350 | FailureCode::Conflict, |
| 351 | "Validity checks are off for this workspace. An owner can turn them on in its Security settings.", |
| 352 | )); |
| 353 | } |
| 354 | let Some(secret) = self.store.secret(&repo.repo_id, &a.id).await? else { |
| 355 | return Ok(fail(FailureCode::NotFound, "No such alert.")); |
| 356 | }; |
| 357 | let validity = self.ask_issuer(&repo, &secret).await; |
| 358 | self.store.set_validity(&repo.repo_id, &secret.id, &validity.validity).await?; |
| 359 | if let Some(detail) = &validity.detail { |
| 360 | worker::console_log!("security: validity of {} unknown: {detail}", secret.id); |
| 361 | } |
| 362 | Ok(match self.store.secret(&repo.repo_id, &a.id).await? { |
| 363 | Some(secret) => Outcome::Ok(secret), |
| 364 | None => fail(FailureCode::NotFound, "No such alert."), |
| 365 | }) |
| 366 | } |
| 367 | |
| 368 | /// Asks the repos service, which can read the secret where it landed, |
| 369 | /// to ask its issuer. A secret that never landed (blocked at a push) |
| 370 | /// cannot be read again, so it is unknown. |
| 371 | async fn ask_issuer(&self, repo: &RepoRow, secret: &SecretFinding) -> SecretValidity { |
| 372 | let supported = SecretKind::parse(&secret.kind).is_some_and(|kind| g1t_scan::validity::SUPPORTED.contains(&kind)); |
| 373 | if !supported { |
| 374 | return SecretValidity { validity: "unsupported".to_owned(), detail: None }; |
| 375 | } |
| 376 | if secret.status == SecretStatus::Blocked { |
| 377 | return SecretValidity { validity: "unknown".to_owned(), detail: Some("it never landed, so it cannot be read again".to_owned()) }; |
| 378 | } |
| 379 | let fingerprint = match self.store.fingerprint_of(&repo.repo_id, &secret.id).await { |
| 380 | Ok(Some(fingerprint)) => fingerprint, |
| 381 | _ => return SecretValidity { validity: "unknown".to_owned(), detail: Some("no fingerprint".to_owned()) }, |
| 382 | }; |
| 383 | let asked: Result<SecretValidity> = g1t_kit::call( |
| 384 | &self.repos, |
| 385 | "check_secret", |
| 386 | &CheckSecretArgs { |
| 387 | repo_id: repo.repo_id.clone(), |
| 388 | commit: secret.commit.clone(), |
| 389 | path: secret.path.clone(), |
| 390 | line: secret.line, |
| 391 | kind: secret.kind.clone(), |
| 392 | fingerprint, |
| 393 | }, |
| 394 | ) |
| 395 | .await; |
| 396 | asked.unwrap_or_else(|error| SecretValidity { validity: "unknown".to_owned(), detail: Some(error.to_string()) }) |
| 397 | } |
| 398 | |
| 399 | /// The sweep's part: in workspaces that turned validity checks on, open |
| 400 | /// secrets not asked about for a week are asked about again. |
| 401 | pub(crate) async fn sweep_validity(&self, repo: &RepoRow) -> Result<()> { |
| 402 | if !self.store.workspace_settings(&repo.namespace).await?.validity_checks || !self.entitled(repo).await? { |
| 403 | return Ok(()); |
| 404 | } |
| 405 | let before = rfc3339(now_ms().saturating_sub(RECHECK_MS)); |
| 406 | for id in self.store.unchecked_secrets(&repo.repo_id, &before, CHECKS_PER_SWEEP).await? { |
| 407 | if let Some(secret) = self.store.secret(&repo.repo_id, &id).await? { |
| 408 | let validity = self.ask_issuer(repo, &secret).await; |
| 409 | self.store.set_validity(&repo.repo_id, &id, &validity.validity).await?; |
| 410 | } |
| 411 | } |
| 412 | Ok(()) |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | #[cfg(test)] |
| 417 | mod tests { |
| 418 | use super::*; |
| 419 | |
| 420 | #[test] |
| 421 | fn titles_name_the_secret_and_its_file() { |
| 422 | assert_eq!(label_of("aws_access_key", None), "an AWS access key"); |
| 423 | assert_eq!(label_of("custom_pattern", Some("Acme key")), "a match for the custom pattern \"Acme key\""); |
| 424 | assert_eq!(label_of("unheard_of", None), "a secret"); |
| 425 | } |
| 426 | |
| 427 | #[test] |
| 428 | fn owners_and_admins_review_bypasses() { |
| 429 | let repo = RepoRow { |
| 430 | repo_id: "rep_1".into(), |
| 431 | namespace: "acme".into(), |
| 432 | name: "rocket".into(), |
| 433 | upkeep: 1, |
| 434 | history: "done".into(), |
| 435 | history_cursor: None, |
| 436 | history_commits: 0, |
| 437 | history_finished_at: None, |
| 438 | deps_scanned_at: None, |
| 439 | deps_error: None, |
| 440 | lockfiles: "[]".into(), |
| 441 | version_updates: None, |
| 442 | }; |
| 443 | let member = |role| User { |
| 444 | id: "usr_1".into(), |
| 445 | username: "ana".into(), |
| 446 | workspaces: vec![g1t_contracts::Membership { role, ..g1t_contracts::Membership::member("acme") }], |
| 447 | ..User::default() |
| 448 | }; |
| 449 | assert!(reviews_bypasses(&member(Role::Owner), &repo)); |
| 450 | assert!(!reviews_bypasses(&User { username: "eve".into(), ..User::default() }, &repo)); |
| 451 | // A security manager reviews them too; a plain member does not. |
| 452 | let mut manager = member(Role::Member); |
| 453 | manager.workspaces[0].base_permission = Some(g1t_contracts::access::BasePermission::None); |
| 454 | assert!(!reviews_bypasses(&manager, &repo)); |
| 455 | manager.workspaces[0].org_roles.push(g1t_contracts::OrgRole::SecurityManager); |
| 456 | assert!(reviews_bypasses(&manager, &repo)); |
| 457 | } |
| 458 | } |