use crate::storage::DbPool;
use async_trait::async_trait;
use sea_query::{Asterisk, Expr, ExprTrait, IntoIden, Order, Query};
use serde::Deserialize;
use super::helpers::{Page, PaginatedResult, Projection};
use crate::errors::OrionError;
use crate::storage::models::Connector;
use crate::storage::{build_sqlx, schema::Connectors};
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateConnectorRequest {
pub id: Option<String>,
pub name: String,
pub connector_type: crate::connector::ConnectorType,
#[serde(default = "default_config")]
pub config: serde_json::Value,
pub enabled: Option<bool>,
#[serde(default)]
pub tags: Vec<String>,
}
fn default_config() -> serde_json::Value {
serde_json::json!({})
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct UpdateConnectorRequest {
pub name: Option<String>,
pub connector_type: Option<crate::connector::ConnectorType>,
pub config: Option<serde_json::Value>,
pub enabled: Option<bool>,
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Default, Deserialize, serde::Serialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ConnectorFilter {
pub tag: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
pub sort_by: Option<String>,
pub sort_order: Option<String>,
}
fn build_condition(filter: &ConnectorFilter) -> sea_query::Condition {
let mut cond = sea_query::Condition::all();
if let Some(ref tag) = filter.tag {
cond = cond.add(
Expr::col(Connectors::TagsJson).like(super::helpers::tag_like_pattern(tag.as_str())),
);
}
cond
}
#[async_trait]
pub trait ConnectorRepository: Send + Sync {
async fn create(&self, req: &CreateConnectorRequest) -> Result<Connector, OrionError>;
async fn get_by_id(&self, id: &str) -> Result<Connector, OrionError>;
async fn list_paginated(
&self,
filter: &ConnectorFilter,
) -> Result<PaginatedResult<Connector>, OrionError>;
async fn update(&self, id: &str, req: &UpdateConnectorRequest)
-> Result<Connector, OrionError>;
async fn delete(&self, id: &str) -> Result<(), OrionError>;
async fn list_enabled(&self) -> Result<Vec<Connector>, OrionError>;
async fn exists_by_name(&self, name: &str) -> Result<bool, OrionError>;
async fn get_by_name(&self, name: &str) -> Result<Connector, OrionError>;
async fn snapshot(&self, filter: &ConnectorFilter) -> Result<Vec<Connector>, OrionError>;
async fn get_oauth_state(
&self,
connector_name: &str,
) -> Result<Option<crate::storage::models::ConnectorOauthStateRow>, OrionError>;
async fn put_oauth_state(
&self,
connector_name: &str,
fingerprint: &str,
state_json: &str,
) -> Result<(), OrionError>;
async fn delete_oauth_state(&self, connector_name: &str) -> Result<(), OrionError>;
}
fn connector_select(id: &str) -> sea_query::SelectStatement {
Query::select()
.column(Asterisk)
.from(Connectors::Table)
.and_where(Expr::col(Connectors::Id).eq(id))
.to_owned()
}
fn connector_not_found(id: &str) -> OrionError {
OrionError::NotFound(format!("Connector '{id}' not found"))
}
pub struct SqlConnectorRepository {
pool: DbPool,
cipher: Option<std::sync::Arc<crate::storage::config_encryption::ConfigCipher>>,
}
impl SqlConnectorRepository {
pub fn new(pool: DbPool) -> Self {
Self { pool, cipher: None }
}
pub fn with_cipher(
pool: DbPool,
cipher: Option<std::sync::Arc<crate::storage::config_encryption::ConfigCipher>>,
) -> Self {
Self { pool, cipher }
}
fn store_form(&self, config_json: &str) -> Result<String, OrionError> {
match &self.cipher {
Some(cipher) => cipher.encrypt(config_json),
None => Ok(config_json.to_string()),
}
}
fn open_row(&self, mut row: Connector) -> Result<Connector, OrionError> {
use crate::storage::config_encryption::ConfigCipher;
row.config_json = match &self.cipher {
Some(cipher) => cipher.decrypt(&row.config_json)?,
None if ConfigCipher::is_encrypted(&row.config_json) => {
return Err(OrionError::internal(format!(
"connector '{}' is encrypted at rest but \
storage.connector_encryption_key is not set",
row.id
)));
}
None => row.config_json,
};
Ok(row)
}
}
#[async_trait]
impl ConnectorRepository for SqlConnectorRepository {
async fn create(&self, req: &CreateConnectorRequest) -> Result<Connector, OrionError> {
crate::metrics::timed_db_op("connectors.create", async {
let id = req
.id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let config_json = self.store_form(&serde_json::to_string(&req.config)?)?;
let tags_json = serde_json::to_string(&req.tags)?;
let mut insert = Query::insert();
insert
.into_table(Connectors::Table)
.columns([
Connectors::Id,
Connectors::Name,
Connectors::ConnectorType,
Connectors::ConfigJson,
Connectors::Enabled,
Connectors::TagsJson,
])
.values_panic([
id.as_str().into(),
req.name.as_str().into(),
req.connector_type.as_str().into(),
config_json.as_str().into(),
req.enabled.unwrap_or(true).into(),
tags_json.as_str().into(),
]);
let row = super::helpers::write_returning_row(
&self.pool,
super::helpers::WriteStatement::Insert(&mut insert),
&mut connector_select(&id),
|e| {
super::helpers::map_duplicate(e, || {
format!("Connector with name '{}' already exists", req.name)
})
},
|| connector_not_found(&id),
)
.await?;
self.open_row(row)
})
.await
}
async fn get_by_id(&self, id: &str) -> Result<Connector, OrionError> {
crate::metrics::timed_db_op("connectors.get_by_id", async {
let (sql, values) = build_sqlx(self.pool.backend(), &mut connector_select(id));
self.pool
.fetch_optional_as::<Connector>(&sql, values)
.await?
.ok_or_else(|| connector_not_found(id))
.and_then(|row| self.open_row(row))
})
.await
}
async fn list_paginated(
&self,
filter: &ConnectorFilter,
) -> Result<PaginatedResult<Connector>, OrionError> {
crate::metrics::timed_db_op("connectors.list_paginated", async {
let (limit, offset) = super::helpers::clamp_pagination(filter.limit, filter.offset);
let sort_iden = match filter.sort_by.as_deref() {
Some("connector_type") => Connectors::ConnectorType,
Some("created_at") => Connectors::CreatedAt,
Some("updated_at") => Connectors::UpdatedAt,
_ => Connectors::Name,
};
let order = match filter.sort_order.as_deref() {
Some("desc") => Order::Desc,
_ => Order::Asc,
};
let cond = build_condition(filter);
let page: PaginatedResult<Connector> = super::helpers::paginate(
&self.pool,
Page {
from: Connectors::Table.into_iden(),
projection: Projection::All,
cond,
sort: sort_iden.into_iden(),
order,
limit,
offset,
},
)
.await?;
Ok(PaginatedResult {
data: page
.data
.into_iter()
.map(|row| self.open_row(row))
.collect::<Result<_, _>>()?,
total: page.total,
limit: page.limit,
offset: page.offset,
})
})
.await
}
async fn update(
&self,
id: &str,
req: &UpdateConnectorRequest,
) -> Result<Connector, OrionError> {
crate::metrics::timed_db_op("connectors.update", async {
let existing = self.get_by_id(id).await?;
let name = req.name.as_deref().unwrap_or(&existing.name);
let connector_type: &str = req
.connector_type
.as_ref()
.map(|c| c.as_str())
.unwrap_or(existing.connector_type.as_str());
let config_json = self.store_form(&match &req.config {
Some(c) => serde_json::to_string(c)?,
None => existing.config_json.clone(),
})?;
let enabled = req.enabled.unwrap_or(existing.enabled);
let tags_json = match &req.tags {
Some(t) => serde_json::to_string(t)?,
None => existing.tags_json.clone(),
};
let mut update = Query::update()
.table(Connectors::Table)
.value(Connectors::Name, name)
.value(Connectors::ConnectorType, connector_type)
.value(Connectors::ConfigJson, &config_json)
.value(Connectors::Enabled, enabled)
.value(Connectors::TagsJson, tags_json.as_str())
.and_where(Expr::col(Connectors::Id).eq(id))
.to_owned();
let row = super::helpers::write_returning_row(
&self.pool,
super::helpers::WriteStatement::Update(&mut update),
&mut connector_select(id),
OrionError::Storage,
|| connector_not_found(id),
)
.await?;
self.open_row(row)
})
.await
}
async fn delete(&self, id: &str) -> Result<(), OrionError> {
crate::metrics::timed_db_op("connectors.delete", async {
let (sql, values) = build_sqlx(
self.pool.backend(),
Query::select()
.column(Connectors::Name)
.from(Connectors::Table)
.and_where(Expr::col(Connectors::Id).eq(id)),
);
let name: String = self
.pool
.fetch_scalar(&sql, values)
.await
.map_err(|_| OrionError::NotFound(format!("Connector '{id}' not found")))?;
let (sql, values) = build_sqlx(
self.pool.backend(),
Query::delete()
.from_table(Connectors::Table)
.and_where(Expr::col(Connectors::Id).eq(id)),
);
let rows_affected = self.pool.execute_query(&sql, values).await?;
if rows_affected == 0 {
return Err(OrionError::NotFound(format!("Connector '{id}' not found")));
}
self.delete_oauth_state(&name).await?;
Ok(())
})
.await
}
async fn list_enabled(&self) -> Result<Vec<Connector>, OrionError> {
crate::metrics::timed_db_op("connectors.list_enabled", async {
let (sql, values) = build_sqlx(
self.pool.backend(),
Query::select()
.column(Asterisk)
.from(Connectors::Table)
.and_where(Expr::col(Connectors::Enabled).eq(true))
.order_by(Connectors::Name, Order::Asc),
);
self.pool
.fetch_all_as::<Connector>(&sql, values)
.await?
.into_iter()
.map(|row| self.open_row(row))
.collect()
})
.await
}
async fn exists_by_name(&self, name: &str) -> Result<bool, OrionError> {
crate::metrics::timed_db_op("connectors.exists_by_name", async {
Ok(super::helpers::count_where(
&self.pool,
Connectors::Table,
sea_query::Condition::all().add(Expr::col(Connectors::Name).eq(name)),
)
.await?
> 0)
})
.await
}
async fn snapshot(&self, filter: &ConnectorFilter) -> Result<Vec<Connector>, OrionError> {
crate::metrics::timed_db_op("connectors.snapshot", async {
let rows: Vec<Connector> = super::helpers::snapshot_pages(
&self.pool,
super::helpers::EXPORT_PAGE_SIZE,
|limit, offset| {
Query::select()
.column(Asterisk)
.from(Connectors::Table)
.cond_where(build_condition(filter))
.order_by(Connectors::Name, Order::Asc)
.limit(limit as u64)
.offset(offset as u64)
.to_owned()
},
)
.await?;
rows.into_iter().map(|row| self.open_row(row)).collect()
})
.await
}
async fn get_by_name(&self, name: &str) -> Result<Connector, OrionError> {
crate::metrics::timed_db_op("connectors.get_by_name", async {
let (sql, values) = build_sqlx(
self.pool.backend(),
Query::select()
.column(Asterisk)
.from(Connectors::Table)
.and_where(Expr::col(Connectors::Name).eq(name)),
);
super::helpers::fetch_required::<Connector>(&self.pool, &sql, values, || {
OrionError::NotFound(format!("Connector '{name}' not found"))
})
.await
.and_then(|row| self.open_row(row))
})
.await
}
async fn get_oauth_state(
&self,
connector_name: &str,
) -> Result<Option<crate::storage::models::ConnectorOauthStateRow>, OrionError> {
use crate::storage::schema::ConnectorOauthState as S;
crate::metrics::timed_db_op("connectors.get_oauth_state", async {
let (sql, values) = build_sqlx(
self.pool.backend(),
Query::select()
.columns([S::Fingerprint, S::StateJson])
.from(S::Table)
.and_where(Expr::col(S::ConnectorName).eq(connector_name)),
);
let row = self
.pool
.fetch_optional_as::<crate::storage::models::ConnectorOauthStateRow>(&sql, values)
.await?;
match row {
None => Ok(None),
Some(mut row) => {
use crate::storage::config_encryption::ConfigCipher;
row.state_json = match &self.cipher {
Some(cipher) => cipher.decrypt(&row.state_json)?,
None if ConfigCipher::is_encrypted(&row.state_json) => {
return Err(OrionError::internal(format!(
"oauth state for connector '{connector_name}' is encrypted \
at rest but storage.connector_encryption_key is not set"
)));
}
None => row.state_json,
};
Ok(Some(row))
}
}
})
.await
}
async fn put_oauth_state(
&self,
connector_name: &str,
fingerprint: &str,
state_json: &str,
) -> Result<(), OrionError> {
use crate::storage::schema::ConnectorOauthState as S;
crate::metrics::timed_db_op("connectors.put_oauth_state", async {
let stored = self.store_form(state_json)?;
let now = Expr::cust(super::helpers::sql_now(self.pool.backend()));
let mut insert = Query::insert()
.into_table(S::Table)
.columns([S::ConnectorName, S::Fingerprint, S::StateJson, S::UpdatedAt])
.values_panic([
connector_name.into(),
fingerprint.into(),
stored.into(),
now,
])
.to_owned();
insert.on_conflict(
sea_query::OnConflict::column(S::ConnectorName)
.update_columns([S::Fingerprint, S::StateJson, S::UpdatedAt])
.to_owned(),
);
let (sql, values) = build_sqlx(self.pool.backend(), &mut insert);
self.pool.execute_query(&sql, values).await?;
Ok(())
})
.await
}
async fn delete_oauth_state(&self, connector_name: &str) -> Result<(), OrionError> {
use crate::storage::schema::ConnectorOauthState as S;
crate::metrics::timed_db_op("connectors.delete_oauth_state", async {
let (sql, values) = build_sqlx(
self.pool.backend(),
Query::delete()
.from_table(S::Table)
.and_where(Expr::col(S::ConnectorName).eq(connector_name)),
);
self.pool.execute_query(&sql, values).await?;
Ok(())
})
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::config_encryption::ConfigCipher;
use serde_json::json;
const TEST_KEY: &str = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
async fn plain_repo() -> (DbPool, SqlConnectorRepository) {
let pool = crate::storage::test_sqlite_pool().await;
let repo = SqlConnectorRepository::new(pool.clone());
(pool, repo)
}
fn encrypting(pool: &DbPool) -> SqlConnectorRepository {
SqlConnectorRepository::with_cipher(
pool.clone(),
Some(std::sync::Arc::new(
ConfigCipher::from_hex(TEST_KEY).expect("cipher"),
)),
)
}
fn request(name: &str, config: serde_json::Value) -> CreateConnectorRequest {
CreateConnectorRequest {
id: Some(name.to_string()),
name: name.to_string(),
connector_type: crate::connector::ConnectorType::Http,
config,
enabled: None,
tags: vec![],
}
}
async fn stored_config(pool: &DbPool, id: &str) -> String {
let (sql, values) = build_sqlx(
pool.backend(),
Query::select()
.column(Connectors::ConfigJson)
.from(Connectors::Table)
.and_where(Expr::col(Connectors::Id).eq(id)),
);
pool.fetch_optional_as::<(String,)>(&sql, values)
.await
.expect("read")
.expect("row")
.0
}
#[tokio::test]
async fn a_connector_round_trips_without_a_cipher() {
let (pool, repo) = plain_repo().await;
let created = repo
.create(&request(
"plain",
json!({"base_url": "https://example.test"}),
))
.await
.expect("create");
assert_eq!(created.name, "plain");
let read = repo.get_by_id("plain").await.expect("read back");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&read.config_json).expect("json")["base_url"],
"https://example.test"
);
assert_eq!(
stored_config(&pool, "plain").await,
read.config_json,
"with no key configured the stored form is the plaintext form"
);
}
#[tokio::test]
async fn a_configured_cipher_encrypts_at_rest_and_decrypts_on_read() {
let (pool, _) = plain_repo().await;
let repo = encrypting(&pool);
repo.create(&request("secretive", json!({"token": "hunter2"})))
.await
.expect("create");
let at_rest = stored_config(&pool, "secretive").await;
assert!(
ConfigCipher::is_encrypted(&at_rest),
"the stored config must be enveloped: {at_rest}"
);
assert!(
!at_rest.contains("hunter2"),
"the credential must not be readable in the table: {at_rest}"
);
let read = repo.get_by_id("secretive").await.expect("read back");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&read.config_json).expect("json")["token"],
"hunter2",
"the repository must hand back plaintext"
);
}
#[tokio::test]
async fn plaintext_rows_written_before_the_key_still_read() {
let (pool, plain) = plain_repo().await;
plain
.create(&request("legacy", json!({"token": "old"})))
.await
.expect("create without a key");
let read = encrypting(&pool)
.get_by_id("legacy")
.await
.expect("a pre-key row must still read once a key is configured");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&read.config_json).expect("json")["token"],
"old"
);
}
#[tokio::test]
async fn an_encrypted_row_without_a_key_is_a_loud_error() {
let (pool, plain) = plain_repo().await;
encrypting(&pool)
.create(&request("enciphered", json!({"token": "hunter2"})))
.await
.expect("create with a key");
let err = plain
.get_by_id("enciphered")
.await
.expect_err("an encrypted row with no key must not be served as-is");
let message = err.to_string();
assert!(
message.contains("connector_encryption_key"),
"the error must name the missing setting: {message}"
);
}
#[tokio::test]
async fn an_update_re_encrypts_the_new_config() {
let (pool, _) = plain_repo().await;
let repo = encrypting(&pool);
repo.create(&request("rotating", json!({"token": "first"})))
.await
.expect("create");
repo.update(
"rotating",
&UpdateConnectorRequest {
name: None,
connector_type: None,
config: Some(json!({"token": "second"})),
enabled: None,
tags: None,
},
)
.await
.expect("update");
let at_rest = stored_config(&pool, "rotating").await;
assert!(ConfigCipher::is_encrypted(&at_rest), "{at_rest}");
assert!(!at_rest.contains("second"), "{at_rest}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(
&repo.get_by_id("rotating").await.expect("read").config_json
)
.expect("json")["token"],
"second"
);
}
#[tokio::test]
async fn list_enabled_skips_disabled_connectors_and_still_decrypts() {
let (pool, _) = plain_repo().await;
let repo = encrypting(&pool);
repo.create(&CreateConnectorRequest {
enabled: Some(false),
..request("off", json!({"token": "no"}))
})
.await
.expect("create disabled");
repo.create(&request("on", json!({"token": "yes"})))
.await
.expect("create enabled");
let enabled = repo.list_enabled().await.expect("list");
let names: Vec<&str> = enabled.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["on"]);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&enabled[0].config_json).expect("json")["token"],
"yes",
"list_enabled feeds the registry, so it must decrypt like get_by_id"
);
}
#[tokio::test]
async fn a_duplicate_name_is_a_conflict() {
let (_pool, repo) = plain_repo().await;
repo.create(&request("only-one", json!({})))
.await
.expect("create");
let err = repo
.create(&CreateConnectorRequest {
id: Some("a-different-id".to_string()),
..request("only-one", json!({}))
})
.await
.expect_err("a duplicate connector name must be refused");
assert!(
matches!(err, OrionError::Conflict(_)),
"expected a Conflict, got: {err:?}"
);
}
}