use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use axess_clock::Clock;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use crate::authn::ids::{DeviceId, TenantId, UserId};
use crate::device::storage::sql_common::{
BindingsCodec, DeviceRow, DeviceWrite, FAMILY_INSERT_CHUNK, GuardedTrustWrite,
SqlDeviceStoreError, SwapPlan, SweepCutoffs, TrustWrite, bindings_update_after_miss,
claim_after_miss, decode_all, decode_optional, parse_stored_trust, plan_bindings_swap,
push_family_rows, require_row, select_devices, transition_after_miss, utc_to_unix,
};
use crate::device::store::{
BindingsUpdate, DeviceStore, OwnerClaim, SweepConfig, SweepCounts, TrustTransition,
};
use crate::device::types::{Device, DeviceBinding, DeviceTrustLevel, FingerprintHash};
use crate::session::crypto::SessionCrypto;
mod sweep;
#[derive(Clone)]
pub struct PostgresDeviceStore {
pool: PgPool,
codec: BindingsCodec,
sweep_config: SweepConfig,
}
impl PostgresDeviceStore {
pub fn new(pool: PgPool, crypto: SessionCrypto) -> Self {
Self {
pool,
codec: BindingsCodec::encrypted(crypto),
sweep_config: SweepConfig::default(),
}
}
pub fn plaintext(pool: PgPool) -> Self {
tracing::warn!(
"PostgresDeviceStore created without encryption; \
do not use in production"
);
Self {
pool,
codec: BindingsCodec::plaintext(),
sweep_config: SweepConfig::default(),
}
}
#[deprecated(note = "the store reads time only from the `now` its methods are given")]
pub fn with_clock(self, clock: Arc<dyn Clock>) -> Self {
let _ = clock;
self
}
pub fn with_sweep_config(mut self, config: SweepConfig) -> Self {
self.sweep_config = config;
self
}
pub async fn init_schema(&self) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS devices (
tenant_id TEXT NOT NULL,
id TEXT NOT NULL,
user_id TEXT,
trust_level TEXT NOT NULL,
fingerprint_hash BYTEA NOT NULL,
first_seen_at BIGINT NOT NULL,
last_seen_at BIGINT NOT NULL,
revoked_at BIGINT,
demoted_at BIGINT,
bindings TEXT NOT NULL,
PRIMARY KEY (tenant_id, id)
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_devices_fingerprint \
ON devices (tenant_id, fingerprint_hash)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_devices_user \
ON devices (tenant_id, user_id, last_seen_at DESC)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_devices_user_fingerprint \
ON devices (tenant_id, user_id, fingerprint_hash, last_seen_at DESC)",
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS device_bindings_refresh (
tenant_id TEXT NOT NULL,
device_id TEXT NOT NULL,
family_id TEXT NOT NULL,
PRIMARY KEY (tenant_id, device_id, family_id),
FOREIGN KEY (tenant_id, device_id)
REFERENCES devices (tenant_id, id) ON DELETE CASCADE
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_device_bindings_refresh_family \
ON device_bindings_refresh (tenant_id, family_id)",
)
.execute(&self.pool)
.await?;
Ok(())
}
}
async fn fetch_trust_level(
pool: &PgPool,
tenant: &str,
device_id: &str,
) -> Result<Option<DeviceTrustLevel>, SqlDeviceStoreError> {
let stored: Option<String> =
sqlx::query_scalar("SELECT trust_level FROM devices WHERE tenant_id = $1 AND id = $2")
.bind(tenant)
.bind(device_id)
.fetch_optional(pool)
.await?;
parse_stored_trust(stored)
}
async fn rewrite_family_index(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
tenant: &str,
device_id: &str,
family_ids: &[String],
) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM device_bindings_refresh WHERE tenant_id = $1 AND device_id = $2")
.bind(tenant)
.bind(device_id)
.execute(&mut **tx)
.await?;
for chunk in family_ids.chunks(FAMILY_INSERT_CHUNK) {
let mut insert = sqlx::QueryBuilder::<sqlx::Postgres>::new(
"INSERT INTO device_bindings_refresh (tenant_id, device_id, family_id) ",
);
push_family_rows(&mut insert, chunk, tenant, device_id);
insert.push(" ON CONFLICT DO NOTHING");
insert.build().execute(&mut **tx).await?;
}
Ok(())
}
impl DeviceStore for PostgresDeviceStore {
type Error = SqlDeviceStoreError;
fn load(
&self,
tenant_id: &TenantId,
id: &DeviceId,
) -> impl Future<Output = Result<Option<Device>, Self::Error>> + Send {
let pool = self.pool.clone();
let codec = self.codec.clone();
let tenant = tenant_id.to_string();
let device_id = id.to_string();
async move {
let row: Option<DeviceRow> =
sqlx::query_as(select_devices!("WHERE tenant_id = $1 AND id = $2"))
.bind(&tenant)
.bind(&device_id)
.fetch_optional(&pool)
.await?;
decode_optional(&codec, row)
}
}
fn find_by_fingerprint(
&self,
tenant_id: &TenantId,
hash: &FingerprintHash,
) -> impl Future<Output = Result<Option<Device>, Self::Error>> + Send {
let pool = self.pool.clone();
let codec = self.codec.clone();
let tenant = tenant_id.to_string();
let bytes = hash.as_bytes().to_vec();
async move {
let row: Option<DeviceRow> = sqlx::query_as(select_devices!(
"WHERE tenant_id = $1 AND fingerprint_hash = $2 \
ORDER BY last_seen_at DESC LIMIT 1"
))
.bind(&tenant)
.bind(&bytes)
.fetch_optional(&pool)
.await?;
decode_optional(&codec, row)
}
}
fn find_by_fingerprint_for_user(
&self,
tenant_id: &TenantId,
user_id: &UserId,
hash: &FingerprintHash,
) -> impl Future<Output = Result<Option<Device>, Self::Error>> + Send {
let pool = self.pool.clone();
let codec = self.codec.clone();
let tenant = tenant_id.to_string();
let uid = user_id.to_string();
let bytes = hash.as_bytes().to_vec();
async move {
let row: Option<DeviceRow> = sqlx::query_as(select_devices!(
"WHERE tenant_id = $1 AND user_id = $2 AND fingerprint_hash = $3 \
ORDER BY last_seen_at DESC LIMIT 1"
))
.bind(&tenant)
.bind(&uid)
.bind(&bytes)
.fetch_optional(&pool)
.await?;
decode_optional(&codec, row)
}
}
fn find_for_user(
&self,
tenant_id: &TenantId,
user_id: &UserId,
limit: usize,
) -> impl Future<Output = Result<Vec<Device>, Self::Error>> + Send {
let pool = self.pool.clone();
let codec = self.codec.clone();
let tenant = tenant_id.to_string();
let uid = user_id.to_string();
let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
async move {
let rows: Vec<DeviceRow> = sqlx::query_as(select_devices!(
"WHERE tenant_id = $1 AND user_id = $2 \
ORDER BY last_seen_at DESC LIMIT $3"
))
.bind(&tenant)
.bind(&uid)
.bind(limit_i64)
.fetch_all(&pool)
.await?;
decode_all(&codec, rows)
}
}
fn find_by_refresh_family(
&self,
tenant_id: &TenantId,
family_id: &str,
) -> impl Future<Output = Result<Vec<Device>, Self::Error>> + Send {
let pool = self.pool.clone();
let codec = self.codec.clone();
let tenant = tenant_id.to_string();
let family = family_id.to_string();
async move {
let rows: Vec<DeviceRow> = sqlx::query_as(
"SELECT d.tenant_id, d.id, d.user_id, d.trust_level, d.fingerprint_hash, \
d.first_seen_at, d.last_seen_at, d.revoked_at, d.demoted_at, d.bindings \
FROM devices d \
INNER JOIN device_bindings_refresh r \
ON d.tenant_id = r.tenant_id AND d.id = r.device_id \
WHERE r.tenant_id = $1 AND r.family_id = $2 \
ORDER BY d.last_seen_at DESC",
)
.bind(&tenant)
.bind(&family)
.fetch_all(&pool)
.await?;
decode_all(&codec, rows)
}
}
fn save(
&self,
device: &Device,
_now: DateTime<Utc>,
) -> impl Future<Output = Result<(), Self::Error>> + Send {
let pool = self.pool.clone();
let codec = self.codec.clone();
let device = device.clone();
async move {
let w = DeviceWrite::new(&codec, &device)?;
let mut tx = pool.begin().await?;
sqlx::query(
r#"
INSERT INTO devices
(tenant_id, id, user_id, trust_level, fingerprint_hash,
first_seen_at, last_seen_at, revoked_at, demoted_at, bindings)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (tenant_id, id) DO UPDATE SET
user_id = EXCLUDED.user_id,
trust_level = EXCLUDED.trust_level,
fingerprint_hash = EXCLUDED.fingerprint_hash,
first_seen_at = EXCLUDED.first_seen_at,
last_seen_at = EXCLUDED.last_seen_at,
revoked_at = EXCLUDED.revoked_at,
demoted_at = EXCLUDED.demoted_at,
bindings = EXCLUDED.bindings
"#,
)
.bind(&w.tenant)
.bind(&w.id)
.bind(w.user_id.as_deref())
.bind(w.trust)
.bind(&w.fingerprint)
.bind(w.first_seen_at)
.bind(w.last_seen_at)
.bind(w.revoked_at)
.bind(w.demoted_at)
.bind(&w.bindings)
.execute(&mut *tx)
.await?;
rewrite_family_index(&mut tx, &w.tenant, &w.id, &w.family_ids).await?;
tx.commit().await?;
Ok(())
}
}
fn record_sighting(
&self,
tenant_id: &TenantId,
id: &DeviceId,
now: DateTime<Utc>,
) -> impl Future<Output = Result<(), Self::Error>> + Send {
let pool = self.pool.clone();
let tenant = tenant_id.to_string();
let device_id = id.to_string();
let ts = utc_to_unix(now);
async move {
let updated = sqlx::query(
"UPDATE devices SET last_seen_at = $3 \
WHERE tenant_id = $1 AND id = $2",
)
.bind(&tenant)
.bind(&device_id)
.bind(ts)
.execute(&pool)
.await?
.rows_affected();
require_row(updated, tenant, device_id)
}
}
fn set_trust_level(
&self,
tenant_id: &TenantId,
id: &DeviceId,
level: DeviceTrustLevel,
now: DateTime<Utc>,
) -> impl Future<Output = Result<(), Self::Error>> + Send {
let pool = self.pool.clone();
let tenant = tenant_id.to_string();
let device_id = id.to_string();
let w = TrustWrite::new(level, now);
async move {
let updated = sqlx::query(
"UPDATE devices SET trust_level = $3, revoked_at = $4, \
demoted_at = CASE WHEN $5::bigint = 0 THEN NULL \
WHEN trust_level = 'Trusted' THEN $6::bigint \
ELSE demoted_at END \
WHERE tenant_id = $1 AND id = $2",
)
.bind(&tenant)
.bind(&device_id)
.bind(w.trust)
.bind(w.revoked_at)
.bind(w.to_seen)
.bind(w.now)
.execute(&pool)
.await?
.rows_affected();
require_row(updated, tenant, device_id)
}
}
fn set_trust_level_if(
&self,
tenant_id: &TenantId,
id: &DeviceId,
expected: DeviceTrustLevel,
level: DeviceTrustLevel,
now: DateTime<Utc>,
) -> impl Future<Output = Result<TrustTransition, Self::Error>> + Send {
let pool = self.pool.clone();
let tenant = tenant_id.to_string();
let device_id = id.to_string();
let w = GuardedTrustWrite::new(expected, level, now);
async move {
let updated = sqlx::query("UPDATE devices SET trust_level = $4, revoked_at = $5, demoted_at = CASE WHEN $6::bigint = 1 THEN demoted_at ELSE $7::bigint END WHERE tenant_id = $1 AND id = $2 AND trust_level = $3")
.bind(&tenant)
.bind(&device_id)
.bind(w.expected)
.bind(w.new)
.bind(w.revoked_at)
.bind(w.keep_demoted)
.bind(w.demoted_at)
.execute(&pool)
.await?
.rows_affected();
if updated > 0 {
return Ok(TrustTransition::Applied);
}
let current = fetch_trust_level(&pool, &tenant, &device_id).await?;
Ok(transition_after_miss(current, expected))
}
}
fn replace_bindings(
&self,
tenant_id: &TenantId,
id: &DeviceId,
expected: &[DeviceBinding],
bindings: &[DeviceBinding],
_now: DateTime<Utc>,
) -> impl Future<Output = Result<BindingsUpdate, Self::Error>> + Send {
let pool = self.pool.clone();
let codec = self.codec.clone();
let expected = expected.to_vec();
let bindings = bindings.to_vec();
let tenant = tenant_id.to_string();
let device_id = id.to_string();
async move {
let row: Option<(String, String)> = sqlx::query_as(
"SELECT trust_level, bindings FROM devices WHERE tenant_id = $1 AND id = $2",
)
.bind(&tenant)
.bind(&device_id)
.fetch_optional(&pool)
.await?;
let swap =
match plan_bindings_swap(&codec, &tenant, &device_id, row, &expected, &bindings)? {
SwapPlan::Settled(outcome) => return Ok(outcome),
SwapPlan::Write(swap) => swap,
};
let mut tx = pool.begin().await?;
let updated = sqlx::query("UPDATE devices SET bindings = $3 WHERE tenant_id = $1 AND id = $2 AND trust_level <> 'Revoked' AND bindings = $4")
.bind(&tenant)
.bind(&device_id)
.bind(&swap.blob)
.bind(&swap.stored)
.execute(&mut *tx)
.await?
.rows_affected();
if updated == 0 {
let current = fetch_trust_level(&pool, &tenant, &device_id).await?;
return Ok(bindings_update_after_miss(current));
}
rewrite_family_index(&mut tx, &tenant, &device_id, &swap.family_ids).await?;
tx.commit().await?;
Ok(BindingsUpdate::Written)
}
}
fn claim_owner(
&self,
tenant_id: &TenantId,
id: &DeviceId,
user: &UserId,
_now: DateTime<Utc>,
) -> impl Future<Output = Result<OwnerClaim, Self::Error>> + Send {
let pool = self.pool.clone();
let tenant = tenant_id.to_string();
let device_id = id.to_string();
let user = user.to_string();
async move {
let updated = sqlx::query(
"UPDATE devices SET user_id = $3 \
WHERE tenant_id = $1 AND id = $2 AND user_id IS NULL",
)
.bind(&tenant)
.bind(&device_id)
.bind(&user)
.execute(&pool)
.await?
.rows_affected();
if updated > 0 {
return Ok(OwnerClaim::Claimed);
}
let owner: Option<Option<String>> =
sqlx::query_scalar("SELECT user_id FROM devices WHERE tenant_id = $1 AND id = $2")
.bind(&tenant)
.bind(&device_id)
.fetch_optional(&pool)
.await?;
Ok(claim_after_miss(owner, &user))
}
}
fn find_active_for_user(
&self,
tenant_id: &TenantId,
user_id: &UserId,
limit: usize,
) -> impl Future<Output = Result<Vec<Device>, Self::Error>> + Send {
let pool = self.pool.clone();
let codec = self.codec.clone();
let tenant = tenant_id.to_string();
let uid = user_id.to_string();
let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
async move {
let rows: Vec<DeviceRow> = sqlx::query_as(select_devices!(
"WHERE tenant_id = $1 AND user_id = $2 \
AND trust_level <> 'Revoked' \
ORDER BY last_seen_at DESC LIMIT $3"
))
.bind(&tenant)
.bind(&uid)
.bind(limit_i64)
.fetch_all(&pool)
.await?;
decode_all(&codec, rows)
}
}
fn delete(
&self,
tenant_id: &TenantId,
id: &DeviceId,
) -> impl Future<Output = Result<(), Self::Error>> + Send {
let pool = self.pool.clone();
let tenant = tenant_id.to_string();
let device_id = id.to_string();
async move {
sqlx::query("DELETE FROM devices WHERE tenant_id = $1 AND id = $2")
.bind(&tenant)
.bind(&device_id)
.execute(&pool)
.await?;
Ok(())
}
}
fn sweep(
&self,
tenant_id: &TenantId,
now: DateTime<Utc>,
) -> impl Future<Output = Result<SweepCounts, Self::Error>> + Send {
let pool = self.pool.clone();
let cutoffs = SweepCutoffs::new(&self.sweep_config, now);
let tenant = tenant_id.to_string();
async move { sweep::run(&pool, &tenant, &cutoffs).await }
}
}
use crate::health::{HealthCheck, HealthStatus};
impl HealthCheck for PostgresDeviceStore {
fn check(
&self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = HealthStatus> + Send + '_>> {
Box::pin(async {
match tokio::time::timeout(
Duration::from_secs(2),
sqlx::query_scalar::<_, i32>("SELECT 1").fetch_one(&self.pool),
)
.await
{
Ok(Ok(_)) => HealthStatus::Healthy,
Ok(Err(e)) => HealthStatus::Unhealthy(format!("postgres SELECT 1 failed: {e}")),
Err(_) => HealthStatus::Unhealthy("postgres SELECT 1 timeout (2s)".into()),
}
})
}
}