mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(migration): read-only legacy directory, off unless configured
The two deployments never shared a database. The legacy instance holds 134 identities whose keys v3 has never seen, so pointing the website at v3 would refuse all of them: nothing in smirk_v3_db can match a key that exists only in smirk_db. Neither obvious join key survives contact with the data. users.pubkey_hash is a plain sha256 on legacy and an HMAC under identity_pepper on v3, so the columns are not comparable at all. Usernames are actively dangerous: 14 of the 16 handles exist in both databases while only 6 users share a raw key, so merging by name would hand one person's keys to someone else's account. What is reliable is the public key a signature just proved control of. This looks up that key and nothing else, so it can only answer about a key the caller could already sign for. The capability is closed by default. With no LEGACY_DATABASE_URL nothing connects, every lookup answers "not found", and sign-in behaves exactly as it does now. A URL that points nowhere degrades to disabled rather than failing the boot, because a migration convenience must never be able to stop the service from serving. Nothing calls it yet.
This commit is contained in:
@@ -147,6 +147,12 @@ pub struct Config {
|
||||
|
||||
pub database_url: String,
|
||||
|
||||
/// Legacy (v0.2.x) database, read-only, for sign-in migration. `None` (the
|
||||
/// default) leaves the capability OFF: nothing connects and every legacy
|
||||
/// lookup answers "not found". Set it only for as long as the tail of legacy
|
||||
/// users is still migrating, then remove it again.
|
||||
pub legacy_database_url: Option<String>,
|
||||
|
||||
/// Base URL for public-tip share links: a tip's share URL is
|
||||
/// `{tip_share_base}/{tip_id}`. Required when `FEATURE_TIPS` is on (checked
|
||||
/// in `validate`); never hardcode a host (federation).
|
||||
@@ -797,6 +803,9 @@ impl Config {
|
||||
|
||||
database_url: env_opt("DATABASE_URL")
|
||||
.ok_or_else(|| cfg_err("DATABASE_URL is required"))?,
|
||||
// Absent means off. No default, and no inference from DATABASE_URL:
|
||||
// a second database is a capability an operator opts into.
|
||||
legacy_database_url: env_opt("LEGACY_DATABASE_URL"),
|
||||
|
||||
tip_share_base: env_opt("TIP_SHARE_BASE_URL"),
|
||||
|
||||
@@ -1507,6 +1516,7 @@ mod tests {
|
||||
deployment_mode: DeploymentMode::Single,
|
||||
environment: "development".into(),
|
||||
database_url: "postgres://localhost/smirk".into(),
|
||||
legacy_database_url: None,
|
||||
tip_share_base: None,
|
||||
auth: AuthConfig {
|
||||
jwt_secret: "a".repeat(32),
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Read-only lookup into the legacy (v0.2.x) database, for sign-in migration.
|
||||
//!
|
||||
//! The two deployments do not share a database, and they never shared one. The
|
||||
//! legacy instance holds 134 identities whose keys the v3 instance has never
|
||||
//! seen, so pointing the website at v3 would refuse every one of them: nothing
|
||||
//! in `smirk_v3_db` can match a key that only exists in `smirk_db`.
|
||||
//!
|
||||
//! Neither of the obvious join keys works. `users.pubkey_hash` is a plain
|
||||
//! `sha256(public_key)` on legacy and an HMAC under `identity_pepper` on v3, so
|
||||
//! the columns are not comparable. Usernames are worse than useless: 14 of the
|
||||
//! 16 handles exist in BOTH databases while only 6 users share a raw key, so
|
||||
//! merging by name would hand one person's keys to a different account.
|
||||
//!
|
||||
//! What IS reliable is the public key a signature just proved control of. This
|
||||
//! module looks up exactly that, and only that. A caller arrives holding a
|
||||
//! verified signature; we answer whether the legacy instance knows that key.
|
||||
//!
|
||||
//! **This capability is OFF unless a legacy URL is configured.** With no URL the
|
||||
//! directory is constructed disabled, every lookup answers `None`, and sign-in
|
||||
//! behaves exactly as it does today. It is meant to be switched off again once
|
||||
//! the tail of legacy users has migrated, which happens on its own as each signs
|
||||
//! in: the path is self-draining rather than a one-shot copy.
|
||||
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::{PgPool, Row};
|
||||
use tracing::{info, instrument, warn};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// One asset key as the legacy instance stored it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LegacyKey {
|
||||
pub asset: String,
|
||||
pub public_key: String,
|
||||
pub public_spend_key: Option<String>,
|
||||
}
|
||||
|
||||
/// A legacy identity, resolved by a key its owner just proved control of.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LegacyIdentity {
|
||||
/// The handle held on the legacy instance. The caller decides whether it can
|
||||
/// be carried over; it is NOT automatically free on v3.
|
||||
pub username: Option<String>,
|
||||
/// Every asset key the legacy instance holds for this user.
|
||||
pub keys: Vec<LegacyKey>,
|
||||
}
|
||||
|
||||
impl LegacyIdentity {
|
||||
/// This identity's key for `asset`, if the legacy instance had one.
|
||||
pub fn key_for(&self, asset: &str) -> Option<&LegacyKey> {
|
||||
self.keys.iter().find(|k| k.asset == asset)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only handle to the legacy database. Disabled unless configured.
|
||||
#[derive(Clone)]
|
||||
pub struct LegacyDirectory {
|
||||
pool: Option<PgPool>,
|
||||
}
|
||||
|
||||
impl LegacyDirectory {
|
||||
/// An explicitly-off directory. Every lookup answers `None`.
|
||||
pub fn disabled() -> Self {
|
||||
Self { pool: None }
|
||||
}
|
||||
|
||||
/// Connect when a URL is configured, otherwise stay off.
|
||||
///
|
||||
/// A bad URL is reported and then treated as off. Sign-in migration is a
|
||||
/// transitional convenience, so it must never be able to stop the service
|
||||
/// from starting: the failure mode is "legacy users wait", not "nobody
|
||||
/// serves".
|
||||
pub async fn connect(database_url: Option<&str>) -> Self {
|
||||
let Some(url) = database_url.filter(|u| !u.trim().is_empty()) else {
|
||||
info!("legacy sign-in migration: disabled (no legacy database configured)");
|
||||
return Self::disabled();
|
||||
};
|
||||
|
||||
// A small pool on purpose: this path runs once per legacy user, ever. The
|
||||
// short timeout matters because this runs during boot, and the default
|
||||
// would stall startup for 30s on a URL that points nowhere.
|
||||
match PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.acquire_timeout(std::time::Duration::from_secs(5))
|
||||
.connect(url)
|
||||
.await
|
||||
{
|
||||
Ok(pool) => {
|
||||
info!("legacy sign-in migration: enabled");
|
||||
Self { pool: Some(pool) }
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "legacy sign-in migration: could not connect, staying disabled");
|
||||
Self::disabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether lookups can resolve anything.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.pool.is_some()
|
||||
}
|
||||
|
||||
/// Resolve the legacy identity owning `public_key` for `asset`.
|
||||
///
|
||||
/// `public_key` must be a value whose control the caller has ALREADY proven.
|
||||
/// The match is verbatim against the stored key, so nothing is re-encoded or
|
||||
/// re-hashed on either side, and a caller cannot use this to enumerate: it
|
||||
/// answers only about a key they could already sign for.
|
||||
#[instrument(skip(self, public_key), fields(asset = %asset))]
|
||||
pub async fn find_by_proven_key(
|
||||
&self,
|
||||
asset: &str,
|
||||
public_key: &str,
|
||||
) -> Result<Option<LegacyIdentity>, AppError> {
|
||||
let Some(pool) = self.pool.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let owner = sqlx::query(
|
||||
"SELECT u.id, u.username FROM users u \
|
||||
JOIN user_keys k ON k.user_id = u.id \
|
||||
WHERE k.asset::text = $1 AND k.public_key = $2",
|
||||
)
|
||||
.bind(asset)
|
||||
.bind(public_key)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let Some(owner) = owner else {
|
||||
return Ok(None);
|
||||
};
|
||||
let user_id: uuid::Uuid = owner.try_get("id")?;
|
||||
let username: Option<String> = owner.try_get("username")?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT asset::text AS asset, public_key, public_spend_key \
|
||||
FROM user_keys WHERE user_id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut keys = Vec::with_capacity(rows.len());
|
||||
for r in rows {
|
||||
keys.push(LegacyKey {
|
||||
asset: r.try_get("asset")?,
|
||||
public_key: r.try_get("public_key")?,
|
||||
public_spend_key: r.try_get("public_spend_key")?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Some(LegacyIdentity { username, keys }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The capability is closed unless it is configured open.
|
||||
#[tokio::test]
|
||||
async fn absent_configuration_leaves_the_directory_disabled() {
|
||||
for url in [None, Some(""), Some(" ")] {
|
||||
let dir = LegacyDirectory::connect(url).await;
|
||||
assert!(!dir.is_enabled(), "url {url:?} must not enable the lookup");
|
||||
}
|
||||
}
|
||||
|
||||
/// A disabled directory resolves nothing, rather than erroring. Sign-in has
|
||||
/// to behave exactly as it did before the capability existed.
|
||||
#[tokio::test]
|
||||
async fn a_disabled_directory_resolves_nothing() {
|
||||
let dir = LegacyDirectory::disabled();
|
||||
let found = dir.find_by_proven_key("btc", &"a".repeat(66)).await;
|
||||
assert!(matches!(found, Ok(None)));
|
||||
}
|
||||
|
||||
/// An unreachable legacy database degrades to "off", never to a failed boot.
|
||||
#[tokio::test]
|
||||
async fn an_unreachable_database_degrades_to_disabled() {
|
||||
// Port 1 is reserved and nothing listens there.
|
||||
let dir = LegacyDirectory::connect(Some("postgres://u:p@127.0.0.1:1/nope")).await;
|
||||
assert!(!dir.is_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_for_selects_by_asset() {
|
||||
let id = LegacyIdentity {
|
||||
username: None,
|
||||
keys: vec![
|
||||
LegacyKey {
|
||||
asset: "btc".into(),
|
||||
public_key: "bb".into(),
|
||||
public_spend_key: None,
|
||||
},
|
||||
LegacyKey {
|
||||
asset: "xmr".into(),
|
||||
public_key: "xx".into(),
|
||||
public_spend_key: Some("ss".into()),
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(id.key_for("xmr").map(|k| k.public_key.as_str()), Some("xx"));
|
||||
assert!(id.key_for("ltc").is_none());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod db;
|
||||
pub mod electrum;
|
||||
pub mod grin;
|
||||
pub mod grin_lws;
|
||||
pub mod legacy_directory;
|
||||
pub mod lws;
|
||||
pub mod payment;
|
||||
pub mod prices;
|
||||
|
||||
@@ -64,6 +64,9 @@ pub struct AppState {
|
||||
/// config on a settings change and to report a field's source (default/env/db).
|
||||
pub config_base: Arc<Config>,
|
||||
pub db: Database,
|
||||
/// Read-only lookup into the legacy database, for sign-in migration.
|
||||
/// Disabled unless `LEGACY_DATABASE_URL` is configured.
|
||||
pub legacy: crate::infra::legacy_directory::LegacyDirectory,
|
||||
pub sessions: SessionManager,
|
||||
/// Per-chain data-source clients (present only for enabled chains).
|
||||
pub chains: ChainClients,
|
||||
|
||||
@@ -107,10 +107,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.admin
|
||||
.enabled
|
||||
.then(|| AdminSessionManager::new(&config.admin.jwt_secret));
|
||||
// Off unless configured. Connecting here (not lazily per request) means an
|
||||
// operator sees "enabled" or "staying disabled" once, at boot, in the log.
|
||||
let legacy = smirk_backend_core::infra::legacy_directory::LegacyDirectory::connect(
|
||||
config_base.legacy_database_url.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
config: Arc::new(arc_swap::ArcSwap::from_pointee(config)),
|
||||
config_base: Arc::new(config_base),
|
||||
db,
|
||||
legacy,
|
||||
sessions,
|
||||
chains,
|
||||
payment,
|
||||
|
||||
Reference in New Issue
Block a user