| 1 | //! Rulesets, enforced here: on every push, and on every other change to a |
| 2 | //! branch or tag made through g1t (renaming a branch, a commit made on the |
| 3 | //! site, a pull request brought up to date). The work service keeps the |
| 4 | //! rulesets and says which hold (`ref_rules`); `g1t_rules` judges; every |
| 5 | //! judgement is sent back to be recorded (`record_evaluations`). Merging a |
| 6 | //! pull request is judged by the work service before it asks `land`. |
| 7 | //! |
| 8 | //! A pull request's working copy (a fork) has no rules of its own: what it |
| 9 | //! brings is judged when it merges. |
| 10 | |
| 11 | use std::collections::HashMap; |
| 12 | |
| 13 | use g1t_contracts::accounts::{EmailOwner, EmailOwnersArgs}; |
| 14 | use g1t_contracts::repos::Repo; |
| 15 | use g1t_contracts::rules::{ |
| 16 | Action, Applicable, CommitFacts, Enforcement, FileChange, InspectCommitsArgs, InspectedCommits, |
| 17 | RecordEvaluationsArgs, RefRules, RefRulesArgs, Rule, Target, |
| 18 | }; |
| 19 | use g1t_contracts::{FailureCode, Outcome, User}; |
| 20 | use g1t_rules::push::{RefChange, judge}; |
| 21 | use g1t_rules::{ActorFacts, Judged, Who, content, outcome, report}; |
| 22 | use g1t_scan::pack::{Pack, pack_start}; |
| 23 | use worker::{Response, Result}; |
| 24 | |
| 25 | use crate::registry::store_key; |
| 26 | use crate::rule_facts::{self, MAX_COMMITS}; |
| 27 | use crate::store::{GitRepo, GitStore}; |
| 28 | use crate::{MAX_ANCESTRY, Repos}; |
| 29 | |
| 30 | /// Where people read the rules of a branch. |
| 31 | const SITE: &str = "https://g1t.sh"; |
| 32 | |
| 33 | /// What the rules said about a change. |
| 34 | pub(crate) enum Ruled { |
| 35 | /// No ruleset holds, or none refuses it. |
| 36 | Allowed, |
| 37 | /// Refused: why, in a sentence. |
| 38 | Refused { message: String }, |
| 39 | } |
| 40 | |
| 41 | /// `ssh_key_owners` on identity: the account that registered each key. |
| 42 | #[derive(serde::Serialize)] |
| 43 | struct KeyOwnersArgs<'a> { |
| 44 | fingerprints: &'a [String], |
| 45 | } |
| 46 | |
| 47 | fn who(actor: Option<&User>, repo: &Repo) -> ActorFacts { |
| 48 | match actor { |
| 49 | Some(actor) => ActorFacts::of(actor, repo), |
| 50 | None => ActorFacts { username: "anonymous".to_owned(), ..ActorFacts::default() }, |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | /// Whether any ruleset that is evaluated (active, or evaluate) has a rule |
| 55 | /// about commits that holds for this actor. |
| 56 | fn needs_commits(rulesets: &[Applicable], kind: Who) -> bool { |
| 57 | rulesets.iter().filter(|ruleset| ruleset.enforcement != Enforcement::Disabled).any(|ruleset| { |
| 58 | ruleset |
| 59 | .rules |
| 60 | .iter() |
| 61 | .any(|entry| entry.applies_to.covers(kind.is_agent()) && content::about_content(&entry.rule)) |
| 62 | }) |
| 63 | } |
| 64 | |
| 65 | fn needs_ancestry(rulesets: &[Applicable]) -> bool { |
| 66 | rulesets |
| 67 | .iter() |
| 68 | .any(|ruleset| ruleset.rules.iter().any(|entry| matches!(entry.rule, Rule::NonFastForward(_)))) |
| 69 | } |
| 70 | |
| 71 | /// Where the rules of a ref are shown. |
| 72 | pub(crate) fn rules_url(repo: &Repo, git_ref: &str) -> String { |
| 73 | let (target, name) = Target::of_ref(git_ref).unwrap_or((Target::Branch, git_ref)); |
| 74 | let key = if target == Target::Tag { "tag" } else { "branch" }; |
| 75 | format!("{SITE}/{}/{}/settings/rules?{key}={name}", repo.namespace, repo.name) |
| 76 | } |
| 77 | |
| 78 | impl<S: GitStore> Repos<S> { |
| 79 | /// The rulesets that hold for `refs`, from the work service. `None` |
| 80 | /// when this installation runs without it. |
| 81 | async fn ref_rules(&self, repo: &Repo, actor: Option<&User>, refs: Vec<String>) -> Result<Option<RefRules>> { |
| 82 | let Some(work) = &self.work else { return Ok(None) }; |
| 83 | let found: Outcome<RefRules> = |
| 84 | g1t_kit::call(work, "ref_rules", &RefRulesArgs { repo: repo.clone(), actor: actor.cloned(), refs }).await?; |
| 85 | match found { |
| 86 | Outcome::Ok(rules) => Ok(Some(rules)), |
| 87 | Outcome::Fail(failure) => Err(worker::Error::RustError(failure.message)), |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | /// Sends judgements to be recorded; a failure is logged. |
| 92 | async fn record_judged(&self, repo: &Repo, judged: &[Judged], action: Action, actor: &ActorFacts, sha: Option<&str>) { |
| 93 | let Some(work) = &self.work else { return }; |
| 94 | if judged.is_empty() { |
| 95 | return; |
| 96 | } |
| 97 | let evaluations = g1t_rules::evaluations(judged, &repo.id, &repo.namespace, action, actor, None, sha); |
| 98 | let recorded: Result<u32> = g1t_kit::call(work, "record_evaluations", &RecordEvaluationsArgs { evaluations }).await; |
| 99 | if let Err(error) = recorded { |
| 100 | worker::console_error!("rule evaluations not recorded: {error}"); |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /// Who owns the keys commits were signed with, and the addresses |
| 105 | /// they were committed as. |
| 106 | async fn signing_owners( |
| 107 | &self, |
| 108 | fingerprints: &[String], |
| 109 | emails: &[String], |
| 110 | ) -> (HashMap<String, String>, HashMap<String, (String, String)>) { |
| 111 | let Some(identity) = &self.identity else { return (HashMap::new(), HashMap::new()) }; |
| 112 | if fingerprints.is_empty() { |
| 113 | return (HashMap::new(), HashMap::new()); |
| 114 | } |
| 115 | let (keys, owners) = futures_util::future::join( |
| 116 | g1t_kit::call::<_, HashMap<String, String>>(identity, "ssh_key_owners", &KeyOwnersArgs { fingerprints }), |
| 117 | g1t_kit::call::<_, HashMap<String, EmailOwner>>(identity, "email_owners", &EmailOwnersArgs { emails: emails.to_vec() }), |
| 118 | ) |
| 119 | .await; |
| 120 | let keys = keys.unwrap_or_else(|error| { |
| 121 | worker::console_error!("ssh_key_owners failed: {error}"); |
| 122 | HashMap::new() |
| 123 | }); |
| 124 | let owners = owners |
| 125 | .unwrap_or_default() |
| 126 | .into_iter() |
| 127 | .map(|(email, owner)| (email.to_lowercase(), (owner.id, owner.username))) |
| 128 | .collect(); |
| 129 | (keys, owners) |
| 130 | } |
| 131 | |
| 132 | /// Judges changes made through g1t (not a push), records how each |
| 133 | /// ruleset judged them, and says whether they may go ahead. |
| 134 | pub(crate) async fn check_changes(&self, repo: &Repo, actor: &User, action: Action, changes: Vec<RefChange>) -> Result<Ruled> { |
| 135 | if repo.fork_of.is_some() || changes.is_empty() { |
| 136 | return Ok(Ruled::Allowed); |
| 137 | } |
| 138 | let refs: Vec<String> = changes.iter().map(|change| change.git_ref.clone()).collect(); |
| 139 | let rules = match self.ref_rules(repo, Some(actor), refs).await { |
| 140 | Ok(Some(rules)) => rules, |
| 141 | Ok(None) => return Ok(Ruled::Allowed), |
| 142 | Err(error) => { |
| 143 | worker::console_error!("ref_rules failed: {error}"); |
| 144 | return Ok(Ruled::Refused { |
| 145 | message: "The rules for this branch could not be checked just now. Try again in a moment.".to_owned(), |
| 146 | }); |
| 147 | } |
| 148 | }; |
| 149 | if rules.rulesets.is_empty() { |
| 150 | return Ok(Ruled::Allowed); |
| 151 | } |
| 152 | let facts = who(Some(actor), repo); |
| 153 | let judged: Vec<Judged> = changes |
| 154 | .iter() |
| 155 | .flat_map(|change| judge(&rules.rulesets, &rules.default_branch, facts.kind, change)) |
| 156 | .collect(); |
| 157 | let sha = changes.iter().find_map(|change| change.new.clone()); |
| 158 | self.record_judged(repo, &judged, action, &facts, sha.as_deref()).await; |
| 159 | if !outcome::refused(&judged) { |
| 160 | return Ok(Ruled::Allowed); |
| 161 | } |
| 162 | let message = report::summary(&outcome::blocking(&judged)).unwrap_or_else(|| "Rules for this branch refuse it.".to_owned()); |
| 163 | Ok(Ruled::Refused { message }) |
| 164 | } |
| 165 | |
| 166 | /// Rules for a push: the response declining it, or `None` to let it |
| 167 | /// on. `body` is as much of the push as was read; `whole` says whether |
| 168 | /// that is all of it, so that its commits can be read. |
| 169 | pub(crate) async fn check_push(&self, repo: &Repo, pusher: Option<&User>, body: &[u8], whole: bool) -> Result<Option<Response>> { |
| 170 | if repo.fork_of.is_some() { |
| 171 | return Ok(None); |
| 172 | } |
| 173 | let updates = crate::git_http::ref_updates(body); |
| 174 | if updates.is_empty() { |
| 175 | return Ok(None); |
| 176 | } |
| 177 | let refs: Vec<String> = updates.iter().map(|(name, _, _)| name.clone()).collect(); |
| 178 | let rules = match self.ref_rules(repo, pusher, refs).await { |
| 179 | Ok(Some(rules)) => rules, |
| 180 | // Without the work service, the old protection holds. |
| 181 | Ok(None) => { |
| 182 | let protected = repo.protected.then(|| repo.default_branch.clone()); |
| 183 | return protected |
| 184 | .and_then(|branch| crate::git_http::refusal(body, &branch)) |
| 185 | .map(crate::git_http::report_response) |
| 186 | .transpose(); |
| 187 | } |
| 188 | Err(error) => { |
| 189 | worker::console_error!("ref_rules failed during a push: {error}"); |
| 190 | return Ok(Some(crate::git_http::declined( |
| 191 | body, |
| 192 | "rules could not be checked", |
| 193 | &["The rules for this repository could not be checked just now. Push again in a moment.".to_owned()], |
| 194 | )?)); |
| 195 | } |
| 196 | }; |
| 197 | if rules.rulesets.is_empty() { |
| 198 | return Ok(None); |
| 199 | } |
| 200 | let facts = who(pusher, repo); |
| 201 | let content = needs_commits(&rules.rulesets, facts.kind); |
| 202 | let ancestry = needs_ancestry(&rules.rulesets); |
| 203 | let git = self.store.open(&store_key(repo)).await?; |
| 204 | // The pack, read when a rule needs what it holds. |
| 205 | let pack = if whole && (content || ancestry) { |
| 206 | match pack_start(body).map(|start| Pack::parse(&body[start..])) { |
| 207 | Some(Ok(mut pack)) => { |
| 208 | crate::secret_scan::supply_bases(&mut pack, &git).await?; |
| 209 | Some(pack) |
| 210 | } |
| 211 | Some(Err(problem)) => { |
| 212 | worker::console_error!("a push's pack could not be read for rules: {problem}"); |
| 213 | None |
| 214 | } |
| 215 | // Nothing but deletions, or pointing refs at commits the |
| 216 | // repository has: an empty pack. |
| 217 | None => Pack::parse(crate::land::EMPTY_PACK).ok(), |
| 218 | } |
| 219 | } else { |
| 220 | None |
| 221 | }; |
| 222 | let signatures = rules |
| 223 | .rulesets |
| 224 | .iter() |
| 225 | .any(|ruleset| ruleset.rules.iter().any(|entry| matches!(entry.rule, Rule::RequiredSignatures(_)))); |
| 226 | let mut changes = Vec::new(); |
| 227 | for (git_ref, old, new) in updates { |
| 228 | let mut change = RefChange { git_ref, old: old.clone(), new: new.clone(), fast_forward: None, commits: Vec::new(), complete: false }; |
| 229 | if let (Some(pack), Some(new)) = (&pack, &new) { |
| 230 | if let Some(old) = &old |
| 231 | && ancestry |
| 232 | { |
| 233 | change.fast_forward = Some(rule_facts::contains(pack, &git, new, old, MAX_ANCESTRY).await?); |
| 234 | } |
| 235 | if content { |
| 236 | match rule_facts::added(pack, new, MAX_COMMITS) { |
| 237 | Some(ids) => { |
| 238 | let owners = if signatures { |
| 239 | let (fingerprints, emails) = rule_facts::signing_facts(pack, &ids); |
| 240 | Some(self.signing_owners(&fingerprints, &emails).await) |
| 241 | } else { |
| 242 | None |
| 243 | }; |
| 244 | change.commits = rule_facts::read_all(pack, &git, &ids, owners.as_ref()).await?; |
| 245 | change.complete = change.commits.iter().all(|commit| commit.files_complete); |
| 246 | } |
| 247 | None => change.complete = false, |
| 248 | } |
| 249 | } else { |
| 250 | change.complete = true; |
| 251 | } |
| 252 | } else if new.is_none() || !content { |
| 253 | change.complete = true; |
| 254 | } |
| 255 | changes.push(change); |
| 256 | } |
| 257 | let judged: Vec<Judged> = changes |
| 258 | .iter() |
| 259 | .flat_map(|change| judge(&rules.rulesets, &rules.default_branch, facts.kind, change)) |
| 260 | .collect(); |
| 261 | let sha = changes.iter().find_map(|change| change.new.clone()); |
| 262 | self.record_judged(repo, &judged, Action::Push, &facts, sha.as_deref()).await; |
| 263 | if !outcome::refused(&judged) { |
| 264 | return Ok(None); |
| 265 | } |
| 266 | let refused_ref = judged.iter().find(|one| one.blocks()).map(|one| one.git_ref.clone()).unwrap_or_default(); |
| 267 | let lines = report::remote_lines(&refused_ref, &judged, &rules_url(repo, &refused_ref)); |
| 268 | Ok(Some(crate::git_http::declined(body, &report::ng_reason(&judged), &lines)?)) |
| 269 | } |
| 270 | |
| 271 | /// Services only: the commits a pull request would land, read as rules |
| 272 | /// look at them, fetched from its source as a pack. |
| 273 | pub(crate) async fn inspect_commits(&self, a: InspectCommitsArgs) -> Result<Outcome<InspectedCommits>> { |
| 274 | let (Some(source), Some(target)) = (self.registry.by_id(&a.source_id).await?, self.registry.by_id(&a.target_id).await?) else { |
| 275 | return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found.")); |
| 276 | }; |
| 277 | self.live(&source).await?; |
| 278 | let (source_git, target_git) = (self.store.open(&store_key(&source)).await?, self.store.open(&store_key(&target)).await?); |
| 279 | let (history, base_history) = |
| 280 | futures_util::future::try_join(source_git.log(&a.head, MAX_ANCESTRY), target_git.log(&a.base_branch, MAX_ANCESTRY)).await?; |
| 281 | let shared: std::collections::HashSet<String> = base_history.into_iter().map(|commit| commit.hash).collect(); |
| 282 | let merge_base = crate::nearest_ancestor_in(&source_git, &history, &shared).await?; |
| 283 | let Some(head) = history.first() else { |
| 284 | return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: true })); |
| 285 | }; |
| 286 | let access = source_git.access(crate::store::Scope::Read).await?; |
| 287 | let fetched = crate::land::fetch_pack(&access, &head.hash, merge_base.as_deref()).await?; |
| 288 | if fetched.len() > crate::secret_scan::MAX_SCANNED_PUSH { |
| 289 | return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: false })); |
| 290 | } |
| 291 | let pack = match Pack::parse(&fetched) { |
| 292 | Ok(pack) => pack, |
| 293 | Err(problem) => { |
| 294 | worker::console_error!("a pull request's commits could not be read for rules: {problem}"); |
| 295 | return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: false })); |
| 296 | } |
| 297 | }; |
| 298 | let limit = a.limit.map_or(MAX_COMMITS, |limit| (limit as usize).min(MAX_COMMITS)); |
| 299 | let Some(ids) = rule_facts::added(&pack, &head.hash, limit) else { |
| 300 | return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: false })); |
| 301 | }; |
| 302 | let (fingerprints, emails) = rule_facts::signing_facts(&pack, &ids); |
| 303 | let owners = self.signing_owners(&fingerprints, &emails).await; |
| 304 | let commits = rule_facts::read_all(&pack, &source_git, &ids, Some(&owners)).await?; |
| 305 | let complete = commits.iter().all(|commit| commit.files_complete); |
| 306 | Ok(Outcome::Ok(InspectedCommits { commits, complete })) |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | /// The facts of one commit g1t makes itself (a web edit, a catch-up |
| 311 | /// merge): it is not signed, and it changes `files`. |
| 312 | pub(crate) fn made_commit(sha: &str, message: &str, email: &str, parents: u32, files: Vec<FileChange>) -> CommitFacts { |
| 313 | CommitFacts { |
| 314 | sha: sha.to_owned(), |
| 315 | message: message.to_owned(), |
| 316 | author_email: Some(email.to_owned()), |
| 317 | committer_email: Some(email.to_owned()), |
| 318 | parents, |
| 319 | signature: g1t_contracts::rules::Signature::Unsigned, |
| 320 | files, |
| 321 | files_complete: true, |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | #[cfg(test)] |
| 326 | mod tests { |
| 327 | use super::*; |
| 328 | use g1t_contracts::rules::{AppliesTo, Level, NoParameters, RefCondition, RuleEntry}; |
| 329 | |
| 330 | fn repo() -> Repo { |
| 331 | serde_json::from_value(serde_json::json!({ |
| 332 | "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": false, |
| 333 | "ownerId": "usr_1", "defaultBranch": "main", "forkOf": null, "createdAt": "" |
| 334 | })) |
| 335 | .unwrap() |
| 336 | } |
| 337 | |
| 338 | fn ruleset(enforcement: Enforcement, rule: Rule, applies_to: AppliesTo) -> Applicable { |
| 339 | Applicable { |
| 340 | id: "rs_1".into(), |
| 341 | name: "R".into(), |
| 342 | level: Level::Repository, |
| 343 | enforcement, |
| 344 | target: Target::Branch, |
| 345 | conditions: RefCondition { include: vec!["~ALL".into()], exclude: Vec::new() }, |
| 346 | rules: vec![RuleEntry { rule, applies_to }], |
| 347 | bypass: None, |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | #[test] |
| 352 | fn rules_are_linked_by_branch_or_tag() { |
| 353 | assert_eq!(rules_url(&repo(), "refs/heads/release/1"), "https://g1t.sh/acme/web/settings/rules?branch=release/1"); |
| 354 | assert_eq!(rules_url(&repo(), "refs/tags/v1"), "https://g1t.sh/acme/web/settings/rules?tag=v1"); |
| 355 | } |
| 356 | |
| 357 | #[test] |
| 358 | fn commits_are_read_only_when_a_rule_for_this_actor_needs_them() { |
| 359 | let signed = ruleset(Enforcement::Evaluate, Rule::RequiredSignatures(NoParameters {}), AppliesTo::Everyone); |
| 360 | assert!(needs_commits(&[signed], Who::Person), "evaluate-mode rulesets are recorded too"); |
| 361 | let agents = ruleset(Enforcement::Active, Rule::RequiredSignatures(NoParameters {}), AppliesTo::Agents); |
| 362 | assert!(!needs_commits(std::slice::from_ref(&agents), Who::Person)); |
| 363 | assert!(needs_commits(&[agents], Who::Agent)); |
| 364 | let off = ruleset(Enforcement::Disabled, Rule::RequiredSignatures(NoParameters {}), AppliesTo::Everyone); |
| 365 | assert!(!needs_commits(&[off], Who::Person)); |
| 366 | assert!(needs_ancestry(&[ruleset(Enforcement::Active, Rule::NonFastForward(NoParameters {}), AppliesTo::Everyone)])); |
| 367 | } |
| 368 | |
| 369 | #[test] |
| 370 | fn a_commit_g1t_makes_is_unsigned_and_lists_its_files() { |
| 371 | let made = made_commit("abc", "Add CI", "ada@acme.com", 1, vec![FileChange { path: ".g1t/workflows/ci.yml".into(), size: Some(12), deleted: false }]); |
| 372 | assert_eq!(made.signature, g1t_contracts::rules::Signature::Unsigned); |
| 373 | assert!(made.files_complete); |
| 374 | } |
| 375 | } |