use axum::extract::FromRequestParts; use axum::http::header; use base64::Engine as _; use models::repo::repo_collaborations::{self, CollaborationMode}; use models::users::access_scope::AccessKeyScope; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use sha2::{Digest, Sha256}; 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 FromRequestParts for BasicAuth { type Rejection = GitError; async fn from_request_parts( parts: &mut axum::http::request::Parts, _state: &S, ) -> Result { let Some(value) = parts.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> { let Some(client) = svc.state.kenauth.as_ref() else { return Err(GitError::Internal( "kenoauth is not configured for access key verification".into(), )); }; match client.verify_access_key(token, Vec::new()).await { Ok(Some(principal)) => { let write = principal.scopes.iter().any(|scope| *scope == 12); Ok(Some(( principal.user_id, if write { AccessKeyScope::Write } else { AccessKeyScope::Read }, ))) } Ok(None) => Ok(None), Err(error) => Err(GitError::Internal(format!( "access key verification failed: {error}" ))), } } /// 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/axum path accepts mutating requests. pub async fn require_writer( svc: &GitService, auth: &BasicAuth, ctx: &GitContext, ) -> Result { if let Some(token) = auth .token .as_deref() .filter(|token| export::space_token::looks_like(token)) { return verify_space_bot_token(svc, token, ctx) .await? .ok_or(GitError::Unauthorized); } if let Some(token) = auth .token .as_deref() .filter(|token| token.starts_with("ci_")) { return verify_ci_job_token(svc, token, ctx, true).await; } let uid = resolve_caller_for_write(svc, auth).await?; authorize_repo_access(svc, uid, ctx, true, false).await?; Ok(uid) } /// Temporary space top-level tokens authenticate as the per-space bot and are /// valid only for repositories inside the space they were issued for. The /// token itself carries every owner-level right in that space, so no further /// repo ACL is consulted (mismatched tokens are rejected, not downgraded). async fn verify_space_bot_token( svc: &GitService, token: &str, ctx: &GitContext, ) -> Result, GitError> { if !export::space_token::looks_like(token) { return Ok(None); } let record = export::space_token::verify(&svc.state.redis, token) .await .map_err(|error| GitError::Internal(format!("space token verification failed: {error}")))?; let Some(record) = record else { return Err(GitError::Unauthorized); }; let space = find_space_for_repo(svc.db_read(), &ctx.model).await?; if space.id != record.space_id { return Err(GitError::Forbidden); } // Register so nested owner-level checks in this process also pass; the // uid is only ever reachable through a token bound to one space. export::authz::register_space_bot(record.bot_uid); Ok(Some(record.bot_uid)) } async fn verify_ci_job_token( svc: &GitService, token: &str, ctx: &GitContext, write: bool, ) -> Result { let (id, secret) = token .strip_prefix("ci_") .and_then(|value| value.split_once('.')) .ok_or(GitError::Unauthorized)?; let id = Uuid::parse_str(id).map_err(|_| GitError::Unauthorized)?; let row = models::ci::job_token::Entity::find_by_id(id) .one(svc.db_write()) .await? .ok_or(GitError::Unauthorized)?; let hash = sha256_hex(secret.as_bytes()); if !constant_time_eq(hash.as_bytes(), row.token_hash.as_bytes()) || row.expires_at <= time::OffsetDateTime::now_utc() { return Err(GitError::Unauthorized); } if row.repo_id != ctx.model.id || (write && !row.contents_write) || (!write && !row.contents_read) { return Err(GitError::Forbidden); } let job = models::ci::workflow_job::Entity::find_by_id(row.job_id) .one(svc.db_write()) .await? .ok_or(GitError::Unauthorized)?; if !job.state.is_active() || job.lease_token.as_deref() != Some(row.lease_token.as_str()) { return Err(GitError::Unauthorized); } authorize_repo_access(svc, row.actor_id, ctx, write, false).await?; Ok(row.actor_id) } /// 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> { if let Some(token) = auth .token .as_deref() .filter(|token| export::space_token::looks_like(token)) { return verify_space_bot_token(svc, token, ctx).await; } if let Some(token) = auth .token .as_deref() .filter(|token| token.starts_with("ci_")) { return verify_ci_job_token(svc, token, ctx, false).await.map(Some); } 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 let Some(token) = auth .token .as_deref() .filter(|token| export::space_token::looks_like(token)) { return verify_space_bot_token(svc, token, ctx) .await? .ok_or(GitError::Unauthorized); } 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> { if export::authz::is_system_uid(uid) { return Ok(()); } 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(db, 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( db: &sea_orm::DatabaseConnection, repo: &models::repo::repos::Model, ) -> Result { space_for_dir(db, 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( db: &sea_orm::DatabaseConnection, mut dir_id: Uuid, ) -> Result, GitError> { for _ in 0..MAX_DIR_DEPTH { let row = models::dir::dirs::Entity::find_by_id(dir_id) .one(db) .await? .ok_or_else(|| GitError::Internal(format!("dir {dir_id} not found")))?; if let Some(space_id) = row.space_id { let space = models::space::spaces::Entity::find_by_id(space_id) .one(db) .await? .ok_or_else(|| GitError::Internal(format!("space {space_id} not found")))?; 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" ))) } fn sha256_hex(data: &[u8]) -> String { let digest = Sha256::digest(data); digest.iter().map(|b| format!("{b:02x}")).collect() } fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } let mut diff: u8 = 0; for (x, y) in a.iter().zip(b.iter()) { diff |= x ^ y; } diff == 0 }