feat(v1.1.8): app_users table + repo (migration 0026)

Per-app end-user table for the v1.1.8 users::* SDK. Distinct from
admin_users (control-plane operators) — same Argon2id password hash
shape but everything else (uniqueness scope, ownership, lifecycle)
independent.

- Uniqueness on (app_id, lower(email)) — case-insensitive within an
  app; same email may exist across two apps.
- AppUserRepository trait + Postgres impl; every method takes app_id
  explicitly so cross-app reads are unmistakable at the call site
  (matches v1.1.3 cross-app discipline).
- Public AppUserRow never includes the password hash; the credentials
  shape is its own struct returned only by the login lookup.
- Cursor-based list keyed on (created_at, id).
- Reserved a timing-flat dummy Argon2id PHC constant in auth.rs for
  the upcoming login path so the bad-email and good-email branches
  share wall-clock cost.
- Added AppUserId + InvitationId id types in shared::ids.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-05 23:15:47 +02:00
parent 6ef9f436c1
commit 97546e2eb2
6 changed files with 446 additions and 1 deletions

View File

@@ -0,0 +1,31 @@
-- v1.1.8 User Management — data-plane app users.
--
-- Distinct from `admin_users` (control-plane operators). These are the
-- end-users of apps built on PiCloud — created and managed by user
-- scripts via the `users::*` SDK, surfaced to the dashboard via
-- `/api/v1/admin/apps/{id}/users/*`.
--
-- Identity tuple is `(app_id, id)`; uniqueness is enforced on
-- `(app_id, lower(email))` so the same email can exist across two apps
-- but not twice within one app. Email case is preserved in storage and
-- normalized only at the index / lookup boundary.
--
-- Password hash is Argon2id PHC (same algorithm as `admin_users` — the
-- script-end-user trust shape and the operator-account trust shape
-- happen to coincide on the hashing primitive even though everything
-- else about the two tables is independent).
CREATE TABLE app_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT,
email_verified_at TIMESTAMPTZ,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_app_users_app_email_lower ON app_users (app_id, lower(email));
CREATE INDEX idx_app_users_app ON app_users (app_id);

View File

@@ -0,0 +1,394 @@
//! 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>;
}
#[derive(Debug, Clone, Default)]
pub struct ListOpts {
pub cursor: Option<DateTime<Utc>>,
pub limit: i64,
}
#[derive(Debug, Clone)]
pub struct ListPage<T> {
pub items: Vec<T>,
pub next_cursor: Option<DateTime<Utc>>,
}
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 < $2 \
ORDER BY created_at DESC, id DESC LIMIT $3",
)
.bind(app_id.into_inner())
.bind(cursor)
.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 items.len() as i64 > limit {
let cursor_row = items.pop().expect("len > limit so there is a row");
Some(cursor_row.created_at)
} 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,
}
}
}

View File

@@ -25,6 +25,16 @@ use sha2::{Digest, Sha256};
#[error("invalid Argon2id PHC hash")]
pub struct InvalidPasswordHash;
/// Real Argon2id PHC string used by the timing-flat login path: on a
/// bad-email lookup the login routine still runs `verify_password`
/// against this hash so the wall-clock cost matches the good-email
/// branch. The plaintext that produced this hash is irrelevant —
/// `verify_password` will return false for any input the caller could
/// realistically present, and the surrounding code drops the result.
/// Reusing the existing admin-auth login pattern from `auth_api.rs`.
pub const TIMING_FLAT_DUMMY_HASH: &str =
"$argon2id$v=19$m=19456,t=2,p=1$dGltaW5nLWZsYXR0ZW4$Ux6dgPqgX1Mhg5fRgIeKZF3MWdYqJplKEz/cKLcSdks";
/// Hash a raw password into an Argon2id PHC-formatted string suitable
/// for `admin_users.password_hash`. The output already encodes the salt
/// and parameters; nothing else needs to be persisted alongside it.

View File

@@ -17,6 +17,7 @@ pub mod app_members_api;
pub mod app_members_repo;
pub mod app_repo;
pub mod app_secrets_repo;
pub mod app_user_repo;
pub mod apps_api;
pub mod auth;
pub mod auth_api;
@@ -76,6 +77,10 @@ pub use admin_user_repo::{
AdminUserCredentials, AdminUserRepository, AdminUserRepositoryError, AdminUserRow,
PostgresAdminUserRepository,
};
pub use app_user_repo::{
AppUserCredentials, AppUserRepository, AppUserRepositoryError, AppUserRow,
ListOpts as AppUserListOpts, ListPage as AppUserListPage, PostgresAppUserRepository,
};
pub use admin_users_api::{admins_router, AdminsState};
pub use api::{admin_router, AdminState};
pub use api_key_repo::{