308 lines
10 KiB
Rust
308 lines
10 KiB
Rust
use crate::error::GitError;
|
|
use base64::{Engine as _, engine::general_purpose};
|
|
use db::database::AppDatabase;
|
|
use models::projects::MemberRole;
|
|
use models::projects::{project, project_history_name, project_members};
|
|
use models::repos::{repo, repo_history_name};
|
|
use models::users::{user, user_ssh_key};
|
|
use sea_orm::sqlx::types::chrono;
|
|
use sea_orm::*;
|
|
use sha2::{Digest, Sha256};
|
|
use slog::{Logger, error, info, warn};
|
|
|
|
/// SSH authentication service optimized for performance
|
|
pub struct SshAuthService {
|
|
db: AppDatabase,
|
|
logger: Logger,
|
|
}
|
|
|
|
impl SshAuthService {
|
|
pub fn new(db: AppDatabase, logger: Logger) -> Self {
|
|
Self { db, logger }
|
|
}
|
|
|
|
pub async fn find_repo(
|
|
&self,
|
|
namespace: &str,
|
|
repo_name: &str,
|
|
) -> Result<repo::Model, GitError> {
|
|
let namespace = self.find_namespace(namespace).await?;
|
|
self.find_repository_by_name_and_project(repo_name, namespace.id)
|
|
.await
|
|
}
|
|
|
|
async fn find_namespace(&self, namespace: &str) -> Result<project::Model, GitError> {
|
|
if let Some(project) = project::Entity::find()
|
|
.filter(project::Column::Name.eq(namespace))
|
|
.one(self.db.reader())
|
|
.await
|
|
.map_err(|e| GitError::Internal(e.to_string()))?
|
|
{
|
|
return Ok(project);
|
|
}
|
|
|
|
if let Some(history) = project_history_name::Entity::find()
|
|
.filter(project_history_name::Column::HistoryName.eq(namespace))
|
|
.one(self.db.reader())
|
|
.await
|
|
.map_err(|e| GitError::Internal(e.to_string()))?
|
|
{
|
|
if let Some(project) = project::Entity::find()
|
|
.filter(project::Column::Id.eq(history.project_uid))
|
|
.one(self.db.reader())
|
|
.await
|
|
.map_err(|e| GitError::Internal(e.to_string()))?
|
|
{
|
|
return Ok(project);
|
|
}
|
|
}
|
|
|
|
Err(GitError::NotFound("Project not found".to_string()))
|
|
}
|
|
|
|
async fn find_repository_by_name_and_project(
|
|
&self,
|
|
repo_name: &str,
|
|
project_id: uuid::Uuid,
|
|
) -> Result<repo::Model, GitError> {
|
|
if let Some(repo) = repo::Entity::find()
|
|
.filter(repo::Column::RepoName.eq(repo_name))
|
|
.filter(repo::Column::Project.eq(project_id))
|
|
.one(self.db.reader())
|
|
.await
|
|
.map_err(|e| GitError::Internal(e.to_string()))?
|
|
{
|
|
return Ok(repo);
|
|
}
|
|
|
|
if let Some(history) = repo_history_name::Entity::find()
|
|
.filter(repo_history_name::Column::Name.eq(repo_name))
|
|
.filter(repo_history_name::Column::Project.eq(project_id))
|
|
.one(self.db.reader())
|
|
.await
|
|
.map_err(|e| GitError::Internal(e.to_string()))?
|
|
{
|
|
if let Some(repo) = repo::Entity::find()
|
|
.filter(repo::Column::Id.eq(history.repo))
|
|
.filter(repo::Column::Project.eq(project_id))
|
|
.one(self.db.reader())
|
|
.await
|
|
.map_err(|e| GitError::Internal(e.to_string()))?
|
|
{
|
|
return Ok(repo);
|
|
}
|
|
}
|
|
|
|
Err(GitError::NotFound("Repository not found".to_string()))
|
|
}
|
|
|
|
pub async fn find_user_by_public_key(
|
|
&self,
|
|
public_key_str: &str,
|
|
) -> Result<Option<user::Model>, DbErr> {
|
|
let fingerprint = match self.generate_fingerprint_from_public_key(public_key_str) {
|
|
Ok(fp) => fp,
|
|
Err(e) => {
|
|
error!(self.logger, "Failed to generate fingerprint"; "error" => %e);
|
|
return Ok(None);
|
|
}
|
|
};
|
|
|
|
let fingerprint_preview = if fingerprint.len() > 16 {
|
|
format!("{}...", &fingerprint[..16])
|
|
} else {
|
|
fingerprint.clone()
|
|
};
|
|
info!(self.logger, "Looking up user with SSH key"; "fingerprint" => %fingerprint_preview);
|
|
|
|
let ssh_key = user_ssh_key::Entity::find()
|
|
.filter(user_ssh_key::Column::Fingerprint.eq(&fingerprint))
|
|
.filter(user_ssh_key::Column::IsRevoked.eq(false))
|
|
.one(self.db.reader())
|
|
.await?;
|
|
|
|
let ssh_key = match ssh_key {
|
|
Some(key) => key,
|
|
None => {
|
|
warn!(self.logger, "No SSH key found"; "fingerprint" => %fingerprint);
|
|
return Ok(None);
|
|
}
|
|
};
|
|
|
|
if self.is_key_expired(&ssh_key) {
|
|
warn!(self.logger, "SSH key expired"; "key_id" => ssh_key.id, "expires_at" => ?ssh_key.expires_at);
|
|
return Ok(None);
|
|
}
|
|
|
|
let user_model = user::Entity::find()
|
|
.filter(user::Column::Uid.eq(ssh_key.user))
|
|
.one(self.db.reader())
|
|
.await?;
|
|
|
|
if let Some(ref user) = user_model {
|
|
info!(self.logger, "User authenticated"; "user" => %user.username, "key" => %ssh_key.title);
|
|
self.update_key_last_used_async(ssh_key.id);
|
|
}
|
|
|
|
Ok(user_model)
|
|
}
|
|
|
|
fn is_key_expired(&self, ssh_key: &user_ssh_key::Model) -> bool {
|
|
if let Some(expires_at) = ssh_key.expires_at {
|
|
let now = chrono::Utc::now();
|
|
now >= expires_at
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
fn update_key_last_used_async(&self, key_id: i64) {
|
|
let db_clone = self.db.clone();
|
|
let logger = self.logger.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(e) = Self::update_key_last_used_sync(db_clone, &logger, key_id).await {
|
|
warn!(&logger, "Failed to update key last_used"; "key_id" => key_id, "error" => %e);
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn update_key_last_used_sync(
|
|
db: AppDatabase,
|
|
logger: &Logger,
|
|
key_id: i64,
|
|
) -> Result<(), DbErr> {
|
|
let key = user_ssh_key::Entity::find_by_id(key_id)
|
|
.one(db.reader())
|
|
.await?;
|
|
|
|
if let Some(key) = key {
|
|
let now = chrono::Utc::now();
|
|
let mut active_key: user_ssh_key::ActiveModel = key.into();
|
|
active_key.last_used_at = Set(Some(now));
|
|
active_key.updated_at = Set(now);
|
|
|
|
active_key.update(db.writer()).await?;
|
|
info!(logger, "Updated key last_used"; "key_id" => key_id);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn check_repo_permission(
|
|
&self,
|
|
user: &user::Model,
|
|
repo: &repo::Model,
|
|
is_write: bool,
|
|
) -> bool {
|
|
if repo.created_by == user.uid {
|
|
info!(self.logger, "User is repo owner"; "user" => %user.username, "repo" => %repo.repo_name);
|
|
return true;
|
|
}
|
|
|
|
if !is_write && !repo.is_private {
|
|
info!(self.logger, "Public repo allows read"; "repo" => %repo.repo_name);
|
|
return true;
|
|
}
|
|
|
|
if self
|
|
.check_collaborator_permission(user, repo, is_write)
|
|
.await
|
|
.unwrap_or(false)
|
|
{
|
|
info!(self.logger, "User has collaborator access"; "user" => %user.username, "repo" => %repo.repo_name);
|
|
return true;
|
|
}
|
|
|
|
let project_id = repo.project;
|
|
if self
|
|
.check_project_member_permission(user, project_id, is_write)
|
|
.await
|
|
.unwrap_or(false)
|
|
{
|
|
info!(self.logger, "User has project member access"; "user" => %user.username, "repo" => %repo.repo_name);
|
|
return true;
|
|
}
|
|
|
|
warn!(self.logger, "Access denied"; "user" => %user.username, "repo" => %repo.repo_name, "write" => is_write);
|
|
false
|
|
}
|
|
|
|
async fn check_collaborator_permission(
|
|
&self,
|
|
user: &user::Model,
|
|
repo: &repo::Model,
|
|
is_write: bool,
|
|
) -> Result<bool, DbErr> {
|
|
use models::repos::repo_collaborator;
|
|
|
|
let collaborator = repo_collaborator::Entity::find()
|
|
.filter(repo_collaborator::Column::Repo.eq(repo.id))
|
|
.filter(repo_collaborator::Column::User.eq(user.uid))
|
|
.one(self.db.reader())
|
|
.await?;
|
|
|
|
if let Some(collab) = collaborator {
|
|
let roles: Vec<&str> = collab.scope.split_whitespace().collect();
|
|
if roles.contains(&"admin") || roles.contains(&"write") {
|
|
return Ok(true);
|
|
}
|
|
|
|
if roles.contains(&"read") && !is_write {
|
|
return Ok(true);
|
|
}
|
|
|
|
warn!(self.logger, "Collaborator has no valid roles"; "scope" => %collab.scope);
|
|
Ok(false)
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
async fn check_project_member_permission(
|
|
&self,
|
|
user: &user::Model,
|
|
project_id: uuid::Uuid,
|
|
is_write: bool,
|
|
) -> Result<bool, DbErr> {
|
|
let member = project_members::Entity::find()
|
|
.filter(project_members::Column::Project.eq(project_id))
|
|
.filter(project_members::Column::User.eq(user.uid))
|
|
.one(self.db.reader())
|
|
.await?;
|
|
|
|
if let Some(member) = member {
|
|
match member.scope_role() {
|
|
Ok(MemberRole::Admin) | Ok(MemberRole::Owner) => Ok(true),
|
|
Ok(MemberRole::Member) => Ok(!is_write),
|
|
Err(_) => Ok(false),
|
|
}
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
fn generate_fingerprint_from_public_key(&self, public_key_str: &str) -> Result<String, String> {
|
|
// Performance: avoid allocating Vec, use split_once for efficiency
|
|
let key_data_base64 = public_key_str
|
|
.split_whitespace()
|
|
.nth(1)
|
|
.ok_or("Invalid SSH key format")?;
|
|
|
|
let key_data = general_purpose::STANDARD
|
|
.decode(key_data_base64)
|
|
.map_err(|e| format!("Base64 decode error: {}", e))?;
|
|
|
|
// Performance: SHA256 is already optimized, compute hash directly
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(&key_data);
|
|
let hash = hasher.finalize();
|
|
|
|
// Performance: pre-allocate string capacity to avoid reallocation
|
|
let mut fingerprint = String::with_capacity(51); // "SHA256:" (7) + base64 (44)
|
|
fingerprint.push_str("SHA256:");
|
|
fingerprint.push_str(&general_purpose::STANDARD_NO_PAD.encode(&hash));
|
|
|
|
Ok(fingerprint)
|
|
}
|
|
}
|