Secrets for pipelines, mounted as files 4ac01a732552
Administration, organisation and repository settings hold secrets, typed once and sealed under keys/secrets.key, shown as names only. A claim carries just the secrets its job declares with secret blocks, each with its mount path and value from the innermost level that sets the name; one no level sets is noted at the top of the job's log. Log intake masks each value and its base64 before a record is stored. oxygenflow pin moved to the runner that writes them. closes #107
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 16:22 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
4ac01a732552332d4e11a4ea206fa92b6a6af98f80f033f9625357511925ca27- Depends on
- 19bb31ff1f5c 2a83f5303458 3556d08e17f9 380996db84e5 42fa95540468 745d225de344 749d9a5fcd9f 7974adb0be66 7d2eb590051f 9008bf6c2b8c 93be9ce4653e a3c8288b0141 afd0be382257 b5668108487f b64e2537d702 b6b7e4cf6b24 d23464c0ac60 e71846aa07b9 fd3db644df22
- Depended on by
- 1062a0f8e985 dc5c64296b0a 8501fd5815b9 737de0fac722 c7067c7bda56 d2c1d6a7d12e 681db69eba17 076c7a7c86b1 c8684d44d549 5d90e0e045e2 cd437c4f5f8d 8ddebaa7e856 1f78655a0466 155cab142b63 83940d53664f 66fd9d6b1452 89e89836cae7 d1727c573f30
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 46 atoms, 4 tree operations, 18 files
Browse the tree at this change
added crates/hub/migrations/0038_flow_secrets.sql +12 −0
| 1 | + | -- Secrets for OxygenFlow jobs (hub spec §13.2, #107), per level like |
|
| 2 | + | -- flow_variables. A job gets only the secrets its pipeline declares by |
|
| 3 | + | -- name, as files Flow mounts (OxygenFlow spec §9), never in the |
|
| 4 | + | -- environment. Values are sealed with keys/secrets.key. |
|
| 5 | + | CREATE TABLE flow_secrets ( |
|
| 6 | + | scope TEXT NOT NULL CHECK (scope IN ('site', 'org', 'repo')), |
|
| 7 | + | scope_id INTEGER NOT NULL DEFAULT 0, |
|
| 8 | + | name TEXT NOT NULL, |
|
| 9 | + | value BLOB NOT NULL, |
|
| 10 | + | updated_ms INTEGER NOT NULL, |
|
| 11 | + | PRIMARY KEY (scope, scope_id, name) |
|
| 12 | + | ); |
added crates/hub/src/secrets.rs +163 −0
| 1 | + | //! Secrets for OxygenFlow jobs (hub spec §13.2, oxygenhub#107): named |
|
| 2 | + | //! values kept per site, organisation and repository, sealed at rest |
|
| 3 | + | //! under `keys/secrets.key`. A job gets only the secrets its pipeline |
|
| 4 | + | //! declares with `secret "name" { mount-path … }`, as files Flow mounts |
|
| 5 | + | //! (OxygenFlow spec §9), never in the environment; the hub masks each |
|
| 6 | + | //! value in the job's log as it arrives. Inner levels override outer by |
|
| 7 | + | //! name, as variables do. |
|
| 8 | + | ||
| 9 | + | use crate::db::{now_ms, Pool}; |
|
| 10 | + | use crate::levels::{self, Scope}; |
|
| 11 | + | use crate::Shared; |
|
| 12 | + | use sqlx::FromRow; |
|
| 13 | + | ||
| 14 | + | /// How many a level may hold. |
|
| 15 | + | pub const MAX_PER_LEVEL: usize = 100; |
|
| 16 | + | /// The longest value, in bytes. |
|
| 17 | + | pub const MAX_VALUE: usize = 64 * 1024; |
|
| 18 | + | /// Values shorter than this are not masked in logs: too many false hits. |
|
| 19 | + | const MIN_MASKED: usize = 4; |
|
| 20 | + | ||
| 21 | + | #[derive(Clone, Debug, FromRow)] |
|
| 22 | + | pub struct Secret { |
|
| 23 | + | pub scope: String, |
|
| 24 | + | pub scope_id: i64, |
|
| 25 | + | pub name: String, |
|
| 26 | + | /// Sealed; `App.secret_key` opens it. |
|
| 27 | + | pub value: Vec<u8>, |
|
| 28 | + | pub updated_ms: i64, |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | /// A secret's name, as a pipeline names it: 1 to 64 of `A-Z a-z 0-9 . _ -`. |
|
| 32 | + | pub fn name_ok(name: &str) -> bool { |
|
| 33 | + | !name.is_empty() && name.len() <= 64 && name.bytes().all(|b| b.is_ascii_alphanumeric() || b".-_".contains(&b)) |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | pub async fn list(pool: &Pool, scope: Scope) -> sqlx::Result<Vec<Secret>> { |
|
| 37 | + | sqlx::query_as("SELECT * FROM flow_secrets WHERE scope = ? AND scope_id = ? ORDER BY name") |
|
| 38 | + | .bind(scope.kind()) |
|
| 39 | + | .bind(scope.id()) |
|
| 40 | + | .fetch_all(pool) |
|
| 41 | + | .await |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | /// Set a secret at a level, new or changed; `sealed` is the value sealed. |
|
| 45 | + | pub async fn set(pool: &Pool, scope: Scope, name: &str, sealed: &[u8]) -> sqlx::Result<()> { |
|
| 46 | + | sqlx::query( |
|
| 47 | + | "INSERT INTO flow_secrets (scope, scope_id, name, value, updated_ms) VALUES (?, ?, ?, ?, ?) \ |
|
| 48 | + | ON CONFLICT (scope, scope_id, name) DO UPDATE SET value = excluded.value, updated_ms = excluded.updated_ms", |
|
| 49 | + | ) |
|
| 50 | + | .bind(scope.kind()) |
|
| 51 | + | .bind(scope.id()) |
|
| 52 | + | .bind(name) |
|
| 53 | + | .bind(sealed) |
|
| 54 | + | .bind(now_ms()) |
|
| 55 | + | .execute(pool) |
|
| 56 | + | .await?; |
|
| 57 | + | Ok(()) |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | /// Remove a secret from a level; false when it was not there. |
|
| 61 | + | pub async fn delete(pool: &Pool, scope: Scope, name: &str) -> sqlx::Result<bool> { |
|
| 62 | + | let r = sqlx::query("DELETE FROM flow_secrets WHERE scope = ? AND scope_id = ? AND name = ?") |
|
| 63 | + | .bind(scope.kind()) |
|
| 64 | + | .bind(scope.id()) |
|
| 65 | + | .bind(name) |
|
| 66 | + | .execute(pool) |
|
| 67 | + | .await?; |
|
| 68 | + | Ok(r.rows_affected() == 1) |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | /// Forget a level's secrets, when the organisation or repository goes. |
|
| 72 | + | pub async fn delete_scope(pool: &Pool, scope: Scope) -> sqlx::Result<()> { |
|
| 73 | + | sqlx::query("DELETE FROM flow_secrets WHERE scope = ? AND scope_id = ?").bind(scope.kind()).bind(scope.id()).execute(pool).await?; |
|
| 74 | + | Ok(()) |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | /// A secret as a level sees it: where from, and whether an inner level |
|
| 78 | + | /// sets the same name. The value stays sealed. |
|
| 79 | + | #[derive(Clone, Debug)] |
|
| 80 | + | pub struct Seen { |
|
| 81 | + | pub secret: Secret, |
|
| 82 | + | /// `the site`, `the acme organisation`, or empty for the level's own. |
|
| 83 | + | pub from: String, |
|
| 84 | + | pub overridden: bool, |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | /// Every secret a level sees, outermost first. |
|
| 88 | + | pub async fn seen(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<Seen>> { |
|
| 89 | + | let mut lvls = levels::above(pool, scope, owner).await?; |
|
| 90 | + | lvls.push((scope, String::new())); |
|
| 91 | + | let mut out: Vec<Seen> = vec![]; |
|
| 92 | + | for (level, from) in lvls { |
|
| 93 | + | for s in list(pool, level).await? { |
|
| 94 | + | for o in out.iter_mut().filter(|o| o.secret.name == s.name) { |
|
| 95 | + | o.overridden = true; |
|
| 96 | + | } |
|
| 97 | + | out.push(Seen { secret: s, from: from.clone(), overridden: false }); |
|
| 98 | + | } |
|
| 99 | + | } |
|
| 100 | + | Ok(out) |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | /// The values of the named secrets as a repository's jobs get them: |
|
| 104 | + | /// `(name, value)` for each that is set and opens; names not set are |
|
| 105 | + | /// left out, as are values sealed under another key. |
|
| 106 | + | pub async fn for_job(app: &Shared, repo: &crate::db::repos::RepoRow, names: &[String]) -> sqlx::Result<Vec<(String, String)>> { |
|
| 107 | + | let all = seen(&app.db, Scope::Repo(repo.id), &repo.owner).await?; |
|
| 108 | + | let mut out = vec![]; |
|
| 109 | + | for name in names { |
|
| 110 | + | if let Some(s) = all.iter().find(|s| !s.overridden && s.secret.name == *name) { |
|
| 111 | + | if let Some(value) = app.secret_key.open(&s.secret.value) { |
|
| 112 | + | out.push((name.clone(), value)); |
|
| 113 | + | } |
|
| 114 | + | } |
|
| 115 | + | } |
|
| 116 | + | Ok(out) |
|
| 117 | + | } |
|
| 118 | + | ||
| 119 | + | /// `text` with every secret value (and its standard base64) replaced by |
|
| 120 | + | /// `***`, for a job's log. |
|
| 121 | + | pub fn mask(text: &str, values: &[String]) -> String { |
|
| 122 | + | let mut out = text.to_string(); |
|
| 123 | + | for v in values { |
|
| 124 | + | if v.len() < MIN_MASKED { |
|
| 125 | + | continue; |
|
| 126 | + | } |
|
| 127 | + | out = out.replace(v.as_str(), "***"); |
|
| 128 | + | let b64 = base64_standard(v.as_bytes()); |
|
| 129 | + | if b64.len() >= MIN_MASKED { |
|
| 130 | + | out = out.replace(&b64, "***"); |
|
| 131 | + | } |
|
| 132 | + | } |
|
| 133 | + | out |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | fn base64_standard(bytes: &[u8]) -> String { |
|
| 137 | + | const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; |
|
| 138 | + | let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); |
|
| 139 | + | for chunk in bytes.chunks(3) { |
|
| 140 | + | let n = chunk.iter().fold(0u32, |acc, b| (acc << 8) | *b as u32) << (8 * (3 - chunk.len())); |
|
| 141 | + | for i in 0..4 { |
|
| 142 | + | if i <= chunk.len() { |
|
| 143 | + | out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char); |
|
| 144 | + | } else { |
|
| 145 | + | out.push('='); |
|
| 146 | + | } |
|
| 147 | + | } |
|
| 148 | + | } |
|
| 149 | + | out |
|
| 150 | + | } |
|
| 151 | + | ||
| 152 | + | #[cfg(test)] |
|
| 153 | + | mod tests { |
|
| 154 | + | use super::*; |
|
| 155 | + | ||
| 156 | + | #[test] |
|
| 157 | + | fn values_and_their_base64_are_masked_in_logs() { |
|
| 158 | + | assert_eq!(base64_standard(b"hunter22"), "aHVudGVyMjI="); |
|
| 159 | + | let line = "token=hunter22 b64=aHVudGVyMjI= short=ab done"; |
|
| 160 | + | assert_eq!(mask(line, &["hunter22".into(), "ab".into()]), "token=*** b64=*** short=ab done"); |
|
| 161 | + | assert!(name_ok("deploy-token") && name_ok("aws.key_1") && !name_ok("") && !name_ok("a b") && !name_ok(&"x".repeat(65))); |
|
| 162 | + | } |
|
| 163 | + | } |
added crates/hub/src/web/secrets.rs +67 −0
| 1 | + | //! The Flow secrets panel (hub spec §13.2, oxygenhub#107) shared by the |
|
| 2 | + | //! administration, organisation and repository settings pages: names |
|
| 3 | + | //! only, values typed once and never shown. |
|
| 4 | + | ||
| 5 | + | use crate::auth::Viewer; |
|
| 6 | + | use crate::db::accounts; |
|
| 7 | + | use crate::levels::At; |
|
| 8 | + | use crate::secrets::{self, Seen}; |
|
| 9 | + | use crate::Shared; |
|
| 10 | + | use serde::Deserialize; |
|
| 11 | + | ||
| 12 | + | #[derive(Deserialize)] |
|
| 13 | + | pub struct SecretForm { |
|
| 14 | + | #[serde(rename = "_csrf")] |
|
| 15 | + | pub csrf_token: String, |
|
| 16 | + | #[serde(default)] |
|
| 17 | + | action: String, |
|
| 18 | + | #[serde(default)] |
|
| 19 | + | name: String, |
|
| 20 | + | #[serde(default)] |
|
| 21 | + | value: String, |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | pub struct Panel { |
|
| 25 | + | pub own: Vec<Seen>, |
|
| 26 | + | pub inherited: Vec<Seen>, |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | pub async fn panel(app: &Shared, at: &At) -> sqlx::Result<Panel> { |
|
| 30 | + | let (own, inherited): (Vec<_>, Vec<_>) = |
|
| 31 | + | secrets::seen(&app.db, at.scope, &at.owner).await?.into_iter().partition(|v| v.from.is_empty()); |
|
| 32 | + | Ok(Panel { own, inherited }) |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | /// Apply the panel's form: `Ok(notice)` or `Err(message for the user)`. |
|
| 36 | + | pub async fn apply(app: &Shared, viewer: &Viewer, at: &At, f: SecretForm) -> sqlx::Result<Result<String, String>> { |
|
| 37 | + | let name = f.name.trim(); |
|
| 38 | + | if !secrets::name_ok(name) { |
|
| 39 | + | return Ok(Err("A secret's name is 1 to 64 of A-Z a-z 0-9 . _ -, as the pipeline's `secret` block names it.".into())); |
|
| 40 | + | } |
|
| 41 | + | Ok(Ok(match f.action.as_str() { |
|
| 42 | + | "set" => { |
|
| 43 | + | let value = f.value.replace("\r\n", "\n"); |
|
| 44 | + | if value.is_empty() { |
|
| 45 | + | return Ok(Err("Type the secret's value.".into())); |
|
| 46 | + | } |
|
| 47 | + | if value.len() > secrets::MAX_VALUE { |
|
| 48 | + | return Ok(Err(format!("A value is up to {} bytes.", secrets::MAX_VALUE))); |
|
| 49 | + | } |
|
| 50 | + | let own = secrets::list(&app.db, at.scope).await?; |
|
| 51 | + | if own.len() >= secrets::MAX_PER_LEVEL && !own.iter().any(|s| s.name == name) { |
|
| 52 | + | return Ok(Err(format!("Up to {} secrets here.", secrets::MAX_PER_LEVEL))); |
|
| 53 | + | } |
|
| 54 | + | secrets::set(&app.db, at.scope, name, &app.secret_key.seal(&value)).await?; |
|
| 55 | + | accounts::audit(&app.db, viewer.account(), "flow.secret.set", &at.label, name).await.ok(); |
|
| 56 | + | format!("{name} set.") |
|
| 57 | + | } |
|
| 58 | + | "delete" => { |
|
| 59 | + | if !secrets::delete(&app.db, at.scope, name).await? { |
|
| 60 | + | return Ok(Err(format!("There is no secret {name} here."))); |
|
| 61 | + | } |
|
| 62 | + | accounts::audit(&app.db, viewer.account(), "flow.secret.deleted", &at.label, name).await.ok(); |
|
| 63 | + | format!("{name} removed.") |
|
| 64 | + | } |
|
| 65 | + | _ => return Ok(Err("Unknown action.".into())), |
|
| 66 | + | })) |
|
| 67 | + | } |
added templates/secrets_panel.html +27 −0
| 1 | + | {% macro panel(base, s, csrf, what) %} |
|
| 2 | + | <section class="panel" id="secrets"> |
|
| 3 | + | <h2>Secrets</h2> |
|
| 4 | + | <p class="small mute">{{ what }} A job gets only the secrets its pipeline declares, as <code>secret "name" { mount-path "/run/secrets/name" }</code>, written there as a file before it starts; never in the environment, and masked in its log. Values are kept sealed and never shown again.</p> |
|
| 5 | + | {% if !s.inherited.is_empty() %} |
|
| 6 | + | <h3>Inherited</h3> |
|
| 7 | + | <div class="table"><table> |
|
| 8 | + | <tr><th>Name</th><th>Set</th><th>From</th></tr> |
|
| 9 | + | {% for i in s.inherited %}<tr{% if i.overridden %} class="mute"{% endif %}><td><code>{{ i.secret.name }}</code></td><td class="small">{{ i.secret.updated_ms|ago_i }}</td><td class="small">{{ i.from }}{% if i.overridden %} · <span title="A level below sets the same name">overridden</span>{% endif %}</td></tr>{% endfor %} |
|
| 10 | + | </table></div> |
|
| 11 | + | {% endif %} |
|
| 12 | + | {% if s.own.is_empty() %}<p class="mute small">{% if s.inherited.is_empty() %}None.{% else %}None of its own.{% endif %}</p>{% else %} |
|
| 13 | + | <div class="table"><table> |
|
| 14 | + | <tr><th>Name</th><th>Set</th><th></th></tr> |
|
| 15 | + | {% for o in s.own %}<tr><td><code>{{ o.secret.name }}</code></td><td class="small">{{ o.secret.updated_ms|ago_i }}</td> |
|
| 16 | + | <td><form class="tiny" method="post" action="{{ base }}/secrets"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="name" value="{{ o.secret.name }}"><button name="action" value="delete" class="danger-btn">remove</button></form></td></tr>{% endfor %} |
|
| 17 | + | </table></div>{% endif %} |
|
| 18 | + | <form class="card" method="post" action="{{ base }}/secrets"> |
|
| 19 | + | <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="action" value="set"> |
|
| 20 | + | <h3>Set a secret</h3> |
|
| 21 | + | <label for="sname">Name</label><input type="text" id="sname" name="name" placeholder="deploy-token" required autocomplete="off"> |
|
| 22 | + | <label for="svalue">Value</label><textarea id="svalue" name="value" rows="2" autocomplete="off"></textarea> |
|
| 23 | + | <p class="small mute">An existing name takes the new value; a name set above is overridden here.</p> |
|
| 24 | + | <button type="submit">SET</button> |
|
| 25 | + | </form> |
|
| 26 | + | </section> |
|
| 27 | + | {% endmacro %} |
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Secrets for pipelines (#107).** Administration, organisation and repository settings hold secrets, typed once and sealed; a job gets only those its pipeline declares with `secret`, as files Flow mounts before it starts, never in the environment; their values are masked in the job's log as it arrives. |
|
| 8 | + | ||
| 7 | 9 | - **Variables for pipelines (#102).** Administration, organisation and repository settings hold variables for OxygenFlow jobs, inner levels overriding outer, each page showing what it inherits; a claim carries the repository's effective set and Flow adds them to the job's environment under `FLOW_*` and the pipeline's own. Not for secrets, which Flow mounts. |
|
| 8 | 10 | ||
| 9 | 11 | - **AI assistants, step three: drafted descriptions (#97).** The new-proposal form and an open proposal's editor have a draft-with button per assistant; the answer, written from the changes and diff, lands in the editor for the person to change before saving. |
|
| … | 97 unchanged lines |
modified crates/hub/src/assistants.rs +7 −1
| … | 102 unchanged lines |
||
| 103 | 103 | ||
| 104 | 104 | impl SecretKey { |
|
| 105 | 105 | pub fn load_or_create(keys: &Path) -> anyhow::Result<SecretKey> { |
|
| 106 | + | SecretKey::load_or_create_named(keys, "assistants.key") |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | /// The same, for another key file beside it (`secrets.key` seals Flow |
|
| 110 | + | /// secrets, #107). |
|
| 111 | + | pub fn load_or_create_named(keys: &Path, file: &str) -> anyhow::Result<SecretKey> { |
|
| 106 | 112 | std::fs::create_dir_all(keys)?; |
|
| 107 | - | let path = keys.join("assistants.key"); |
|
| 113 | + | let path = keys.join(file); |
|
| 108 | 114 | if let Ok(bytes) = std::fs::read(&path) { |
|
| 109 | 115 | if bytes.len() == 32 { |
|
| 110 | 116 | return Ok(SecretKey(bytes.try_into().unwrap())); |
|
| … | 641 unchanged lines |
modified crates/hub/src/flow.rs +38 −1
| … | 505 unchanged lines |
||
| 506 | 506 | run_number: run.number, |
|
| 507 | 507 | // The site's, the owner's and the repository's variables (#102). |
|
| 508 | 508 | env: crate::variables::env_for_repo(&app.db, &row).await?, |
|
| 509 | + | secrets: vec![], |
|
| 509 | 510 | }; |
|
| 510 | 511 | let mut claim = claim; |
|
| 511 | - | let (inputs, ids, left_out) = subrepo_inputs(&app, &row, &run).await; |
|
| 512 | + | let (inputs, ids, mut left_out) = subrepo_inputs(&app, &row, &run).await; |
|
| 513 | + | // The secrets the job declares, as files (#107); one not set is said |
|
| 514 | + | // at the top of the log and left out, so the job fails on the file. |
|
| 515 | + | let names: Vec<String> = claim.job.secrets.iter().map(|s| s.name.clone()).collect(); |
|
| 516 | + | let found = crate::secrets::for_job(&app, &row, &names).await?; |
|
| 517 | + | for s in &claim.job.secrets { |
|
| 518 | + | match found.iter().find(|(n, _)| *n == s.name) { |
|
| 519 | + | Some((name, value)) => { |
|
| 520 | + | claim.secrets.push(flow_core::api::SecretInput { name: name.clone(), path: s.mount_path.clone(), value: value.clone() }) |
|
| 521 | + | } |
|
| 522 | + | None => left_out.push(format!( |
|
| 523 | + | "secret {:?} is not set for this repository, its owner or the site; {} will be missing", |
|
| 524 | + | s.name, s.mount_path |
|
| 525 | + | )), |
|
| 526 | + | } |
|
| 527 | + | } |
|
| 512 | 528 | if !ids.is_empty() { |
|
| 513 | 529 | fdb::set_also_read(&app.db, job.id, &ids).await?; |
|
| 514 | 530 | } |
|
| … | 127 unchanged lines |
||
| 642 | 658 | if batch.records.is_empty() { |
|
| 643 | 659 | return Ok(StatusCode::NO_CONTENT); |
|
| 644 | 660 | } |
|
| 661 | + | // A secret's value never reaches the stored log (#107). |
|
| 662 | + | let values: Vec<String> = match serde_json::from_str::<flow_core::Job>(&job.spec) { |
|
| 663 | + | Ok(spec) if !spec.secrets.is_empty() => { |
|
| 664 | + | let run = fdb::run_by_id(&app.db, job.run_id).await?; |
|
| 665 | + | let row = match run { |
|
| 666 | + | Some(run) => db::repos::get_by_id(&app.db, run.repo_id).await?, |
|
| 667 | + | None => None, |
|
| 668 | + | }; |
|
| 669 | + | match row { |
|
| 670 | + | Some(row) => { |
|
| 671 | + | let names: Vec<String> = spec.secrets.iter().map(|s| s.name.clone()).collect(); |
|
| 672 | + | crate::secrets::for_job(&app, &row, &names).await?.into_iter().map(|(_, v)| v).collect() |
|
| 673 | + | } |
|
| 674 | + | None => vec![], |
|
| 675 | + | } |
|
| 676 | + | } |
|
| 677 | + | _ => vec![], |
|
| 678 | + | }; |
|
| 645 | 679 | for r in &mut batch.records { |
|
| 680 | + | if !values.is_empty() { |
|
| 681 | + | r.text = crate::secrets::mask(&r.text, &values); |
|
| 682 | + | } |
|
| 646 | 683 | if r.text.len() > MAX_RECORD_TEXT { |
|
| 647 | 684 | let mut cut = MAX_RECORD_TEXT; |
|
| 648 | 685 | while !r.text.is_char_boundary(cut) { |
|
| … | 427 unchanged lines |
modified crates/hub/src/lib.rs +5 −0
| … | 21 unchanged lines |
||
| 22 | 22 | pub mod protocol; |
|
| 23 | 23 | pub mod render; |
|
| 24 | 24 | pub mod repos; |
|
| 25 | + | pub mod secrets; |
|
| 25 | 26 | pub mod variables; |
|
| 26 | 27 | pub mod web; |
|
| 27 | 28 | ||
| … | 11 unchanged lines |
||
| 39 | 40 | pub cookie_key: auth::CookieKey, |
|
| 40 | 41 | /// Seals AI assistants' tokens at rest (hub spec §13.4). |
|
| 41 | 42 | pub assistant_key: assistants::SecretKey, |
|
| 43 | + | /// Seals Flow secrets at rest (hub spec §13.2, #107). |
|
| 44 | + | pub secret_key: assistants::SecretKey, |
|
| 42 | 45 | /// Which assistants are answering which proposals, and recent calls |
|
| 43 | 46 | /// per repository (hub spec §13.4). |
|
| 44 | 47 | pub assistant_gate: std::sync::Mutex<assistants::Gate>, |
|
| … | 17 unchanged lines |
||
| 62 | 65 | let hub_key = keys::ensure_identity(&config.keys_dir(), &config.site.name, config.base_url())?; |
|
| 63 | 66 | let cookie_key = auth::CookieKey::load_or_create(&config.keys_dir())?; |
|
| 64 | 67 | let assistant_key = assistants::SecretKey::load_or_create(&config.keys_dir())?; |
|
| 68 | + | let secret_key = assistants::SecretKey::load_or_create_named(&config.keys_dir(), "secrets.key")?; |
|
| 65 | 69 | let backend = protocol::HubBackend::new(repos.clone(), db.clone(), config.auth.push_tokens.clone(), tokio::runtime::Handle::current()); |
|
| 66 | 70 | let slot = backend.app_slot(); |
|
| 67 | 71 | let server = oxy_server::Server::new(backend); |
|
| … | 7 unchanged lines |
||
| 75 | 79 | hub_key, |
|
| 76 | 80 | cookie_key, |
|
| 77 | 81 | assistant_key, |
|
| 82 | + | secret_key, |
|
| 78 | 83 | assistant_gate: std::sync::Mutex::new(assistants::Gate::default()), |
|
| 79 | 84 | events: events::Events::default(), |
|
| 80 | 85 | rooms: Arc::new(live::Rooms::default()), |
|
| … | 51 unchanged lines |
modified crates/hub/src/web/admin.rs +17 −0
| … | 31 unchanged lines |
||
| 32 | 32 | assistants: super::assistants::Panel, |
|
| 33 | 33 | /// The site's Flow variables (#102). |
|
| 34 | 34 | variables: super::variables::Panel, |
|
| 35 | + | /// The site's Flow secrets (#107). |
|
| 36 | + | secrets: super::secrets::Panel, |
|
| 35 | 37 | } |
|
| 36 | 38 | ||
| 37 | 39 | fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> { |
|
| … | 18 unchanged lines |
||
| 56 | 58 | }; |
|
| 57 | 59 | let assistants = super::assistants::panel(app, &site_assistants()).await.map_err(e)?; |
|
| 58 | 60 | let variables = super::variables::panel(app, &site_assistants()).await.map_err(e)?; |
|
| 61 | + | let secrets = super::secrets::panel(app, &site_assistants()).await.map_err(e)?; |
|
| 59 | 62 | Ok(Page(AdminPage { |
|
| 60 | 63 | assistants, |
|
| 61 | 64 | variables, |
|
| 65 | + | secrets, |
|
| 62 | 66 | shell, |
|
| 63 | 67 | accounts: accounts_list, |
|
| 64 | 68 | orgs, |
|
| … | 334 unchanged lines |
||
| 399 | 403 | let outcome = super::variables::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 400 | 404 | admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await |
|
| 401 | 405 | } |
|
| 406 | + | ||
| 407 | + | /// `POST /admin/secrets`: the site's Flow secrets (#107). |
|
| 408 | + | pub async fn secrets( |
|
| 409 | + | State(app): State<Shared>, |
|
| 410 | + | viewer: Viewer, |
|
| 411 | + | Form(f): Form<super::secrets::SecretForm>, |
|
| 412 | + | ) -> Result<Page<AdminPage>, Error> { |
|
| 413 | + | let shell = Shell::new(&app, "/admin", &viewer); |
|
| 414 | + | require_admin(&viewer, &shell)?; |
|
| 415 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 416 | + | let outcome = super::secrets::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 417 | + | admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await |
|
| 418 | + | } |
modified crates/hub/src/web/mod.rs +4 −0
| … | 27 unchanged lines |
||
| 28 | 28 | mod repo_settings; |
|
| 29 | 29 | mod resolve; |
|
| 30 | 30 | pub mod runners; |
|
| 31 | + | mod secrets; |
|
| 31 | 32 | mod session; |
|
| 32 | 33 | pub(crate) mod settings; |
|
| 33 | 34 | mod statics; |
|
| … | 52 unchanged lines |
||
| 86 | 87 | .route("/orgs/{name}/settings/hooks", post(orgs::hooks)) |
|
| 87 | 88 | .route("/orgs/{name}/settings/assistants", post(orgs::assistants)) |
|
| 88 | 89 | .route("/orgs/{name}/settings/variables", post(orgs::variables)) |
|
| 90 | + | .route("/orgs/{name}/settings/secrets", post(orgs::secrets)) |
|
| 89 | 91 | .route("/orgs/{name}/settings/runners/token", post(orgs::runner_token)) |
|
| 90 | 92 | .route("/orgs/{name}/settings/runners/{id}/remove", post(orgs::runner_remove)) |
|
| 91 | 93 | .route("/orgs/{name}/settings/hooks/{id}/deliveries", get(orgs::deliveries)) |
|
| … | 20 unchanged lines |
||
| 112 | 114 | .route("/admin/mail/test", post(admin::test_mail)) |
|
| 113 | 115 | .route("/admin/assistants", post(admin::assistants)) |
|
| 114 | 116 | .route("/admin/variables", post(admin::variables)) |
|
| 117 | + | .route("/admin/secrets", post(admin::secrets)) |
|
| 115 | 118 | .route("/admin/runners/{id}/remove", post(runners::remove)) |
|
| 116 | 119 | .nest("/api/v1", crate::api::router(app.config.limits.asset_limit.0 as usize)) |
|
| 117 | 120 | .route("/{owner}", get(home::owner)) |
|
| … | 55 unchanged lines |
||
| 173 | 176 | .route("/{owner}/{name}/settings/hooks", post(repo_settings::hooks)) |
|
| 174 | 177 | .route("/{owner}/{name}/settings/assistants", post(repo_settings::assistants)) |
|
| 175 | 178 | .route("/{owner}/{name}/settings/variables", post(repo_settings::variables)) |
|
| 179 | + | .route("/{owner}/{name}/settings/secrets", post(repo_settings::secrets)) |
|
| 176 | 180 | .route("/{owner}/{name}/settings/hooks/{id}/deliveries", get(repo_settings::deliveries)) |
|
| 177 | 181 | .route("/{owner}/{name}/settings/hooks/{id}/redeliver/{delivery}", post(repo_settings::redeliver)) |
|
| 178 | 182 | .route("/{owner}/{name}/settings/checks", post(repo_settings::required_checks)) |
|
| … | 460 unchanged lines |
modified crates/hub/src/web/orgs.rs +34 −2
| 1 | 1 | //! Organisations and teams (hub spec §5.2). |
|
| 2 | 2 | ||
| 3 | 3 | use super::settings::{csrf, require}; |
|
| 4 | - | use super::{Error, Page, Shell}; |
|
| 4 | + | use super::{filters, Error, Page, Shell}; |
|
| 5 | 5 | use crate::auth::{roles, Viewer}; |
|
| 6 | 6 | use crate::db::accounts; |
|
| 7 | 7 | use crate::Shared; |
|
| … | 66 unchanged lines |
||
| 74 | 74 | assistants: super::assistants::Panel, |
|
| 75 | 75 | /// The organisation's Flow variables (#102). |
|
| 76 | 76 | variables: super::variables::Panel, |
|
| 77 | + | /// The organisation's Flow secrets (#107). |
|
| 78 | + | secrets: super::secrets::Panel, |
|
| 77 | 79 | } |
|
| 78 | 80 | ||
| 79 | 81 | async fn org_page( |
|
| … | 23 unchanged lines |
||
| 103 | 105 | } |
|
| 104 | 106 | let assistants = super::assistants::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 105 | 107 | let variables = super::variables::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 106 | - | Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error, runners, runner_token: None, assistants, variables })) |
|
| 108 | + | let secrets = super::secrets::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 109 | + | Ok(Page(OrgSettingsPage { |
|
| 110 | + | shell, |
|
| 111 | + | org, |
|
| 112 | + | members, |
|
| 113 | + | teams, |
|
| 114 | + | hooks, |
|
| 115 | + | notice, |
|
| 116 | + | error, |
|
| 117 | + | runners, |
|
| 118 | + | runner_token: None, |
|
| 119 | + | assistants, |
|
| 120 | + | variables, |
|
| 121 | + | secrets, |
|
| 122 | + | })) |
|
| 107 | 123 | } |
|
| 108 | 124 | ||
| 109 | 125 | async fn owned_org(app: &Shared, shell: &Shell, viewer: &Viewer, name: &str) -> Result<(accounts::Account, accounts::Organisation), Error> { |
|
| … | 293 unchanged lines |
||
| 403 | 419 | Err(error) => org_page(&app, shell, org, None, Some(error)).await, |
|
| 404 | 420 | } |
|
| 405 | 421 | } |
|
| 422 | + | ||
| 423 | + | /// `POST /orgs/{name}/settings/secrets`: the organisation's Flow secrets (#107). |
|
| 424 | + | pub async fn secrets( |
|
| 425 | + | State(app): State<Shared>, |
|
| 426 | + | viewer: Viewer, |
|
| 427 | + | Path(name): Path<String>, |
|
| 428 | + | Form(f): Form<super::secrets::SecretForm>, |
|
| 429 | + | ) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 430 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); |
|
| 431 | + | let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 432 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 433 | + | match super::secrets::apply(&app, &viewer, &org_assistants(&org), f).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 434 | + | Ok(notice) => org_page(&app, shell, org, Some(notice), None).await, |
|
| 435 | + | Err(error) => org_page(&app, shell, org, None, Some(error)).await, |
|
| 436 | + | } |
|
| 437 | + | } |
modified crates/hub/src/web/repo_settings.rs +22 −0
| … | 103 unchanged lines |
||
| 104 | 104 | assistants: super::assistants::Panel, |
|
| 105 | 105 | /// The repository's Flow variables (#102). |
|
| 106 | 106 | variables: super::variables::Panel, |
|
| 107 | + | /// The repository's Flow secrets (#107). |
|
| 108 | + | secrets: super::secrets::Panel, |
|
| 107 | 109 | } |
|
| 108 | 110 | ||
| 109 | 111 | async fn settings_page( |
|
| … | 35 unchanged lines |
||
| 145 | 147 | .collect(); |
|
| 146 | 148 | let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 147 | 149 | let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 150 | + | let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 148 | 151 | Ok(Page(RepoSettingsPage { |
|
| 149 | 152 | assistants, |
|
| 150 | 153 | variables, |
|
| 154 | + | secrets, |
|
| 151 | 155 | runners, |
|
| 152 | 156 | runner_token: None, |
|
| 153 | 157 | shell, |
|
| … | 152 unchanged lines |
||
| 306 | 310 | db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 307 | 311 | crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok(); |
|
| 308 | 312 | crate::variables::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok(); |
|
| 313 | + | crate::secrets::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok(); |
|
| 309 | 314 | accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok(); |
|
| 310 | 315 | Ok(Redirect::to(&format!("/{owner}")).into_response()) |
|
| 311 | 316 | } |
|
| … | 253 unchanged lines |
||
| 565 | 570 | Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await, |
|
| 566 | 571 | } |
|
| 567 | 572 | } |
|
| 573 | + | ||
| 574 | + | /// `POST /{owner}/{name}/settings/secrets`: the repository's Flow secrets |
|
| 575 | + | /// (#107), for its admins. |
|
| 576 | + | pub async fn secrets( |
|
| 577 | + | State(app): State<Shared>, |
|
| 578 | + | viewer: Viewer, |
|
| 579 | + | Path((owner, name)): Path<(String, String)>, |
|
| 580 | + | Form(f): Form<super::secrets::SecretForm>, |
|
| 581 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 582 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 583 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 584 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 585 | + | match super::secrets::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 586 | + | Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await, |
|
| 587 | + | Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await, |
|
| 588 | + | } |
|
| 589 | + | } |
modified crates/hub/tests/flow.rs +111 −0
| … | 1811 unchanged lines |
||
| 1812 | 1812 | let got = claim(app).await.expect("another job"); |
|
| 1813 | 1813 | assert_eq!(got["env"], serde_json::json!({ "CC": "clang", "RUST_LOG": "debug" }), "{got}"); |
|
| 1814 | 1814 | } |
|
| 1815 | + | ||
| 1816 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1817 | + | async fn secrets_a_job_declares_come_with_the_claim_and_never_reach_the_log() { |
|
| 1818 | + | let h = hub().await; |
|
| 1819 | + | let app = &h.app; |
|
| 1820 | + | let res = oxygenhub::router(app.clone()) |
|
| 1821 | + | .oneshot( |
|
| 1822 | + | Request::post("/register") |
|
| 1823 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 1824 | + | .body(Body::from("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 1825 | + | .unwrap(), |
|
| 1826 | + | ) |
|
| 1827 | + | .await |
|
| 1828 | + | .unwrap(); |
|
| 1829 | + | let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string(); |
|
| 1830 | + | let page = |path: &str, form: Option<String>| { |
|
| 1831 | + | let mut req = |
|
| 1832 | + | Request::builder().method(if form.is_some() { "POST" } else { "GET" }).uri(path).header(header::COOKIE, cookie.clone()); |
|
| 1833 | + | if form.is_some() { |
|
| 1834 | + | req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 1835 | + | } |
|
| 1836 | + | let req = req.body(Body::from(form.unwrap_or_default())).unwrap(); |
|
| 1837 | + | let app = app.clone(); |
|
| 1838 | + | async move { |
|
| 1839 | + | let res = oxygenhub::router(app).oneshot(req).await.unwrap(); |
|
| 1840 | + | let status = res.status(); |
|
| 1841 | + | let body = String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap(); |
|
| 1842 | + | (status, body) |
|
| 1843 | + | } |
|
| 1844 | + | }; |
|
| 1845 | + | let csrf_of = |page: &str| { |
|
| 1846 | + | let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); |
|
| 1847 | + | page[i..].split('"').next().unwrap().to_string() |
|
| 1848 | + | }; |
|
| 1849 | + | create(app, "andy", "demo").await; |
|
| 1850 | + | // The site sets deploy-token; the repository overrides it. Neither |
|
| 1851 | + | // page shows a value, only that it is set. |
|
| 1852 | + | let (_, admin) = page("/admin", None).await; |
|
| 1853 | + | let csrf = csrf_of(&admin); |
|
| 1854 | + | let (_, body) = page("/admin/secrets", Some(format!("_csrf={csrf}&action=set&name=deploy-token&value=site-level-value"))).await; |
|
| 1855 | + | assert!(body.contains("deploy-token set.") && !body.contains("site-level-value"), "{body}"); |
|
| 1856 | + | let (_, body) = page("/admin/secrets", Some(format!("_csrf={csrf}&action=set&name=bad+name&value=x"))).await; |
|
| 1857 | + | assert!(body.contains("name is 1 to 64 of"), "{body}"); |
|
| 1858 | + | let (_, settings) = page("/andy/demo/settings", None).await; |
|
| 1859 | + | assert!( |
|
| 1860 | + | settings.contains("Secrets") && settings.contains("<code>deploy-token</code>") && settings.contains("the site</td>"), |
|
| 1861 | + | "{settings}" |
|
| 1862 | + | ); |
|
| 1863 | + | let csrf_r = csrf_of(&settings); |
|
| 1864 | + | let (_, body) = |
|
| 1865 | + | page("/andy/demo/settings/secrets", Some(format!("_csrf={csrf_r}&action=set&name=deploy-token&value=hunter22-repo-value"))).await; |
|
| 1866 | + | assert!(body.contains("deploy-token set.") && body.contains("overridden") && !body.contains("hunter22"), "{body}"); |
|
| 1867 | + | let repo = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 1868 | + | assert!( |
|
| 1869 | + | !String::from_utf8_lossy(&oxygenhub::secrets::list(&app.db, oxygenhub::levels::Scope::Repo(repo.id)).await.unwrap()[0].value) |
|
| 1870 | + | .contains("hunter22"), |
|
| 1871 | + | "sealed at rest" |
|
| 1872 | + | ); |
|
| 1873 | + | ||
| 1874 | + | // A job that declares the secret, and one the levels do not hold. |
|
| 1875 | + | let url = format!("{}/andy/demo", h.base); |
|
| 1876 | + | let work = tempfile::tempdir().unwrap(); |
|
| 1877 | + | let c = client(&url, work.path()); |
|
| 1878 | + | write( |
|
| 1879 | + | work.path(), |
|
| 1880 | + | ".flow/pipeline.kdl", |
|
| 1881 | + | &pipeline("job \"deploy\" {\n sandbox \"alpine\"\n secret \"deploy-token\" {\n mount-path \"/run/secrets/token\"\n }\n secret \"missing\" {\n mount-path \"/run/secrets/missing\"\n }\n exec \"deploy\"\n}\n"), |
|
| 1882 | + | ); |
|
| 1883 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 1884 | + | c.snapshot("test").unwrap(); |
|
| 1885 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 1886 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1887 | + | let runs = runs_of(app, "andy", "demo").await; |
|
| 1888 | + | let jobs = match runs.first() { |
|
| 1889 | + | Some(r) => db::flow::jobs(&app.db, r.id).await.unwrap().iter().map(|j| format!("{}:{}", j.name, j.status)).collect::<Vec<_>>(), |
|
| 1890 | + | None => vec![], |
|
| 1891 | + | }; |
|
| 1892 | + | let mut deploy = None; |
|
| 1893 | + | for _ in 0..50 { |
|
| 1894 | + | let Some(got) = claim(app).await else { |
|
| 1895 | + | tokio::time::sleep(std::time::Duration::from_millis(100)).await; |
|
| 1896 | + | continue; |
|
| 1897 | + | }; |
|
| 1898 | + | if got["job"]["name"] == "deploy" { |
|
| 1899 | + | deploy = Some(got); |
|
| 1900 | + | break; |
|
| 1901 | + | } |
|
| 1902 | + | assert_eq!(got["secrets"], serde_json::Value::Null, "{got}"); |
|
| 1903 | + | } |
|
| 1904 | + | let got = deploy.unwrap_or_else(|| panic!("the deploy job: runs {runs:?} jobs {jobs:?}")); |
|
| 1905 | + | assert_eq!( |
|
| 1906 | + | got["secrets"], |
|
| 1907 | + | serde_json::json!([{ "name": "deploy-token", "path": "/run/secrets/token", "value": "hunter22-repo-value" }]), |
|
| 1908 | + | "the repository's value, the missing one left out: {got}" |
|
| 1909 | + | ); |
|
| 1910 | + | let id = got["job_id"].as_i64().unwrap(); |
|
| 1911 | + | let log = db::flow::log(&app.db, id, 0, 100).await.unwrap(); |
|
| 1912 | + | assert!(log.iter().any(|l| l.stream == "flow" && l.text.contains("secret \"missing\" is not set")), "{log:?}"); |
|
| 1913 | + | ||
| 1914 | + | // The value never reaches the stored log, plain or base64. |
|
| 1915 | + | let records = serde_json::json!({ "records": [ |
|
| 1916 | + | { "t": 1, "stream": "out", "text": "token=hunter22-repo-value" }, |
|
| 1917 | + | { "t": 2, "stream": "err", "text": "b64 aHVudGVyMjItcmVwby12YWx1ZQ== end" }, |
|
| 1918 | + | ] }); |
|
| 1919 | + | let (st, _) = call(app, "POST", &format!("/api/v1/flow/jobs/{id}/log"), Some("runner-secret"), Some(records.to_string())).await; |
|
| 1920 | + | assert_eq!(st, StatusCode::NO_CONTENT); |
|
| 1921 | + | let log = db::flow::log(&app.db, id, 0, 100).await.unwrap(); |
|
| 1922 | + | let texts: Vec<&str> = log.iter().map(|l| l.text.as_str()).collect(); |
|
| 1923 | + | assert!(texts.contains(&"token=***") && texts.contains(&"b64 *** end"), "{texts:?}"); |
|
| 1924 | + | assert!(!texts.iter().any(|t| t.contains("hunter22")), "{texts:?}"); |
|
| 1925 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.
modified docs/spec.md +1 −0
| … | 496 unchanged lines |
||
| 497 | 497 | ||
| 498 | 498 | Otherwise the job's result still stands, and the run page says why the receipt was not kept. The run page links each kept receipt, with its signer. The change page lists the receipts for the state whose only head is that change. `/{owner}/{name}/receipts/{attest}` downloads a signed receipt for `oxy receipt verify` or `import`. |
|
| 499 | 499 | - **Variables** (oxygenhub#102): Administration, an organisation's settings and a repository's settings each hold `NAME=value` pairs (a name is a letter or `_` then letters, digits and `_`, up to 64, never `FLOW_*`; a value up to 4096 characters; up to 100 per level) under **Variables**, each page showing what it inherits and which inherited names it overrides. A claim carries the repository's effective set as `env`, inner levels overriding outer; Flow puts them in the job's environment under `FLOW_*` and the pipeline's own `env`, so they are part of the receipt. They are not secrets and are shown in full to whoever sees the page; secrets are mounted by Flow (OxygenFlow spec §9), never put in the environment. Changes are audited (`flow.variable.set`, `flow.variable.deleted`); `flow run --var NAME=VALUE` gives a local run the same. |
|
| 500 | + | - **Secrets** (oxygenhub#107; OxygenFlow spec §9): the same three levels hold **Secrets**, named as a pipeline's `secret "name" { mount-path "/run/secrets/…" }` names them (1 to 64 of `A-Z a-z 0-9 . _ -`, a value up to 64 KiB, up to 100 per level), typed once and sealed under `keys/secrets.key`; pages show names, when each was set and where it comes from, never a value. A claim carries only the secrets its job declares, each with its path and value, from the innermost level that sets the name; one no level sets is noted at the top of the job's log and left out, so the job fails on the missing file rather than running with a stand-in. Flow writes each as a file (0400, the job's user's, on an in-memory mount) before the job starts and never puts it in the environment, the receipt or an artifact. Log intake replaces each value, and its base64, with `***` before a record is stored. Changes are audited (`flow.secret.set`, `flow.secret.deleted`), never with the value. |
|
| 500 | 501 | - **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`. |
|
| 501 | 502 | - The runner holding the job uploads each one with `POST /api/v1/flow/jobs/{id}/artifacts/{name}` (raw bytes, at most 512 MiB). |
|
| 502 | 503 | - The hub refuses names the job does not declare. It keeps each artifact as a blob of the run's repository, records its path, size and BLAKE3 in `flow_artifacts`, and answers with the blob's ObjectRef. |
|
| … | 196 unchanged lines |
modified templates/admin.html +2 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% import "assistants_panel.html" as ai %} |
|
| 3 | 3 | {% import "variables_panel.html" as va %} |
|
| 4 | + | {% import "secrets_panel.html" as se %} |
|
| 4 | 5 | {% block title %}Administration{% endblock %} |
|
| 5 | 6 | {% block content %} |
|
| 6 | 7 | <div class="head"><h1>Administration <small>{{ accounts.len() }} accounts · {{ orgs.len() }} organisations · {{ repos }} repositories</small></h1></div> |
|
| … | 51 unchanged lines |
||
| 58 | 59 | </aside> |
|
| 59 | 60 | </div> |
|
| 60 | 61 | {% call va::panel("/admin", variables, shell.csrf, "Every repository's jobs get these, unless an organisation or repository sets the same name.") %} |
|
| 62 | + | {% call se::panel("/admin", secrets, shell.csrf, "Every repository's jobs may use these, unless an organisation or repository sets the same name.") %} |
|
| 61 | 63 | {% call ai::panel("/admin", assistants, shell.csrf, "Every repository on the site may ask these, unless an organisation or repository turns one off.") %} |
|
| 62 | 64 | {% endblock %} |
modified templates/org_settings.html +3 −0
| … | 1 unchanged lines |
||
| 2 | 2 | {% import "webhooks_panel.html" as w %} |
|
| 3 | 3 | {% import "assistants_panel.html" as ai %} |
|
| 4 | 4 | {% import "variables_panel.html" as va %} |
|
| 5 | + | {% import "secrets_panel.html" as se %} |
|
| 5 | 6 | {% block title %}{{ org.name }} settings{% endblock %} |
|
| 6 | 7 | {% block content %} |
|
| 7 | 8 | <div class="head"><h1>{{ org.name }} <small>organisation settings · <a href="/{{ org.name }}">repositories</a></small></h1></div> |
|
| … | 4 unchanged lines |
||
| 12 | 13 | <a href="#members">Members</a> |
|
| 13 | 14 | <a href="#webhooks">Webhooks</a> |
|
| 14 | 15 | <a href="#variables">Variables</a> |
|
| 16 | + | <a href="#secrets">Secrets</a> |
|
| 15 | 17 | <a href="#assistants">AI assistants</a> |
|
| 16 | 18 | <a href="#teams">Teams</a> |
|
| 17 | 19 | </nav> |
|
| … | 60 unchanged lines |
||
| 78 | 80 | </section> |
|
| 79 | 81 | {% call w::panel("/orgs/" ~ org.name ~ "/settings", hooks, shell.csrf, "These hear every repository of the organisation, as well as each repository's own webhooks.") %} |
|
| 80 | 82 | {% call va::panel("/orgs/" ~ org.name ~ "/settings", variables, shell.csrf, "Jobs of every repository of the organisation get these, beside the site's and each repository's own.") %} |
|
| 83 | + | {% call se::panel("/orgs/" ~ org.name ~ "/settings", secrets, shell.csrf, "Jobs of every repository of the organisation may use these, beside the site's and each repository's own.") %} |
|
| 81 | 84 | {% call ai::panel("/orgs/" ~ org.name ~ "/settings", assistants, shell.csrf, "Every repository of the organisation may ask these, beside the site's and its own.") %} |
|
| 82 | 85 | </div> |
|
| 83 | 86 | </div> |
|
| … | 1 unchanged lines |
modified templates/repo_settings.html +3 −0
| … | 1 unchanged lines |
||
| 2 | 2 | {% import "webhooks_panel.html" as w %} |
|
| 3 | 3 | {% import "assistants_panel.html" as ai %} |
|
| 4 | 4 | {% import "variables_panel.html" as va %} |
|
| 5 | + | {% import "secrets_panel.html" as se %} |
|
| 5 | 6 | {% import "repo_tabs.html" as m %} |
|
| 6 | 7 | {% block title %}Settings · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 7 | 8 | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| … | 11 unchanged lines |
||
| 19 | 20 | <a href="#flow">OxygenFlow</a> |
|
| 20 | 21 | {% if role == crate::auth::Role::Admin %}<a href="#webhooks">Webhooks</a> |
|
| 21 | 22 | <a href="#variables">Variables</a> |
|
| 23 | + | <a href="#secrets">Secrets</a> |
|
| 22 | 24 | <a href="#assistants">AI assistants</a> |
|
| 23 | 25 | <a href="#collaborators">Collaborators</a> |
|
| 24 | 26 | <a href="#danger">Delete</a>{% endif %} |
|
| … | 69 unchanged lines |
||
| 94 | 96 | </section> |
|
| 95 | 97 | {% call w::panel(repo.url ~ "/settings", hooks, shell.csrf, "Each delivery is signed with the secret (X-Oxyhub-Signature) and retried with backoff for a day.") %} |
|
| 96 | 98 | {% call va::panel(repo.url ~ "/settings", variables, shell.csrf, "This repository's jobs get these, beside the ones the site and its owner set.") %} |
|
| 99 | + | {% call se::panel(repo.url ~ "/settings", secrets, shell.csrf, "This repository's jobs may use these, beside the ones the site and its owner set.") %} |
|
| 97 | 100 | {% call ai::panel(repo.url ~ "/settings", assistants, shell.csrf, "This repository may ask these, beside the ones the site and its owner provide.") %} |
|
| 98 | 101 | <section class="panel" id="collaborators"> |
|
| 99 | 102 | <h2>Collaborators</h2> |
|
| … | 30 unchanged lines |