//! `impl forge_ssh::GitBackend` for forge: key lookup, authz and git process construction. use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use forge_core::lfs_token::{self, LfsOp}; use forge_core::{Repo, User}; use forge_git::GitService; use forge_ssh::{GitBackend, LfsOperation}; use crate::access::{self, AccessError, Transport}; use crate::state::AppState; pub struct SshBackend { pub state: AppState, } const MSG_NOT_FOUND: &str = "repository not found"; const MSG_DENIED: &str = "permission denied"; const MSG_INTERNAL: &str = "internal error"; /// Lifetime of the LFS transfer token handed out by `git-lfs-authenticate`. const LFS_TOKEN_TTL_SECS: i64 = 3600; impl SshBackend { /// Looks up the user and applies the same repository authorization git uses for `service`. async fn authorize( &self, user_id: &str, owner: &str, name: &str, service: GitService, ) -> Result<(User, Repo, PathBuf), String> { let user = match self.state.store.user_by_id(user_id).await { Ok(Some(user)) if !user.is_org() => user, Ok(_) => return Err(MSG_DENIED.to_string()), Err(e) => { tracing::error!(error = %e, "ssh: user lookup failed"); return Err(MSG_INTERNAL.to_string()); } }; match access::resolve_repo(&self.state, Some(&user), owner, name, service).await { Ok((repo, path)) => Ok((user, repo, path)), Err(AccessError::NotFound | AccessError::Unauthorized) => { Err(MSG_NOT_FOUND.to_string()) } Err(AccessError::Forbidden) => Err(MSG_DENIED.to_string()), Err(AccessError::Internal(detail)) => { tracing::error!(detail = %detail, "ssh: repo resolution failed"); Err(MSG_INTERNAL.to_string()) } } } } fn now_unix() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .ok() .and_then(|d| i64::try_from(d.as_secs()).ok()) .unwrap_or(0) } impl GitBackend for SshBackend { async fn authenticate_key(&self, _algorithm: &str, key_blob: &[u8]) -> Option { let key = match self.state.store.ssh_key_by_blob(key_blob).await { Ok(Some(key)) => key, Ok(None) => return None, Err(e) => { tracing::warn!(error = %e, "ssh key lookup failed"); return None; } }; if let Err(e) = self.state.store.touch_ssh_key(&key.id).await { tracing::warn!(error = %e, "could not record ssh key use"); } // A key that belongs to an organization is refused here, not only in `authorize`. match self.state.store.user_by_id(&key.user_id).await { Ok(Some(owner)) if !owner.is_org() => Some(key.user_id), _ => None, } } async fn prepare( &self, user_id: &str, service: GitService, repo_path: &str, git_protocol: Option<&str>, ) -> Result { // parse_exec has already stripped the leading '/' and the trailing ".git". let (owner, name) = repo_path .split_once('/') .ok_or_else(|| MSG_NOT_FOUND.to_string())?; let (user, repo, path) = self.authorize(user_id, owner, name, service).await?; Ok(access::git_process( &self.state, &repo, &path, Some(&user), service, Transport::Ssh, false, git_protocol, ) .into_tokio()) } async fn lfs_authenticate( &self, user_id: &str, repo_path: &str, operation: LfsOperation, ) -> Result { // parse_lfs_authenticate has already stripped the leading '/' and the trailing ".git". let (owner, name) = repo_path .split_once('/') .ok_or_else(|| MSG_NOT_FOUND.to_string())?; // Download is a read and upload is a write, checked exactly as git does. let service = match operation { LfsOperation::Download => GitService::UploadPack, LfsOperation::Upload => GitService::ReceivePack, }; let (_user, repo, _path) = self.authorize(user_id, owner, name, service).await?; if operation == LfsOperation::Upload { // Archived repos are refused by the Write check. Pull mirrors are read-only. match self.state.store.pull_mirror(&repo.id).await { Ok(None) => {} Ok(Some(_)) => return Err(MSG_DENIED.to_string()), Err(e) => { tracing::error!(error = %e, "ssh: pull mirror lookup failed"); return Err(MSG_INTERNAL.to_string()); } } } let token = lfs_token::issue( &self.state.key, &repo.id, LfsOp::Any, now_unix() + LFS_TOKEN_TTL_SECS, ); let base = self.state.config.base_url.trim_end_matches('/'); let reply = serde_json::json!({ "href": format!("{base}/{}/{}.git/info/lfs", repo.owner_name, repo.name), "header": { "Authorization": format!("RemoteAuth {token}") }, "expires_in": LFS_TOKEN_TTL_SECS, }); Ok(reply.to_string()) } } #[cfg(test)] mod org_tests { use super::*; /// A key that belongs to an organization does not authenticate; a person's key does. #[tokio::test] async fn an_org_key_does_not_authenticate() { let dir = tempfile::tempdir().unwrap(); let state = crate::test_support::test_state(dir.path()).await; let alice = state .store .create_user("alice", None, "h", false) .await .unwrap(); let org = state.store.create_org("acme", &alice.id).await.unwrap(); let org_blob = b"org-key-blob"; let person_blob = b"person-key-blob"; state .store .add_ssh_key(&org.id, "ci", "ssh-ed25519", org_blob, "SHA256:org") .await .unwrap(); state .store .add_ssh_key(&alice.id, "laptop", "ssh-ed25519", person_blob, "SHA256:me") .await .unwrap(); let backend = SshBackend { state }; assert_eq!( backend.authenticate_key("ssh-ed25519", org_blob).await, None ); assert_eq!( backend.authenticate_key("ssh-ed25519", person_blob).await, Some(alice.id.clone()) ); } }