Skip to content
1,107 linesCodeBlameRaw
1//! Keeping runs safe: a repository's choices for its workflows (what a
2//! job's token may do when its workflow says nothing, and which pull
3//! requests' runs wait for approval), environments' protection rules, the
4//! reviews of the jobs they hold, and approving a pull request's run.
5//!
6//! - A job that names an environment with rules is `pending` once its needs
7//! are done: until a reviewer approves it (when the environment has
8//! reviewers), its wait timer has run out (when it has one), and only on
9//! a ref the environment lets deploy. Its secrets are read when it
10//! starts, so the environment's are never handed out before then. One
11//! review approves every job of the run attempt that names the
12//! environment.
13//! - A run of a pull request from outside the workspace may wait as
14//! `action_required` until someone with the Write role approves it, by
15//! the repository's approval policy.
16
17use g1t_actions::filter::Pattern;
18use g1t_actions::permissions::TokenDefault;
19use g1t_contracts::access::{self, AccessSource, Capability, CollaboratorPermissionArgs, PermissionInfo};
20use g1t_contracts::actions::{
21 APPROVAL_POLICIES, ActionsSettings, ActionsSettingsArgs, BranchPattern, DeleteEnvironmentArgs, Environment, EnvironmentReviewer,
22 EnvironmentsArgs, MAX_ENVIRONMENT_REVIEWERS, MAX_WAIT_MINUTES, PendingDeployment, PendingDeploymentsArgs, ReviewDeploymentsArgs,
23 RunActionArgs, RunApproval, SetActionsSettingsArgs, SetEnvironmentArgs, WorkflowRun,
24};
25use g1t_contracts::identity::AGENT_ID;
26use g1t_contracts::repos::{Repo, RepoPath};
27use g1t_contracts::time::rfc3339;
28use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer};
29use g1t_kit::now_ms;
30use serde::Deserialize;
31use serde_json::{Value, json};
32use worker::Result;
33
34use crate::plan::{JobRow, RunRow};
35use crate::{Actions, check, fail, optional};
36
37/// What the approval policy is when a repository chose none.
38pub const DEFAULT_APPROVAL_POLICY: &str = "outside_contributors";
39
40fn now() -> String {
41 rfc3339(now_ms())
42}
43
44#[derive(Deserialize)]
45struct SettingsRow {
46 default_permissions: String,
47 approval_policy: String,
48}
49
50#[derive(Clone, Deserialize)]
51struct EnvRow {
52 name: String,
53 reviewers: String,
54 prevent_self_review: u32,
55 wait_minutes: u32,
56 branch_policy: String,
57 branch_patterns: String,
58 admins_bypass: u32,
59 updated_at: String,
60 updated_by: Option<String>,
61}
62
63impl EnvRow {
64 fn view(&self) -> Environment {
65 Environment {
66 name: self.name.clone(),
67 reviewers: serde_json::from_str(&self.reviewers).unwrap_or_default(),
68 prevent_self_review: self.prevent_self_review != 0,
69 wait_minutes: self.wait_minutes,
70 branch_policy: self.branch_policy.clone(),
71 branch_patterns: serde_json::from_str(&self.branch_patterns).unwrap_or_default(),
72 admins_bypass: self.admins_bypass != 0,
73 protected: true,
74 updated_at: Some(self.updated_at.clone()),
75 updated_by: self.updated_by.clone(),
76 }
77 }
78}
79
80/// An environment no rules hold.
81fn unprotected(name: &str) -> Environment {
82 Environment {
83 name: name.to_owned(),
84 reviewers: Vec::new(),
85 prevent_self_review: false,
86 wait_minutes: 0,
87 branch_policy: "all".to_owned(),
88 branch_patterns: Vec::new(),
89 admins_bypass: true,
90 protected: false,
91 updated_at: None,
92 updated_by: None,
93 }
94}
95
96#[derive(Clone, Deserialize)]
97struct GateRow {
98 environment: String,
99 state: String,
100 needs_review: u32,
101 wait_until: Option<String>,
102 reviewed_by: Option<String>,
103 comment: Option<String>,
104 reviewed_at: Option<String>,
105}
106
107/// An environment's name as rules and secrets keep it: lowercase, up to 40
108/// letters, digits, `-` and `_`.
109pub(crate) fn environment_name(name: &str) -> std::result::Result<String, String> {
110 let lower = name.trim().to_ascii_lowercase();
111 if lower.is_empty() || lower.len() > 40 || !lower.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
112 return Err(format!("`{}` is not an environment's name: up to 40 letters, digits, - and _.", name.trim()));
113 }
114 Ok(lower)
115}
116
117/// Whether `git_ref` may deploy to an environment by its `policy`.
118/// `protected` says whether the branch is protected, for `protected`.
119pub(crate) fn branch_allowed(policy: &str, patterns: &[BranchPattern], git_ref: &str, protected: bool, environment: &str) -> std::result::Result<(), String> {
120 let (kind, name) = match (git_ref.strip_prefix("refs/heads/"), git_ref.strip_prefix("refs/tags/")) {
121 (Some(branch), _) => ("branch", branch),
122 (_, Some(tag)) => ("tag", tag),
123 _ => ("ref", git_ref),
124 };
125 match policy {
126 "protected" if kind == "branch" && protected => Ok(()),
127 "protected" => Err(format!("{environment} takes deployments from protected branches only, and `{name}` is not one.")),
128 "selected" => {
129 let allowed = patterns
130 .iter()
131 .filter(|pattern| pattern.kind == kind)
132 .any(|pattern| Pattern::parse(&pattern.name).matches(name));
133 if allowed {
134 Ok(())
135 } else {
136 Err(format!("The {kind} `{name}` may not deploy to {environment}: its deployment rules list the branches and tags that may."))
137 }
138 }
139 _ => Ok(()),
140 }
141}
142
143/// How someone may decide on an environment's jobs.
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145pub(crate) enum Reviewer {
146 /// One of its reviewers.
147 Listed,
148 /// An admin, past its rules, which also skips its wait timer.
149 AdminBypass,
150}
151
152/// Whether `username` (in `teams`, an admin or not) may approve or reject
153/// the environment's jobs in a run `started_by` began.
154pub(crate) fn may_review(
155 environment: &Environment,
156 username: &str,
157 teams: &[String],
158 admin: bool,
159 started_by: Option<&str>,
160) -> std::result::Result<Reviewer, String> {
161 let listed = environment.reviewers.iter().any(|reviewer| match reviewer.kind.as_str() {
162 "team" => teams.iter().any(|team| team.eq_ignore_ascii_case(&reviewer.name)),
163 _ => reviewer.name.eq_ignore_ascii_case(username),
164 });
165 let started = started_by.is_some_and(|by| by.eq_ignore_ascii_case(username));
166 if listed && !(environment.prevent_self_review && started) {
167 return Ok(Reviewer::Listed);
168 }
169 if admin && environment.admins_bypass {
170 return Ok(Reviewer::AdminBypass);
171 }
172 Err(if listed {
173 format!("You started this run, and {} does not let whoever started a run approve its deployments.", environment.name)
174 } else if environment.reviewers.is_empty() {
175 format!("{} has no reviewers: its jobs wait for its timer. Only an admin can start them sooner.", environment.name)
176 } else {
177 format!("You are not one of {}'s reviewers.", environment.name)
178 })
179}
180
181/// Whether a gate lets its jobs start at `now`.
182pub(crate) fn gate_open(state: &str, needs_review: bool, wait_until: Option<&str>, now: &str) -> bool {
183 state != "rejected" && (!needs_review || state == "approved") && wait_until.is_none_or(|until| until <= now)
184}
185
186/// Why a pull request's run waits for approval, if it does, by `policy`:
187/// `member` of the workspace, whether they `can_push`, and whether this is
188/// their `first_time` (no pull request of theirs merged here).
189pub(crate) fn approval_reason(policy: &str, member: bool, can_push: bool, first_time: bool) -> Option<&'static str> {
190 if member {
191 return None;
192 }
193 match policy {
194 "all_external_contributors" => Some("is not a member of the workspace"),
195 "first_time_contributors" => first_time.then_some("has not had a pull request merged here yet"),
196 _ if !can_push => Some("cannot push to the repository"),
197 _ => first_time.then_some("has not had a pull request merged here yet"),
198 }
199}
200
201/// What a job waits for at its environment, in words.
202fn held_reason(environment: &str, needs_review: bool, wait_until: Option<&str>) -> String {
203 match (needs_review, wait_until) {
204 (true, Some(until)) => format!("Waiting for a review to deploy to {environment}, and for its wait timer, until {until}."),
205 (true, None) => format!("Waiting for a review to deploy to {environment}."),
206 (false, Some(until)) => format!("Waiting for {environment}'s wait timer, until {until}."),
207 (false, None) => format!("Waiting to deploy to {environment}."),
208 }
209}
210
211/// What an environment's rules say about a job that names it.
212pub(crate) enum Gate {
213 /// No rules hold it: it starts.
214 Open,
215 /// It waits, for this.
216 Held(String),
217 /// It may not deploy there.
218 Refused(String),
219}
220
221impl Actions {
222 // --- The repository's choices -----------------------------------------------
223
224 /// A repository's choices, or the defaults.
225 pub(crate) async fn repo_settings(&self, repo_id: &str) -> Result<ActionsSettings> {
226 let row = self
227 .db
228 .prepare("SELECT default_permissions, approval_policy FROM repo_settings WHERE repo_id = ?")
229 .bind(&[repo_id.into()])?
230 .first::<SettingsRow>(None)
231 .await?;
232 Ok(match row {
233 Some(row) => ActionsSettings { default_permissions: row.default_permissions, approval_policy: row.approval_policy },
234 None => ActionsSettings {
235 default_permissions: TokenDefault::Restricted.as_str().to_owned(),
236 approval_policy: DEFAULT_APPROVAL_POLICY.to_owned(),
237 },
238 })
239 }
240
241 /// What a workflow without `permissions:` gets in the repository.
242 pub(crate) async fn token_default(&self, repo_id: &str) -> Result<TokenDefault> {
243 let settings = self.repo_settings(repo_id).await?;
244 Ok(TokenDefault::parse(&settings.default_permissions).unwrap_or_default())
245 }
246
247 pub async fn actions_settings(&self, a: ActionsSettingsArgs) -> Result<Outcome<ActionsSettings>> {
248 let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else {
249 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
250 };
251 Ok(Outcome::Ok(self.repo_settings(&repo.id).await?))
252 }
253
254 pub async fn set_actions_settings(&self, a: SetActionsSettingsArgs) -> Result<Outcome<ActionsSettings>> {
255 if let Some(refused) = refuse_job_token(&a.actor) {
256 return Ok(refused);
257 }
258 let repo = check!(self.may(&a.actor, &a.repo, Capability::ManageIntegrations).await?);
259 let mut settings = self.repo_settings(&repo.id).await?;
260 if let Some(default) = &a.default_permissions {
261 match TokenDefault::parse(default) {
262 Some(parsed) => settings.default_permissions = parsed.as_str().to_owned(),
263 None => return Ok(fail(FailureCode::Invalid, "default_permissions is read or write.")),
264 }
265 }
266 if let Some(policy) = &a.approval_policy {
267 let policy = policy.trim();
268 if !APPROVAL_POLICIES.contains(&policy) {
269 return Ok(fail(FailureCode::Invalid, format!("approval_policy is one of {}.", APPROVAL_POLICIES.join(", "))));
270 }
271 settings.approval_policy = policy.to_owned();
272 }
273 self.db
274 .prepare(
275 "INSERT INTO repo_settings (repo_id, default_permissions, approval_policy, updated_at, updated_by) VALUES (?1, ?2, ?3, ?4, ?5)
276 ON CONFLICT (repo_id) DO UPDATE SET default_permissions = ?2, approval_policy = ?3, updated_at = ?4, updated_by = ?5",
277 )
278 .bind(&[
279 repo.id.as_str().into(),
280 settings.default_permissions.as_str().into(),
281 settings.approval_policy.as_str().into(),
282 now().into(),
283 a.actor.username.as_str().into(),
284 ])?
285 .run()
286 .await?;
287 Ok(Outcome::Ok(settings))
288 }
289
290 // --- Environments -------------------------------------------------------------
291
292 async fn environment_row(&self, repo_id: &str, name: &str) -> Result<Option<EnvRow>> {
293 self.db
294 .prepare("SELECT * FROM environments WHERE repo_id = ? AND name = ?")
295 .bind(&[repo_id.into(), name.to_ascii_lowercase().into()])?
296 .first::<EnvRow>(None)
297 .await
298 }
299
300 /// An environment and its rules; one without rules if it has none.
301 pub(crate) async fn environment_rules(&self, repo_id: &str, name: &str) -> Result<Environment> {
302 Ok(match self.environment_row(repo_id, name).await? {
303 Some(row) => row.view(),
304 None => unprotected(&name.to_ascii_lowercase()),
305 })
306 }
307
308 /// `environments`: every environment the repository's rules, secrets,
309 /// workflows or jobs name; or with `name`, the one.
310 pub async fn environments(&self, a: EnvironmentsArgs) -> Result<Outcome<Vec<Environment>>> {
311 let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else {
312 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
313 };
314 if let Some(name) = &a.name {
315 let name = check!(environment_name(name).map_or_else(|problem| fail(FailureCode::Invalid, problem), Outcome::Ok));
316 return Ok(Outcome::Ok(vec![self.environment_rules(&repo.id, &name).await?]));
317 }
318 let rows = self
319 .db
320 .prepare("SELECT * FROM environments WHERE repo_id = ? ORDER BY name")
321 .bind(&[repo.id.as_str().into()])?
322 .all()
323 .await?
324 .results::<EnvRow>()?;
325 let mut out: Vec<Environment> = rows.iter().map(EnvRow::view).collect();
326 let mut named: Vec<String> = self.secret_environments(&repo.id).await?;
327 #[derive(Deserialize)]
328 struct Named {
329 environment: String,
330 }
331 let used = self
332 .db
333 .prepare("SELECT DISTINCT environment FROM jobs WHERE repo_id = ? AND environment IS NOT NULL LIMIT 100")
334 .bind(&[repo.id.as_str().into()])?
335 .all()
336 .await?
337 .results::<Named>()?;
338 named.extend(used.into_iter().map(|n| n.environment));
339 #[derive(Deserialize)]
340 struct Source {
341 source: String,
342 }
343 let sources = self
344 .db
345 .prepare("SELECT source FROM workflows WHERE repo_id = ? AND error IS NULL")
346 .bind(&[repo.id.as_str().into()])?
347 .all()
348 .await?
349 .results::<Source>()?;
350 for source in sources {
351 let Ok(workflow) = g1t_actions::workflow::parse(&source.source) else { continue };
352 for job in &workflow.jobs {
353 let written = match job.raw.get("environment") {
354 Some(Value::String(name)) => Some(name.clone()),
355 Some(Value::Object(env)) => env.get("name").and_then(Value::as_str).map(str::to_owned),
356 _ => None,
357 };
358 if let Some(name) = written.filter(|name| !name.contains("${{")) {
359 named.push(name);
360 }
361 }
362 }
363 for name in named {
364 if let Ok(name) = environment_name(&name)
365 && !out.iter().any(|env| env.name == name)
366 {
367 out.push(unprotected(&name));
368 }
369 }
370 out.sort_by(|a, b| b.protected.cmp(&a.protected).then(a.name.cmp(&b.name)));
371 Ok(Outcome::Ok(out))
372 }
373
374 pub async fn set_environment(&self, a: SetEnvironmentArgs) -> Result<Outcome<Environment>> {
375 if let Some(refused) = refuse_job_token(&a.actor) {
376 return Ok(refused);
377 }
378 let repo = check!(self.may(&a.actor, &a.repo, Capability::ManageIntegrations).await?);
379 let name = check!(environment_name(&a.name).map_or_else(|problem| fail(FailureCode::Invalid, problem), Outcome::Ok));
380 let mut env = self.environment_rules(&repo.id, &name).await?;
381 if let Some(reviewers) = a.reviewers {
382 let reviewers = check!(self.valid_reviewers(&repo, reviewers).await?);
383 env.reviewers = reviewers;
384 }
385 if let Some(flag) = a.prevent_self_review {
386 env.prevent_self_review = flag;
387 }
388 if let Some(minutes) = a.wait_minutes {
389 if minutes > MAX_WAIT_MINUTES {
390 return Ok(fail(FailureCode::Invalid, format!("A wait timer is at most {MAX_WAIT_MINUTES} minutes (30 days).")));
391 }
392 env.wait_minutes = minutes;
393 }
394 if let Some(policy) = a.branch_policy {
395 let policy = policy.trim().to_owned();
396 if !["all", "protected", "selected"].contains(&policy.as_str()) {
397 return Ok(fail(FailureCode::Invalid, "branch_policy is all, protected or selected."));
398 }
399 env.branch_policy = policy;
400 }
401 if let Some(patterns) = a.branch_patterns {
402 let mut clean: Vec<BranchPattern> = Vec::new();
403 for pattern in patterns {
404 let name = pattern.name.trim().to_owned();
405 let kind = pattern.kind.trim().to_ascii_lowercase();
406 if name.is_empty() || name.len() > 255 {
407 return Ok(fail(FailureCode::Invalid, "A branch or tag pattern is 1 to 255 characters."));
408 }
409 if kind != "branch" && kind != "tag" {
410 return Ok(fail(FailureCode::Invalid, format!("`{name}`: a pattern's type is branch or tag.")));
411 }
412 if !clean.iter().any(|known| known.name == name && known.kind == kind) {
413 clean.push(BranchPattern { name, kind });
414 }
415 }
416 if clean.len() > 50 {
417 return Ok(fail(FailureCode::Invalid, "An environment lists at most 50 branch and tag patterns."));
418 }
419 env.branch_patterns = clean;
420 }
421 if let Some(flag) = a.admins_bypass {
422 env.admins_bypass = flag;
423 }
424 let at = now();
425 self.db
426 .prepare(
427 "INSERT INTO environments (repo_id, name, reviewers, prevent_self_review, wait_minutes, branch_policy, branch_patterns,
428 admins_bypass, created_at, updated_at, updated_by)
429 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9, ?10)
430 ON CONFLICT (repo_id, name) DO UPDATE SET reviewers = ?3, prevent_self_review = ?4, wait_minutes = ?5,
431 branch_policy = ?6, branch_patterns = ?7, admins_bypass = ?8, updated_at = ?9, updated_by = ?10",
432 )
433 .bind(&[
434 repo.id.as_str().into(),
435 name.as_str().into(),
436 serde_json::to_string(&env.reviewers)?.into(),
437 u32::from(env.prevent_self_review).into(),
438 env.wait_minutes.into(),
439 env.branch_policy.as_str().into(),
440 serde_json::to_string(&env.branch_patterns)?.into(),
441 u32::from(env.admins_bypass).into(),
442 at.as_str().into(),
443 a.actor.username.as_str().into(),
444 ])?
445 .run()
446 .await?;
447 Ok(Outcome::Ok(self.environment_rules(&repo.id, &name).await?))
448 }
449
450 pub async fn delete_environment(&self, a: DeleteEnvironmentArgs) -> Result<Outcome<bool>> {
451 if let Some(refused) = refuse_job_token(&a.actor) {
452 return Ok(refused);
453 }
454 let repo = check!(self.may(&a.actor, &a.repo, Capability::ManageIntegrations).await?);
455 let name = check!(environment_name(&a.name).map_or_else(|problem| fail(FailureCode::Invalid, problem), Outcome::Ok));
456 let removed = self
457 .db
458 .prepare("DELETE FROM environments WHERE repo_id = ? AND name = ? RETURNING name")
459 .bind(&[repo.id.as_str().into(), name.as_str().into()])?
460 .first::<Value>(None)
461 .await?;
462 if removed.is_none() {
463 return Ok(fail(FailureCode::NotFound, format!("{name} has no protection rules.")));
464 }
465 Ok(Outcome::Ok(true))
466 }
467
468 /// Reviewers as given, checked: at most six, each a person with access
469 /// to the repository or a team of its workspace.
470 async fn valid_reviewers(&self, repo: &Repo, given: Vec<EnvironmentReviewer>) -> Result<Outcome<Vec<EnvironmentReviewer>>> {
471 if given.len() > MAX_ENVIRONMENT_REVIEWERS {
472 return Ok(fail(FailureCode::Invalid, format!("An environment has at most {MAX_ENVIRONMENT_REVIEWERS} reviewers.")));
473 }
474 let Some(ws) = self.workspace_actor(&repo.namespace).await? else {
475 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
476 };
477 let mut out: Vec<EnvironmentReviewer> = Vec::new();
478 for reviewer in given {
479 let kind = reviewer.kind.trim().to_ascii_lowercase();
480 let name = reviewer.name.trim().trim_start_matches('@').to_ascii_lowercase();
481 match kind.as_str() {
482 "user" => {
483 let permission: Outcome<PermissionInfo> = g1t_kit::call(
484 &self.identity,
485 "collaborator_permission",
486 &CollaboratorPermissionArgs {
487 viewer: Some(ws.clone()),
488 path: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() },
489 username: name.clone(),
490 },
491 )
492 .await?;
493 if !permission.into_result().ok().is_some_and(|info| info.role.is_some()) {
494 return Ok(fail(FailureCode::Invalid, format!("{name} has no access to {}/{}, so cannot review.", repo.namespace, repo.name)));
495 }
496 }
497 "team" => {
498 let slug = name.rsplit('/').next().unwrap_or(&name).to_owned();
499 if self.team_people(&repo.namespace, &slug).await?.is_none() {
500 return Ok(fail(FailureCode::Invalid, format!("{} has no team called {slug}.", repo.namespace)));
501 }
502 if !out.iter().any(|known| known.kind == "team" && known.name == slug) {
503 out.push(EnvironmentReviewer { kind, name: slug });
504 }
505 continue;
506 }
507 _ => return Ok(fail(FailureCode::Invalid, "A reviewer's type is user or team.")),
508 }
509 if !out.iter().any(|known| known.kind == kind && known.name == name) {
510 out.push(EnvironmentReviewer { kind, name });
511 }
512 }
513 Ok(Outcome::Ok(out))
514 }
515
516 /// Everyone in a team of `workspace` and its child teams, by username;
517 /// `None` when there is no such team.
518 async fn team_people(&self, workspace: &str, slug: &str) -> Result<Option<Vec<String>>> {
519 let resolved: Vec<Value> = g1t_kit::call(
520 &self.identity,
521 "resolve_teams",
522 &json!({ "teams": [format!("{workspace}/{slug}")] }),
523 )
524 .await
525 .unwrap_or_default();
526 Ok(resolved.into_iter().next().map(|team| {
527 ["members", "child_members"]
528 .iter()
529 .flat_map(|key| team[*key].as_array().cloned().unwrap_or_default())
530 .filter_map(|person| person["username"].as_str().map(str::to_lowercase))
531 .collect()
532 }))
533 }
534
535 /// The teams of the environment's reviewers that `username` is in.
536 async fn reviewer_teams(&self, workspace: &str, environment: &Environment, username: &str) -> Result<Vec<String>> {
537 let mut teams = Vec::new();
538 for reviewer in environment.reviewers.iter().filter(|r| r.kind == "team") {
539 if self
540 .team_people(workspace, &reviewer.name)
541 .await?
542 .is_some_and(|people| people.iter().any(|person| person.eq_ignore_ascii_case(username)))
543 {
544 teams.push(reviewer.name.clone());
545 }
546 }
547 Ok(teams)
548 }
549
550 // --- Gates ---------------------------------------------------------------------
551
552 /// What `environment`'s rules say about a job of `run` that names it,
553 /// recording the run's gate there the first time one of its jobs
554 /// reaches it.
555 pub(crate) async fn gate(&self, run: &RunRow, environment: &str) -> Result<Gate> {
556 let Ok(name) = environment_name(environment) else {
557 return Ok(Gate::Open);
558 };
559 let Some(row) = self.environment_row(&run.repo_id, &name).await? else {
560 return Ok(Gate::Open);
561 };
562 let env = row.view();
563 // Which refs may deploy, before anything waits.
564 let protected = if env.branch_policy == "protected" { self.branch_protected(run).await? } else { false };
565 if let Err(problem) = branch_allowed(&env.branch_policy, &env.branch_patterns, &run.git_ref, protected, &name) {
566 return Ok(Gate::Refused(problem));
567 }
568 let needs_review = !env.reviewers.is_empty();
569 if !needs_review && env.wait_minutes == 0 {
570 return Ok(Gate::Open);
571 }
572 let existing = self.gate_row(&run.id, run.attempt as u32, &name).await?;
573 let gate = match existing {
574 Some(gate) => gate,
575 None => {
576 let wait_until = (env.wait_minutes > 0).then(|| rfc3339(now_ms() + u64::from(env.wait_minutes) * 60_000));
577 self.db
578 .prepare(
579 "INSERT OR IGNORE INTO environment_gates (run_id, attempt, environment, repo_id, state, needs_review, wait_until, created_at)
580 VALUES (?, ?, ?, ?, 'waiting', ?, ?, ?)",
581 )
582 .bind(&[
583 run.id.as_str().into(),
584 (run.attempt as u32).into(),
585 name.as_str().into(),
586 run.repo_id.as_str().into(),
587 u32::from(needs_review).into(),
588 optional(wait_until.as_deref()),
589 now().into(),
590 ])?
591 .run()
592 .await?;
593 if needs_review {
594 self.ask_reviewers(run, &env).await;
595 }
596 match self.gate_row(&run.id, run.attempt as u32, &name).await? {
597 Some(gate) => gate,
598 None => return Ok(Gate::Held(held_reason(&name, needs_review, wait_until.as_deref()))),
599 }
600 }
601 };
602 if gate.state == "rejected" {
603 let by = gate.reviewed_by.as_deref().unwrap_or("A reviewer");
604 return Ok(Gate::Refused(format!("{by} rejected the deployment to {name}.")));
605 }
606 if gate_open(&gate.state, gate.needs_review != 0, gate.wait_until.as_deref(), &now()) {
607 return Ok(Gate::Open);
608 }
609 let review_left = gate.needs_review != 0 && gate.state != "approved";
610 Ok(Gate::Held(held_reason(&name, review_left, gate.wait_until.as_deref())))
611 }
612
613 async fn gate_row(&self, run_id: &str, attempt: u32, environment: &str) -> Result<Option<GateRow>> {
614 self.db
615 .prepare("SELECT * FROM environment_gates WHERE run_id = ? AND attempt = ? AND environment = ?")
616 .bind(&[run_id.into(), attempt.into(), environment.into()])?
617 .first::<GateRow>(None)
618 .await
619 }
620
621 /// Whether the run's branch is protected: the default branch, or one an
622 /// active rule holds.
623 async fn branch_protected(&self, run: &RunRow) -> Result<bool> {
624 let Some(branch) = run.git_ref.strip_prefix("refs/heads/") else { return Ok(false) };
625 let path = crate::repo_path(&run.repo);
626 let Some(ws) = self.workspace_actor(&path.namespace).await? else { return Ok(false) };
627 let rules: Outcome<Value> = g1t_kit::call(
628 &self.work,
629 "effective_rules",
630 &json!({ "viewer": ws, "repo": path, "name": branch, "target": "branch" }),
631 )
632 .await
633 .unwrap_or_else(|_| Outcome::Ok(Value::Null));
634 Ok(match rules {
635 Outcome::Ok(rules) => {
636 rules["default_branch"].as_bool() == Some(true)
637 || rules["rules"].as_array().is_some_and(|rules| rules.iter().any(|rule| rule["enforcement"] == "active"))
638 }
639 Outcome::Fail(_) => false,
640 })
641 }
642
643 /// Tells an environment's reviewers that a run waits for them.
644 async fn ask_reviewers(&self, run: &RunRow, env: &Environment) {
645 let path = crate::repo_path(&run.repo);
646 let mut people: Vec<String> = Vec::new();
647 for reviewer in &env.reviewers {
648 let names = match reviewer.kind.as_str() {
649 "team" => self.team_people(&path.namespace, &reviewer.name).await.ok().flatten().unwrap_or_default(),
650 _ => vec![reviewer.name.clone()],
651 };
652 for name in names {
653 // Never whoever cannot approve their own run.
654 let own = env.prevent_self_review && run.actor.as_deref().is_some_and(|by| by.eq_ignore_ascii_case(&name));
655 if !own && !people.contains(&name) {
656 people.push(name);
657 }
658 }
659 }
660 if people.is_empty() {
661 return;
662 }
663 let published: Result<()> = g1t_kit::call(
664 &self.events,
665 "publish",
666 &g1t_contracts::events::Publish {
667 events: vec![g1t_contracts::events::NewEvent {
668 kind: "deployment.review_requested",
669 source: "actions",
670 repo_id: Some(run.repo_id.clone()),
671 actor: run.actor_id.clone(),
672 data: json!({
673 "repoId": run.repo_id,
674 "runId": run.id,
675 "attempt": run.attempt,
676 "environment": env.name,
677 "workflow": run.name,
678 "title": run.title,
679 "number": run.number,
680 "notify": people,
681 "link": format!("/{}/actions/runs/{}", run.repo, run.id),
682 }),
683 }],
684 },
685 )
686 .await;
687 if let Err(error) = published {
688 worker::console_error!("actions: reviewers of {} not told for run {}: {error}", env.name, run.id);
689 }
690 }
691
692 /// The environments holding a run's jobs this attempt, and whether
693 /// `viewer` may decide on each.
694 pub(crate) async fn pending_for(&self, run: &RunRow, viewer: &Viewer) -> Result<Vec<PendingDeployment>> {
695 let gates = self
696 .db
697 .prepare("SELECT * FROM environment_gates WHERE run_id = ? AND attempt = ? ORDER BY environment")
698 .bind(&[run.id.as_str().into(), (run.attempt as u32).into()])?
699 .all()
700 .await?
701 .results::<GateRow>()?;
702 if gates.is_empty() {
703 return Ok(Vec::new());
704 }
705 let jobs = self.job_rows(&run.id).await?;
706 let repo = self.visible_repo(&crate::repo_path(&run.repo), viewer).await?;
707 let mut out = Vec::new();
708 for gate in gates {
709 let env = self.environment_rules(&run.repo_id, &gate.environment).await?;
710 let held: Vec<&JobRow> = jobs.iter().filter(|job| job.environment.as_deref().is_some_and(|e| e.eq_ignore_ascii_case(&gate.environment))).collect();
711 let waiting = gate.state == "waiting" && held.iter().any(|job| job.status == "pending");
712 let can_review = match (viewer, &repo, waiting) {
713 (Some(user), Some(repo), true) if user.kind == PrincipalKind::User && user.token.as_ref().is_none_or(|t| t.job.is_none()) => {
714 let teams = self.reviewer_teams(&repo.namespace, &env, &user.username).await?;
715 let admin = access::can(Some(user), repo, Capability::Administer);
716 may_review(&env, &user.username, &teams, admin, run.actor.as_deref()).is_ok()
717 }
718 _ => false,
719 };
720 out.push(PendingDeployment {
721 environment: gate.environment.clone(),
722 state: gate.state.clone(),
723 needs_review: gate.needs_review != 0,
724 wait_until: gate.wait_until.clone(),
725 reviewers: env.reviewers.clone(),
726 jobs: held.iter().map(|job| job.name.clone()).collect(),
727 can_review,
728 reviewed_by: gate.reviewed_by.clone(),
729 comment: gate.comment.clone(),
730 reviewed_at: gate.reviewed_at.clone(),
731 });
732 }
733 Ok(out)
734 }
735
736 pub async fn pending_deployments(&self, a: PendingDeploymentsArgs) -> Result<Outcome<Vec<PendingDeployment>>> {
737 if self.visible_repo(&a.repo, &a.viewer).await?.is_none() {
738 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
739 }
740 let run = check!(self.run_in(&a.repo, &a.id).await?);
741 Ok(Outcome::Ok(self.pending_for(&run, &a.viewer).await?))
742 }
743
744 /// `review_deployments`: a reviewer approves or rejects the jobs an
745 /// environment holds in a run.
746 pub async fn review_deployments(&self, a: ReviewDeploymentsArgs) -> Result<Outcome<Vec<PendingDeployment>>> {
747 if let Some(refused) = refuse_job_token(&a.actor) {
748 return Ok(refused);
749 }
750 if a.actor.kind != PrincipalKind::User {
751 return Ok(fail(FailureCode::Forbidden, "Only a person can review a deployment."));
752 }
753 let repo = check!(self.may(&a.actor, &a.repo, Capability::Read).await?);
754 let approve = match a.state.trim() {
755 "approved" | "approve" => true,
756 "rejected" | "reject" => false,
757 _ => return Ok(fail(FailureCode::Invalid, "state is approved or rejected.")),
758 };
759 let run = check!(self.run_in(&a.repo, &a.id).await?);
760 let wanted: Vec<String> = a.environments.iter().filter_map(|name| environment_name(name).ok()).collect();
761 let pending = self.pending_for(&run, &Some(a.actor.clone())).await?;
762 let waiting: Vec<&PendingDeployment> = pending
763 .iter()
764 .filter(|p| p.state == "waiting" && !p.jobs.is_empty())
765 .filter(|p| wanted.is_empty() || wanted.contains(&p.environment))
766 .collect();
767 if waiting.is_empty() {
768 return Ok(fail(FailureCode::Conflict, "No deployment of this run is waiting for review."));
769 }
770 let comment: Option<String> = a.comment.as_deref().map(str::trim).filter(|c| !c.is_empty()).map(|c| c.chars().take(1000).collect());
771 let admin = access::can(Some(&a.actor), &repo, Capability::Administer);
772 let at = now();
773 for pending in &waiting {
774 let env = self.environment_rules(&run.repo_id, &pending.environment).await?;
775 let teams = self.reviewer_teams(&repo.namespace, &env, &a.actor.username).await?;
776 let how = match may_review(&env, &a.actor.username, &teams, admin, run.actor.as_deref()) {
777 Ok(how) => how,
778 Err(problem) => return Ok(fail(FailureCode::Forbidden, problem)),
779 };
780 let state = if approve { "approved" } else { "rejected" };
781 // An admin's approval past the rules skips the wait timer too.
782 let clear_wait = approve && how == Reviewer::AdminBypass;
783 self.db
784 .prepare(
785 "UPDATE environment_gates SET state = ?, reviewed_by = ?, comment = ?, reviewed_at = ?,
786 wait_until = CASE WHEN ? THEN NULL ELSE wait_until END
787 WHERE run_id = ? AND attempt = ? AND environment = ? AND state = 'waiting'",
788 )
789 .bind(&[
790 state.into(),
791 a.actor.username.as_str().into(),
792 optional(comment.as_deref()),
793 at.as_str().into(),
794 u32::from(clear_wait).into(),
795 run.id.as_str().into(),
796 (run.attempt as u32).into(),
797 pending.environment.as_str().into(),
798 ])?
799 .run()
800 .await?;
801 if !approve {
802 let reason = match &comment {
803 Some(comment) => format!("{} rejected the deployment to {}: {comment}", a.actor.username, pending.environment),
804 None => format!("{} rejected the deployment to {}.", a.actor.username, pending.environment),
805 };
806 self.db
807 .prepare(
808 "UPDATE jobs SET status = 'completed', conclusion = 'failure', reason = ?, finished_at = ?
809 WHERE run_id = ? AND status = 'pending' AND lower(environment) = ?",
810 )
811 .bind(&[reason.into(), at.as_str().into(), run.id.as_str().into(), pending.environment.as_str().into()])?
812 .run()
813 .await?;
814 }
815 }
816 self.release_gates_of(&run).await?;
817 self.advance(&run.id).await?;
818 let run = self.run_row(&run.id).await?.unwrap_or(run);
819 Ok(Outcome::Ok(self.pending_for(&run, &Some(a.actor.clone())).await?))
820 }
821
822 /// Queues a run's pending jobs whose environments now let them start.
823 pub(crate) async fn release_gates_of(&self, run: &RunRow) -> Result<bool> {
824 let jobs = self.job_rows(&run.id).await?;
825 let at = now();
826 let mut released = false;
827 for job in jobs.iter().filter(|job| job.status == "pending") {
828 let Some(environment) = job.environment.as_deref() else { continue };
829 match self.gate(run, environment).await? {
830 Gate::Open => {
831 // A self-hosted job waits for a runner again.
832 let reason = job
833 .labels
834 .as_deref()
835 .and_then(|labels| serde_json::from_str::<Vec<String>>(labels).ok())
836 .map(|stored| g1t_contracts::runners::waiting_reason(&g1t_contracts::runners::Wanted::from_stored(&stored)));
837 self.db
838 .prepare("UPDATE jobs SET status = 'queued', reason = ?, queued_at = ? WHERE id = ? AND status = 'pending'")
839 .bind(&[optional(reason.as_deref()), at.as_str().into(), job.id.as_str().into()])?
840 .run()
841 .await?;
842 released = true;
843 }
844 Gate::Held(reason) => {
845 if job.reason.as_deref() != Some(reason.as_str()) {
846 self.db
847 .prepare("UPDATE jobs SET reason = ? WHERE id = ? AND status = 'pending'")
848 .bind(&[reason.into(), job.id.as_str().into()])?
849 .run()
850 .await?;
851 }
852 }
853 Gate::Refused(reason) => {
854 self.db
855 .prepare("UPDATE jobs SET status = 'completed', conclusion = 'failure', reason = ?, finished_at = ? WHERE id = ? AND status = 'pending'")
856 .bind(&[reason.into(), at.as_str().into(), job.id.as_str().into()])?
857 .run()
858 .await?;
859 released = true;
860 }
861 }
862 }
863 Ok(released)
864 }
865
866 /// Every minute: runs whose jobs wait at an environment whose timer has
867 /// run out, or whose review came in, move along.
868 pub(crate) async fn release_gates(&self) -> Result<()> {
869 #[derive(Deserialize)]
870 struct Waiting {
871 run_id: String,
872 }
873 let runs = self
874 .db
875 .prepare("SELECT DISTINCT run_id FROM jobs WHERE status = 'pending' LIMIT 100")
876 .all()
877 .await?
878 .results::<Waiting>()?;
879 for waiting in runs {
880 let Some(run) = self.run_row(&waiting.run_id).await? else { continue };
881 if self.release_gates_of(&run).await? {
882 self.advance(&run.id).await?;
883 }
884 }
885 Ok(())
886 }
887
888 // --- Approving runs of pull requests from outside --------------------------
889
890 /// Why a pull request's run waits for approval, if it does: by the
891 /// repository's policy, whoever the pull request is for (its owner).
892 pub(crate) async fn approval_needed(&self, repo: &Repo, owner: &User, ws: &User) -> Result<Option<String>> {
893 // g1t's own work, nobody asked for, is the workspace's.
894 if owner.id == AGENT_ID || owner.kind == PrincipalKind::System {
895 return Ok(None);
896 }
897 let policy = self.repo_settings(&repo.id).await?.approval_policy;
898 let permission: Outcome<PermissionInfo> = g1t_kit::call(
899 &self.identity,
900 "collaborator_permission",
901 &CollaboratorPermissionArgs {
902 viewer: Some(ws.clone()),
903 path: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() },
904 username: owner.username.clone(),
905 },
906 )
907 .await?;
908 let info = permission.into_result().ok();
909 let member = owner.is_member(&repo.namespace.to_lowercase())
910 || info.as_ref().is_some_and(|info| matches!(info.source, Some(AccessSource::Owner | AccessSource::Base | AccessSource::Team)));
911 if member {
912 return Ok(None);
913 }
914 let can_push = info.as_ref().and_then(|info| info.role).is_some_and(|role| access::allows(role, Capability::Push));
915 let first_time = !self.has_merged(repo, owner, ws).await?;
916 Ok(approval_reason(&policy, member, can_push, first_time).map(|why| format!("{} {why}, so someone with the Write role approves its runs first.", owner.username)))
917 }
918
919 /// Whether a pull request of `owner`'s has been merged into `repo`.
920 async fn has_merged(&self, repo: &Repo, owner: &User, ws: &User) -> Result<bool> {
921 let closed: Outcome<Vec<g1t_contracts::work::Pull>> = g1t_kit::call(
922 &self.work,
923 "list_pulls",
924 &g1t_contracts::work::ListPullsArgs {
925 repo: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() },
926 viewer: Some(ws.clone()),
927 state: Some(g1t_contracts::work::State::Closed),
928 label: None,
929 milestone: None,
930 base: None,
931 },
932 )
933 .await
934 .unwrap_or_else(|_| Outcome::Ok(Vec::new()));
935 Ok(closed.into_result().unwrap_or_default().iter().any(|pull| pull.merged_at.is_some() && pull.owner().id == owner.id))
936 }
937
938 /// `approve_run`: someone with the Write role lets a pull request's run
939 /// that waits for approval start.
940 pub async fn approve_run(&self, a: RunActionArgs) -> Result<Outcome<WorkflowRun>> {
941 if let Some(refused) = refuse_job_token(&a.actor) {
942 return Ok(refused);
943 }
944 check!(self.may(&a.actor, &a.repo, Capability::Run).await?);
945 let run = check!(self.run_in(&a.repo, &a.id).await?);
946 if run.status != "action_required" {
947 return Ok(fail(FailureCode::Conflict, "This run is not waiting for approval."));
948 }
949 let mut approval = run.approval().unwrap_or(RunApproval { state: "required".into(), reason: String::new(), approved_by: None });
950 approval.state = "approved".to_owned();
951 approval.approved_by = Some(a.actor.username.clone());
952 let started = self
953 .db
954 .prepare("UPDATE runs SET status = 'queued', approval = ?, approved_by = ? WHERE id = ? AND status = 'action_required' RETURNING id")
955 .bind(&[serde_json::to_string(&approval)?.into(), a.actor.username.as_str().into(), run.id.as_str().into()])?
956 .first::<Value>(None)
957 .await?;
958 if started.is_none() {
959 return Ok(fail(FailureCode::Conflict, "This run is not waiting for approval."));
960 }
961 if let Some(run) = self.run_row(&run.id).await? {
962 self.enter_group(&run).await?;
963 }
964 self.run_summary(&run.id).await
965 }
966}
967
968/// A workflow job's own token may not approve or change what keeps runs
969/// safe: a workflow could otherwise let itself through.
970fn refuse_job_token<T>(actor: &User) -> Option<Outcome<T>> {
971 actor
972 .token
973 .as_ref()
974 .is_some_and(|token| token.job.is_some())
975 .then(|| fail(FailureCode::Forbidden, "A workflow job's token cannot approve runs or deployments, or change their rules."))
976}
977
978#[cfg(test)]
979mod tests {
980 use super::*;
981
982 fn env(reviewers: &[(&str, &str)]) -> Environment {
983 Environment {
984 reviewers: reviewers.iter().map(|(kind, name)| EnvironmentReviewer { kind: (*kind).into(), name: (*name).into() }).collect(),
985 ..unprotected("production")
986 }
987 }
988
989 fn pattern(name: &str, kind: &str) -> BranchPattern {
990 BranchPattern { name: name.into(), kind: kind.into() }
991 }
992
993 #[test]
994 fn which_refs_may_deploy() {
995 assert!(branch_allowed("all", &[], "refs/pull/3/merge", false, "production").is_ok());
996 assert!(branch_allowed("protected", &[], "refs/heads/main", true, "production").is_ok());
997 assert!(branch_allowed("protected", &[], "refs/heads/feature", false, "production").unwrap_err().contains("protected branches only"));
998 // A pull request's merge ref is never a protected branch.
999 assert!(branch_allowed("protected", &[], "refs/pull/3/merge", true, "production").is_err());
1000 let selected = [pattern("main", "branch"), pattern("release/*", "branch"), pattern("v*", "tag")];
1001 assert!(branch_allowed("selected", &selected, "refs/heads/release/1.x", false, "production").is_ok());
1002 assert!(branch_allowed("selected", &selected, "refs/tags/v1.2.0", false, "production").is_ok());
1003 assert!(branch_allowed("selected", &selected, "refs/heads/feature", false, "production").is_err());
1004 // A tag pattern does not let a branch of that name through.
1005 assert!(branch_allowed("selected", &[pattern("v*", "tag")], "refs/heads/v2", false, "production").is_err());
1006 assert!(branch_allowed("selected", &[], "refs/heads/main", false, "production").is_err());
1007 }
1008
1009 #[test]
1010 fn who_may_review() {
1011 let production = env(&[("user", "ada"), ("team", "deployers")]);
1012 assert_eq!(may_review(&production, "ada", &[], false, Some("bo")), Ok(Reviewer::Listed));
1013 assert_eq!(may_review(&production, "Ada", &[], false, None), Ok(Reviewer::Listed), "names compare without case");
1014 assert_eq!(may_review(&production, "cy", &["deployers".into()], false, None), Ok(Reviewer::Listed));
1015 assert!(may_review(&production, "cy", &[], false, None).unwrap_err().contains("not one of"));
1016 // An admin past the rules, unless the environment says no.
1017 assert_eq!(may_review(&production, "cy", &[], true, None), Ok(Reviewer::AdminBypass));
1018 let strict = Environment { admins_bypass: false, ..production.clone() };
1019 assert!(may_review(&strict, "cy", &[], true, None).is_err());
1020 // Whoever started the run, when self-review is off.
1021 let no_self = Environment { prevent_self_review: true, ..production.clone() };
1022 assert!(may_review(&no_self, "ada", &[], false, Some("ada")).unwrap_err().contains("You started this run"));
1023 assert_eq!(may_review(&no_self, "ada", &[], false, Some("bo")), Ok(Reviewer::Listed));
1024 assert_eq!(may_review(&no_self, "ada", &[], true, Some("ada")), Ok(Reviewer::AdminBypass));
1025 // Without reviewers there is nothing to review; an admin may start it.
1026 let timer = Environment { wait_minutes: 30, ..env(&[]) };
1027 assert!(may_review(&timer, "ada", &[], false, None).unwrap_err().contains("no reviewers"));
1028 assert_eq!(may_review(&timer, "ada", &[], true, None), Ok(Reviewer::AdminBypass));
1029 }
1030
1031 #[test]
1032 fn a_gate_opens_on_review_and_time() {
1033 let now = "2026-10-08T12:00:00Z";
1034 assert!(!gate_open("waiting", true, None, now));
1035 assert!(gate_open("approved", true, None, now));
1036 assert!(!gate_open("approved", true, Some("2026-10-08T12:30:00Z"), now));
1037 assert!(gate_open("approved", true, Some("2026-10-08T11:30:00Z"), now));
1038 assert!(gate_open("waiting", false, Some("2026-10-08T11:59:00Z"), now));
1039 assert!(!gate_open("waiting", false, Some("2026-10-08T12:01:00Z"), now));
1040 assert!(!gate_open("rejected", false, None, now));
1041 assert_eq!(held_reason("production", true, None), "Waiting for a review to deploy to production.");
1042 assert!(held_reason("staging", false, Some("2026-10-08T12:30:00Z")).contains("wait timer"));
1043 }
1044
1045 #[test]
1046 fn which_pull_requests_wait_for_approval() {
1047 // Members never wait.
1048 for policy in APPROVAL_POLICIES {
1049 assert_eq!(approval_reason(policy, true, true, true), None);
1050 }
1051 // The default: anyone who cannot push, and an outside collaborator's first.
1052 assert!(approval_reason("outside_contributors", false, false, false).is_some());
1053 assert!(approval_reason("outside_contributors", false, true, true).is_some());
1054 assert_eq!(approval_reason("outside_contributors", false, true, false), None);
1055 // First-time contributors only.
1056 assert!(approval_reason("first_time_contributors", false, false, true).is_some());
1057 assert_eq!(approval_reason("first_time_contributors", false, false, false), None);
1058 // Everyone outside the workspace.
1059 assert!(approval_reason("all_external_contributors", false, true, false).is_some());
1060 }
1061
1062 #[test]
1063 fn a_jobs_permissions_are_g1t_scopes_and_outside_runs_read_only() {
1064 use g1t_actions::permissions::{Access, Permissions};
1065 use g1t_contracts::scopes::{Level, Scope, TokenAccess};
1066 for scope in Permissions::all(Access::Write).scopes() {
1067 assert!(Scope::parse(scope).is_some(), "{scope} is not a g1t scope");
1068 }
1069 let token = |permissions: &Permissions| TokenAccess {
1070 scopes: Some(permissions.scopes().into_iter().map(str::to_owned).collect()),
1071 repo: Some("acme/web".into()),
1072 ..TokenAccess::default()
1073 };
1074 // The default: it can clone, and nothing more.
1075 let restricted = token(&Permissions::default_for(TokenDefault::Restricted));
1076 assert!(restricted.allows(Scope::CodeRead));
1077 assert!(!restricted.allows(Scope::CodeWrite));
1078 assert!(!restricted.allows(Scope::IssuesWrite));
1079 // A pull request from outside reads, whatever it asks for.
1080 let outside = token(&Permissions::all(Access::Write).read_only());
1081 assert!(Scope::ALL.iter().filter(|scope| outside.allows(**scope)).all(|scope| scope.level() == Level::Read));
1082 // Never more than a repository's: no admin, no secrets, no agents.
1083 let everything = token(&Permissions::all(Access::Write));
1084 for never in [Scope::RepoAdmin, Scope::SecretsRead, Scope::AgentsRun, Scope::WorkspaceRead, Scope::RunnersRead] {
1085 assert!(!everything.allows(never), "{}", never.as_str());
1086 }
1087 }
1088
1089 #[test]
1090 fn environment_names_are_checked() {
1091 assert_eq!(environment_name(" Production ").unwrap(), "production");
1092 assert!(environment_name("staging env").is_err());
1093 assert!(environment_name("").is_err());
1094 assert!(environment_name(&"a".repeat(41)).is_err());
1095 }
1096
1097 #[test]
1098 fn a_job_token_cannot_let_itself_through() {
1099 let mut actor = User { id: "wsp_1".into(), username: "acme".into(), kind: PrincipalKind::Workspace, ..User::default() };
1100 assert!(refuse_job_token::<()>(&actor).is_none());
1101 actor.token = Some(Box::new(g1t_contracts::scopes::TokenAccess {
1102 job: Some(g1t_contracts::scopes::JobToken { run_id: "run_1".into(), job_id: "job_1".into() }),
1103 ..Default::default()
1104 }));
1105 assert!(refuse_job_token::<()>(&actor).is_some());
1106 }
1107}