feat(lib/git): expand share errors and add access module
ZhenYi committed
Added lib/git/src/share/access.rs lib/git/src/share/access.rs +239 −0 View lib/git/src/share/access.rs
@@ -0,0 +1,239 @@
1use axum::extract::FromRequestParts;
2use axum::http::header;
3use base64::Engine as _;
4use models::repo::repo_collaborations::{self, CollaborationMode};
5use models::users::user;
6use models::users::user_access_keys::{self, AccessKeyScope};
7use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
8use sha2::{Digest, Sha256};
9use uuid::Uuid;
10
11use crate::context::facade::GitContext;
12use crate::service::GitService;
13use crate::share::errors::GitError;
14
15#[derive(Debug, Clone)]
16pub struct BasicAuth {
17 pub token: Option<String>,
18}
19
20impl<S: Send + Sync> FromRequestParts<S> for BasicAuth {
21 type Rejection = GitError;
22
23 async fn from_request_parts(
24 parts: &mut axum::http::request::Parts,
25 _state: &S,
26 ) -> Result<Self, Self::Rejection> {
27 let Some(value) = parts.headers.get(header::AUTHORIZATION) else {
28 return Ok(BasicAuth { token: None });
29 };
30 let raw = value
31 .to_str()
32 .map_err(|_| GitError::InvalidArgument("malformed authorization header".into()))?;
33 let Some(b64) = raw.strip_prefix("Basic ") else {
34 return Ok(BasicAuth { token: None });
35 };
36 let decoded = base64::engine::general_purpose::STANDARD
37 .decode(b64)
38 .map_err(|_| GitError::InvalidArgument("malformed basic credentials".into()))?;
39 let creds = String::from_utf8(decoded)
40 .map_err(|_| GitError::InvalidArgument("malformed basic credentials".into()))?;
41 let token = creds.split_once(':').map(|(_, pass)| pass.to_string());
42 Ok(BasicAuth { token })
43 }
44}
45
46pub async fn verify_access_key(
47 svc: &GitService,
48 token: &str,
49) -> Result<Option<(Uuid, AccessKeyScope)>, GitError> {
50 let Some((key_id, secret)) = token.split_once('.') else {
51 return Ok(None);
52 };
53 let Ok(key_id) = Uuid::parse_str(key_id) else {
54 return Ok(None);
55 };
56 let db = svc.state.database.clone();
57 let Some(row) = user_access_keys::Entity::find_by_id(key_id)
58 .one(db.read())
59 .await?
60 else {
61 return Ok(None);
62 };
63 let Some(caller) = user::Entity::find_by_id(row.user_id).one(db.read()).await? else {
64 return Ok(None);
65 };
66 if !caller.is_active || caller.prohibit_login || caller.is_restricted {
67 return Ok(None);
68 }
69 let computed = sha256_hex(format!("{secret}{}", row.token_salt).as_bytes());
70 if !constant_time_eq(computed.as_bytes(), row.token_hash.as_bytes()) {
71 return Ok(None);
72 }
73 Ok(Some((row.user_id, row.scope)))
74}
75
76/// HTTP write-path auth: token must be valid AND the resolved `uid` must
77/// have write membership on the resolved `ctx` repo. Use everywhere the
78/// HTTP/axum path accepts mutating requests.
79pub async fn require_writer(
80 svc: &GitService,
81 auth: &BasicAuth,
82 ctx: &GitContext,
83) -> Result<Uuid, GitError> {
84 let uid = resolve_caller_for_write(svc, auth).await?;
85 authorize_repo_access(svc, uid, ctx, true, false).await?;
86 Ok(uid)
87}
88
89/// HTTP read-path auth: `Ok(None)` only for anonymous reads on public
90/// repos; otherwise returns `Unauthorized` / `Forbidden` / `Some(uid)`.
91pub async fn allow_repo_read(
92 svc: &GitService,
93 auth: &BasicAuth,
94 ctx: &GitContext,
95) -> Result<Option<Uuid>, GitError> {
96 let Some(token) = &auth.token else {
97 if ctx.model.is_private {
98 return Err(GitError::Unauthorized);
99 }
100 return Ok(None);
101 };
102 let (uid, _) = verify_access_key(svc, token)
103 .await?
104 .ok_or(GitError::Unauthorized)?;
105 authorize_repo_access(svc, uid, ctx, false, false).await?;
106 Ok(Some(uid))
107}
108
109/// Admin-only HTTP auth: caller must hold a valid write-scope token AND
110/// be the space owner or an Admin collaborator. Use for LFS force-unlock
111/// and any action spec'd as admin-only.
112pub async fn require_repo_admin(
113 svc: &GitService,
114 auth: &BasicAuth,
115 ctx: &GitContext,
116) -> Result<Uuid, GitError> {
117 let uid = resolve_caller_for_write(svc, auth).await?;
118 authorize_repo_access(svc, uid, ctx, true, true).await?;
119 Ok(uid)
120}
121
122async fn resolve_caller_for_write(
123 svc: &GitService,
124 auth: &BasicAuth,
125) -> Result<Uuid, GitError> {
126 let Some(token) = &auth.token else {
127 return Err(GitError::Unauthorized);
128 };
129 let (uid, scope) = verify_access_key(svc, token)
130 .await?
131 .ok_or(GitError::Unauthorized)?;
132 if !scope.allows(true) {
133 return Err(GitError::Forbidden);
134 }
135 Ok(uid)
136}
137
138/// Per-repo authorization — the central ACL that was previously only on
139/// the SSH path (`ssh/handler/command.rs::authorize_repo`) and missing
140/// from every HTTP/LFS path. Centralized so HTTP, SSH, and the LFS router
141/// enforce identical membership rules.
142///
143/// Rules:
144/// - space owner of the repo's owning space: always allowed
145/// - read on public repo: allowed
146/// - read on private repo: requires `repo_collaborations` row
147/// - write: requires `repo_collaborations.mode ∈ {Write, Admin}`
148/// - admin: requires `repo_collaborations.mode == Admin`
149pub async fn authorize_repo_access(
150 svc: &GitService,
151 uid: Uuid,
152 ctx: &GitContext,
153 want_write: bool,
154 require_admin: bool,
155) -> Result<(), GitError> {
156 let repo = &ctx.model;
157 let db = svc.db_read();
158
159 // Walk dir_id up to the owning space row, then load the space to
160 // compare owner_id. Mirrors `ssh/handler/command.rs::space_id_for`.
161 let space = find_space_for_repo(db, repo).await?;
162 if space.owner_id == uid {
163 return Ok(());
164 }
165
166 let collab = repo_collaborations::Entity::find()
167 .filter(repo_collaborations::Column::RepoId.eq(repo.id))
168 .filter(repo_collaborations::Column::UserId.eq(uid))
169 .one(db)
170 .await?;
171
172 if require_admin {
173 return match collab.map(|c| c.mode) {
174 Some(CollaborationMode::Admin) => Ok(()),
175 _ => Err(GitError::Forbidden),
176 };
177 }
178
179 if want_write {
180 return match collab.map(|c| c.mode) {
181 Some(CollaborationMode::Write) | Some(CollaborationMode::Admin) => Ok(()),
182 _ => Err(GitError::Forbidden),
183 };
184 }
185
186 if !repo.is_private {
187 return Ok(());
188 }
189 if collab.is_some() {
190 Ok(())
191 } else {
192 Err(GitError::Forbidden)
193 }
194}
195
196async fn find_space_for_repo(
197 db: &sea_orm::DatabaseConnection,
198 repo: &models::repo::repos::Model,
199) -> Result<models::space::spaces::Model, GitError> {
200 let mut dir_id = repo.dir_id;
201 let mut space_id: Option<Uuid> = None;
202 for _ in 0..32 {
203 let row = models::dir::dirs::Entity::find_by_id(dir_id)
204 .one(db)
205 .await?
206 .ok_or_else(|| GitError::Internal(format!("dir {dir_id} not found")))?;
207 if row.space_id.is_some() {
208 space_id = row.space_id;
209 break;
210 }
211 match row.parent_dir_id {
212 Some(p) => dir_id = p,
213 None => break,
214 }
215 }
216 let sid = space_id.ok_or_else(|| {
217 GitError::Internal(format!("space for repo {} not found", repo.id))
218 })?;
219 models::space::spaces::Entity::find_by_id(sid)
220 .one(db)
221 .await?
222 .ok_or_else(|| GitError::Internal(format!("space {sid} not found")))
223}
224
225fn sha256_hex(data: &[u8]) -> String {
226 let digest = Sha256::digest(data);
227 digest.iter().map(|b| format!("{b:02x}")).collect()
228}
229
230fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
231 if a.len() != b.len() {
232 return false;
233 }
234 let mut diff: u8 = 0;
235 for (x, y) in a.iter().zip(b.iter()) {
236 diff |= x ^ y;
237 }
238 diff == 0
239}
No newline at end of file
Modified lib/git/src/share/errors.rs lib/git/src/share/errors.rs +24 −0 View lib/git/src/share/errors.rs
@@ -10,6 +10,8 @@
1010 Conflict(String),
1111 #[error("resource not found")]
1212 NotFound,
13 #[error("unauthorized")]
14 Unauthorized,
1315 #[error("forbidden")]
1416 Forbidden,
1517 #[error("git error: {0}")]
@@ -54,6 +56,28 @@
5456 }
5557}
5658
59impl axum::response::IntoResponse for GitError {
60 fn into_response(self) -> axum::response::Response {
61 use axum::http::StatusCode;
62 let status = match &self {
63 Self::NotFound => StatusCode::NOT_FOUND,
64 Self::Unauthorized => StatusCode::UNAUTHORIZED,
65 Self::Forbidden => StatusCode::FORBIDDEN,
66 Self::Conflict(_) => StatusCode::CONFLICT,
67 Self::InvalidArgument(_) => StatusCode::BAD_REQUEST,
68 Self::Cache(_) | Self::Git(_) | Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
69 };
70 let mut resp = (status, self.to_string()).into_response();
71 if status == StatusCode::UNAUTHORIZED {
72 resp.headers_mut().insert(
73 axum::http::header::WWW_AUTHENTICATE,
74 axum::http::HeaderValue::from_static("Basic realm=\"gitdata\""),
75 );
76 }
77 resp
78 }
79}
80
5781fn is_unique_violation(err: &sea_orm::DbErr) -> bool {
5882 let msg = err.to_string().to_lowercase();
5983 msg.contains("unique") || msg.contains("duplicate") || msg.contains("23505")
Modified lib/git/src/share/mod.rs lib/git/src/share/mod.rs +1 −0 View lib/git/src/share/mod.rs
@@ -1,3 +1,4 @@
1pub mod access;
12pub mod errors;
23pub mod identity;
34pub mod policy;