Skip to content
Clone
951a522
231 lines 7.4 KB Rust
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 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<S: Send + Sync> FromRequestParts<S> for BasicAuth {
type Rejection = GitError;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
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<Option<(i64, AccessKeyScope)>, 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<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 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<models::space::spaces::Model, GitError> {
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<Option<models::space::spaces::Model>, 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"
)))
}