OXYGENHUB

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