use std::future::Ready; use actix_web::dev::Payload; use actix_web::http::header; use actix_web::{FromRequest, HttpRequest}; use base64::Engine as _; use models::repo::repo_collaborations::{self, CollaborationMode}; use models::users::access_scope::AccessKeyScope; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use uuid::Uuid; use crate::context::facade::GitContext; use crate::service::GitService; use crate::share::errors::GitError; #[derive(Debug, Clone)] pub struct BasicAuth { pub token: Option, } impl FromRequest for BasicAuth { type Error = GitError; type Future = Ready>; fn from_request(request: &HttpRequest, _payload: &mut Payload) -> Self::Future { std::future::ready(parse_basic_auth(request)) } } fn parse_basic_auth(request: &HttpRequest) -> Result { let Some(value) = request.headers().get(header::AUTHORIZATION) else { return Ok(BasicAuth { token: None }); }; let raw = value .to_str() .map_err(|_| GitError::InvalidArgument("malformed authorization header".into()))?; let Some(b64) = raw.strip_prefix("Basic ") else { return Ok(BasicAuth { token: None }); }; let decoded = base64::engine::general_purpose::STANDARD .decode(b64) .map_err(|_| GitError::InvalidArgument("malformed basic credentials".into()))?; let creds = String::from_utf8(decoded) .map_err(|_| GitError::InvalidArgument("malformed basic credentials".into()))?; let token = creds.split_once(':').map(|(_, pass)| pass.to_string()); Ok(BasicAuth { token }) } pub async fn verify_access_key( _svc: &GitService, _token: &str, ) -> Result, GitError> { Ok(None) } /// HTTP write-path auth: token must be valid AND the resolved `uid` must /// have write membership on the resolved `ctx` repo. Use everywhere the /// HTTP path accepts mutating requests. pub async fn require_writer( svc: &GitService, auth: &BasicAuth, ctx: &GitContext, ) -> Result { let uid = resolve_caller_for_write(svc, auth).await?; authorize_repo_access(svc, uid, ctx, true, false).await?; Ok(uid) } /// HTTP read-path auth: `Ok(None)` only for anonymous reads on public /// repos; otherwise returns `Unauthorized` / `Forbidden` / `Some(uid)`. pub async fn allow_repo_read( svc: &GitService, auth: &BasicAuth, ctx: &GitContext, ) -> Result, GitError> { let Some(token) = &auth.token else { if ctx.model.is_private { return Err(GitError::Unauthorized); } return Ok(None); }; let (uid, _) = verify_access_key(svc, token) .await? .ok_or(GitError::Unauthorized)?; authorize_repo_access(svc, uid, ctx, false, false).await?; Ok(Some(uid)) } /// Admin-only HTTP auth: caller must hold a valid write-scope token AND /// be the space owner or an Admin collaborator. Use for LFS force-unlock /// and any action spec'd as admin-only. pub async fn require_repo_admin( svc: &GitService, auth: &BasicAuth, ctx: &GitContext, ) -> Result { if auth .token .as_deref() .is_some_and(|token| token.starts_with("ci_")) { return Err(GitError::Forbidden); } let uid = resolve_caller_for_write(svc, auth).await?; authorize_repo_access(svc, uid, ctx, true, true).await?; Ok(uid) } async fn resolve_caller_for_write(svc: &GitService, auth: &BasicAuth) -> Result { let Some(token) = &auth.token else { return Err(GitError::Unauthorized); }; let (uid, scope) = verify_access_key(svc, token) .await? .ok_or(GitError::Unauthorized)?; if !scope.allows(true) { return Err(GitError::Forbidden); } Ok(uid) } /// Per-repo authorization — the central ACL that was previously only on /// the SSH path (`ssh/handler/command.rs::authorize_repo`) and missing /// from every HTTP/LFS path. Centralized so HTTP, SSH, and the LFS router /// enforce identical membership rules. /// /// Rules: /// - space owner of the repo's owning space: always allowed /// - read on public repo: allowed /// - read on private repo: requires `repo_collaborations` row /// - write: requires `repo_collaborations.mode ∈ {Write, Admin}` /// - admin: requires `repo_collaborations.mode == Admin` pub async fn authorize_repo_access( svc: &GitService, uid: i64, ctx: &GitContext, want_write: bool, require_admin: bool, ) -> Result<(), GitError> { let repo = &ctx.model; let db = svc.db_read(); // Walk dir_id up to the owning space row, then load the space to // compare owner_id. Mirrors `ssh/handler/command.rs::space_id_for`. let space = find_space_for_repo(&svc.state, repo).await?; if space.owner_id == uid { return Ok(()); } let collab = repo_collaborations::Entity::find() .filter(repo_collaborations::Column::RepoId.eq(repo.id)) .filter(repo_collaborations::Column::UserId.eq(uid)) .one(db) .await?; if require_admin { return match collab.map(|c| c.mode) { Some(CollaborationMode::Admin) => Ok(()), _ => Err(GitError::Forbidden), }; } if want_write { return match collab.map(|c| c.mode) { Some(CollaborationMode::Write) | Some(CollaborationMode::Admin) => Ok(()), _ => Err(GitError::Forbidden), }; } if !repo.is_private { return Ok(()); } if collab.is_some() { Ok(()) } else { Err(GitError::Forbidden) } } async fn find_space_for_repo( state: &export::AppState, repo: &models::repo::repos::Model, ) -> Result { space_for_dir(state, repo.dir_id) .await? .ok_or_else(|| GitError::Internal(format!("space for repo {} not found", repo.id))) } pub const MAX_DIR_DEPTH: usize = 64; pub async fn space_for_dir( state: &export::AppState, mut dir_id: Uuid, ) -> Result, GitError> { for _ in 0..MAX_DIR_DEPTH { let row = models::dir::dirs::Entity::find_by_id(dir_id) .one(state.database.read()) .await? .ok_or_else(|| GitError::Internal(format!("dir {dir_id} not found")))?; if let Some(space_id) = row.space_id { let space = space::authority::space_by_id(state, 0, space_id).await?; return Ok(Some(space)); } match row.parent_dir_id { Some(parent) => dir_id = parent, None => return Ok(None), } } Err(GitError::Internal(format!( "dir tree deeper than {MAX_DIR_DEPTH} levels" ))) }