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::users::access_scope::AccessKeyScope; 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 owner = find_space_for_repo(svc, &ctx.model).await?; let capability = if require_admin { space::Capability::RepositoryAdmin { repository: ctx.model.id, } } else if want_write { space::Capability::RepositoryWrite { repository: ctx.model.id, } } else { space::Capability::RepositoryRead { repository: ctx.model.id, private: ctx.model.is_private, } }; Ok(space::require(&owner, uid, capability).await?) } async fn find_space_for_repo( service: &GitService, repo: &models::repo::repos::Model, ) -> Result { space_for_dir(&service.access, repo.dir_id) .await? .ok_or_else(|| GitError::Internal(format!("space for repo {} not found", repo.id))) } pub async fn space_for_dir( access: &space::Access, dir_id: Uuid, ) -> Result, GitError> { Ok(access.for_dir(dir_id).await?) }