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:
31
crates/manager-core/migrations/0026_app_users.sql
Normal file
31
crates/manager-core/migrations/0026_app_users.sql
Normal 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);
|
||||
394
crates/manager-core/src/app_user_repo.rs
Normal file
394
crates/manager-core/src/app_user_repo.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -54,3 +54,5 @@ id_type!(AdminUserId);
|
||||
id_type!(AppId);
|
||||
id_type!(ApiKeyId);
|
||||
id_type!(TriggerId);
|
||||
id_type!(AppUserId);
|
||||
id_type!(InvitationId);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user