forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
6.5 KiB
1//! `impl forge_ssh::GitBackend` for forge: key lookup, authz and git process construction.
2
3use std::path::PathBuf;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use forge_core::lfs_token::{self, LfsOp};
7use forge_core::{Repo, User};
8use forge_git::GitService;
9use forge_ssh::{GitBackend, LfsOperation};
10
11use crate::access::{self, AccessError, Transport};
12use crate::state::AppState;
13
14pub struct SshBackend {
15 pub state: AppState,
16}
17
18const MSG_NOT_FOUND: &str = "repository not found";
19const MSG_DENIED: &str = "permission denied";
20const MSG_INTERNAL: &str = "internal error";
21
22/// Lifetime of the LFS transfer token handed out by `git-lfs-authenticate`.
23const LFS_TOKEN_TTL_SECS: i64 = 3600;
24
25impl SshBackend {
26 /// Looks up the user and applies the same repository authorization git uses for `service`.
27 async fn authorize(
28 &self,
29 user_id: &str,
30 owner: &str,
31 name: &str,
32 service: GitService,
33 ) -> Result<(User, Repo, PathBuf), String> {
34 let user = match self.state.store.user_by_id(user_id).await {
35 Ok(Some(user)) if !user.is_org() => user,
36 Ok(_) => return Err(MSG_DENIED.to_string()),
37 Err(e) => {
38 tracing::error!(error = %e, "ssh: user lookup failed");
39 return Err(MSG_INTERNAL.to_string());
40 }
41 };
42
43 match access::resolve_repo(&self.state, Some(&user), owner, name, service).await {
44 Ok((repo, path)) => Ok((user, repo, path)),
45 Err(AccessError::NotFound | AccessError::Unauthorized) => {
46 Err(MSG_NOT_FOUND.to_string())
47 }
48 Err(AccessError::Forbidden) => Err(MSG_DENIED.to_string()),
49 Err(AccessError::Internal(detail)) => {
50 tracing::error!(detail = %detail, "ssh: repo resolution failed");
51 Err(MSG_INTERNAL.to_string())
52 }
53 }
54 }
55}
56
57fn now_unix() -> i64 {
58 SystemTime::now()
59 .duration_since(UNIX_EPOCH)
60 .ok()
61 .and_then(|d| i64::try_from(d.as_secs()).ok())
62 .unwrap_or(0)
63}
64
65impl GitBackend for SshBackend {
66 async fn authenticate_key(&self, _algorithm: &str, key_blob: &[u8]) -> Option<String> {
67 let key = match self.state.store.ssh_key_by_blob(key_blob).await {
68 Ok(Some(key)) => key,
69 Ok(None) => return None,
70 Err(e) => {
71 tracing::warn!(error = %e, "ssh key lookup failed");
72 return None;
73 }
74 };
75 if let Err(e) = self.state.store.touch_ssh_key(&key.id).await {
76 tracing::warn!(error = %e, "could not record ssh key use");
77 }
78 // A key that belongs to an organization is refused here, not only in `authorize`.
79 match self.state.store.user_by_id(&key.user_id).await {
80 Ok(Some(owner)) if !owner.is_org() => Some(key.user_id),
81 _ => None,
82 }
83 }
84
85 async fn prepare(
86 &self,
87 user_id: &str,
88 service: GitService,
89 repo_path: &str,
90 git_protocol: Option<&str>,
91 ) -> Result<tokio::process::Command, String> {
92 // parse_exec has already stripped the leading '/' and the trailing ".git".
93 let (owner, name) = repo_path
94 .split_once('/')
95 .ok_or_else(|| MSG_NOT_FOUND.to_string())?;
96
97 let (user, repo, path) = self.authorize(user_id, owner, name, service).await?;
98
99 Ok(access::git_process(
100 &self.state,
101 &repo,
102 &path,
103 Some(&user),
104 service,
105 Transport::Ssh,
106 false,
107 git_protocol,
108 )
109 .into_tokio())
110 }
111
112 async fn lfs_authenticate(
113 &self,
114 user_id: &str,
115 repo_path: &str,
116 operation: LfsOperation,
117 ) -> Result<String, String> {
118 // parse_lfs_authenticate has already stripped the leading '/' and the trailing ".git".
119 let (owner, name) = repo_path
120 .split_once('/')
121 .ok_or_else(|| MSG_NOT_FOUND.to_string())?;
122
123 // Download is a read and upload is a write, checked exactly as git does.
124 let service = match operation {
125 LfsOperation::Download => GitService::UploadPack,
126 LfsOperation::Upload => GitService::ReceivePack,
127 };
128 let (_user, repo, _path) = self.authorize(user_id, owner, name, service).await?;
129
130 if operation == LfsOperation::Upload {
131 // Archived repos are refused by the Write check. Pull mirrors are read-only.
132 match self.state.store.pull_mirror(&repo.id).await {
133 Ok(None) => {}
134 Ok(Some(_)) => return Err(MSG_DENIED.to_string()),
135 Err(e) => {
136 tracing::error!(error = %e, "ssh: pull mirror lookup failed");
137 return Err(MSG_INTERNAL.to_string());
138 }
139 }
140 }
141
142 let token = lfs_token::issue(
143 &self.state.key,
144 &repo.id,
145 LfsOp::Any,
146 now_unix() + LFS_TOKEN_TTL_SECS,
147 );
148 let base = self.state.config.base_url.trim_end_matches('/');
149 let reply = serde_json::json!({
150 "href": format!("{base}/{}/{}.git/info/lfs", repo.owner_name, repo.name),
151 "header": { "Authorization": format!("RemoteAuth {token}") },
152 "expires_in": LFS_TOKEN_TTL_SECS,
153 });
154 Ok(reply.to_string())
155 }
156}
157
158#[cfg(test)]
159mod org_tests {
160 use super::*;
161
162 /// A key that belongs to an organization does not authenticate; a person's key does.
163 #[tokio::test]
164 async fn an_org_key_does_not_authenticate() {
165 let dir = tempfile::tempdir().unwrap();
166 let state = crate::test_support::test_state(dir.path()).await;
167 let alice = state
168 .store
169 .create_user("alice", None, "h", false)
170 .await
171 .unwrap();
172 let org = state.store.create_org("acme", &alice.id).await.unwrap();
173 let org_blob = b"org-key-blob";
174 let person_blob = b"person-key-blob";
175 state
176 .store
177 .add_ssh_key(&org.id, "ci", "ssh-ed25519", org_blob, "SHA256:org")
178 .await
179 .unwrap();
180 state
181 .store
182 .add_ssh_key(&alice.id, "laptop", "ssh-ed25519", person_blob, "SHA256:me")
183 .await
184 .unwrap();
185 let backend = SshBackend { state };
186
187 assert_eq!(
188 backend.authenticate_key("ssh-ed25519", org_blob).await,
189 None
190 );
191 assert_eq!(
192 backend.authenticate_key("ssh-ed25519", person_blob).await,
193 Some(alice.id.clone())
194 );
195 }
196}