feat: complete Epic 1 production foundation
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
use std::{process::ExitCode, time::Duration};
|
||||
#[path = "crank_migrate/admin_auth_command.rs"]
|
||||
mod admin_auth_command;
|
||||
#[path = "crank_migrate/db_connect.rs"]
|
||||
mod db_connect;
|
||||
|
||||
use crank_config::{ConfigSource, DatabaseSettings, parse_migrator};
|
||||
use crank_config::{ConfigSource, parse_migrator};
|
||||
use crank_registry::{
|
||||
BackfillPolicy, MigrationApplyResult, MigrationAuthority, MigrationPreflight,
|
||||
BackfillPolicy, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate,
|
||||
MasterKeyRotationRecord, MigrationApplyResult, MigrationAuthority, MigrationPreflight,
|
||||
PostgresRegistry, RegistryError, SecretVersionRecord,
|
||||
};
|
||||
use crank_runtime::SecretCrypto;
|
||||
use serde_json::json;
|
||||
use sqlx::{
|
||||
PgPool,
|
||||
postgres::{PgConnectOptions, PgPoolOptions},
|
||||
};
|
||||
|
||||
use std::{path::Path, process::ExitCode};
|
||||
use time::OffsetDateTime;
|
||||
const MASTER_KEY_ROTATION_PAGE_SIZE: i64 = 1_000;
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
match run().await {
|
||||
@@ -37,7 +41,6 @@ struct CliError {
|
||||
recovery: &'static str,
|
||||
version: Option<i64>,
|
||||
}
|
||||
|
||||
impl CliError {
|
||||
const fn new(code: &'static str, stage: &'static str, recovery: &'static str) -> Self {
|
||||
Self {
|
||||
@@ -47,7 +50,6 @@ impl CliError {
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_migration(error: crank_registry::MigrationError) -> Self {
|
||||
Self {
|
||||
code: error.code(),
|
||||
@@ -56,20 +58,87 @@ impl CliError {
|
||||
version: error.version(),
|
||||
}
|
||||
}
|
||||
fn from_registry(error: RegistryError) -> Self {
|
||||
match error {
|
||||
RegistryError::MasterKeyIdentityMismatch { .. } => Self::new(
|
||||
"master_key_identity_mismatch",
|
||||
"master_key.identity",
|
||||
"use_matching_master_key",
|
||||
),
|
||||
RegistryError::InvalidMasterKeyIdentity => Self::new(
|
||||
"master_key_identity_invalid",
|
||||
"master_key.identity",
|
||||
"verify_operator_input",
|
||||
),
|
||||
RegistryError::MasterKeyRotationInProgress => Self::new(
|
||||
"master_key_rotation_in_progress",
|
||||
"master_key.rotation",
|
||||
"resume_verify_promote_or_abort_rotation",
|
||||
),
|
||||
RegistryError::MasterKeyRotationNotFound { .. } => Self::new(
|
||||
"master_key_rotation_not_found",
|
||||
"master_key.rotation",
|
||||
"run_status",
|
||||
),
|
||||
RegistryError::MasterKeyRotationConflict => Self::new(
|
||||
"master_key_rotation_conflict",
|
||||
"master_key.rotation",
|
||||
"run_status",
|
||||
),
|
||||
RegistryError::MasterKeyRotationVerificationFailed => Self::new(
|
||||
"master_key_rotation_verification_failed",
|
||||
"master_key.rotation",
|
||||
"rerun_rotation_or_abort",
|
||||
),
|
||||
RegistryError::AdminBootstrapUnavailable => Self::new(
|
||||
"admin_bootstrap_unavailable",
|
||||
"admin_auth.bootstrap",
|
||||
"use_existing_active_contract_or_wait_until_expired",
|
||||
),
|
||||
RegistryError::AdminBootstrapRejected => Self::new(
|
||||
"admin_bootstrap_rejected",
|
||||
"admin_auth.bootstrap",
|
||||
"create_new_local_bootstrap_contract",
|
||||
),
|
||||
RegistryError::AdminRecoveryRejected => Self::new(
|
||||
"admin_recovery_rejected",
|
||||
"admin_auth.recovery",
|
||||
"verify_local_inputs_and_master_key",
|
||||
),
|
||||
RegistryError::AdminLoginRateLimited { .. } => Self::new(
|
||||
"admin_login_rate_limited",
|
||||
"admin_auth.login",
|
||||
"retry_after_delay",
|
||||
),
|
||||
RegistryError::AdminCsrfRejected => {
|
||||
Self::new("admin_csrf_rejected", "admin_auth.csrf", "refresh_session")
|
||||
}
|
||||
RegistryError::Storage(_) => {
|
||||
Self::new("storage_unavailable", "database.query", "contact_operator")
|
||||
}
|
||||
_ => Self::new("registry_error", "registry.operation", "contact_operator"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<ExitCode, CliError> {
|
||||
let mut arguments = std::env::args().skip(1);
|
||||
let requested = arguments.next();
|
||||
let option = arguments.next();
|
||||
if arguments.next().is_some() {
|
||||
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||
if args.first().map(String::as_str) == Some("master-key") {
|
||||
return run_master_key(&args[1..]).await;
|
||||
}
|
||||
if args.first().map(String::as_str) == Some("admin-auth") {
|
||||
return admin_auth_command::run_admin_auth(&args[1..]).await;
|
||||
}
|
||||
let requested = args.first().map(String::as_str);
|
||||
let option = args.get(1).map(String::as_str);
|
||||
if args.len() > 2 {
|
||||
return Err(CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"run_preflight",
|
||||
));
|
||||
}
|
||||
let command = match requested.as_deref() {
|
||||
let command = match requested {
|
||||
None | Some("preflight") => "preflight",
|
||||
Some("plan") => "plan",
|
||||
Some("apply") => "apply",
|
||||
@@ -117,7 +186,7 @@ async fn run() -> Result<ExitCode, CliError> {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let plan = json!({ "schema_version": 1, "sequence": sequence });
|
||||
if option.as_deref() == Some("--check") {
|
||||
if option == Some("--check") {
|
||||
let bytes = std::fs::read("docs/schemas/migration-sequence.json")
|
||||
.map_err(|_| CliError::new("contract_drift", "plan.read", "contact_operator"))?;
|
||||
if bytes.len() > 65_536 {
|
||||
@@ -162,7 +231,7 @@ async fn run() -> Result<ExitCode, CliError> {
|
||||
.map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?,
|
||||
)
|
||||
.map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?;
|
||||
let pool = connect(&config.database).await?;
|
||||
let pool = db_connect::connect(&config.database).await?;
|
||||
|
||||
if command == "apply" {
|
||||
let result = MigrationAuthority::apply(&pool)
|
||||
@@ -204,39 +273,680 @@ async fn run() -> Result<ExitCode, CliError> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect(config: &DatabaseSettings) -> Result<PgPool, CliError> {
|
||||
let options = if let Some(url) = &config.url {
|
||||
url.expose_secret()
|
||||
.parse::<PgConnectOptions>()
|
||||
.map_err(|_| CliError::new("config_invalid", "database.source", "run_preflight"))?
|
||||
} else {
|
||||
PgConnectOptions::new()
|
||||
.host(&config.host)
|
||||
.port(config.port)
|
||||
.database(&config.database)
|
||||
.username(&config.username)
|
||||
.password(config.password.expose_secret())
|
||||
async fn run_master_key(arguments: &[String]) -> Result<ExitCode, CliError> {
|
||||
let Some(command) = arguments.first().map(String::as_str) else {
|
||||
return Err(CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"run_master_key_status",
|
||||
));
|
||||
};
|
||||
for attempt in 1..=10 {
|
||||
let result = PgPoolOptions::new()
|
||||
.max_connections(config.pool.max_connections)
|
||||
.min_connections(config.pool.min_connections)
|
||||
.acquire_timeout(Duration::from_millis(config.pool.acquire_timeout_ms))
|
||||
.idle_timeout(Duration::from_millis(config.pool.idle_timeout_ms))
|
||||
.max_lifetime(Duration::from_millis(config.pool.max_lifetime_ms))
|
||||
.connect_with(options.clone())
|
||||
.await;
|
||||
match result {
|
||||
Ok(pool) => return Ok(pool),
|
||||
Err(_) if attempt < 10 => {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
let options = MasterKeyOptions::parse(&arguments[1..])?;
|
||||
let config = parse_migrator(
|
||||
ConfigSource::from_os_for_migrator()
|
||||
.map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?,
|
||||
)
|
||||
.map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?;
|
||||
let registry = db_connect::connect_registry(&config.database).await?;
|
||||
|
||||
match command {
|
||||
"status" => {
|
||||
ensure_no_options(&options)?;
|
||||
let status = registry
|
||||
.master_key_rotation_status()
|
||||
.await
|
||||
.map_err(CliError::from_registry)?;
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"status": "ok",
|
||||
"active_epoch": status.active_identity.as_ref().map(|identity| identity.epoch),
|
||||
"rotation_count": status.rotations.len(),
|
||||
"rotations": status.rotations.iter().map(rotation_json).collect::<Vec<_>>(),
|
||||
})
|
||||
);
|
||||
}
|
||||
"preflight" => {
|
||||
let preflight = master_key_preflight(®istry, &options).await?;
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"status": "preflight_ok",
|
||||
"source_epoch": preflight.source_epoch,
|
||||
"target_epoch": preflight.target_epoch,
|
||||
"affected_secret_versions": preflight.affected_secret_versions,
|
||||
"backup_ref": preflight.backup_ref.map(|_| "configured"),
|
||||
})
|
||||
);
|
||||
}
|
||||
"rotate" => {
|
||||
let (rotation, current_crypto, target_crypto) =
|
||||
if let Some(rotation) = active_rotation(®istry, &["running"]).await? {
|
||||
let (current_crypto, target_crypto) =
|
||||
rotation_crypto_for_resume(®istry, &options, &rotation).await?;
|
||||
(rotation, current_crypto, target_crypto)
|
||||
} else {
|
||||
let preflight = master_key_preflight(®istry, &options).await?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let rotation = registry
|
||||
.begin_master_key_rotation(
|
||||
preflight.source_epoch,
|
||||
preflight.target_epoch,
|
||||
preflight.target_crypto.master_key_fingerprint(),
|
||||
preflight.backup_ref.as_deref(),
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.map_err(CliError::from_registry)?;
|
||||
(rotation, preflight.current_crypto, preflight.target_crypto)
|
||||
};
|
||||
let processed = process_rotation_batch(
|
||||
®istry,
|
||||
&rotation,
|
||||
¤t_crypto,
|
||||
&target_crypto,
|
||||
options.max_versions,
|
||||
)
|
||||
.await?;
|
||||
let status = if rotation.processed_secret_versions + processed
|
||||
>= rotation.total_secret_versions
|
||||
{
|
||||
let now = OffsetDateTime::now_utc();
|
||||
registry
|
||||
.finish_master_key_rotation_batches(&rotation.id, &now)
|
||||
.await
|
||||
.map_err(CliError::from_registry)?
|
||||
} else {
|
||||
registry
|
||||
.master_key_rotation_status()
|
||||
.await
|
||||
.map_err(CliError::from_registry)?
|
||||
.rotations
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.id == rotation.id)
|
||||
.ok_or_else(|| {
|
||||
CliError::new(
|
||||
"master_key_rotation_not_found",
|
||||
"master_key.rotation",
|
||||
"run_status",
|
||||
)
|
||||
})?
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"status": status.state,
|
||||
"rotation_id": status.id,
|
||||
"source_epoch": status.source_epoch,
|
||||
"target_epoch": status.target_epoch,
|
||||
"processed_secret_versions": status.processed_secret_versions,
|
||||
"total_secret_versions": status.total_secret_versions,
|
||||
})
|
||||
);
|
||||
}
|
||||
"verify" => {
|
||||
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
|
||||
let rotation = active_rotation(®istry, &["verifying"])
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
CliError::new(
|
||||
"master_key_rotation_conflict",
|
||||
"master_key.rotation",
|
||||
"run_rotate",
|
||||
)
|
||||
})?;
|
||||
let target_crypto = SecretCrypto::with_epoch(&target_key, rotation.target_epoch)
|
||||
.map_err(|_| {
|
||||
CliError::new(
|
||||
"master_key_invalid",
|
||||
"master_key.input",
|
||||
"verify_operator_input",
|
||||
)
|
||||
})?;
|
||||
if target_crypto.master_key_fingerprint() != rotation.target_fingerprint {
|
||||
return Err(CliError::new(
|
||||
"master_key_identity_mismatch",
|
||||
"master_key.identity",
|
||||
"use_matching_target_key",
|
||||
));
|
||||
}
|
||||
Err(_) => break,
|
||||
let verified = verify_staged_targets(®istry, &rotation, &target_crypto).await?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let status = registry
|
||||
.verify_master_key_rotation(&rotation.id, verified, &now)
|
||||
.await
|
||||
.map_err(CliError::from_registry)?;
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"status": status.state,
|
||||
"rotation_id": status.id,
|
||||
"verified_secret_versions": status.verified_secret_versions,
|
||||
"total_secret_versions": status.total_secret_versions,
|
||||
})
|
||||
);
|
||||
}
|
||||
"promote" => {
|
||||
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
|
||||
let rotation = active_rotation(®istry, &["verified"])
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
CliError::new(
|
||||
"master_key_rotation_conflict",
|
||||
"master_key.rotation",
|
||||
"run_verify",
|
||||
)
|
||||
})?;
|
||||
let target_crypto = SecretCrypto::with_epoch(&target_key, rotation.target_epoch)
|
||||
.map_err(|_| {
|
||||
CliError::new(
|
||||
"master_key_invalid",
|
||||
"master_key.input",
|
||||
"verify_operator_input",
|
||||
)
|
||||
})?;
|
||||
if target_crypto.master_key_fingerprint() != rotation.target_fingerprint {
|
||||
return Err(CliError::new(
|
||||
"master_key_identity_mismatch",
|
||||
"master_key.identity",
|
||||
"use_matching_target_key",
|
||||
));
|
||||
}
|
||||
verify_staged_targets(®istry, &rotation, &target_crypto).await?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let status = registry
|
||||
.promote_master_key_rotation(
|
||||
&rotation.id,
|
||||
MasterKeyIdentityCandidate {
|
||||
epoch: rotation.target_epoch,
|
||||
fingerprint: target_crypto.master_key_fingerprint(),
|
||||
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
|
||||
observed_at: &now,
|
||||
},
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.map_err(CliError::from_registry)?;
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"status": status.state,
|
||||
"rotation_id": status.id,
|
||||
"active_epoch": status.target_epoch,
|
||||
})
|
||||
);
|
||||
}
|
||||
"abort" => {
|
||||
let rotation_id = options
|
||||
.rotation_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "run_status"))?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let status = registry
|
||||
.abort_master_key_rotation(rotation_id, &now)
|
||||
.await
|
||||
.map_err(CliError::from_registry)?;
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"status": status.state,
|
||||
"rotation_id": status.id,
|
||||
"active_epoch": status.source_epoch,
|
||||
})
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
return Err(CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"run_master_key_status",
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(CliError::new(
|
||||
"storage_unavailable",
|
||||
"database.connect",
|
||||
"contact_operator",
|
||||
))
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct MasterKeyOptions {
|
||||
current_key_file: Option<String>,
|
||||
target_key_file: Option<String>,
|
||||
backup_ref: Option<String>,
|
||||
rotation_id: Option<String>,
|
||||
max_versions: Option<usize>,
|
||||
}
|
||||
impl MasterKeyOptions {
|
||||
fn parse(arguments: &[String]) -> Result<Self, CliError> {
|
||||
let mut options = Self::default();
|
||||
let mut index = 0;
|
||||
while index < arguments.len() {
|
||||
let key = arguments[index].as_str();
|
||||
let Some(value) = arguments.get(index + 1) else {
|
||||
return Err(CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"run_master_key_status",
|
||||
));
|
||||
};
|
||||
match key {
|
||||
"--current-key-file" => options.current_key_file = Some(value.clone()),
|
||||
"--target-key-file" => options.target_key_file = Some(value.clone()),
|
||||
"--backup-ref" => options.backup_ref = Some(value.clone()),
|
||||
"--rotation-id" => options.rotation_id = Some(value.clone()),
|
||||
"--max-versions" => {
|
||||
let parsed = value.parse::<usize>().map_err(|_| {
|
||||
CliError::new("invalid_command", "cli.arguments", "run_master_key_status")
|
||||
})?;
|
||||
if parsed == 0 || parsed > 10_000 {
|
||||
return Err(CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"run_master_key_status",
|
||||
));
|
||||
}
|
||||
options.max_versions = Some(parsed);
|
||||
}
|
||||
_ => {
|
||||
return Err(CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"run_master_key_status",
|
||||
));
|
||||
}
|
||||
}
|
||||
index += 2;
|
||||
}
|
||||
Ok(options)
|
||||
}
|
||||
}
|
||||
|
||||
struct MasterKeyPreflight {
|
||||
source_epoch: i64,
|
||||
target_epoch: i64,
|
||||
affected_secret_versions: usize,
|
||||
backup_ref: Option<String>,
|
||||
current_crypto: SecretCrypto,
|
||||
target_crypto: SecretCrypto,
|
||||
}
|
||||
|
||||
async fn master_key_preflight(
|
||||
registry: &PostgresRegistry,
|
||||
options: &MasterKeyOptions,
|
||||
) -> Result<MasterKeyPreflight, CliError> {
|
||||
let current_key = read_required_key_file(options.current_key_file.as_deref())?;
|
||||
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
|
||||
let status = registry
|
||||
.master_key_rotation_status()
|
||||
.await
|
||||
.map_err(CliError::from_registry)?;
|
||||
if status.rotations.iter().any(|rotation| {
|
||||
matches!(
|
||||
rotation.state.as_str(),
|
||||
"running" | "verifying" | "verified"
|
||||
)
|
||||
}) {
|
||||
return Err(CliError::new(
|
||||
"master_key_rotation_in_progress",
|
||||
"master_key.rotation",
|
||||
"resume_verify_promote_or_abort_rotation",
|
||||
));
|
||||
}
|
||||
let active = status.active_identity.ok_or_else(|| {
|
||||
CliError::new(
|
||||
"master_key_identity_missing",
|
||||
"master_key.identity",
|
||||
"start_secret_process_once",
|
||||
)
|
||||
})?;
|
||||
let current_crypto = SecretCrypto::with_epoch(¤t_key, active.epoch).map_err(|_| {
|
||||
CliError::new(
|
||||
"master_key_invalid",
|
||||
"master_key.input",
|
||||
"verify_operator_input",
|
||||
)
|
||||
})?;
|
||||
if current_crypto.master_key_fingerprint() != active.fingerprint {
|
||||
return Err(CliError::new(
|
||||
"master_key_identity_mismatch",
|
||||
"master_key.identity",
|
||||
"use_matching_current_key",
|
||||
));
|
||||
}
|
||||
let target_epoch = active.epoch + 1;
|
||||
let target_crypto = SecretCrypto::with_epoch(&target_key, target_epoch).map_err(|_| {
|
||||
CliError::new(
|
||||
"master_key_invalid",
|
||||
"master_key.input",
|
||||
"verify_operator_input",
|
||||
)
|
||||
})?;
|
||||
if target_crypto.master_key_fingerprint() == current_crypto.master_key_fingerprint()
|
||||
|| registry
|
||||
.master_key_fingerprint_exists(target_crypto.master_key_fingerprint())
|
||||
.await
|
||||
.map_err(CliError::from_registry)?
|
||||
{
|
||||
return Err(CliError::new(
|
||||
"master_key_rotation_conflict",
|
||||
"master_key.rotation",
|
||||
"choose_new_target_key",
|
||||
));
|
||||
}
|
||||
let affected_secret_versions =
|
||||
count_and_verify_current_versions(registry, active.epoch, ¤t_crypto).await?;
|
||||
Ok(MasterKeyPreflight {
|
||||
source_epoch: active.epoch,
|
||||
target_epoch,
|
||||
affected_secret_versions,
|
||||
backup_ref: options.backup_ref.clone(),
|
||||
current_crypto,
|
||||
target_crypto,
|
||||
})
|
||||
}
|
||||
|
||||
async fn rotation_crypto_for_resume(
|
||||
registry: &PostgresRegistry,
|
||||
options: &MasterKeyOptions,
|
||||
rotation: &MasterKeyRotationRecord,
|
||||
) -> Result<(SecretCrypto, SecretCrypto), CliError> {
|
||||
let current_key = read_required_key_file(options.current_key_file.as_deref())?;
|
||||
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
|
||||
let active = registry
|
||||
.active_master_key_identity()
|
||||
.await
|
||||
.map_err(CliError::from_registry)?
|
||||
.ok_or_else(|| {
|
||||
CliError::new(
|
||||
"master_key_identity_missing",
|
||||
"master_key.identity",
|
||||
"start_secret_process_once",
|
||||
)
|
||||
})?;
|
||||
let current_crypto =
|
||||
SecretCrypto::with_epoch(¤t_key, rotation.source_epoch).map_err(|_| {
|
||||
CliError::new(
|
||||
"master_key_invalid",
|
||||
"master_key.input",
|
||||
"verify_operator_input",
|
||||
)
|
||||
})?;
|
||||
if active.epoch != rotation.source_epoch
|
||||
|| active.fingerprint != current_crypto.master_key_fingerprint()
|
||||
{
|
||||
return Err(CliError::new(
|
||||
"master_key_identity_mismatch",
|
||||
"master_key.identity",
|
||||
"use_matching_current_key",
|
||||
));
|
||||
}
|
||||
let target_crypto =
|
||||
SecretCrypto::with_epoch(&target_key, rotation.target_epoch).map_err(|_| {
|
||||
CliError::new(
|
||||
"master_key_invalid",
|
||||
"master_key.input",
|
||||
"verify_operator_input",
|
||||
)
|
||||
})?;
|
||||
if target_crypto.master_key_fingerprint() != rotation.target_fingerprint {
|
||||
return Err(CliError::new(
|
||||
"master_key_identity_mismatch",
|
||||
"master_key.identity",
|
||||
"use_matching_target_key",
|
||||
));
|
||||
}
|
||||
Ok((current_crypto, target_crypto))
|
||||
}
|
||||
|
||||
async fn process_rotation_batch(
|
||||
registry: &PostgresRegistry,
|
||||
rotation: &MasterKeyRotationRecord,
|
||||
current_crypto: &SecretCrypto,
|
||||
target_crypto: &SecretCrypto,
|
||||
max_versions: Option<usize>,
|
||||
) -> Result<i64, CliError> {
|
||||
let mut processed = 0_i64;
|
||||
let mut after_secret_id: Option<String> = None;
|
||||
let mut after_version: Option<u32> = None;
|
||||
loop {
|
||||
let versions = registry
|
||||
.list_secret_versions_for_master_key_epoch_page(
|
||||
rotation.source_epoch,
|
||||
after_secret_id.as_deref(),
|
||||
after_version,
|
||||
MASTER_KEY_ROTATION_PAGE_SIZE,
|
||||
)
|
||||
.await
|
||||
.map_err(CliError::from_registry)?;
|
||||
if versions.is_empty() {
|
||||
break;
|
||||
}
|
||||
for version in versions {
|
||||
after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned());
|
||||
after_version = Some(version.secret_version.version);
|
||||
if version.target_master_key_epoch == Some(rotation.target_epoch) {
|
||||
continue;
|
||||
}
|
||||
if max_versions.is_some_and(|limit| processed as usize >= limit) {
|
||||
return Ok(processed);
|
||||
}
|
||||
let plaintext = decrypt_current(&version, current_crypto)?;
|
||||
let target_ciphertext = target_crypto.encrypt(&plaintext).map_err(|_| {
|
||||
CliError::new(
|
||||
"master_key_rotation_verification_failed",
|
||||
"master_key.rotation",
|
||||
"rerun_rotation_or_abort",
|
||||
)
|
||||
})?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
registry
|
||||
.stage_master_key_rotation_ciphertext(
|
||||
&rotation.id,
|
||||
&version.secret_version.secret_id,
|
||||
version.secret_version.version,
|
||||
rotation.source_epoch,
|
||||
&target_ciphertext,
|
||||
target_crypto.key_version(),
|
||||
rotation.target_epoch,
|
||||
&now,
|
||||
)
|
||||
.await
|
||||
.map_err(CliError::from_registry)?;
|
||||
processed += 1;
|
||||
}
|
||||
}
|
||||
Ok(processed)
|
||||
}
|
||||
|
||||
async fn verify_staged_targets(
|
||||
registry: &PostgresRegistry,
|
||||
rotation: &MasterKeyRotationRecord,
|
||||
target_crypto: &SecretCrypto,
|
||||
) -> Result<i64, CliError> {
|
||||
let mut verified = 0_i64;
|
||||
let mut after_secret_id: Option<String> = None;
|
||||
let mut after_version: Option<u32> = None;
|
||||
loop {
|
||||
let versions = registry
|
||||
.list_target_secret_versions_for_master_key_rotation_page(
|
||||
rotation.target_epoch,
|
||||
after_secret_id.as_deref(),
|
||||
after_version,
|
||||
MASTER_KEY_ROTATION_PAGE_SIZE,
|
||||
)
|
||||
.await
|
||||
.map_err(CliError::from_registry)?;
|
||||
if versions.is_empty() {
|
||||
break;
|
||||
}
|
||||
for version in versions {
|
||||
after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned());
|
||||
after_version = Some(version.secret_version.version);
|
||||
let ciphertext = version.target_ciphertext.as_deref().ok_or_else(|| {
|
||||
CliError::new(
|
||||
"master_key_rotation_verification_failed",
|
||||
"master_key.rotation",
|
||||
"rerun_rotation_or_abort",
|
||||
)
|
||||
})?;
|
||||
let key_version = version.target_key_version.as_deref().ok_or_else(|| {
|
||||
CliError::new(
|
||||
"master_key_rotation_verification_failed",
|
||||
"master_key.rotation",
|
||||
"rerun_rotation_or_abort",
|
||||
)
|
||||
})?;
|
||||
target_crypto
|
||||
.decrypt_for_epoch(key_version, rotation.target_epoch, ciphertext)
|
||||
.map_err(|_| {
|
||||
CliError::new(
|
||||
"master_key_rotation_verification_failed",
|
||||
"master_key.rotation",
|
||||
"rerun_rotation_or_abort",
|
||||
)
|
||||
})?;
|
||||
verified += 1;
|
||||
}
|
||||
}
|
||||
if verified != rotation.total_secret_versions {
|
||||
return Err(CliError::new(
|
||||
"master_key_rotation_verification_failed",
|
||||
"master_key.rotation",
|
||||
"rerun_rotation_or_abort",
|
||||
));
|
||||
}
|
||||
Ok(verified)
|
||||
}
|
||||
|
||||
async fn count_and_verify_current_versions(
|
||||
registry: &PostgresRegistry,
|
||||
epoch: i64,
|
||||
current_crypto: &SecretCrypto,
|
||||
) -> Result<usize, CliError> {
|
||||
let mut count = 0_usize;
|
||||
let mut after_secret_id: Option<String> = None;
|
||||
let mut after_version: Option<u32> = None;
|
||||
loop {
|
||||
let versions = registry
|
||||
.list_secret_versions_for_master_key_epoch_page(
|
||||
epoch,
|
||||
after_secret_id.as_deref(),
|
||||
after_version,
|
||||
MASTER_KEY_ROTATION_PAGE_SIZE,
|
||||
)
|
||||
.await
|
||||
.map_err(CliError::from_registry)?;
|
||||
if versions.is_empty() {
|
||||
break;
|
||||
}
|
||||
for version in versions {
|
||||
after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned());
|
||||
after_version = Some(version.secret_version.version);
|
||||
decrypt_current(&version, current_crypto)?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn decrypt_current(
|
||||
version: &SecretVersionRecord,
|
||||
current_crypto: &SecretCrypto,
|
||||
) -> Result<serde_json::Value, CliError> {
|
||||
current_crypto
|
||||
.decrypt_for_epoch(
|
||||
&version.secret_version.key_version,
|
||||
version.master_key_epoch,
|
||||
&version.secret_version.ciphertext,
|
||||
)
|
||||
.map_err(|_| {
|
||||
CliError::new(
|
||||
"master_key_rotation_verification_failed",
|
||||
"master_key.rotation",
|
||||
"rerun_rotation_or_abort",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn active_rotation(
|
||||
registry: &PostgresRegistry,
|
||||
allowed_states: &[&str],
|
||||
) -> Result<Option<MasterKeyRotationRecord>, CliError> {
|
||||
let status = registry
|
||||
.master_key_rotation_status()
|
||||
.await
|
||||
.map_err(CliError::from_registry)?;
|
||||
Ok(status
|
||||
.rotations
|
||||
.into_iter()
|
||||
.find(|rotation| allowed_states.contains(&rotation.state.as_str())))
|
||||
}
|
||||
|
||||
fn rotation_json(rotation: &MasterKeyRotationRecord) -> serde_json::Value {
|
||||
json!({
|
||||
"id": rotation.id,
|
||||
"source_epoch": rotation.source_epoch,
|
||||
"target_epoch": rotation.target_epoch,
|
||||
"state": rotation.state,
|
||||
"backup_ref": rotation.backup_ref.as_ref().map(|_| "configured"),
|
||||
"checkpoint_secret_id": rotation.checkpoint_secret_id,
|
||||
"total_secret_versions": rotation.total_secret_versions,
|
||||
"processed_secret_versions": rotation.processed_secret_versions,
|
||||
"verified_secret_versions": rotation.verified_secret_versions,
|
||||
"failure_code": rotation.failure_code,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_required_key_file(path: Option<&str>) -> Result<String, CliError> {
|
||||
let path = path.ok_or_else(|| {
|
||||
CliError::new("invalid_command", "cli.arguments", "run_master_key_status")
|
||||
})?;
|
||||
if path.len() > 512 || path.bytes().any(|byte| byte.is_ascii_control()) {
|
||||
return Err(CliError::new(
|
||||
"master_key_input_invalid",
|
||||
"master_key.input",
|
||||
"verify_operator_input",
|
||||
));
|
||||
}
|
||||
let metadata = std::fs::metadata(Path::new(path)).map_err(|_| {
|
||||
CliError::new(
|
||||
"master_key_input_invalid",
|
||||
"master_key.input",
|
||||
"verify_operator_input",
|
||||
)
|
||||
})?;
|
||||
if !metadata.is_file() || metadata.len() > 16_384 {
|
||||
return Err(CliError::new(
|
||||
"master_key_input_invalid",
|
||||
"master_key.input",
|
||||
"verify_operator_input",
|
||||
));
|
||||
}
|
||||
let value = std::fs::read_to_string(Path::new(path)).map_err(|_| {
|
||||
CliError::new(
|
||||
"master_key_input_invalid",
|
||||
"master_key.input",
|
||||
"verify_operator_input",
|
||||
)
|
||||
})?;
|
||||
if value.trim().is_empty() {
|
||||
return Err(CliError::new(
|
||||
"master_key_input_invalid",
|
||||
"master_key.input",
|
||||
"verify_operator_input",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
fn ensure_no_options(options: &MasterKeyOptions) -> Result<(), CliError> {
|
||||
if options.current_key_file.is_some()
|
||||
|| options.target_key_file.is_some()
|
||||
|| options.backup_ref.is_some()
|
||||
|| options.rotation_id.is_some()
|
||||
|| options.max_versions.is_some()
|
||||
{
|
||||
return Err(CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"run_master_key_status",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use crank_community_auth::hash_password;
|
||||
use crank_config::{ConfigSource, parse_migrator};
|
||||
use crank_registry::{
|
||||
CreateAdminBootstrapContractRequest, MASTER_KEY_CIPHER_CONTRACT, PostgresRegistry,
|
||||
RecoverAdminPasswordRequest,
|
||||
};
|
||||
use crank_runtime::SecretCrypto;
|
||||
use rand::RngExt;
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::ExitCode,
|
||||
};
|
||||
use time::{Duration as TimeDuration, OffsetDateTime};
|
||||
|
||||
use super::{CliError, db_connect::connect_registry};
|
||||
|
||||
pub(super) async fn run_admin_auth(arguments: &[String]) -> Result<ExitCode, CliError> {
|
||||
let Some(command) = arguments.first().map(String::as_str) else {
|
||||
return Err(CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"run_admin_auth_bootstrap_create",
|
||||
));
|
||||
};
|
||||
let options = AdminAuthOptions::parse(&arguments[1..])?;
|
||||
let config = parse_migrator(
|
||||
ConfigSource::from_os_for_migrator()
|
||||
.map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?,
|
||||
)
|
||||
.map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?;
|
||||
let registry = connect_registry(&config.database).await?;
|
||||
match command {
|
||||
"bootstrap-create" => create_bootstrap_contract(®istry, options).await?,
|
||||
"bootstrap-complete" => complete_bootstrap_contract(®istry, options).await?,
|
||||
"recover" => recover_admin_password(®istry, options).await?,
|
||||
_ => {
|
||||
return Err(CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"run_admin_auth_bootstrap_create",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
async fn complete_bootstrap_contract(
|
||||
registry: &PostgresRegistry,
|
||||
options: AdminAuthOptions,
|
||||
) -> Result<(), CliError> {
|
||||
let token_path = options
|
||||
.token_file
|
||||
.as_deref()
|
||||
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_token_file"))?;
|
||||
let password_path = options.password_file.as_deref().ok_or_else(|| {
|
||||
CliError::new("invalid_command", "cli.arguments", "provide_password_file")
|
||||
})?;
|
||||
let pepper_path = options.password_pepper_file.as_deref().ok_or_else(|| {
|
||||
CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"provide_password_pepper_file",
|
||||
)
|
||||
})?;
|
||||
let token = read_secret_file(token_path, "admin_auth.bootstrap_token")?;
|
||||
let password = read_secret_file(password_path, "admin_auth.password")?;
|
||||
let pepper = read_secret_file(pepper_path, "admin_auth.password_pepper")?;
|
||||
if !(32..=256).contains(&token.len())
|
||||
|| !(12..=256).contains(&password.len())
|
||||
|| pepper.is_empty()
|
||||
|| pepper.len() > 1_024
|
||||
{
|
||||
return Err(CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"provide_bounded_secret_files",
|
||||
));
|
||||
}
|
||||
|
||||
let token_hash = admin_auth_hash("bootstrap", &token);
|
||||
let password_hash = hash_password(&password, &pepper).map_err(|_| {
|
||||
CliError::new(
|
||||
"admin_bootstrap_rejected",
|
||||
"admin_auth.bootstrap",
|
||||
"verify_local_inputs",
|
||||
)
|
||||
})?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let user_id = registry
|
||||
.consume_admin_bootstrap_contract(crank_registry::ConsumeAdminBootstrapContractRequest {
|
||||
token_hash: &token_hash,
|
||||
password_hash: &password_hash,
|
||||
now: &now,
|
||||
})
|
||||
.await
|
||||
.map_err(CliError::from_registry)?;
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"status": "bootstrap_completed",
|
||||
"user_id": user_id.as_str()
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recover_admin_password(
|
||||
registry: &PostgresRegistry,
|
||||
options: AdminAuthOptions,
|
||||
) -> Result<(), CliError> {
|
||||
let email = options
|
||||
.email
|
||||
.as_deref()
|
||||
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_email"))?;
|
||||
let password_path = options.password_file.as_deref().ok_or_else(|| {
|
||||
CliError::new("invalid_command", "cli.arguments", "provide_password_file")
|
||||
})?;
|
||||
let pepper_path = options.password_pepper_file.as_deref().ok_or_else(|| {
|
||||
CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"provide_password_pepper_file",
|
||||
)
|
||||
})?;
|
||||
let master_key_path = options.master_key_file.as_deref().ok_or_else(|| {
|
||||
CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"provide_master_key_file",
|
||||
)
|
||||
})?;
|
||||
|
||||
let master_key = read_secret_file(master_key_path, "master_key.input")?;
|
||||
let active = registry
|
||||
.active_master_key_identity()
|
||||
.await
|
||||
.map_err(CliError::from_registry)?
|
||||
.ok_or_else(|| {
|
||||
CliError::new(
|
||||
"master_key_identity_missing",
|
||||
"master_key.identity",
|
||||
"start_service_once_with_current_master_key",
|
||||
)
|
||||
})?;
|
||||
let crypto = SecretCrypto::with_epoch(&master_key, active.epoch).map_err(|_| {
|
||||
CliError::new(
|
||||
"master_key_invalid",
|
||||
"master_key.input",
|
||||
"provide_current_master_key_file",
|
||||
)
|
||||
})?;
|
||||
if active.cipher_contract != MASTER_KEY_CIPHER_CONTRACT
|
||||
|| active.fingerprint != crypto.master_key_fingerprint()
|
||||
{
|
||||
return Err(CliError::new(
|
||||
"master_key_identity_mismatch",
|
||||
"master_key.identity",
|
||||
"use_matching_master_key",
|
||||
));
|
||||
}
|
||||
|
||||
let password = read_secret_file(password_path, "admin_auth.password")?;
|
||||
let pepper = read_secret_file(pepper_path, "admin_auth.password_pepper")?;
|
||||
if !(12..=256).contains(&password.len()) || pepper.is_empty() || pepper.len() > 1_024 {
|
||||
return Err(CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"provide_bounded_secret_files",
|
||||
));
|
||||
}
|
||||
let password_hash = hash_password(&password, &pepper).map_err(|_| {
|
||||
CliError::new(
|
||||
"admin_recovery_rejected",
|
||||
"admin_auth.recovery",
|
||||
"verify_local_inputs",
|
||||
)
|
||||
})?;
|
||||
let audit_id = format!("audit_{}", uuid::Uuid::now_v7().simple());
|
||||
let user_id = registry
|
||||
.recover_admin_password(RecoverAdminPasswordRequest {
|
||||
email,
|
||||
password_hash: &password_hash,
|
||||
audit_id: &audit_id,
|
||||
})
|
||||
.await
|
||||
.map_err(CliError::from_registry)?;
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"status": "admin_recovered",
|
||||
"user_id": user_id.as_str(),
|
||||
"sessions_revoked": true,
|
||||
"audit_id": audit_id
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_bootstrap_contract(
|
||||
registry: &PostgresRegistry,
|
||||
options: AdminAuthOptions,
|
||||
) -> Result<(), CliError> {
|
||||
let email = options
|
||||
.email
|
||||
.as_deref()
|
||||
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_email"))?;
|
||||
let display_name = options.display_name.as_deref().unwrap_or("Crank Owner");
|
||||
let ttl_seconds = options.ttl_seconds.unwrap_or(900);
|
||||
if !(60..=86_400).contains(&ttl_seconds) {
|
||||
return Err(CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"set_ttl_between_60_and_86400",
|
||||
));
|
||||
}
|
||||
let token = random_token();
|
||||
let contract_id = format!("boot_{}", uuid::Uuid::now_v7().simple());
|
||||
let token_hash = admin_auth_hash("bootstrap", &token);
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let expires_at = now
|
||||
.checked_add(TimeDuration::seconds(ttl_seconds))
|
||||
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "reduce_ttl"))?;
|
||||
let contract = registry
|
||||
.create_admin_bootstrap_contract(CreateAdminBootstrapContractRequest {
|
||||
id: &contract_id,
|
||||
token_hash: &token_hash,
|
||||
email,
|
||||
display_name,
|
||||
expires_at: &expires_at,
|
||||
})
|
||||
.await
|
||||
.map_err(CliError::from_registry)?;
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"status": "bootstrap_created",
|
||||
"contract_id": contract.id,
|
||||
"expires_at": contract.expires_at,
|
||||
"bootstrap_token": token,
|
||||
"warning": "copy_once_token_not_logged_by_services"
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AdminAuthOptions {
|
||||
email: Option<String>,
|
||||
display_name: Option<String>,
|
||||
ttl_seconds: Option<i64>,
|
||||
token_file: Option<PathBuf>,
|
||||
password_file: Option<PathBuf>,
|
||||
password_pepper_file: Option<PathBuf>,
|
||||
master_key_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl AdminAuthOptions {
|
||||
fn parse(arguments: &[String]) -> Result<Self, CliError> {
|
||||
let mut options = Self::default();
|
||||
let mut index = 0;
|
||||
while index < arguments.len() {
|
||||
let key = arguments[index].as_str();
|
||||
let Some(value) = arguments.get(index + 1) else {
|
||||
return Err(CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"run_admin_auth_bootstrap_create",
|
||||
));
|
||||
};
|
||||
match key {
|
||||
"--email" => options.email = Some(value.clone()),
|
||||
"--display-name" => options.display_name = Some(value.clone()),
|
||||
"--ttl-seconds" => {
|
||||
options.ttl_seconds = Some(value.parse::<i64>().map_err(|_| {
|
||||
CliError::new("invalid_command", "cli.arguments", "set_ttl_seconds")
|
||||
})?);
|
||||
}
|
||||
"--password-file" => options.password_file = Some(PathBuf::from(value)),
|
||||
"--token-file" => options.token_file = Some(PathBuf::from(value)),
|
||||
"--password-pepper-file" => {
|
||||
options.password_pepper_file = Some(PathBuf::from(value));
|
||||
}
|
||||
"--master-key-file" => options.master_key_file = Some(PathBuf::from(value)),
|
||||
_ => {
|
||||
return Err(CliError::new(
|
||||
"invalid_command",
|
||||
"cli.arguments",
|
||||
"run_admin_auth_bootstrap_create",
|
||||
));
|
||||
}
|
||||
}
|
||||
index += 2;
|
||||
}
|
||||
Ok(options)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_secret_file(path: &Path, stage: &'static str) -> Result<String, CliError> {
|
||||
let metadata = std::fs::metadata(path)
|
||||
.map_err(|_| CliError::new("invalid_command", stage, "provide_readable_secret_file"))?;
|
||||
if !metadata.is_file() || metadata.len() > 8_192 {
|
||||
return Err(CliError::new(
|
||||
"invalid_command",
|
||||
stage,
|
||||
"provide_bounded_secret_file",
|
||||
));
|
||||
}
|
||||
let bytes = std::fs::read(path)
|
||||
.map_err(|_| CliError::new("invalid_command", stage, "provide_readable_secret_file"))?;
|
||||
if bytes.len() > 8_192 {
|
||||
return Err(CliError::new(
|
||||
"invalid_command",
|
||||
stage,
|
||||
"provide_bounded_secret_file",
|
||||
));
|
||||
}
|
||||
let mut value = String::from_utf8(bytes)
|
||||
.map_err(|_| CliError::new("invalid_command", stage, "provide_utf8_secret_file"))?;
|
||||
if value.ends_with("\r\n") {
|
||||
value.truncate(value.len() - 2);
|
||||
} else if value.ends_with('\n') {
|
||||
value.truncate(value.len() - 1);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn random_token() -> String {
|
||||
let mut bytes = [0_u8; 32];
|
||||
rand::rng().fill(&mut bytes);
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
fn admin_auth_hash(scope: &str, token: &str) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(scope.as_bytes());
|
||||
digest.update(b":");
|
||||
digest.update(token.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(digest.finalize())
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use crank_config::DatabaseSettings;
|
||||
use crank_registry::{PostgresPoolConfig, PostgresRegistry, RegistryError};
|
||||
use sqlx::{
|
||||
PgPool,
|
||||
postgres::{PgConnectOptions, PgPoolOptions},
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::CliError;
|
||||
|
||||
pub(super) async fn connect(config: &DatabaseSettings) -> Result<PgPool, CliError> {
|
||||
let options = connect_options(config)?;
|
||||
for attempt in 1..=10 {
|
||||
let result = PgPoolOptions::new()
|
||||
.max_connections(config.pool.max_connections)
|
||||
.min_connections(config.pool.min_connections)
|
||||
.acquire_timeout(Duration::from_millis(config.pool.acquire_timeout_ms))
|
||||
.idle_timeout(Duration::from_millis(config.pool.idle_timeout_ms))
|
||||
.max_lifetime(Duration::from_millis(config.pool.max_lifetime_ms))
|
||||
.connect_with(options.clone())
|
||||
.await;
|
||||
match result {
|
||||
Ok(pool) => return Ok(pool),
|
||||
Err(_) if attempt < 10 => tokio::time::sleep(Duration::from_secs(1)).await,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
Err(CliError::new(
|
||||
"storage_unavailable",
|
||||
"database.connect",
|
||||
"contact_operator",
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn connect_registry(
|
||||
config: &DatabaseSettings,
|
||||
) -> Result<PostgresRegistry, CliError> {
|
||||
let options = connect_options(config)?;
|
||||
let pool_config = PostgresPoolConfig {
|
||||
max_connections: config.pool.max_connections,
|
||||
min_connections: config.pool.min_connections,
|
||||
acquire_timeout_ms: config.pool.acquire_timeout_ms,
|
||||
idle_timeout_ms: config.pool.idle_timeout_ms,
|
||||
max_lifetime_ms: config.pool.max_lifetime_ms,
|
||||
};
|
||||
for attempt in 1..=10 {
|
||||
let result =
|
||||
PostgresRegistry::connect_with_options_and_pool_config(options.clone(), pool_config)
|
||||
.await;
|
||||
match result {
|
||||
Ok(registry) => return Ok(registry),
|
||||
Err(RegistryError::Storage(_)) if attempt < 10 => {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
Err(error) => return Err(CliError::from_registry(error)),
|
||||
}
|
||||
}
|
||||
Err(CliError::new(
|
||||
"storage_unavailable",
|
||||
"database.connect",
|
||||
"contact_operator",
|
||||
))
|
||||
}
|
||||
|
||||
fn connect_options(config: &DatabaseSettings) -> Result<PgConnectOptions, CliError> {
|
||||
if let Some(url) = &config.url {
|
||||
url.expose_secret()
|
||||
.parse::<PgConnectOptions>()
|
||||
.map_err(|_| CliError::new("config_invalid", "database.source", "run_preflight"))
|
||||
} else {
|
||||
Ok(PgConnectOptions::new()
|
||||
.host(&config.host)
|
||||
.port(config.port)
|
||||
.database(&config.database)
|
||||
.username(&config.username)
|
||||
.password(config.password.expose_secret()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user