Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
703 linesCodeBlameRaw

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.

Billing's usage total is labeled at price, and Usage says what part of it was paid for1//! Secrets and variables, a repository's or its workspace's: one list for
2//! every reader, shaped like Vercel's environment variables. Each row is a
3//! key, its type (a secret, or a variable shown as Config), the
4//! environments it applies to and who reads it: workflows, deployments, or
5//! both. A key may have one row per environment, so production and
6//! previews can hold different values; a key's rows never overlap.
7//!
8//! A reader asking for an environment gets the row naming it, else the
9//! key's row for every environment. A project's row overrides its
10//! workspace's of the same key.
11//!
12//! A repository's rows belong to its project (its primary one, when it
13//! carries several): asked for by repository, as GitHub's API does, they
14//! are the project's. Rows from before projects move over the first time
15//! they are touched. Names are upper-cased, as GitHub treats
16//! them without regard to case. Agents never read any.
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.17//!
18//! A secret may be brokered (broker.rs): its row names the hosts it is for,
19//! and workflow jobs get a stand-in in place of its value. Only workflows
20//! read a brokered secret: a deployment's app needs the value itself.
21
22use std::collections::BTreeMap;
Billing's usage total is labeled at price, and Usage says what part of it was paid for23
24use g1t_contracts::access::Capability;
25use g1t_contracts::actions::{
26 CONSUMERS, DeleteSettingArgs, ResolveSettingsArgs, ResolvedSettings, SetSettingArgs, Setting, SettingsArgs,
27 SettingsOwner,
28};
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.29use g1t_contracts::guardrails::normalize_domain;
Billing's usage total is labeled at price, and Usage says what part of it was paid for30use g1t_contracts::projects::{ByRepoArgs, ProjectRef};
31use g1t_contracts::time::rfc3339;
32use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id};
33use g1t_kit::now_ms;
34use serde::Deserialize;
35use serde_json::{Map, Value};
36use worker::Result;
37use worker::wasm_bindgen::JsValue;
38
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.39use crate::broker::{Broker, MAX_HOSTS, MIN_VALUE_CHARS};
Billing's usage total is labeled at price, and Usage says what part of it was paid for40use crate::{Actions, check, fail};
41
42/// The largest value, as on GitHub.
43const MAX_VALUE_BYTES: usize = 48 * 1024;
44const MAX_PER_OWNER: u32 = 200;
45const MAX_NOTE: usize = 500;
46
47#[derive(Deserialize)]
48struct SettingRow {
49 id: String,
50 scope: String,
51 kind: String,
52 name: String,
53 value: String,
54 updated_at: String,
55 available_to: String,
56 environments: String,
57 repositories: Option<String>,
58 note: Option<String>,
59 updated_by: Option<String>,
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.60 /// A brokered secret's hosts, as a JSON array (migration 0019).
61 #[serde(default)]
62 brokered_hosts: Option<String>,
Billing's usage total is labeled at price, and Usage says what part of it was paid for63}
64
65/// A name GitHub would accept: letters, digits and `_`, not starting with
66/// a digit, `GITHUB_` or `G1T_`, which are g1t's own (`G1T_TOKEN` and its
67/// alias `GITHUB_TOKEN`).
68fn valid_name(name: &str) -> Result<String, String> {
69 let upper = name.trim().to_ascii_uppercase();
70 if upper.is_empty() || upper.len() > 100 {
71 return Err("A name is 1 to 100 characters.".to_owned());
72 }
73 if !upper.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
74 return Err("A name has only letters, digits and underscores.".to_owned());
75 }
76 if upper.starts_with(|c: char| c.is_ascii_digit()) {
77 return Err("A name cannot start with a digit.".to_owned());
78 }
79 if upper.starts_with("GITHUB_") || upper.starts_with("G1T_") {
80 return Err("Names starting with G1T_ or GITHUB_ are kept for g1t's own, such as G1T_TOKEN.".to_owned());
81 }
82 Ok(upper)
83}
84
85/// Environments' names: lowercase letters, digits, `-` and `_`, each once.
86fn valid_environments(list: &[String]) -> Result<Vec<String>, String> {
87 let mut out: Vec<String> = Vec::new();
88 for name in list {
89 let lower = name.trim().to_ascii_lowercase();
90 if lower.is_empty() {
91 continue;
92 }
93 if lower.len() > 40 || !lower.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
94 return Err(format!("`{name}` is not an environment's name: up to 40 letters, digits, - and _."));
95 }
96 if !out.contains(&lower) {
97 out.push(lower);
98 }
99 }
100 out.sort();
101 Ok(out)
102}
103
104fn consumers(list: &[String]) -> Result<Vec<String>, String> {
105 let mut out: Vec<String> = Vec::new();
106 for item in list {
107 let item = item.trim().to_ascii_lowercase();
108 if !CONSUMERS.contains(&item.as_str()) {
109 return Err(format!("`{item}` is not a reader: use workflows or deployments."));
110 }
111 if !out.contains(&item) {
112 out.push(item);
113 }
114 }
115 if out.is_empty() {
116 return Err("Choose who reads it: workflows, deployments, or both.".to_owned());
117 }
118 Ok(out)
119}
120
Merge branch 'main' into ask-integration121/// A brokered secret's hosts, tidied as host names: each once, at
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.122/// most `MAX_HOSTS`.
123fn valid_hosts(list: &[String]) -> Result<Vec<String>, String> {
124 let mut out: Vec<String> = Vec::new();
125 for host in list {
126 if host.trim().is_empty() {
127 continue;
128 }
129 let host = normalize_domain(host)?;
130 if !out.contains(&host) {
131 out.push(host);
132 }
133 }
134 if out.len() > MAX_HOSTS {
135 return Err(format!("A brokered secret is for at most {MAX_HOSTS} hosts."));
136 }
137 Ok(out)
138}
139
140/// What readers of one kind get: each key's value, and the hosts of the
141/// brokered secrets among them.
142#[derive(Default)]
143pub(crate) struct Resolved {
144 pub values: Map<String, Value>,
145 pub brokered: BTreeMap<String, Vec<String>>,
146}
147
148impl Resolved {
149 /// The brokered secrets among the values.
150 pub(crate) fn brokers(&self) -> Vec<Broker> {
151 self.brokered
152 .iter()
153 .filter_map(|(name, hosts)| {
154 let value = self.values.get(name)?.as_str()?;
155 Some(Broker { name: name.clone(), value: value.to_owned(), hosts: hosts.clone() })
156 })
157 .collect()
158 }
159
160 /// The values, less the brokered secrets: for readers that would put
161 /// them where no stand-in can work.
162 fn unbrokered(mut self) -> Map<String, Value> {
163 for name in self.brokered.keys() {
164 self.values.remove(name);
165 }
166 self.values
167 }
168}
169
Billing's usage total is labeled at price, and Usage says what part of it was paid for170fn split(list: &str) -> Vec<String> {
171 list.split(',').filter(|s| !s.is_empty()).map(str::to_owned).collect()
172}
173
174impl SettingRow {
175 fn environments(&self) -> Vec<String> {
176 split(&self.environments)
177 }
178
179 /// A workspace's row: the projects it reaches. The column predates
180 /// projects; it holds their slugs.
181 fn projects(&self) -> Vec<String> {
182 self.repositories.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default()
183 }
184
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.185 fn brokered_hosts(&self) -> Vec<String> {
186 self.brokered_hosts.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default()
187 }
188
Billing's usage total is labeled at price, and Usage says what part of it was paid for189 fn reaches(&self, project: &str) -> bool {
190 let list = self.projects();
191 list.is_empty() || list.iter().any(|p| p.eq_ignore_ascii_case(project))
192 }
193
194 /// Whether it and rows for `environments` would both apply somewhere.
195 fn overlaps(&self, environments: &[String]) -> bool {
196 let mine = self.environments();
197 mine.is_empty() == environments.is_empty() && (mine.is_empty() || mine.iter().any(|e| environments.contains(e)))
198 }
199
200 fn describe(self) -> Setting {
201 Setting {
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.202 brokered_hosts: if self.kind == "secret" { self.brokered_hosts() } else { Vec::new() },
Billing's usage total is labeled at price, and Usage says what part of it was paid for203 available_to: split(&self.available_to),
204 environments: self.environments(),
205 projects: self.projects(),
206 value: (self.kind == "variable").then_some(self.value),
207 id: self.id,
208 name: self.name,
209 kind: self.kind,
210 scope: self.scope,
211 updated_at: self.updated_at,
212 note: self.note,
213 updated_by: self.updated_by,
214 }
215 }
216}
217
218/// Where settings live: `(scope, owner)` with the owner a project id or a
219/// workspace slug.
220struct Place {
221 scope: &'static str,
222 owner: String,
223 namespace: String,
224 /// The project's slug, for a project's place.
225 slug: String,
226}
227
228impl Actions {
229 async fn place(&self, actor: &User, owner: &SettingsOwner, changing: bool) -> Result<Outcome<Place>> {
230 // A workspace's tokens, G1T_TOKEN among them, read the names but
231 // never change them: a workflow must not rewrite what it runs with.
232 if changing && actor.kind == PrincipalKind::Workspace {
233 return Ok(fail(
234 FailureCode::Forbidden,
235 "A workspace's tokens, G1T_TOKEN included, cannot change secrets and variables. Use a person's token or the site.",
236 ));
237 }
238 match (&owner.repo, &owner.workspace) {
239 (Some(path), _) => {
240 // A repository's secrets and variables, seen or changed,
241 // go with its webhooks and deployments: the Admin role.
242 let repo = match self.may(actor, path, Capability::ManageIntegrations).await? {
243 Outcome::Ok(repo) => repo,
244 Outcome::Fail(refused) => return Ok(Outcome::Fail(refused)),
245 };
246 let Some(project) = self.project_of(&repo.id).await? else {
247 return Ok(fail(FailureCode::NotFound, "The repository has no project."));
248 };
249 Ok(Outcome::Ok(Place { scope: "project", owner: project.id, namespace: repo.namespace, slug: project.slug }))
250 }
251 (None, Some(slug)) => {
252 let slug = slug.to_lowercase();
253 let role = actor.workspaces.iter().find(|m| m.slug.eq_ignore_ascii_case(&slug)).map(|m| m.role);
254 match role {
255 None => Ok(fail(FailureCode::Forbidden, format!("Only members of {slug} can see its secrets and variables."))),
256 Some(Role::Member) if changing => Ok(fail(FailureCode::Forbidden, format!("Only owners of {slug} can change its secrets and variables."))),
257 Some(_) => Ok(Outcome::Ok(Place { scope: "workspace", owner: slug.clone(), namespace: slug, slug: String::new() })),
258 }
259 }
260 (None, None) => Ok(fail(FailureCode::Invalid, "Give `repo` or `workspace`.")),
261 }
262 }
263
264 /// A repository's primary project, with the rows kept under the
265 /// repository before projects moved to it.
266 pub(crate) async fn project_of(&self, repo_id: &str) -> Result<Option<ProjectRef>> {
267 let projects: Vec<ProjectRef> =
268 g1t_kit::call(&self.projects, "by_repo", &ByRepoArgs { repo_id: repo_id.to_owned() }).await?;
269 let Some(project) = projects.into_iter().find(|p| p.primary) else {
270 return Ok(None);
271 };
272 self.db
273 .prepare("UPDATE settings SET owner = ?, scope = 'project' WHERE owner = ?")
274 .bind(&[project.id.as_str().into(), repo_id.into()])?
275 .run()
276 .await?;
277 Ok(Some(project))
278 }
279
Merge branch 'worktree-agent-a3abfcce648e87dca'280 /// The environments a repository's own secrets and variables name.
281 pub(crate) async fn secret_environments(&self, repo_id: &str) -> Result<Vec<String>> {
282 let Some(project) = self.project_of(repo_id).await? else {
283 return Ok(Vec::new());
284 };
285 let mut out: Vec<String> = Vec::new();
286 for row in self.rows(&project.id).await? {
287 for environment in row.environments() {
288 if !out.contains(&environment) {
289 out.push(environment);
290 }
291 }
292 }
293 Ok(out)
294 }
295
Billing's usage total is labeled at price, and Usage says what part of it was paid for296 /// `secret`, `variable`, or `None` for both.
297 fn kind(kind: &str) -> Outcome<Option<&'static str>> {
298 match kind {
299 "secret" | "secrets" => Outcome::Ok(Some("secret")),
300 "variable" | "variables" | "config" => Outcome::Ok(Some("variable")),
301 "" | "all" => Outcome::Ok(None),
302 _ => fail(FailureCode::Invalid, "`kind` is `secret` or `variable`."),
303 }
304 }
305
306 async fn rows(&self, owner: &str) -> Result<Vec<SettingRow>> {
307 self.db
308 .prepare("SELECT * FROM settings WHERE owner = ? ORDER BY name, environments")
309 .bind(&[owner.into()])?
310 .all()
311 .await?
312 .results::<SettingRow>()
313 }
314
315 pub async fn settings(&self, a: SettingsArgs) -> Result<Outcome<Vec<Setting>>> {
316 let kind = check!(Self::kind(&a.kind));
317 let place = check!(self.place(&a.actor, &a.owner, false).await?);
318 let mut out: Vec<Setting> = Vec::new();
319 // A project's list shows the workspace's rows that reach it, but for
320 // keys it sets itself.
321 if place.scope == "project" {
322 let own: Vec<String> = self.rows(&place.owner).await?.into_iter().map(|row| row.name).collect();
323 for row in self.rows(&place.namespace.to_lowercase()).await? {
324 if row.reaches(&place.slug) && !own.contains(&row.name) {
325 out.push(row.describe());
326 }
327 }
328 }
329 out.extend(self.rows(&place.owner).await?.into_iter().map(SettingRow::describe));
330 out.retain(|setting| kind.is_none_or(|kind| setting.kind == kind));
331 out.sort_by(|a, b| a.name.cmp(&b.name).then(a.environments.cmp(&b.environments)));
332 Ok(Outcome::Ok(out))
333 }
334
335 fn seal(&self, value: &str, id: &str) -> Outcome<String> {
336 match &self.sealer {
337 Some(sealer) => Outcome::Ok(sealer.seal(value, id)),
338 None => fail(FailureCode::Conflict, "Secrets cannot be saved yet: g1t's key for them is not set."),
339 }
340 }
341
342 pub async fn set_setting(&self, a: SetSettingArgs) -> Result<Outcome<Setting>> {
343 let Some(kind) = check!(Self::kind(&a.kind)) else {
344 return Ok(fail(FailureCode::Invalid, "`kind` is `secret` or `variable`."));
345 };
346 let name = match valid_name(&a.name) {
347 Ok(name) => name,
348 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
349 };
350 if a.value.as_ref().is_some_and(|v| v.len() > MAX_VALUE_BYTES) {
351 return Ok(fail(FailureCode::Invalid, "A value is at most 48 KB."));
352 }
353 if a.note.as_ref().is_some_and(|n| n.len() > MAX_NOTE) {
354 return Ok(fail(FailureCode::Invalid, "A note is at most 500 characters."));
355 }
356 let readers = match a.available_to.as_deref().map(consumers).transpose() {
357 Ok(readers) => readers,
358 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
359 };
360 let environments = match a.environments.as_deref().map(valid_environments).transpose() {
361 Ok(environments) => environments,
362 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
363 };
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.364 let brokered = match a.brokered_hosts.as_deref().map(valid_hosts).transpose() {
365 Ok(hosts) => hosts,
366 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
367 };
368 if kind == "variable" && brokered.as_ref().is_some_and(|hosts| !hosts.is_empty()) {
369 return Ok(fail(FailureCode::Invalid, "Only a secret can be brokered."));
370 }
Billing's usage total is labeled at price, and Usage says what part of it was paid for371 let place = check!(self.place(&a.actor, &a.owner, true).await?);
372 if a.projects.as_ref().is_some_and(|r| !r.is_empty()) && place.scope != "workspace" {
373 return Ok(fail(FailureCode::Invalid, "Only a workspace's rows choose projects."));
374 }
375 let rows = self.rows(&place.owner).await?;
376 // A secret and a variable may share a key, as on GitHub, where
377 // workflows read them apart (`secrets.X`, `vars.X`).
378 let same_key: Vec<&SettingRow> = rows.iter().filter(|row| row.name == name).collect();
379 // The row being changed: by id, else the key's row for every
380 // environment (GitHub's API names a secret by its key alone).
381 let existing = match &a.id {
382 Some(id) => match rows.iter().find(|row| &row.id == id) {
383 Some(row) => Some(row),
384 None => return Ok(fail(FailureCode::NotFound, "There is no such row.")),
385 },
386 None if a.environments.is_none() => same_key.iter().copied().find(|row| row.environments.is_empty() && row.kind == kind),
387 None => None,
388 };
389 if existing.is_some_and(|row| row.kind == "secret" && kind == "variable") {
390 return Ok(fail(FailureCode::Invalid, "A secret cannot become config: its value is sealed. Add a config row and remove the secret."));
391 }
392 let environments = environments.unwrap_or_else(|| existing.map(SettingRow::environments).unwrap_or_default());
393 // A key's rows never apply to the same environment twice.
394 if let Some(clash) = same_key
395 .iter()
396 .find(|row| row.kind == kind && existing.is_none_or(|e| e.id != row.id) && row.overlaps(&environments))
397 {
398 let at = if clash.environments.is_empty() { "all environments".to_owned() } else { clash.environments.replace(',', ", ") };
399 let what = if kind == "secret" { "secret" } else { "config" };
400 return Ok(fail(
401 FailureCode::Conflict,
402 format!("{name} already has a {what} row for {at}. Edit that row, or choose other environments."),
403 ));
404 }
405 if existing.is_none() && rows.len() as u32 >= MAX_PER_OWNER {
406 return Ok(fail(FailureCode::Invalid, format!("There can be at most {MAX_PER_OWNER} secrets and variables here.")));
407 }
408 let id = existing.map(|row| row.id.clone()).unwrap_or_else(|| new_id("set", now_ms()));
409 let value = match (&a.value, existing) {
410 (Some(value), _) if kind == "secret" => check!(self.seal(value, &id)),
411 (Some(value), _) => value.clone(),
412 // Config becoming a secret: its value is sealed now.
413 (None, Some(row)) if row.kind == "variable" && kind == "secret" => check!(self.seal(&row.value, &id)),
414 (None, Some(row)) => row.value.clone(),
415 (None, None) => return Ok(fail(FailureCode::Invalid, "A new row needs a `value`.")),
416 };
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.417 let brokered: Vec<String> = match brokered {
418 Some(hosts) => hosts,
419 None => existing.filter(|_| kind == "secret").map(SettingRow::brokered_hosts).unwrap_or_default(),
420 };
421 // A new brokered secret is read by workflows alone, unless it says
422 // otherwise, which is refused below.
423 let default_readers = if brokered.is_empty() { CONSUMERS.join(",") } else { "workflows".to_owned() };
Billing's usage total is labeled at price, and Usage says what part of it was paid for424 let available_to = readers
425 .map(|r| r.join(","))
426 .or_else(|| existing.map(|row| row.available_to.clone()))
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.427 .unwrap_or(default_readers);
428 if !brokered.is_empty() {
429 if split(&available_to).iter().any(|r| r == "deployments") {
430 return Ok(fail(
431 FailureCode::Invalid,
432 "A brokered secret is read by workflows only: a deployment's app needs the value itself. Leave Deployments out, or stop brokering it.",
433 ));
434 }
435 // Too short a value would be found inside other secrets.
436 let plain = match (&a.value, existing) {
437 (Some(value), _) => Some(value.clone()),
438 (None, Some(row)) if row.kind == "secret" => self.sealer.as_ref().and_then(|sealer| sealer.open(&row.value, &row.id)),
439 (None, Some(row)) => Some(row.value.clone()),
440 (None, None) => None,
441 };
442 if plain.is_some_and(|value| value.trim().chars().count() < MIN_VALUE_CHARS) {
443 return Ok(fail(FailureCode::Invalid, format!("A brokered secret's value is at least {MIN_VALUE_CHARS} characters.")));
444 }
445 }
Billing's usage total is labeled at price, and Usage says what part of it was paid for446 let repositories: Option<String> = match &a.projects {
447 Some(list) if list.is_empty() => None,
448 Some(list) => Some(serde_json::to_string(list).unwrap_or_default()),
449 None => existing.and_then(|row| row.repositories.clone()),
450 };
451 let note = match &a.note {
452 Some(note) if note.trim().is_empty() => None,
453 Some(note) => Some(note.trim().to_owned()),
454 None => existing.and_then(|row| row.note.clone()),
455 };
456 let at = rfc3339(now_ms());
457 let optional = |v: Option<&str>| v.map_or(JsValue::NULL, JsValue::from);
458 self.db
459 .prepare(
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.460 "INSERT INTO settings (id, scope, owner, kind, name, value, updated_at, available_to, environments, repositories, note, updated_by, brokered_hosts)
461 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Billing's usage total is labeled at price, and Usage says what part of it was paid for462 ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, value = excluded.value, updated_at = excluded.updated_at,
463 available_to = excluded.available_to, environments = excluded.environments,
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.464 repositories = excluded.repositories, note = excluded.note, updated_by = excluded.updated_by,
465 brokered_hosts = excluded.brokered_hosts",
Billing's usage total is labeled at price, and Usage says what part of it was paid for466 )
467 .bind(&[
468 id.as_str().into(),
469 place.scope.into(),
470 place.owner.as_str().into(),
471 kind.into(),
472 name.as_str().into(),
473 value.into(),
474 at.as_str().into(),
475 available_to.as_str().into(),
476 environments.join(",").into(),
477 optional(repositories.as_deref()),
478 optional(note.as_deref()),
479 a.actor.username.as_str().into(),
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.480 optional((!brokered.is_empty()).then(|| serde_json::to_string(&brokered).unwrap_or_default()).as_deref()),
Billing's usage total is labeled at price, and Usage says what part of it was paid for481 ])?
482 .run()
483 .await?;
484 let row = self
485 .db
486 .prepare("SELECT * FROM settings WHERE id = ?")
487 .bind(&[id.as_str().into()])?
488 .first::<SettingRow>(None)
489 .await?
490 .expect("just written");
491 Ok(Outcome::Ok(row.describe()))
492 }
493
494 pub async fn delete_setting(&self, a: DeleteSettingArgs) -> Result<Outcome<bool>> {
495 let kind = check!(Self::kind(&a.kind));
496 let place = check!(self.place(&a.actor, &a.owner, true).await?);
497 let name = a.name.trim().to_ascii_uppercase();
498 let removed = match &a.id {
499 Some(id) => self
500 .db
501 .prepare("DELETE FROM settings WHERE owner = ? AND id = ? RETURNING id")
502 .bind(&[place.owner.as_str().into(), id.as_str().into()])?
503 .all()
504 .await?,
505 None => self
506 .db
507 .prepare("DELETE FROM settings WHERE owner = ? AND name = ? AND (?3 IS NULL OR kind = ?3) RETURNING id")
508 .bind(&[place.owner.as_str().into(), name.as_str().into(), kind.map_or(JsValue::NULL, JsValue::from)])?
509 .all()
510 .await?,
511 };
512 Ok(if removed.results::<Value>()?.is_empty() {
513 fail(FailureCode::NotFound, format!("There is nothing called {} here.", a.name))
514 } else {
515 Outcome::Ok(true)
516 })
517 }
518
519 /// What one reader of a repository gets: per key, the row for
520 /// `environment`, else the row for every environment; the repository's
521 /// over its workspace's. No secrets unless `trusted`.
522 #[allow(clippy::too_many_arguments)]
523 async fn resolved(
524 &self,
525 project_id: &str,
526 project_slug: &str,
527 namespace: &str,
528 kind: &str,
529 consumer: &str,
530 environment: Option<&str>,
531 trusted: bool,
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.532 ) -> Result<Resolved> {
Billing's usage total is labeled at price, and Usage says what part of it was paid for533 if kind == "secret" && !trusted {
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.534 return Ok(Resolved::default());
Billing's usage total is labeled at price, and Usage says what part of it was paid for535 }
536 let environment = environment.map(str::to_ascii_lowercase);
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.537 let mut out = Resolved::default();
Billing's usage total is labeled at price, and Usage says what part of it was paid for538 for owner in [namespace.to_lowercase(), project_id.to_owned()] {
539 let rows: Vec<SettingRow> = self
540 .rows(&owner)
541 .await?
542 .into_iter()
543 .filter(|row| row.kind == kind)
544 .filter(|row| split(&row.available_to).iter().any(|r| r == consumer))
545 .filter(|row| row.scope != "workspace" || row.reaches(project_slug))
546 .collect();
547 let mut names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect();
548 names.dedup();
549 for name in names {
550 let of_key: Vec<&SettingRow> = rows.iter().filter(|row| row.name == name).collect();
551 let chosen = environment
552 .as_deref()
553 .and_then(|env| of_key.iter().find(|row| row.environments().iter().any(|e| e == env)))
554 .or_else(|| of_key.iter().find(|row| row.environments.is_empty()));
555 let Some(row) = chosen else {
556 // Rows only for other environments: this reader gets
557 // none, nor the workspace's.
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.558 out.values.remove(name);
559 out.brokered.remove(name);
Billing's usage total is labeled at price, and Usage says what part of it was paid for560 continue;
561 };
562 let value = if kind == "secret" {
563 match self.sealer.as_ref().and_then(|sealer| sealer.open(&row.value, &row.id)) {
564 Some(value) => value,
565 None => continue,
566 }
567 } else {
568 row.value.clone()
569 };
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.570 out.values.insert(name.to_owned(), Value::String(value));
571 // The row that wins decides whether the key is brokered.
572 match row.brokered_hosts() {
573 hosts if kind == "secret" && !hosts.is_empty() => out.brokered.insert(name.to_owned(), hosts),
574 _ => out.brokered.remove(name),
575 };
Billing's usage total is labeled at price, and Usage says what part of it was paid for576 }
577 }
578 Ok(out)
579 }
580
581 /// The `vars` context of a repository's runs. `environment` is the job's
582 /// `environment:`, when it has one.
583 pub async fn variables_for(&self, repo_id: &str, repo: &str, environment: Option<&str>, trusted: bool) -> Result<Map<String, Value>> {
584 let (namespace, _) = repo.split_once('/').unwrap_or((repo, ""));
585 let Some(project) = self.project_of(repo_id).await? else {
586 return Ok(Map::new());
587 };
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.588 Ok(self.resolved(&project.id, &project.slug, namespace, "variable", "workflows", environment, trusted).await?.values)
Billing's usage total is labeled at price, and Usage says what part of it was paid for589 }
590
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.591 /// The `secrets` context of a repository's runs, opened, with which of
592 /// them are brokered: `job_spec` gives the job stand-ins for those.
593 pub(crate) async fn secrets_for(&self, repo_id: &str, repo: &str, environment: Option<&str>, trusted: bool) -> Result<Resolved> {
Billing's usage total is labeled at price, and Usage says what part of it was paid for594 let (namespace, _) = repo.split_once('/').unwrap_or((repo, ""));
595 let Some(project) = self.project_of(repo_id).await? else {
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.596 return Ok(Resolved::default());
Billing's usage total is labeled at price, and Usage says what part of it was paid for597 };
598 self.resolved(&project.id, &project.slug, namespace, "secret", "workflows", environment, trusted).await
599 }
600
601 /// `resolve_settings`, for the deployments service: what a deploy build
602 /// and its running app get.
603 pub async fn resolve_settings(&self, a: ResolveSettingsArgs) -> Result<ResolvedSettings> {
604 let environment = a.environment.as_deref();
605 // Rows kept under the repository from before projects move to its
606 // primary project first, however the project is named here.
607 let primary = self.project_of(&a.repo_id).await?;
608 let (project_id, slug) = match (a.project_id, a.project_slug, primary) {
609 (Some(id), Some(slug), _) => (id, slug),
610 (_, _, Some(project)) => (project.id, project.slug),
611 _ => return Ok(ResolvedSettings::default()),
612 };
613 Ok(ResolvedSettings {
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.614 // A brokered secret's value goes only to workflow jobs, as a
615 // stand-in; a version update's registries go without it.
Billing's usage total is labeled at price, and Usage says what part of it was paid for616 secrets: self
617 .resolved(&project_id, &slug, &a.repo.namespace, "secret", &a.consumer, environment, a.trusted)
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.618 .await?
619 .unbrokered(),
Billing's usage total is labeled at price, and Usage says what part of it was paid for620 variables: self
621 .resolved(&project_id, &slug, &a.repo.namespace, "variable", &a.consumer, environment, a.trusted)
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.622 .await?
623 .values,
Billing's usage total is labeled at price, and Usage says what part of it was paid for624 })
625 }
626}
627
628#[cfg(test)]
629mod tests {
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.630 use std::collections::BTreeMap;
631
632 use serde_json::json;
633
634 use super::{Resolved, SettingRow, consumers, valid_environments, valid_hosts, valid_name};
Billing's usage total is labeled at price, and Usage says what part of it was paid for635
636 fn row(environments: &str) -> SettingRow {
637 SettingRow {
638 id: "set_1".into(),
639 scope: "repository".into(),
640 kind: "secret".into(),
641 name: "STRIPE_KEY".into(),
642 value: String::new(),
643 updated_at: String::new(),
644 available_to: "workflows,deployments".into(),
645 environments: environments.into(),
646 repositories: None,
647 note: None,
648 updated_by: None,
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.649 brokered_hosts: None,
Billing's usage total is labeled at price, and Usage says what part of it was paid for650 }
651 }
652
653 #[test]
654 fn names_follow_githubs_rules_and_keep_g1ts_own() {
655 assert_eq!(valid_name("npm_token").unwrap(), "NPM_TOKEN");
656 assert!(valid_name("GITHUB_TOKEN").is_err());
657 assert!(valid_name("G1T_TOKEN").is_err());
658 assert!(valid_name("1PASSWORD").is_err());
659 assert!(valid_name("MY-TOKEN").is_err());
660 assert!(valid_name("").is_err());
661 }
662
663 #[test]
664 fn environments_and_readers_are_checked() {
665 assert_eq!(valid_environments(&["Production".into(), "preview".into(), "production".into()]).unwrap(), vec!["preview", "production"]);
666 assert!(valid_environments(&["staging env".into()]).is_err());
667 assert_eq!(consumers(&["Deployments".into(), "deployments".into()]).unwrap(), vec!["deployments"]);
668 assert!(consumers(&["agents".into()]).is_err());
669 assert!(consumers(&[]).is_err());
670 }
671
672 #[test]
673 fn a_keys_rows_cannot_share_an_environment() {
674 assert!(row("production").overlaps(&["production".into(), "preview".into()]));
675 assert!(!row("production").overlaps(&["preview".into()]));
676 // One row for every environment, and others for some, live together.
677 assert!(!row("").overlaps(&["preview".into()]));
678 assert!(row("").overlaps(&[]));
679 }
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.680
681 #[test]
682 fn brokered_hosts_are_domains_each_once() {
683 let hosts = valid_hosts(&["HTTPS://Registry.npmjs.org/".into(), "*.example.com".into(), "registry.npmjs.org".into(), " ".into()]).unwrap();
684 assert_eq!(hosts, ["registry.npmjs.org", "*.example.com"]);
685 assert!(valid_hosts(&["localhost".into()]).is_err());
686 assert!(valid_hosts(&(0..21).map(|n| format!("h{n}.example.com")).collect::<Vec<_>>()).is_err());
687 assert!(valid_hosts(&[]).unwrap().is_empty());
688 }
689
690 #[test]
691 fn readers_outside_workflows_never_get_a_brokered_value() {
692 let resolved = Resolved {
693 values: json!({ "NPM_TOKEN": "npm_realvalue123", "PLAIN": "value" }).as_object().cloned().unwrap(),
694 brokered: BTreeMap::from([("NPM_TOKEN".to_owned(), vec!["registry.npmjs.org".to_owned()])]),
695 };
696 let brokers = resolved.brokers();
697 assert_eq!(brokers.len(), 1);
698 assert_eq!(brokers[0].value, "npm_realvalue123");
699 let plain = resolved.unbrokered();
700 assert!(!plain.contains_key("NPM_TOKEN"));
701 assert_eq!(plain["PLAIN"], "value");
702 }
Billing's usage total is labeled at price, and Usage says what part of it was paid for703}

This file's history is long; its oldest lines are credited to the oldest commit read.