use anyhow::{Context, Result};
use sqlx::PgPool;
use std::collections::HashMap;
use uuid::Uuid;
use crate::db::models::{Project, ServiceAccount, User};
pub async fn create(
pool: &PgPool,
project_id: Uuid,
issuer_url: &str,
claims: &HashMap<String, String>,
) -> Result<ServiceAccount> {
let mut tx = pool.begin().await.context("Failed to begin transaction")?;
let project = sqlx::query_as!(
Project,
r#"
SELECT id, name, status as "status: _", access_class,
owner_user_id, owner_team_id, finalizers,
created_at, updated_at
FROM projects
WHERE id = $1
"#,
project_id
)
.fetch_one(&mut *tx)
.await
.context("Failed to fetch project")?;
let sequence: Option<i32> = sqlx::query_scalar!(
r#"
SELECT COALESCE(MAX(sequence), 0) + 1 as "sequence"
FROM service_accounts
WHERE project_id = $1
"#,
project_id
)
.fetch_one(&mut *tx)
.await
.context("Failed to calculate sequence")?;
let sequence = sequence.unwrap_or(1);
let email = format!("{}+{}@sa.rise.local", project.name, sequence);
let existing_user = sqlx::query_as!(
User,
r#"
SELECT id, email, created_at, updated_at
FROM users
WHERE email = $1
"#,
email
)
.fetch_optional(&mut *tx)
.await
.context("Failed to check for existing user")?;
let user = if let Some(user) = existing_user {
tracing::debug!("Reusing existing user for service account: {}", email);
user
} else {
sqlx::query_as!(
User,
r#"
INSERT INTO users (email)
VALUES ($1)
RETURNING id, email, created_at, updated_at
"#,
email
)
.fetch_one(&mut *tx)
.await
.context("Failed to create user for service account")?
};
let claims_json = serde_json::to_value(claims).context("Failed to serialize claims")?;
let sa = sqlx::query_as!(
ServiceAccount,
r#"
INSERT INTO service_accounts (project_id, user_id, issuer_url, claims, sequence)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, project_id, user_id, issuer_url, claims, sequence,
deleted_at, created_at, updated_at
"#,
project_id,
user.id,
issuer_url,
claims_json,
sequence
)
.fetch_one(&mut *tx)
.await
.context("Failed to create service account")?;
tx.commit().await.context("Failed to commit transaction")?;
Ok(sa)
}
pub async fn list_by_project(pool: &PgPool, project_id: Uuid) -> Result<Vec<ServiceAccount>> {
let sas = sqlx::query_as!(
ServiceAccount,
r#"
SELECT id, project_id, user_id, issuer_url, claims, sequence,
deleted_at, created_at, updated_at
FROM service_accounts
WHERE project_id = $1 AND deleted_at IS NULL
ORDER BY sequence ASC
"#,
project_id
)
.fetch_all(pool)
.await
.context("Failed to list service accounts")?;
Ok(sas)
}
pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result<Option<ServiceAccount>> {
let sa = sqlx::query_as!(
ServiceAccount,
r#"
SELECT id, project_id, user_id, issuer_url, claims, sequence,
deleted_at, created_at, updated_at
FROM service_accounts
WHERE id = $1
"#,
id
)
.fetch_optional(pool)
.await
.context("Failed to get service account by ID")?;
Ok(sa)
}
#[allow(dead_code)]
pub async fn get_claims(
pool: &PgPool,
service_account_id: Uuid,
) -> Result<HashMap<String, String>> {
let sa = sqlx::query_as!(
ServiceAccount,
r#"
SELECT id, project_id, user_id, issuer_url, claims, sequence,
deleted_at, created_at, updated_at
FROM service_accounts
WHERE id = $1
"#,
service_account_id
)
.fetch_one(pool)
.await
.context("Failed to fetch service account")?;
let claims: HashMap<String, String> =
serde_json::from_value(sa.claims).context("Failed to deserialize claims")?;
Ok(claims)
}
pub async fn find_by_issuer(pool: &PgPool, issuer_url: &str) -> Result<Vec<ServiceAccount>> {
let sas = sqlx::query_as!(
ServiceAccount,
r#"
SELECT id, project_id, user_id, issuer_url, claims, sequence,
deleted_at, created_at, updated_at
FROM service_accounts
WHERE issuer_url = $1 AND deleted_at IS NULL
"#,
issuer_url
)
.fetch_all(pool)
.await
.context("Failed to find service accounts by issuer")?;
Ok(sas)
}
pub async fn find_by_user_and_project(
pool: &PgPool,
user_id: Uuid,
project_id: Uuid,
) -> Result<Option<ServiceAccount>> {
let sa = sqlx::query_as!(
ServiceAccount,
r#"
SELECT id, project_id, user_id, issuer_url, claims, sequence,
deleted_at, created_at, updated_at
FROM service_accounts
WHERE user_id = $1 AND project_id = $2 AND deleted_at IS NULL
"#,
user_id,
project_id
)
.fetch_optional(pool)
.await
.context("Failed to find service account by user and project")?;
Ok(sa)
}
pub async fn is_service_account(pool: &PgPool, user_id: Uuid) -> Result<bool> {
let exists = sqlx::query_scalar!(
r#"
SELECT EXISTS(
SELECT 1
FROM service_accounts
WHERE user_id = $1 AND deleted_at IS NULL
) as "exists!"
"#,
user_id
)
.fetch_one(pool)
.await
.context("Failed to check if user is a service account")?;
Ok(exists)
}
pub async fn update(
pool: &PgPool,
id: Uuid,
issuer_url: Option<&str>,
claims: Option<&HashMap<String, String>>,
) -> Result<ServiceAccount> {
let claims_json = if let Some(c) = claims {
Some(serde_json::to_value(c).context("Failed to serialize claims")?)
} else {
None
};
let sa = sqlx::query_as!(
ServiceAccount,
r#"
UPDATE service_accounts
SET
issuer_url = COALESCE($2, issuer_url),
claims = COALESCE($3, claims),
updated_at = NOW()
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, project_id, user_id, issuer_url, claims, sequence,
deleted_at, created_at, updated_at
"#,
id,
issuer_url,
claims_json
)
.fetch_one(pool)
.await
.context("Failed to update service account")?;
Ok(sa)
}
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<()> {
sqlx::query!(
r#"
UPDATE service_accounts
SET deleted_at = NOW(), updated_at = NOW()
WHERE id = $1
"#,
id
)
.execute(pool)
.await
.context("Failed to soft delete service account")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{models::ProjectStatus, projects, users};
#[sqlx::test]
async fn test_create_service_account(pool: PgPool) -> Result<()> {
let user = users::create(&pool, "owner@example.com").await?;
let project = projects::create(
&pool,
"test-project",
ProjectStatus::Stopped,
"public".to_string(),
Some(user.id),
None,
)
.await?;
let mut claims = HashMap::new();
claims.insert("sub".to_string(), "test-subject".to_string());
claims.insert("iss".to_string(), "https://gitlab.com".to_string());
let sa = create(&pool, project.id, "https://gitlab.com", &claims).await?;
assert_eq!(sa.project_id, project.id);
assert_eq!(sa.issuer_url, "https://gitlab.com");
assert_eq!(sa.sequence, 1);
assert!(sa.deleted_at.is_none());
let sa_user = users::find_by_id(&pool, sa.user_id).await?;
assert!(sa_user.is_some());
assert_eq!(sa_user.unwrap().email, "test-project+1@sa.rise.local");
Ok(())
}
#[sqlx::test]
async fn test_sequence_increment(pool: PgPool) -> Result<()> {
let user = users::create(&pool, "owner@example.com").await?;
let project = projects::create(
&pool,
"test-project",
ProjectStatus::Stopped,
"public".to_string(),
Some(user.id),
None,
)
.await?;
let claims = HashMap::new();
let sa1 = create(&pool, project.id, "https://gitlab.com", &claims).await?;
assert_eq!(sa1.sequence, 1);
let sa2 = create(&pool, project.id, "https://github.com", &claims).await?;
assert_eq!(sa2.sequence, 2);
Ok(())
}
#[sqlx::test]
async fn test_list_by_project(pool: PgPool) -> Result<()> {
let user = users::create(&pool, "owner@example.com").await?;
let project = projects::create(
&pool,
"test-project",
ProjectStatus::Stopped,
"public".to_string(),
Some(user.id),
None,
)
.await?;
let claims = HashMap::new();
create(&pool, project.id, "https://gitlab.com", &claims).await?;
create(&pool, project.id, "https://github.com", &claims).await?;
let sas = list_by_project(&pool, project.id).await?;
assert_eq!(sas.len(), 2);
Ok(())
}
#[sqlx::test]
async fn test_find_by_issuer(pool: PgPool) -> Result<()> {
let user = users::create(&pool, "owner@example.com").await?;
let project1 = projects::create(
&pool,
"project1",
ProjectStatus::Stopped,
"public".to_string(),
Some(user.id),
None,
)
.await?;
let project2 = projects::create(
&pool,
"project2",
ProjectStatus::Stopped,
"public".to_string(),
Some(user.id),
None,
)
.await?;
let claims = HashMap::new();
create(&pool, project1.id, "https://gitlab.com", &claims).await?;
create(&pool, project2.id, "https://gitlab.com", &claims).await?;
create(&pool, project1.id, "https://github.com", &claims).await?;
let sas = find_by_issuer(&pool, "https://gitlab.com").await?;
assert_eq!(sas.len(), 2);
Ok(())
}
#[sqlx::test]
async fn test_soft_delete(pool: PgPool) -> Result<()> {
let user = users::create(&pool, "owner@example.com").await?;
let project = projects::create(
&pool,
"test-project",
ProjectStatus::Stopped,
"public".to_string(),
Some(user.id),
None,
)
.await?;
let claims = HashMap::new();
let sa = create(&pool, project.id, "https://gitlab.com", &claims).await?;
soft_delete(&pool, sa.id).await?;
let sas = list_by_project(&pool, project.id).await?;
assert_eq!(sas.len(), 0);
let sa_deleted = get_by_id(&pool, sa.id).await?;
assert!(sa_deleted.is_some());
assert!(sa_deleted.unwrap().deleted_at.is_some());
Ok(())
}
#[sqlx::test]
async fn test_is_service_account(pool: PgPool) -> Result<()> {
let regular_user = users::create(&pool, "regular@example.com").await?;
let project = projects::create(
&pool,
"test-project",
ProjectStatus::Stopped,
"public".to_string(),
Some(regular_user.id),
None,
)
.await?;
let claims = HashMap::new();
let sa = create(&pool, project.id, "https://gitlab.com", &claims).await?;
assert!(!is_service_account(&pool, regular_user.id).await?);
assert!(is_service_account(&pool, sa.user_id).await?);
Ok(())
}
}