OXYGENHUB
# c79a1111dd56 oxygenhub / crates / hub / src / protocol.rs
12.4 KiB · Rust · last changed Changes that mention #N put 'referenced by change' on that issue's or proposal's timeline, on any bookmark, with the change linked 1 month ago source blame history raw
d23464c0 Andy McCall ↶ 1
//! The wire protocol, embedded (hub spec §3.3, §5.4): `oxy-server`'s
19bb31ff Andy McCall ↶ 2
//! `Server` over a backend that resolves `owner/name`, authorises reads
3
//! and pushes by account role and token scope, and records who pushed.
d23464c0 Andy McCall ↶ 4
19bb31ff Andy McCall ↶ 5
use crate::auth::{self, Access, Role};
6
use crate::db::{self, accounts::Account};
d23464c0 Andy McCall ↶ 7
use crate::repos::{RepoName, Repos};
19bb31ff Andy McCall ↶ 8
use crate::Shared;
d23464c0 Andy McCall ↶ 9
use axum::body::Bytes;
10
use axum::extract::{Path, State};
11
use axum::http::{HeaderMap, Method, StatusCode};
12
use axum::response::{IntoResponse, Response};
62d6e969 Andy McCall ↶ 13
use oxy_core::repo::{PushReport, Repo};
19bb31ff Andy McCall ↶ 14
use oxy_server::{Backend, Endpoint, Failure};
d23464c0 Andy McCall ↶ 15
use std::sync::Arc;
16
17
pub struct HubBackend {
18
    repos: Arc<Repos>,
19
    pool: db::Pool,
19bb31ff Andy McCall ↶ 20
    /// Site tokens from `hub.toml`: full access, for bootstrapping.
21
    site_tokens: Vec<String>,
d23464c0 Andy McCall ↶ 22
    runtime: tokio::runtime::Handle,
18e381ea Andy McCall ↶ 23
    /// The application, set once it exists (the backend is built first).
24
    app_hint: Arc<std::sync::Mutex<Option<std::sync::Weak<crate::App>>>>,
d23464c0 Andy McCall ↶ 25
}
26
19bb31ff Andy McCall ↶ 27
/// Who a request's credential resolved to.
28
enum Caller {
29
    Anonymous,
30
    /// A site token from the configuration.
31
    Site,
32
    Token(Box<Account>, Box<db::accounts::Token>),
42fa9554 Andy McCall ↶ 33
    /// A running OxygenFlow job: reads its own repository, nothing else (hub spec §13.2).
34
    Job {
35
        repo_id: i64,
36
    },
19bb31ff Andy McCall ↶ 37
}
38
d23464c0 Andy McCall ↶ 39
impl HubBackend {
19bb31ff Andy McCall ↶ 40
    pub fn new(repos: Arc<Repos>, pool: db::Pool, site_tokens: Vec<String>, runtime: tokio::runtime::Handle) -> HubBackend {
18e381ea Andy McCall ↶ 41
        HubBackend { repos, pool, site_tokens, runtime, app_hint: Arc::new(std::sync::Mutex::new(None)) }
42
    }
43
44
    /// The slot the application registers itself in after construction.
45
    pub fn app_slot(&self) -> Arc<std::sync::Mutex<Option<std::sync::Weak<crate::App>>>> {
46
        self.app_hint.clone()
19bb31ff Andy McCall ↶ 47
    }
48
49
    fn block<T>(&self, f: impl std::future::Future<Output = T>) -> T {
50
        tokio::task::block_in_place(|| self.runtime.block_on(f))
d23464c0 Andy McCall ↶ 51
    }
52
53
    fn row(&self, name: &RepoName) -> Result<db::repos::RepoRow, Failure> {
54
        let pool = self.pool.clone();
19bb31ff Andy McCall ↶ 55
        match self.block(db::repos::get(&pool, &name.owner, &name.name)) {
d23464c0 Andy McCall ↶ 56
            Ok(Some(r)) => Ok(r),
57
            Ok(None) => Err(not_found()),
58
            Err(e) => Err(Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
59
        }
60
    }
61
19bb31ff Andy McCall ↶ 62
    fn caller(&self, headers: &HeaderMap) -> Result<Caller, Failure> {
63
        let Some(presented) = oxy_server::bearer_token(headers) else { return Ok(Caller::Anonymous) };
64
        if self.site_tokens.iter().any(|t| auth::mac_equal(t, presented)) {
65
            return Ok(Caller::Site);
d23464c0 Andy McCall ↶ 66
        }
19bb31ff Andy McCall ↶ 67
        let hash = auth::token_hash(presented);
68
        let pool = self.pool.clone();
42fa9554 Andy McCall ↶ 69
        if presented.starts_with(crate::flow::JOB_TOKEN_PREFIX) {
70
            let job = self.block(db::flow::job_by_token(&pool, &hash, crate::flow::JOB_TOKEN_MAX_AGE_MS)).map_err(internal)?;
71
            let Some(job) = job else { return Err(Failure(StatusCode::UNAUTHORIZED, "this job token is not valid".into())) };
72
            let run = self.block(db::flow::run_by_id(&pool, job.run_id)).map_err(internal)?;
73
            let Some(run) = run else { return Err(Failure(StatusCode::UNAUTHORIZED, "this job token is not valid".into())) };
74
            return Ok(Caller::Job { repo_id: run.repo_id });
75
        }
19bb31ff Andy McCall ↶ 76
        let token = self.block(db::accounts::token_by_hash(&pool, &hash)).map_err(internal)?;
77
        let Some(token) = token else { return Err(Failure(StatusCode::UNAUTHORIZED, "unknown token".into())) };
78
        if token.expired(db::now_ms()) {
79
            return Err(Failure(StatusCode::UNAUTHORIZED, "this token has expired".into()));
80
        }
81
        let account = self.block(db::accounts::account_by_id(&pool, token.account_id)).map_err(internal)?;
82
        let Some(account) = account else { return Err(Failure(StatusCode::UNAUTHORIZED, "unknown token".into())) };
83
        if account.banned {
84
            return Err(Failure(StatusCode::UNAUTHORIZED, "this account is banned".into()));
85
        }
86
        let _ = self.block(db::accounts::touch_token(&pool, token.id));
87
        Ok(Caller::Token(Box::new(account), Box::new(token)))
d23464c0 Andy McCall ↶ 88
    }
89
}
90
19bb31ff Andy McCall ↶ 91
fn internal(e: sqlx::Error) -> Failure {
92
    Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
93
}
94
d23464c0 Andy McCall ↶ 95
fn not_found() -> Failure {
96
    Failure(StatusCode::NOT_FOUND, "no such repository".into())
97
}
98
99
impl Backend for HubBackend {
100
    fn open(&self, name: &str) -> Result<Repo, Failure> {
101
        let name = RepoName::parse(name).ok_or_else(not_found)?;
102
        self.repos.open(&name).map_err(|e| match e {
103
            crate::repos::RepoError::NotFound => not_found(),
104
            other => Failure(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
105
        })
106
    }
107
19bb31ff Andy McCall ↶ 108
    fn authorize(&self, name: &str, access: oxy_server::Access, headers: &HeaderMap) -> Result<(), Failure> {
d23464c0 Andy McCall ↶ 109
        let parsed = RepoName::parse(name).ok_or_else(not_found)?;
110
        let row = self.row(&parsed)?;
19bb31ff Andy McCall ↶ 111
        let caller = self.caller(headers)?;
42fa9554 Andy McCall ↶ 112
        if let Caller::Job { repo_id } = caller {
113
            // Only reads, only of the job's own repository.
114
            return match access {
115
                oxy_server::Access::Read if repo_id == row.id => Ok(()),
116
                oxy_server::Access::Read => Err(not_found()),
117
                oxy_server::Access::Push => Err(Failure(StatusCode::FORBIDDEN, "a job token cannot push".into())),
118
            };
119
        }
19bb31ff Andy McCall ↶ 120
        let (role, scope_ok) = match &caller {
42fa9554 Andy McCall ↶ 121
            Caller::Job { .. } => unreachable!(),
19bb31ff Andy McCall ↶ 122
            Caller::Site => (Some(Role::Admin), true),
123
            Caller::Anonymous => (self.block(auth::roles::role_for(&self.pool, None, &row)).map_err(internal)?, true),
124
            Caller::Token(account, token) => {
125
                let role = self.block(auth::roles::role_for(&self.pool, Some(account), &row)).map_err(internal)?;
126
                let needed = match access {
127
                    oxy_server::Access::Read => {
128
                        token.has_scope("repo:read") || token.has_scope("repo:write") || token.has_scope("repo:admin")
129
                    }
130
                    oxy_server::Access::Push => token.has_scope("repo:write") || token.has_scope("repo:admin"),
131
                };
132
                (role, needed && token.covers(&parsed.to_string()))
133
            }
134
        };
135
        // An unreadable repository does not exist for the caller (hub spec §4.4).
136
        if !auth::roles::allows(role, Access::Read) {
d23464c0 Andy McCall ↶ 137
            return Err(not_found());
138
        }
139
        match access {
19bb31ff Andy McCall ↶ 140
            oxy_server::Access::Read if scope_ok => Ok(()),
141
            oxy_server::Access::Read => Err(Failure(StatusCode::FORBIDDEN, "this token cannot read repositories".into())),
142
            oxy_server::Access::Push => {
143
                if matches!(caller, Caller::Anonymous) {
144
                    return Err(Failure(StatusCode::UNAUTHORIZED, "a personal access token is required to push".into()));
145
                }
146
                if !auth::roles::allows(role, Access::Write) {
147
                    return Err(Failure(StatusCode::FORBIDDEN, "you do not have write access to this repository".into()));
148
                }
149
                if !scope_ok {
150
                    return Err(Failure(StatusCode::FORBIDDEN, "this token lacks the repo:write scope for this repository".into()));
151
                }
152
                Ok(())
153
            }
d23464c0 Andy McCall ↶ 154
        }
155
    }
156
19bb31ff Andy McCall ↶ 157
    fn after_push(&self, name: &str, report: &PushReport, headers: &HeaderMap) {
d23464c0 Andy McCall ↶ 158
        let Some(parsed) = RepoName::parse(name) else { return };
159
        let Ok(row) = self.row(&parsed) else { return };
19bb31ff Andy McCall ↶ 160
        let pusher = match self.caller(headers) {
161
            Ok(Caller::Token(account, _)) => Some((account.id, account.username)),
162
            Ok(Caller::Site) => Some((0, "site token".to_string())),
163
            _ => None,
164
        };
d23464c0 Andy McCall ↶ 165
        let pool = self.pool.clone();
62d6e969 Andy McCall ↶ 166
        let results: Vec<db::repos::PushOutcome> = report
167
            .results
168
            .iter()
169
            .map(|r| {
170
                let updated = report.updated.iter().find(|u| u.name == r.name);
171
                db::repos::PushOutcome {
172
                    reference: r.name.clone(),
173
                    ok: r.ok,
174
                    reason: r.reason.clone(),
175
                    old_state: updated.map(|u| u.old_state.to_hex()).unwrap_or_default(),
176
                    new_state: updated.map(|u| u.new_state.to_hex()).unwrap_or_default(),
177
                    heads: updated.map(|u| u.heads.len() as i64).unwrap_or(0),
178
                    changes: if r.ok { report.imported.len() as i64 } else { 0 },
179
                }
180
            })
181
            .collect();
78001759 Andy McCall ↶ 182
        // Messages of the changes stored: `closes #N` when the default
183
        // bookmark moved, and `#N` references on any bookmark (hub spec §8).
184
        let bookmarks: Vec<String> = report
185
            .updated
186
            .iter()
187
            .filter(|u| !u.name.starts_with("tags/") && report.results.iter().any(|r| r.ok && r.name == u.name))
188
            .map(|u| u.name.clone())
189
            .collect();
190
        let imported: Vec<(String, String)> = if bookmarks.is_empty() {
191
            vec![]
192
        } else {
18e381ea Andy McCall ↶ 193
            self.repos
194
                .open(&parsed)
195
                .map(|r| report.imported.iter().filter_map(|h| r.store().get_change(h).ok().map(|c| (h.to_hex(), c.message))).collect())
196
                .unwrap_or_default()
78001759 Andy McCall ↶ 197
        };
198
        let messages: Vec<(String, String)> = if bookmarks.contains(&row.default_bookmark) { imported.clone() } else { vec![] };
199
        let on = if bookmarks.contains(&row.default_bookmark) {
200
            row.default_bookmark.clone()
18e381ea Andy McCall ↶ 201
        } else {
78001759 Andy McCall ↶ 202
            bookmarks.first().cloned().unwrap_or_default()
18e381ea Andy McCall ↶ 203
        };
42fa9554 Andy McCall ↶ 204
        // Bookmarks that moved, with their new heads, for OxygenFlow runs.
205
        let moved: Vec<(String, String, Vec<oxy_core::hash::ChangeHash>)> = report
206
            .updated
207
            .iter()
208
            .filter(|u| report.results.iter().any(|r| r.ok && r.name == u.name))
209
            .map(|u| (u.name.clone(), u.new_state.to_hex(), u.heads.clone()))
210
            .collect();
18e381ea Andy McCall ↶ 211
        let app_hint = self.app_hint.clone();
d23464c0 Andy McCall ↶ 212
        self.runtime.spawn(async move {
18e381ea Andy McCall ↶ 213
            let who = pusher.as_ref().map(|(id, name)| (*id, name.as_str()));
214
            if let Err(e) = db::repos::record_push(&pool, row.id, who, &results).await {
d23464c0 Andy McCall ↶ 215
                tracing::warn!("recording push to {}: {e}", parsed);
216
            }
18e381ea Andy McCall ↶ 217
            let app = app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade());
218
            if let Some(app) = app {
219
                crate::web::issues::close_from_messages(&app, &row, &messages).await;
78001759 Andy McCall ↶ 220
                let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
221
                crate::web::issues::references_from_messages(&app, &row, &imported, &on, &actor).await;
3f4b0ea6 Andy McCall ↶ 222
                if results.iter().any(|r| r.ok && r.reference == row.default_bookmark) {
223
                    crate::mirror::refresh(&app, &row).await;
224
                }
b64e2537 Andy McCall ↶ 225
                let accepted: Vec<serde_json::Value> = results
226
                    .iter()
227
                    .filter(|r| r.ok)
228
                    .map(|r| serde_json::json!({ "reference": r.reference, "old_state": r.old_state, "new_state": r.new_state, "heads": r.heads, "changes": r.changes }))
229
                    .collect();
230
                if !accepted.is_empty() {
231
                    let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
232
                    crate::hooks::emit(&app, &row, "push", &actor, serde_json::json!({ "updates": accepted })).await;
42fa9554 Andy McCall ↶ 233
                }
234
                let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
235
                for (bookmark, state, heads) in &moved {
236
                    crate::flow::on_push(&app, &row, bookmark, state, heads, &actor).await;
b64e2537 Andy McCall ↶ 237
                }
18e381ea Andy McCall ↶ 238
            }
d23464c0 Andy McCall ↶ 239
        });
240
    }
241
}
242
243
/// `/{owner}/{name}/oxy/v1/{endpoint}`.
244
pub async fn dispatch(
245
    State(app): State<Shared>,
246
    Path((owner, name, endpoint)): Path<(String, String, String)>,
247
    method: Method,
248
    headers: HeaderMap,
249
    body: Bytes,
250
) -> Response {
251
    let Some(endpoint) = Endpoint::parse(&endpoint) else {
252
        return Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response();
253
    };
254
    let Some(repo) = RepoName::new(&owner, &name) else {
255
        return not_found().into_response();
256
    };
257
    let _guard = match endpoint {
258
        Endpoint::Push | Endpoint::Checkpoint => Guard::Write(app.repos.write_guard(&repo).await),
259
        _ => Guard::Read(app.repos.read_guard(&repo).await),
260
    };
261
    app.server.handle(repo.to_string(), endpoint, &method, &headers, body).await
262
}
263
264
enum Guard {
265
    Read(#[allow(dead_code)] tokio::sync::OwnedRwLockReadGuard<()>),
266
    Write(#[allow(dead_code)] tokio::sync::OwnedRwLockWriteGuard<()>),
267
}