Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,11 @@ Any files modified by steps 1–2 (e.g., `openapi.yaml`, JSON schema files) must

## Endpoint Patterns

- Endpoints are registered in a `configure()` function that takes `ServiceConfig`, `Database`, and config params
- Endpoints are registered in a `configure()` function that takes `ServiceConfig`, the `ReadOnly` and/or `ReadWrite` connection types, and config params
- Services are injected via `web::Data<T>` (Actix application data)
- Authorization uses `Require<Permission>` extractor or `authorizer.require(&user, Permission::...)` call
- Read operations acquire a read transaction: `let tx = db.begin_read().await?;`
- Read operations use the `ReadOnly` connection: `let tx = db.begin().await?;`
- Write operations use the `ReadWrite` connection and its `transaction()` method
- List endpoints accept `Query` (search/filter), `Paginated` (pagination), and return `PaginatedResults<T>`
- Every endpoint has a `#[utoipa::path(...)]` attribute for OpenAPI documentation with `tag`, `operation_id`, `params`, and `responses`
- Route attributes use Actix macros: `#[get("/v3/...")]`, `#[post("/v3/...")]`, `#[delete("/v3/...")]`
Expand Down
177 changes: 177 additions & 0 deletions common/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ const ENV_DB_MAX_LIFETIME: &str = "TRUSTD_DB_MAX_LIFETIME";
const ENV_DB_IDLE_TIMEOUT: &str = "TRUSTD_DB_IDLE_TIMEOUT";
const ENV_DB_SSLMODE: &str = "TRUSTD_DB_SSLMODE";

const ENV_DB_RO_URL: &str = "TRUSTD_DB_RO_URL";
const ENV_DB_RO_NAME: &str = "TRUSTD_DB_RO_NAME";
const ENV_DB_RO_USER: &str = "TRUSTD_DB_RO_USER";
const ENV_DB_RO_PASS: &str = "TRUSTD_DB_RO_PASSWORD";
const ENV_DB_RO_HOST: &str = "TRUSTD_DB_RO_HOST";
const ENV_DB_RO_PORT: &str = "TRUSTD_DB_RO_PORT";
const ENV_DB_RO_MAX_CONN: &str = "TRUSTD_DB_RO_MAX_CONN";
const ENV_DB_RO_MIN_CONN: &str = "TRUSTD_DB_RO_MIN_CONN";
const ENV_DB_RO_CONNECT_TIMEOUT: &str = "TRUSTD_DB_RO_CONNECT_TIMEOUT";
const ENV_DB_RO_ACQUIRE_TIMEOUT: &str = "TRUSTD_DB_RO_ACQUIRE_TIMEOUT";
const ENV_DB_RO_MAX_LIFETIME: &str = "TRUSTD_DB_RO_MAX_LIFETIME";
const ENV_DB_RO_IDLE_TIMEOUT: &str = "TRUSTD_DB_RO_IDLE_TIMEOUT";
const ENV_DB_RO_SSLMODE: &str = "TRUSTD_DB_RO_SSLMODE";

/// PostgreSQL SSL mode
#[derive(Copy, Clone, Debug, Default, clap::ValueEnum, Eq, PartialEq, strum::Display)]
#[strum(serialize_all = "kebab-case")]
Expand Down Expand Up @@ -164,6 +178,70 @@ impl Database {
}
}

/// Read-only database options, mirroring `Database` with all fields optional.
///
/// When a field is not set, the corresponding value from the R/W `Database` config is used.
/// If no R/O fields are set at all, the R/W connection is reused for reads.
#[derive(clap::Parser, Debug, Clone, Default, Eq, PartialEq)]
#[command(next_help_heading = "Read-Only Database")]
#[group(id = "database-ro")]
pub struct DatabaseReadOnly {
/// A complete URL for the read-only database.
#[arg(id = "db-ro-url", long, env = ENV_DB_RO_URL)]
pub url: Option<String>,
#[arg(id = "db-ro-user", long, env = ENV_DB_RO_USER)]
pub username: Option<String>,
#[arg(id = "db-ro-password", long, env = ENV_DB_RO_PASS)]
pub password: Option<Hide<String>>,
#[arg(id = "db-ro-host", long, env = ENV_DB_RO_HOST)]
pub host: Option<String>,
#[arg(id = "db-ro-port", long, env = ENV_DB_RO_PORT)]
pub port: Option<u16>,
#[arg(id = "db-ro-name", long, env = ENV_DB_RO_NAME)]
pub name: Option<String>,
#[arg(id = "db-ro-max-conn", long, env = ENV_DB_RO_MAX_CONN)]
pub max_conn: Option<u32>,
#[arg(id = "db-ro-min-conn", long, env = ENV_DB_RO_MIN_CONN)]
pub min_conn: Option<u32>,
#[arg(id = "db-ro-sslmode", long, env = ENV_DB_RO_SSLMODE, value_enum)]
pub sslmode: Option<SslMode>,
#[arg(id = "db-ro-conn-timeout", long, env = ENV_DB_RO_CONNECT_TIMEOUT)]
pub connect_timeout: Option<u64>,
#[arg(id = "db-ro-acquire-timeout", long, env = ENV_DB_RO_ACQUIRE_TIMEOUT)]
pub acquire_timeout: Option<u64>,
#[arg(id = "db-ro-max-lifetime", long, env = ENV_DB_RO_MAX_LIFETIME)]
pub max_lifetime: Option<u64>,
#[arg(id = "db-ro-idle-timeout", long, env = ENV_DB_RO_IDLE_TIMEOUT)]
pub idle_timeout: Option<u64>,
}

