ORDER BY created_at DESC, id DESC but cursor was `WHERE created_at <
$2` — when two users were created at the same instant, pagination
could skip the second row at a page boundary or return it twice.
- Add ListCursor { created_at, id } with `<rfc3339>_<uuid>` encode /
decode helpers.
- Change WHERE predicate to `(created_at, id) < ($2, $3)` matching the
ORDER BY.
- Surface the opaque cursor string through UsersListOpts /
UsersListPage / users_admin_api ListUsersResponse instead of the raw
DateTime — keeps the wire format stable while the id half stops the
boundary bug.
Same shape as F-P-005 (execution_logs).
AUDIT.md anchor: F-P-012.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
432 lines
14 KiB
Rust
432 lines
14 KiB
Rust
//! CRUD over the `app_users` table (v1.1.8 data-plane user management).
|
|
//!
|
|
//! Distinct from `admin_user_repo.rs`: that's the control-plane
|
|
//! operator table (you / me / the dashboard login). This is the
|
|
//! script-end-user surface — created by app scripts via `users::*`,
|
|
//! managed by the dashboard's per-app Users tab.
|
|
//!
|
|
//! Identity tuple is `(app_id, id)`; uniqueness is enforced on
|
|
//! `(app_id, lower(email))` so the same email can exist in different
|
|
//! apps but not twice in the same app. The repo always filters by
|
|
//! `app_id` — there is no "get any user" query. Cross-app reads must
|
|
//! pass two `app_id` values explicitly, which keeps the v1.1.3-style
|
|
//! cross-app discipline obvious at the call site.
|
|
//!
|
|
//! Password hashes go in and come out as opaque Argon2id PHC strings —
|
|
//! the repo never computes or inspects them; that's `auth.rs`'s job.
|
|
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, Utc};
|
|
use picloud_shared::{AppId, AppUserId};
|
|
use sqlx::PgPool;
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum AppUserRepositoryError {
|
|
#[error("database error: {0}")]
|
|
Db(#[from] sqlx::Error),
|
|
|
|
#[error("not found: {0}")]
|
|
NotFound(AppUserId),
|
|
|
|
#[error("email already in use: {0}")]
|
|
DuplicateEmail(String),
|
|
}
|
|
|
|
/// Row returned to handlers and the service layer. Never includes the
|
|
/// password hash — that ships out of [`AppUserCredentials`] on the
|
|
/// dedicated login lookup, mirroring how `admin_user_repo` splits its
|
|
/// public row from its credential row.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppUserRow {
|
|
pub id: AppUserId,
|
|
pub app_id: AppId,
|
|
pub email: String,
|
|
pub display_name: Option<String>,
|
|
pub email_verified_at: Option<DateTime<Utc>>,
|
|
pub last_login_at: Option<DateTime<Utc>>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// Credentials fetched for the login path only. Splitting the hash off
|
|
/// from the public row makes it obvious in service code which calls
|
|
/// touch a secret.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppUserCredentials {
|
|
pub id: AppUserId,
|
|
pub app_id: AppId,
|
|
pub email: String,
|
|
pub password_hash: String,
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait AppUserRepository: Send + Sync {
|
|
async fn get(
|
|
&self,
|
|
app_id: AppId,
|
|
id: AppUserId,
|
|
) -> Result<Option<AppUserRow>, AppUserRepositoryError>;
|
|
/// Case-insensitive email lookup, scoped to a single app.
|
|
async fn find_by_email(
|
|
&self,
|
|
app_id: AppId,
|
|
email: &str,
|
|
) -> Result<Option<AppUserRow>, AppUserRepositoryError>;
|
|
/// Credentials lookup for the login path. Case-insensitive on email.
|
|
/// Returns `None` for missing — callers run a timing-flat dummy
|
|
/// verify on miss to avoid leaking which path was taken.
|
|
async fn get_credentials_by_email(
|
|
&self,
|
|
app_id: AppId,
|
|
email: &str,
|
|
) -> Result<Option<AppUserCredentials>, AppUserRepositoryError>;
|
|
async fn list(
|
|
&self,
|
|
app_id: AppId,
|
|
opts: ListOpts,
|
|
) -> Result<ListPage<AppUserRow>, AppUserRepositoryError>;
|
|
async fn create(
|
|
&self,
|
|
app_id: AppId,
|
|
email: &str,
|
|
password_hash: &str,
|
|
display_name: Option<&str>,
|
|
) -> Result<AppUserRow, AppUserRepositoryError>;
|
|
async fn update_display_name(
|
|
&self,
|
|
app_id: AppId,
|
|
id: AppUserId,
|
|
display_name: Option<&str>,
|
|
) -> Result<AppUserRow, AppUserRepositoryError>;
|
|
async fn update_password_hash(
|
|
&self,
|
|
app_id: AppId,
|
|
id: AppUserId,
|
|
password_hash: &str,
|
|
) -> Result<AppUserRow, AppUserRepositoryError>;
|
|
async fn mark_email_verified(
|
|
&self,
|
|
app_id: AppId,
|
|
id: AppUserId,
|
|
) -> Result<AppUserRow, AppUserRepositoryError>;
|
|
async fn touch_last_login(
|
|
&self,
|
|
app_id: AppId,
|
|
id: AppUserId,
|
|
) -> Result<(), AppUserRepositoryError>;
|
|
async fn delete(&self, app_id: AppId, id: AppUserId) -> Result<bool, AppUserRepositoryError>;
|
|
}
|
|
|
|
/// F-P-012: cursor carries `(created_at, id)` for keyset pagination.
|
|
/// Without the `id` tiebreaker, two users created at the same instant
|
|
/// could be skipped or duplicated at a page boundary.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct ListCursor {
|
|
pub created_at: DateTime<Utc>,
|
|
pub id: picloud_shared::AppUserId,
|
|
}
|
|
|
|
impl ListCursor {
|
|
/// Encode as `<rfc3339>_<uuid>` — what we surface to script-land
|
|
/// via `users::list`'s `next_cursor` field.
|
|
#[must_use]
|
|
pub fn encode(&self) -> String {
|
|
format!("{}_{}", self.created_at.to_rfc3339(), self.id.into_inner())
|
|
}
|
|
|
|
/// Decode the wire format. Returns `None` on any parse error — the
|
|
/// caller treats that as "start of page" rather than 400'ing on a
|
|
/// stale cursor from a previous schema.
|
|
#[must_use]
|
|
pub fn decode(s: &str) -> Option<Self> {
|
|
let (ts, id) = s.rsplit_once('_')?;
|
|
let created_at = DateTime::parse_from_rfc3339(ts).ok()?.to_utc();
|
|
let id = uuid::Uuid::parse_str(id).ok()?;
|
|
Some(Self {
|
|
created_at,
|
|
id: picloud_shared::AppUserId::from(id),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ListOpts {
|
|
pub cursor: Option<ListCursor>,
|
|
pub limit: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ListPage<T> {
|
|
pub items: Vec<T>,
|
|
pub next_cursor: Option<ListCursor>,
|
|
}
|
|
|
|
pub struct PostgresAppUserRepository {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl PostgresAppUserRepository {
|
|
#[must_use]
|
|
pub fn new(pool: PgPool) -> Self {
|
|
Self { pool }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AppUserRepository for PostgresAppUserRepository {
|
|
async fn get(
|
|
&self,
|
|
app_id: AppId,
|
|
id: AppUserId,
|
|
) -> Result<Option<AppUserRow>, AppUserRepositoryError> {
|
|
let row = sqlx::query_as::<_, AppUserRecord>(
|
|
"SELECT id, app_id, email, display_name, email_verified_at, \
|
|
last_login_at, created_at, updated_at \
|
|
FROM app_users WHERE app_id = $1 AND id = $2",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.bind(id.into_inner())
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
Ok(row.map(Into::into))
|
|
}
|
|
|
|
async fn find_by_email(
|
|
&self,
|
|
app_id: AppId,
|
|
email: &str,
|
|
) -> Result<Option<AppUserRow>, AppUserRepositoryError> {
|
|
let row = sqlx::query_as::<_, AppUserRecord>(
|
|
"SELECT id, app_id, email, display_name, email_verified_at, \
|
|
last_login_at, created_at, updated_at \
|
|
FROM app_users WHERE app_id = $1 AND lower(email) = lower($2)",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.bind(email)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
Ok(row.map(Into::into))
|
|
}
|
|
|
|
async fn get_credentials_by_email(
|
|
&self,
|
|
app_id: AppId,
|
|
email: &str,
|
|
) -> Result<Option<AppUserCredentials>, AppUserRepositoryError> {
|
|
let row = sqlx::query_as::<_, AppUserCredsRecord>(
|
|
"SELECT id, app_id, email, password_hash \
|
|
FROM app_users WHERE app_id = $1 AND lower(email) = lower($2)",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.bind(email)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
Ok(row.map(Into::into))
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
app_id: AppId,
|
|
opts: ListOpts,
|
|
) -> Result<ListPage<AppUserRow>, AppUserRepositoryError> {
|
|
let limit = opts.limit.clamp(1, 500);
|
|
// Fetch one extra to detect whether more pages exist.
|
|
let rows = if let Some(cursor) = opts.cursor {
|
|
sqlx::query_as::<_, AppUserRecord>(
|
|
"SELECT id, app_id, email, display_name, email_verified_at, \
|
|
last_login_at, created_at, updated_at \
|
|
FROM app_users WHERE app_id = $1 \
|
|
AND (created_at, id) < ($2, $3) \
|
|
ORDER BY created_at DESC, id DESC LIMIT $4",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.bind(cursor.created_at)
|
|
.bind(cursor.id.into_inner())
|
|
.bind(limit + 1)
|
|
.fetch_all(&self.pool)
|
|
.await?
|
|
} else {
|
|
sqlx::query_as::<_, AppUserRecord>(
|
|
"SELECT id, app_id, email, display_name, email_verified_at, \
|
|
last_login_at, created_at, updated_at \
|
|
FROM app_users WHERE app_id = $1 \
|
|
ORDER BY created_at DESC, id DESC LIMIT $2",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.bind(limit + 1)
|
|
.fetch_all(&self.pool)
|
|
.await?
|
|
};
|
|
|
|
let mut items: Vec<AppUserRow> = rows.into_iter().map(Into::into).collect();
|
|
let next_cursor = if i64::try_from(items.len()).unwrap_or(i64::MAX) > limit {
|
|
let cursor_row = items.pop().expect("len > limit so there is a row");
|
|
Some(ListCursor {
|
|
created_at: cursor_row.created_at,
|
|
id: cursor_row.id,
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
Ok(ListPage { items, next_cursor })
|
|
}
|
|
|
|
async fn create(
|
|
&self,
|
|
app_id: AppId,
|
|
email: &str,
|
|
password_hash: &str,
|
|
display_name: Option<&str>,
|
|
) -> Result<AppUserRow, AppUserRepositoryError> {
|
|
let res = sqlx::query_as::<_, AppUserRecord>(
|
|
"INSERT INTO app_users (app_id, email, password_hash, display_name) \
|
|
VALUES ($1, $2, $3, $4) \
|
|
RETURNING id, app_id, email, display_name, email_verified_at, \
|
|
last_login_at, created_at, updated_at",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.bind(email)
|
|
.bind(password_hash)
|
|
.bind(display_name)
|
|
.fetch_one(&self.pool)
|
|
.await;
|
|
|
|
match res {
|
|
Ok(row) => Ok(row.into()),
|
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
|
Err(AppUserRepositoryError::DuplicateEmail(email.to_string()))
|
|
}
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
|
|
async fn update_display_name(
|
|
&self,
|
|
app_id: AppId,
|
|
id: AppUserId,
|
|
display_name: Option<&str>,
|
|
) -> Result<AppUserRow, AppUserRepositoryError> {
|
|
let row = sqlx::query_as::<_, AppUserRecord>(
|
|
"UPDATE app_users SET display_name = $3, updated_at = NOW() \
|
|
WHERE app_id = $1 AND id = $2 \
|
|
RETURNING id, app_id, email, display_name, email_verified_at, \
|
|
last_login_at, created_at, updated_at",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.bind(id.into_inner())
|
|
.bind(display_name)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
row.map(Into::into)
|
|
.ok_or(AppUserRepositoryError::NotFound(id))
|
|
}
|
|
|
|
async fn update_password_hash(
|
|
&self,
|
|
app_id: AppId,
|
|
id: AppUserId,
|
|
password_hash: &str,
|
|
) -> Result<AppUserRow, AppUserRepositoryError> {
|
|
let row = sqlx::query_as::<_, AppUserRecord>(
|
|
"UPDATE app_users SET password_hash = $3, updated_at = NOW() \
|
|
WHERE app_id = $1 AND id = $2 \
|
|
RETURNING id, app_id, email, display_name, email_verified_at, \
|
|
last_login_at, created_at, updated_at",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.bind(id.into_inner())
|
|
.bind(password_hash)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
row.map(Into::into)
|
|
.ok_or(AppUserRepositoryError::NotFound(id))
|
|
}
|
|
|
|
async fn mark_email_verified(
|
|
&self,
|
|
app_id: AppId,
|
|
id: AppUserId,
|
|
) -> Result<AppUserRow, AppUserRepositoryError> {
|
|
let row = sqlx::query_as::<_, AppUserRecord>(
|
|
"UPDATE app_users SET email_verified_at = NOW(), updated_at = NOW() \
|
|
WHERE app_id = $1 AND id = $2 \
|
|
RETURNING id, app_id, email, display_name, email_verified_at, \
|
|
last_login_at, created_at, updated_at",
|
|
)
|
|
.bind(app_id.into_inner())
|
|
.bind(id.into_inner())
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
row.map(Into::into)
|
|
.ok_or(AppUserRepositoryError::NotFound(id))
|
|
}
|
|
|
|
async fn touch_last_login(
|
|
&self,
|
|
app_id: AppId,
|
|
id: AppUserId,
|
|
) -> Result<(), AppUserRepositoryError> {
|
|
sqlx::query("UPDATE app_users SET last_login_at = NOW() WHERE app_id = $1 AND id = $2")
|
|
.bind(app_id.into_inner())
|
|
.bind(id.into_inner())
|
|
.execute(&self.pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn delete(&self, app_id: AppId, id: AppUserId) -> Result<bool, AppUserRepositoryError> {
|
|
let res = sqlx::query("DELETE FROM app_users WHERE app_id = $1 AND id = $2")
|
|
.bind(app_id.into_inner())
|
|
.bind(id.into_inner())
|
|
.execute(&self.pool)
|
|
.await?;
|
|
Ok(res.rows_affected() > 0)
|
|
}
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct AppUserRecord {
|
|
id: uuid::Uuid,
|
|
app_id: uuid::Uuid,
|
|
email: String,
|
|
display_name: Option<String>,
|
|
email_verified_at: Option<DateTime<Utc>>,
|
|
last_login_at: Option<DateTime<Utc>>,
|
|
created_at: DateTime<Utc>,
|
|
updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl From<AppUserRecord> for AppUserRow {
|
|
fn from(r: AppUserRecord) -> Self {
|
|
Self {
|
|
id: r.id.into(),
|
|
app_id: r.app_id.into(),
|
|
email: r.email,
|
|
display_name: r.display_name,
|
|
email_verified_at: r.email_verified_at,
|
|
last_login_at: r.last_login_at,
|
|
created_at: r.created_at,
|
|
updated_at: r.updated_at,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct AppUserCredsRecord {
|
|
id: uuid::Uuid,
|
|
app_id: uuid::Uuid,
|
|
email: String,
|
|
password_hash: String,
|
|
}
|
|
|
|
impl From<AppUserCredsRecord> for AppUserCredentials {
|
|
fn from(r: AppUserCredsRecord) -> Self {
|
|
Self {
|
|
id: r.id.into(),
|
|
app_id: r.app_id.into(),
|
|
email: r.email,
|
|
password_hash: r.password_hash,
|
|
}
|
|
}
|
|
}
|