use axum::{
extract::{Path, State},
http::StatusCode,
Extension, Json,
};
use uuid::Uuid;
use crate::db::{projects, service_accounts, users, User};
use crate::server::project::handlers::{check_read_permission, check_write_permission};
use crate::server::state::AppState;
use crate::server::workload_identity::models::{
CreateWorkloadIdentityRequest, ListWorkloadIdentitiesResponse, UpdateWorkloadIdentityRequest,
WorkloadIdentityResponse,
};
type Result<T> = std::result::Result<T, (StatusCode, String)>;
async fn verify_oidc_issuer(issuer_url: &str) -> Result<()> {
let discovery_url = if issuer_url.ends_with('/') {
format!("{}well-known/openid-configuration", issuer_url)
} else {
format!("{}/.well-known/openid-configuration", issuer_url)
};
tracing::debug!("Verifying OIDC issuer at: {}", discovery_url);
let response = reqwest::get(&discovery_url)
.await
.map_err(|e| {
tracing::warn!("Failed to reach OIDC issuer {}: {:#}", issuer_url, e);
(
StatusCode::BAD_REQUEST,
format!("Failed to reach OIDC issuer: {}. Please verify the issuer URL is correct and accessible.", e),
)
})?;
if !response.status().is_success() {
tracing::warn!(
"OIDC issuer {} returned non-success status: {}",
issuer_url,
response.status()
);
return Err((
StatusCode::BAD_REQUEST,
format!(
"OIDC issuer returned status {}: {}. Please verify the issuer URL points to a valid OIDC provider.",
response.status(),
response.status().canonical_reason().unwrap_or("Unknown")
),
));
}
let config: serde_json::Value = response.json().await.map_err(|e| {
tracing::warn!("Failed to parse OIDC configuration from {}: {:#}", issuer_url, e);
(
StatusCode::BAD_REQUEST,
format!("Invalid OIDC configuration: {}. The issuer URL does not return valid OIDC discovery metadata.", e),
)
})?;
if config.get("issuer").is_none() {
return Err((
StatusCode::BAD_REQUEST,
"OIDC configuration missing required 'issuer' field".to_string(),
));
}
if config.get("jwks_uri").is_none() {
return Err((
StatusCode::BAD_REQUEST,
"OIDC configuration missing required 'jwks_uri' field".to_string(),
));
}
tracing::info!("Successfully verified OIDC issuer: {}", issuer_url);
Ok(())
}
pub async fn create_workload_identity(
State(state): State<AppState>,
Extension(user): Extension<User>,
Path(project_name): Path<String>,
Json(req): Json<CreateWorkloadIdentityRequest>,
) -> Result<Json<WorkloadIdentityResponse>> {
let project = projects::find_by_name(&state.db_pool, &project_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| (StatusCode::NOT_FOUND, "Project not found".to_string()))?;
if !check_write_permission(&state, &project, &user)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
{
return Err((
StatusCode::FORBIDDEN,
"Cannot manage service accounts for this project".to_string(),
));
}
if req.issuer_url.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"Issuer URL cannot be empty".to_string(),
));
}
if !req.issuer_url.starts_with("http://") && !req.issuer_url.starts_with("https://") {
return Err((
StatusCode::BAD_REQUEST,
"Issuer URL must be a valid HTTP(S) URL".to_string(),
));
}
if req.claims.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"At least one claim is required".to_string(),
));
}
if !req.claims.contains_key("aud") {
return Err((
StatusCode::BAD_REQUEST,
"The 'aud' (audience) claim is required for service accounts".to_string(),
));
}
if req.claims.len() < 2 {
return Err((
StatusCode::BAD_REQUEST,
"At least one claim in addition to 'aud' is required (e.g., project_path, ref_protected)".to_string(),
));
}
verify_oidc_issuer(&req.issuer_url).await?;
let sa = service_accounts::create(&state.db_pool, project.id, &req.issuer_url, &req.claims)
.await
.map_err(|e| {
tracing::error!("Failed to create service account: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create service account: {}", e),
)
})?;
let sa_user = users::find_by_id(&state.db_pool, sa.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Service account user not found".to_string(),
)
})?;
let claims: std::collections::HashMap<String, String> = serde_json::from_value(sa.claims)
.map_err(|e| {
tracing::error!("Failed to deserialize claims: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to deserialize claims".to_string(),
)
})?;
Ok(Json(WorkloadIdentityResponse {
id: sa.id.to_string(),
email: sa_user.email,
project_name: project.name,
issuer_url: sa.issuer_url,
claims,
created_at: sa.created_at.to_rfc3339(),
}))
}
pub async fn list_workload_identities(
State(state): State<AppState>,
Extension(user): Extension<User>,
Path(project_name): Path<String>,
) -> Result<Json<ListWorkloadIdentitiesResponse>> {
let project = projects::find_by_name(&state.db_pool, &project_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| (StatusCode::NOT_FOUND, "Project not found".to_string()))?;
if !check_read_permission(&state, &project, &user)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
{
return Err((StatusCode::NOT_FOUND, "Project not found".to_string()));
}
let sas = service_accounts::list_by_project(&state.db_pool, project.id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut workload_identities = Vec::new();
for sa in sas {
let sa_user = users::find_by_id(&state.db_pool, sa.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Service account user not found".to_string(),
)
})?;
let claims: std::collections::HashMap<String, String> =
serde_json::from_value(sa.claims.clone()).map_err(|e| {
tracing::error!("Failed to deserialize claims: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to deserialize claims".to_string(),
)
})?;
workload_identities.push(WorkloadIdentityResponse {
id: sa.id.to_string(),
email: sa_user.email,
project_name: project.name.clone(),
issuer_url: sa.issuer_url,
claims,
created_at: sa.created_at.to_rfc3339(),
});
}
Ok(Json(ListWorkloadIdentitiesResponse {
workload_identities,
}))
}
pub async fn get_workload_identity(
State(state): State<AppState>,
Extension(user): Extension<User>,
Path((project_name, sa_id)): Path<(String, Uuid)>,
) -> Result<Json<WorkloadIdentityResponse>> {
let project = projects::find_by_name(&state.db_pool, &project_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| (StatusCode::NOT_FOUND, "Project not found".to_string()))?;
if !check_read_permission(&state, &project, &user)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
{
return Err((StatusCode::NOT_FOUND, "Project not found".to_string()));
}
let sa = service_accounts::get_by_id(&state.db_pool, sa_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
"Service account not found".to_string(),
)
})?;
if sa.project_id != project.id {
return Err((
StatusCode::NOT_FOUND,
"Service account not found".to_string(),
));
}
if sa.deleted_at.is_some() {
return Err((
StatusCode::NOT_FOUND,
"Service account not found".to_string(),
));
}
let sa_user = users::find_by_id(&state.db_pool, sa.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Service account user not found".to_string(),
)
})?;
let claims: std::collections::HashMap<String, String> = serde_json::from_value(sa.claims)
.map_err(|e| {
tracing::error!("Failed to deserialize claims: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to deserialize claims".to_string(),
)
})?;
Ok(Json(WorkloadIdentityResponse {
id: sa.id.to_string(),
email: sa_user.email,
project_name: project.name,
issuer_url: sa.issuer_url,
claims,
created_at: sa.created_at.to_rfc3339(),
}))
}
pub async fn update_workload_identity(
State(state): State<AppState>,
Extension(user): Extension<User>,
Path((project_name, sa_id)): Path<(String, Uuid)>,
Json(req): Json<UpdateWorkloadIdentityRequest>,
) -> Result<Json<WorkloadIdentityResponse>> {
let project = projects::find_by_name(&state.db_pool, &project_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| (StatusCode::NOT_FOUND, "Project not found".to_string()))?;
if !check_write_permission(&state, &project, &user)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
{
return Err((
StatusCode::FORBIDDEN,
"Cannot manage service accounts for this project".to_string(),
));
}
let sa = service_accounts::get_by_id(&state.db_pool, sa_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
"Service account not found".to_string(),
)
})?;
if sa.project_id != project.id {
return Err((
StatusCode::NOT_FOUND,
"Service account not found".to_string(),
));
}
if sa.deleted_at.is_some() {
return Err((
StatusCode::NOT_FOUND,
"Service account not found".to_string(),
));
}
if req.issuer_url.is_none() && req.claims.is_none() {
return Err((
StatusCode::BAD_REQUEST,
"At least one field (issuer_url or claims) must be provided for update".to_string(),
));
}
if let Some(ref issuer_url) = req.issuer_url {
if issuer_url.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"Issuer URL cannot be empty".to_string(),
));
}
if !issuer_url.starts_with("http://") && !issuer_url.starts_with("https://") {
return Err((
StatusCode::BAD_REQUEST,
"Issuer URL must be a valid HTTP(S) URL".to_string(),
));
}
}
if let Some(ref claims) = req.claims {
if claims.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"Claims cannot be empty".to_string(),
));
}
if !claims.contains_key("aud") {
return Err((
StatusCode::BAD_REQUEST,
"The 'aud' (audience) claim is required for service accounts".to_string(),
));
}
if claims.len() < 2 {
return Err((
StatusCode::BAD_REQUEST,
"At least one claim in addition to 'aud' is required (e.g., project_path, ref_protected)".to_string(),
));
}
}
let updated_sa = service_accounts::update(
&state.db_pool,
sa_id,
req.issuer_url.as_deref(),
req.claims.as_ref(),
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let sa_user = users::find_by_id(&state.db_pool, updated_sa.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Service account user not found".to_string(),
)
})?;
let claims: std::collections::HashMap<String, String> =
serde_json::from_value(updated_sa.claims).map_err(|e| {
tracing::error!("Failed to deserialize claims: {:#}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to deserialize claims".to_string(),
)
})?;
Ok(Json(WorkloadIdentityResponse {
id: updated_sa.id.to_string(),
email: sa_user.email,
project_name: project.name,
issuer_url: updated_sa.issuer_url,
claims,
created_at: updated_sa.created_at.to_rfc3339(),
}))
}
pub async fn delete_workload_identity(
State(state): State<AppState>,
Extension(user): Extension<User>,
Path((project_name, sa_id)): Path<(String, Uuid)>,
) -> Result<StatusCode> {
let project = projects::find_by_name(&state.db_pool, &project_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| (StatusCode::NOT_FOUND, "Project not found".to_string()))?;
if !check_write_permission(&state, &project, &user)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
{
return Err((
StatusCode::FORBIDDEN,
"Cannot manage service accounts for this project".to_string(),
));
}
let sa = service_accounts::get_by_id(&state.db_pool, sa_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
"Service account not found".to_string(),
)
})?;
if sa.project_id != project.id {
return Err((
StatusCode::NOT_FOUND,
"Service account not found".to_string(),
));
}
if sa.deleted_at.is_some() {
return Err((
StatusCode::NOT_FOUND,
"Service account not found".to_string(),
));
}
service_accounts::soft_delete(&state.db_pool, sa_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
}