1480255
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<String>,
}
impl FromRequest for BasicAuth {
type Error = GitError;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(request: &HttpRequest, _payload: &mut Payload) -> Self::Future {
std::future::ready(parse_basic_auth(request))
}
}
fn parse_basic_auth(request: &HttpRequest) -> Result<BasicAuth, GitError> {
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<Option<(i64, AccessKeyScope)>, 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<i64, GitError> {
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<Option<i64>, 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<i64, GitError> {
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<i64, GitError> {
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::Space, GitError> {
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<Option<space::Space>, GitError> {
Ok(access.for_dir(dir_id).await?)
}