diff --git a/crates/manager-core/migrations/0026_app_users.sql b/crates/manager-core/migrations/0026_app_users.sql new file mode 100644 index 0000000..ad1d170 --- /dev/null +++ b/crates/manager-core/migrations/0026_app_users.sql @@ -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); diff --git a/crates/manager-core/src/app_user_repo.rs b/crates/manager-core/src/app_user_repo.rs new file mode 100644 index 0000000..45fba24 --- /dev/null +++ b/crates/manager-core/src/app_user_repo.rs @@ -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, + pub email_verified_at: Option>, + pub last_login_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 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, AppUserRepositoryError>; + /// Case-insensitive email lookup, scoped to a single app. + async fn find_by_email( + &self, + app_id: AppId, + email: &str, + ) -> Result, 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, AppUserRepositoryError>; + async fn list( + &self, + app_id: AppId, + opts: ListOpts, + ) -> Result, AppUserRepositoryError>; + async fn create( + &self, + app_id: AppId, + email: &str, + password_hash: &str, + display_name: Option<&str>, + ) -> Result; + async fn update_display_name( + &self, + app_id: AppId, + id: AppUserId, + display_name: Option<&str>, + ) -> Result; + async fn update_password_hash( + &self, + app_id: AppId, + id: AppUserId, + password_hash: &str, + ) -> Result; + async fn mark_email_verified( + &self, + app_id: AppId, + id: AppUserId, + ) -> Result; + async fn touch_last_login( + &self, + app_id: AppId, + id: AppUserId, + ) -> Result<(), AppUserRepositoryError>; + async fn delete(&self, app_id: AppId, id: AppUserId) -> Result; +} + +#[derive(Debug, Clone, Default)] +pub struct ListOpts { + pub cursor: Option>, + pub limit: i64, +} + +#[derive(Debug, Clone)] +pub struct ListPage { + pub items: Vec, + pub next_cursor: Option>, +} + +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, 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, 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, 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, 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 = 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 { + 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 { + 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 { + 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 { + 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 { + 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, + email_verified_at: Option>, + last_login_at: Option>, + created_at: DateTime, + updated_at: DateTime, +} + +impl From 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 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, + } + } +} diff --git a/crates/manager-core/src/auth.rs b/crates/manager-core/src/auth.rs index 9203646..dabc78d 100644 --- a/crates/manager-core/src/auth.rs +++ b/crates/manager-core/src/auth.rs @@ -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. diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 7e83ece..e666dca 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -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::{ diff --git a/crates/shared/src/ids.rs b/crates/shared/src/ids.rs index ba21d07..f599a91 100644 --- a/crates/shared/src/ids.rs +++ b/crates/shared/src/ids.rs @@ -54,3 +54,5 @@ id_type!(AdminUserId); id_type!(AppId); id_type!(ApiKeyId); id_type!(TriggerId); +id_type!(AppUserId); +id_type!(InvitationId); diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 1445183..80756d1 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -51,7 +51,10 @@ pub use files::{ FilesListPage, FilesService, NewFile, NoopFilesService, }; pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService}; -pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId, TriggerId}; +pub use ids::{ + AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, InvitationId, RequestId, ScriptId, + TriggerId, +}; pub use inbox::{ InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver, };