g1t/services/actions/src/settings.rs

239 lines9,959 bytesCodeBlame
1//! Secrets and variables, a repository's or its workspace's. A
2//! repository's override its workspace's of the same name. Names are
3//! upper-cased, as GitHub treats them without regard to case.
4
5use g1t_contracts::actions::{DeleteSettingArgs, SetSettingArgs, Setting, SettingsArgs, SettingsOwner};
6use g1t_contracts::time::rfc3339;
7use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id};
8use g1t_kit::now_ms;
9use serde::Deserialize;
10use serde_json::{Map, Value};
11use worker::Result;
12
13use crate::{Actions, check, fail};
14
15/// The largest value, as on GitHub.
16const MAX_VALUE_BYTES: usize = 48 * 1024;
17const MAX_PER_OWNER: u32 = 100;
18
19#[derive(Deserialize)]
20struct SettingRow {
21 id: String,
22 scope: String,
23 name: String,
24 value: String,
25 updated_at: String,
26}
27
28#[derive(Deserialize)]
29struct Count {
30 n: u32,
31}
32
33/// A name GitHub would accept: letters, digits and `_`, not starting with
34/// a digit or `GITHUB_`.
35fn valid_name(name: &str) -> Result<String, String> {
36 let upper = name.trim().to_ascii_uppercase();
37 if upper.is_empty() || upper.len() > 100 {
38 return Err("A name is 1 to 100 characters.".to_owned());
39 }
40 if !upper.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
41 return Err("A name has only letters, digits and underscores.".to_owned());
42 }
43 if upper.starts_with(|c: char| c.is_ascii_digit()) {
44 return Err("A name cannot start with a digit.".to_owned());
45 }
46 if upper.starts_with("GITHUB_") {
47 return Err("Names starting with GITHUB_ are kept for GitHub's own.".to_owned());
48 }
49 Ok(upper)
50}
51
52/// Where settings live: `(scope, owner)` with the owner a repository id or
53/// a workspace slug, and whether the actor may change them.
54struct Place {
55 scope: &'static str,
56 owner: String,
57 namespace: String,
58}
59
60impl Actions {
61 async fn place(&self, actor: &User, owner: &SettingsOwner, changing: bool) -> Result<Outcome<Place>> {
62 if actor.kind == PrincipalKind::Agent {
63 return Ok(fail(FailureCode::Forbidden, "Agents cannot read or change secrets and variables."));
64 }
65 match (&owner.repo, &owner.workspace) {
66 (Some(path), _) => {
67 if !actor.is_member(&path.namespace.to_lowercase()) {
68 return Ok(fail(FailureCode::Forbidden, format!("Only members of {} can see its secrets and variables.", path.namespace)));
69 }
70 let Some(repo) = self.visible_repo(path, &Some(actor.clone())).await? else {
71 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
72 };
73 Ok(Outcome::Ok(Place { scope: "repository", owner: repo.id, namespace: repo.namespace }))
74 }
75 (None, Some(slug)) => {
76 let slug = slug.to_lowercase();
77 let role = actor.workspaces.iter().find(|m| m.slug.eq_ignore_ascii_case(&slug)).map(|m| m.role);
78 match role {
79 None => Ok(fail(FailureCode::Forbidden, format!("Only members of {slug} can see its secrets and variables."))),
80 Some(Role::Member) if changing => Ok(fail(FailureCode::Forbidden, format!("Only owners of {slug} can change its secrets and variables."))),
81 Some(_) => Ok(Outcome::Ok(Place { scope: "workspace", owner: slug.clone(), namespace: slug })),
82 }
83 }
84 (None, None) => Ok(fail(FailureCode::Invalid, "Give `repo` or `workspace`.")),
85 }
86 }
87
88 fn kind(kind: &str) -> Outcome<&'static str> {
89 match kind {
90 "secret" | "secrets" => Outcome::Ok("secret"),
91 "variable" | "variables" => Outcome::Ok("variable"),
92 _ => fail(FailureCode::Invalid, "`kind` is `secret` or `variable`."),
93 }
94 }
95
96 pub async fn settings(&self, a: SettingsArgs) -> Result<Outcome<Vec<Setting>>> {
97 let kind = check!(Self::kind(&a.kind));
98 let place = check!(self.place(&a.actor, &a.owner, false).await?);
99 // A repository's list shows its workspace's too, which it inherits.
100 let owners: Vec<&str> = if place.scope == "repository" { vec![place.namespace.as_str(), place.owner.as_str()] } else { vec![place.owner.as_str()] };
101 let mut out: Vec<Setting> = Vec::new();
102 for owner in owners {
103 let rows = self
104 .db
105 .prepare("SELECT * FROM settings WHERE owner = ? AND kind = ? ORDER BY name")
106 .bind(&[owner.into(), kind.into()])?
107 .all()
108 .await?
109 .results::<SettingRow>()?;
110 for row in rows {
111 out.retain(|setting| setting.name != row.name);
112 out.push(Setting {
113 name: row.name,
114 value: (kind == "variable").then_some(row.value),
115 scope: row.scope,
116 updated_at: row.updated_at,
117 });
118 }
119 }
120 out.sort_by(|a, b| a.name.cmp(&b.name));
121 Ok(Outcome::Ok(out))
122 }
123
124 pub async fn set_setting(&self, a: SetSettingArgs) -> Result<Outcome<Setting>> {
125 let kind = check!(Self::kind(&a.kind));
126 let name = match valid_name(&a.name) {
127 Ok(name) => name,
128 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
129 };
130 if a.value.len() > MAX_VALUE_BYTES {
131 return Ok(fail(FailureCode::Invalid, "A value is at most 48 KB."));
132 }
133 let place = check!(self.place(&a.actor, &a.owner, true).await?);
134 let count = self
135 .db
136 .prepare("SELECT COUNT(*) AS n FROM settings WHERE owner = ? AND kind = ? AND name != ?")
137 .bind(&[place.owner.as_str().into(), kind.into(), name.as_str().into()])?
138 .first::<Count>(None)
139 .await?
140 .map_or(0, |c| c.n);
141 if count >= MAX_PER_OWNER {
142 return Ok(fail(FailureCode::Invalid, format!("There can be at most {MAX_PER_OWNER} {kind}s here.")));
143 }
144 let existing = self
145 .db
146 .prepare("SELECT * FROM settings WHERE owner = ? AND kind = ? AND name = ?")
147 .bind(&[place.owner.as_str().into(), kind.into(), name.as_str().into()])?
148 .first::<SettingRow>(None)
149 .await?;
150 let id = existing.map(|row| row.id).unwrap_or_else(|| new_id("set", now_ms()));
151 let value = if kind == "secret" {
152 let Some(sealer) = &self.sealer else {
153 return Ok(fail(FailureCode::Conflict, "Secrets cannot be saved yet: g1t's key for them is not set."));
154 };
155 sealer.seal(&a.value, &id)
156 } else {
157 a.value.clone()
158 };
159 let at = rfc3339(now_ms());
160 self.db
161 .prepare(
162 "INSERT INTO settings (id, scope, owner, kind, name, value, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)
163 ON CONFLICT (owner, kind, name) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
164 )
165 .bind(&[id.as_str().into(), place.scope.into(), place.owner.as_str().into(), kind.into(), name.as_str().into(), value.into(), at.as_str().into()])?
166 .run()
167 .await?;
168 Ok(Outcome::Ok(Setting {
169 name,
170 value: (kind == "variable").then_some(a.value),
171 scope: place.scope.to_owned(),
172 updated_at: at,
173 }))
174 }
175
176 pub async fn delete_setting(&self, a: DeleteSettingArgs) -> Result<Outcome<bool>> {
177 let kind = check!(Self::kind(&a.kind));
178 let place = check!(self.place(&a.actor, &a.owner, true).await?);
179 let removed = self
180 .db
181 .prepare("DELETE FROM settings WHERE owner = ? AND kind = ? AND name = ? RETURNING id")
182 .bind(&[place.owner.as_str().into(), kind.into(), a.name.trim().to_ascii_uppercase().into()])?
183 .first::<Value>(None)
184 .await?;
185 Ok(match removed {
186 Some(_) => Outcome::Ok(true),
187 None => fail(FailureCode::NotFound, format!("There is no {kind} called {}.", a.name)),
188 })
189 }
190
191 async fn resolved(&self, repo_id: &str, namespace: &str, kind: &str) -> Result<Map<String, Value>> {
192 let mut out = Map::new();
193 for owner in [namespace.to_lowercase(), repo_id.to_owned()] {
194 let rows = self
195 .db
196 .prepare("SELECT * FROM settings WHERE owner = ? AND kind = ?")
197 .bind(&[owner.into(), kind.into()])?
198 .all()
199 .await?
200 .results::<SettingRow>()?;
201 for row in rows {
202 let value = if kind == "secret" {
203 match self.sealer.as_ref().and_then(|sealer| sealer.open(&row.value, &row.id)) {
204 Some(value) => value,
205 None => continue,
206 }
207 } else {
208 row.value
209 };
210 out.insert(row.name, Value::String(value));
211 }
212 }
213 Ok(out)
214 }
215
216 /// The `vars` context of a repository's runs.
217 pub async fn variables_for(&self, repo_id: &str, namespace: &str) -> Result<Map<String, Value>> {
218 self.resolved(repo_id, namespace, "variable").await
219 }
220
221 /// The `secrets` context of a repository's runs, opened.
222 pub async fn secrets_for(&self, repo_id: &str, namespace: &str) -> Result<Map<String, Value>> {
223 self.resolved(repo_id, namespace, "secret").await
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::valid_name;
230
231 #[test]
232 fn names_follow_githubs_rules() {
233 assert_eq!(valid_name("npm_token").unwrap(), "NPM_TOKEN");
234 assert!(valid_name("GITHUB_TOKEN").is_err());
235 assert!(valid_name("1PASSWORD").is_err());
236 assert!(valid_name("MY-TOKEN").is_err());
237 assert!(valid_name("").is_err());
238 }
239}