impl DatabaseReadOnly {
/// Builds a `Database` config by overlaying R/O values on top of the R/W fallback.
pub fn to_database_config(&self, fallback: &Database) -> Database {
Database {
url: self.url.clone().or_else(|| fallback.url.clone()),
username: self
.username
.clone()
.unwrap_or_else(|| fallback.username.clone()),
password: self
.password
.clone()
.unwrap_or_else(|| fallback.password.clone()),
host: self.host.clone().unwrap_or_else(|| fallback.host.clone()),
port: self.port.unwrap_or(fallback.port),
name: self.name.clone().unwrap_or_else(|| fallback.name.clone()),
max_conn: self.max_conn.unwrap_or(fallback.max_conn),
min_conn: self.min_conn.unwrap_or(fallback.min_conn),
sslmode: self.sslmode.unwrap_or(fallback.sslmode),
connect_timeout: self.connect_timeout.unwrap_or(fallback.connect_timeout),
acquire_timeout: self.acquire_timeout.unwrap_or(fallback.acquire_timeout),
max_lifetime: self.max_lifetime.unwrap_or(fallback.max_lifetime),
idle_timeout: self.idle_timeout.unwrap_or(fallback.idle_timeout),
}
}
}

#[cfg(test)]
mod test {
use super::*;
Expand Down Expand Up @@ -223,4 +301,103 @@ mod test {
"postgres://postgres:trustify@localhost:5432/trustify?sslmode=disable"
);
}

/// Helper to create a default R/W config for use in R/O fallback tests.
fn rw_default() -> Database {
Database {
url: None,
username: DB_USER.into(),
password: DB_PASS.into(),
host: DB_HOST.into(),
port: DB_PORT,
name: DB_NAME.into(),
max_conn: DB_MAX_CONN,
min_conn: DB_MIN_CONN,
connect_timeout: DB_CONNECT_TIMEOUT,
acquire_timeout: DB_ACQUIRE_TIMEOUT,
max_lifetime: DB_MAX_LIFETIME,
idle_timeout: DB_IDLE_TIMEOUT,
sslmode: SslMode::default(),
}
}

/// Verify that an unconfigured R/O config falls back to the R/W config entirely.
#[test]
fn ro_fallback_uses_rw_config() {
// given: a default R/W config and an empty R/O config
let rw = rw_default();
let ro = DatabaseReadOnly::default();

// when: building the R/O database config
let result = ro.to_database_config(&rw);

// then: the result is identical to the R/W config
assert_eq!(result, rw);
}

/// Verify that individual R/O fields override the R/W fallback while inheriting the rest.
#[test]
fn ro_overrides_host_and_port() {
// given: a default R/W config and an R/O config with host and port set
let rw = rw_default();
let ro = DatabaseReadOnly {
host: Some("replica.example.com".into()),
port: Some(5433),
..Default::default()
};

// when: building the R/O database config
let result = ro.to_database_config(&rw);

// then: host and port are overridden, everything else falls back to R/W
assert_eq!(result.host, "replica.example.com");
assert_eq!(result.port, 5433);
assert_eq!(result.username, rw.username);
assert_eq!(result.password, rw.password);
assert_eq!(result.name, rw.name);
}

/// Verify that the R/O URL takes precedence over the R/W URL.
#[test]
fn ro_url_overrides_rw_url() {
// given: both R/W and R/O specify a URL
let rw = Database {
url: Some("postgres://primary:5432/trustify".into()),
..rw_default()
};
let ro = DatabaseReadOnly {
url: Some("postgres://replica:5433/trustify".into()),
..Default::default()
};

// when: building the R/O database config
let result = ro.to_database_config(&rw);

// then: the R/O URL wins
assert_eq!(
result.url.as_deref(),
Some("postgres://replica:5433/trustify")
);
}

/// Verify that R/O credentials override R/W credentials independently.
#[test]
fn ro_separate_credentials() {
// given: an R/O config that only overrides username and password
let rw = rw_default();
let ro = DatabaseReadOnly {
username: Some("readonly_user".into()),
password: Some("readonly_pass".into()),
..Default::default()
};

// when: building the R/O database config
let result = ro.to_database_config(&rw);

// then: credentials come from R/O, connection target falls back to R/W
assert_eq!(result.username, "readonly_user");
assert_eq!(result.password.0, "readonly_pass");
assert_eq!(result.host, rw.host);
assert_eq!(result.port, rw.port);
}
}
Loading
Loading