Skip to content
703 linesCodeBlameRaw
1//! 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.
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;
23
24use g1t_contracts::access::Capability;
25use g1t_contracts::actions::{
26 CONSUMERS, DeleteSettingArgs, ResolveSettingsArgs, ResolvedSettings, SetSettingArgs, Setting, SettingsArgs,
27 SettingsOwner,
28};
29use g1t_contracts::guardrails::normalize_domain;
30use 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
39use crate::broker::{Broker, MAX_HOSTS, MIN_VALUE_CHARS};
40use 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>,
60 /// A brokered secret's hosts, as a JSON array (migration 0019).
61 #[serde(default)]
62 brokered_hosts: Option<String>,
63}
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
121/// A brokered secret's hosts, tidied as allowed domains are: each once, at
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
170fn 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
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
189 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 {
202 brokered_hosts: if self.kind == "secret" { self.brokered_hosts() } else { Vec::new() },
203 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
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
296 /// `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 };
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 }
371 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 };
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() };
424 let available_to = readers
425 .map(|r| r.join(","))
426 .or_else(|| existing.map(|row| row.available_to.clone()))
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 }
446 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(
460 "INSERT INTO settings (id, scope, owner, kind, name, value, updated_at, available_to, environments, repositories, note, updated_by, brokered_hosts)
461 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
462 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,
464 repositories = excluded.repositories, note = excluded.note, updated_by = excluded.updated_by,
465 brokered_hosts = excluded.brokered_hosts",
466 )
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(),
480 optional((!brokered.is_empty()).then(|| serde_json::to_string(&brokered).unwrap_or_default()).as_deref()),
481 ])?
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,
532 ) -> Result<Resolved> {
533 if kind == "secret" && !trusted {
534 return Ok(Resolved::default());
535 }
536 let environment = environment.map(str::to_ascii_lowercase);
537 let mut out = Resolved::default();
538 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.
558 out.values.remove(name);
559 out.brokered.remove(name);
560 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 };
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 };
576 }
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 };
588 Ok(self.resolved(&project.id, &project.slug, namespace, "variable", "workflows", environment, trusted).await?.values)
589 }
590
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> {
594 let (namespace, _) = repo.split_once('/').unwrap_or((repo, ""));
595 let Some(project) = self.project_of(repo_id).await? else {
596 return Ok(Resolved::default());
597 };
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 {
614 // A brokered secret's value goes only to workflow jobs, as a
615 // stand-in; a version update's registries go without it.
616 secrets: self
617 .resolved(&project_id, &slug, &a.repo.namespace, "secret", &a.consumer, environment, a.trusted)
618 .await?
619 .unbrokered(),
620 variables: self
621 .resolved(&project_id, &slug, &a.repo.namespace, "variable", &a.consumer, environment, a.trusted)
622 .await?
623 .values,
624 })
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use std::collections::BTreeMap;
631
632 use serde_json::json;
633
634 use super::{Resolved, SettingRow, consumers, valid_environments, valid_hosts, valid_name};
635
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,
649 brokered_hosts: None,
650 }
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 }
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 }
703}