feat(v1.1.8): UsersService trait + impl — CRUD + login/verify/logout

UsersService trait in shared::users + Postgres-backed impl in
manager-core::users_service. Wired into the Services bundle (new
`users` field) and the picloud binary's startup.

Commit 4 ships:

  * CRUD: create / get / find_by_email / update / delete / list
    (cursor-paged on created_at). create validates email shape,
    8-char password minimum, optional display_name; throws
    DuplicateEmail on (app_id, lower(email)) collision.
  * Auth: login (timing-flat — runs verify_password against the
    shared dummy Argon2id PHC even on email miss, so the
    bad-email and good-email branches share wall-clock cost);
    verify (sliding TTL bump capped at absolute_expires_at);
    logout (revoke by token).
  * verify_session_for_realtime — non-SDK method taking app_id
    explicitly; used by the F3 realtime auth_mode='session' path
    in a later commit.
  * Cross-app isolation everywhere: cx.app_id (or explicit app_id)
    is the only source of truth; a logout for a foreign-app token
    is a silent no-op rather than a probe of session existence.

Email-tied flows (verification, password reset, invitations), roles,
and admin-mediated helpers are stubbed UsersError::Backend and ship
in later commits in this series.

Config (UsersServiceConfig) sources from env with documented
defaults:
  PICLOUD_APP_USER_SESSION_TTL_HOURS         (24)
  PICLOUD_APP_USER_SESSION_ABSOLUTE_HOURS    (720 = 30d)
  PICLOUD_APP_USER_VERIFICATION_TTL_HOURS    (48)
  PICLOUD_APP_USER_PASSWORD_RESET_TTL_HOURS  (1)
  PICLOUD_APP_USER_INVITATION_TTL_DAYS       (7)

Reserved a TIMING_FLAT_DUMMY_HASH constant on manager-core::auth so
the dummy PHC is one shared definition.

Added AppUserId + InvitationId id types in picloud-shared.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-06 11:57:06 +02:00
parent 7a44cbf5a4
commit c6bf8d3de5
6 changed files with 1198 additions and 10 deletions

View File

@@ -0,0 +1,627 @@
//! `UsersService` implementation backed by Postgres (v1.1.8).
//!
//! Wires the `app_user_repo` (Argon2id-hashed user rows), the
//! `app_user_session_repo` (SHA-256-hashed sliding-window sessions),
//! the authz layer (so script-side calls require the right capability
//! when the principal is non-public), the `ServiceEventEmitter` (so
//! every mutation publishes an event for future triggers), and the
//! shared email service (for the verification / reset / invitation
//! flows in later commits).
//!
//! Cross-app isolation lives on every method that takes
//! `&SdkCallCx`: we read `cx.app_id` and never trust a script-passed
//! identifier. The `verify_session_for_realtime` method takes
//! `app_id` explicitly because it's invoked from the realtime
//! authority (no script execution context); the value comes from the
//! topic row, also not script-controlled.
//!
//! Commit 4 ships CRUD + login/verify/logout + verify_session_for_realtime.
//! Email-tied flows (verification, password reset, invitations),
//! roles, and admin-mediated helpers are stubbed with
//! `UsersError::Backend("not yet implemented")` and ship in later
//! commits in the v1.1.8 series.
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use chrono::Utc;
use picloud_shared::{
AppId, AppUser, AppUserId, CreateUserInput, EmailTemplateOpts, GeneratedAccept,
GeneratedSession, InviteOpts, Invitation, InvitationId, Principal, SdkCallCx, ServiceEvent,
ServiceEventEmitter, UpdateUserInput, UsersError, UsersListOpts, UsersListPage, UsersService,
};
use crate::app_user_repo::{
AppUserRepository, AppUserRepositoryError, AppUserRow, ListOpts as RepoListOpts,
};
use crate::app_user_session_repo::{AppUserSessionRepository, AppUserSessionRepositoryError};
use crate::auth::{
self, generate_session_token, hash_password, hash_token, verify_password, TIMING_FLAT_DUMMY_HASH,
};
use crate::authz::{self, AuthzDenied, AuthzRepo, Capability};
const NOT_YET_IMPL: &str = "users::* feature not yet implemented in this v1.1.8 commit";
/// Runtime configuration. Defaults match the v1.1.8 brief — overridable
/// via env vars wired in the picloud binary.
#[derive(Debug, Clone, Copy)]
pub struct UsersServiceConfig {
/// Sliding-window TTL on a session (default 24h).
pub session_ttl: Duration,
/// Hard cap from session creation (default 30 days). Beyond this
/// the session is dead regardless of recent activity.
pub session_absolute_ttl: Duration,
/// Verification-token TTL (default 48h).
pub verification_ttl: Duration,
/// Password-reset-token TTL (default 1h).
pub password_reset_ttl: Duration,
/// Invitation-token TTL (default 7 days).
pub invitation_ttl: Duration,
}
impl Default for UsersServiceConfig {
fn default() -> Self {
Self {
session_ttl: Duration::from_secs(24 * 3600),
session_absolute_ttl: Duration::from_secs(30 * 24 * 3600),
verification_ttl: Duration::from_secs(48 * 3600),
password_reset_ttl: Duration::from_secs(3600),
invitation_ttl: Duration::from_secs(7 * 24 * 3600),
}
}
}
impl UsersServiceConfig {
/// Resolve all knobs from the process environment. Falls back to
/// the documented defaults on any parse failure (logging a warn).
#[must_use]
pub fn from_env() -> Self {
let default = Self::default();
Self {
session_ttl: env_duration_hours("PICLOUD_APP_USER_SESSION_TTL_HOURS", default.session_ttl),
session_absolute_ttl: env_duration_hours(
"PICLOUD_APP_USER_SESSION_ABSOLUTE_HOURS",
default.session_absolute_ttl,
),
verification_ttl: env_duration_hours(
"PICLOUD_APP_USER_VERIFICATION_TTL_HOURS",
default.verification_ttl,
),
password_reset_ttl: env_duration_hours(
"PICLOUD_APP_USER_PASSWORD_RESET_TTL_HOURS",
default.password_reset_ttl,
),
invitation_ttl: env_duration_days(
"PICLOUD_APP_USER_INVITATION_TTL_DAYS",
default.invitation_ttl,
),
}
}
}
fn env_duration_hours(var: &str, default: Duration) -> Duration {
std::env::var(var)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(|h| Duration::from_secs(h * 3600))
.unwrap_or(default)
}
fn env_duration_days(var: &str, default: Duration) -> Duration {
std::env::var(var)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(|d| Duration::from_secs(d * 24 * 3600))
.unwrap_or(default)
}
/// Postgres-backed `UsersService`.
pub struct UsersServiceImpl {
users: Arc<dyn AppUserRepository>,
sessions: Arc<dyn AppUserSessionRepository>,
authz: Arc<dyn AuthzRepo>,
events: Arc<dyn ServiceEventEmitter>,
config: UsersServiceConfig,
}
impl UsersServiceImpl {
#[must_use]
pub fn new(
users: Arc<dyn AppUserRepository>,
sessions: Arc<dyn AppUserSessionRepository>,
authz: Arc<dyn AuthzRepo>,
events: Arc<dyn ServiceEventEmitter>,
config: UsersServiceConfig,
) -> Self {
Self {
users,
sessions,
authz,
events,
config,
}
}
async fn require(&self, principal: Option<&Principal>, cap: Capability) -> Result<(), UsersError> {
let Some(p) = principal else {
// Public HTTP execution — script-as-gate; not denied here.
return Ok(());
};
match authz::require(&*self.authz, p, cap).await {
Ok(()) => Ok(()),
Err(AuthzDenied::Denied) => Err(UsersError::Forbidden),
Err(AuthzDenied::Repo(e)) => Err(UsersError::Backend(e.to_string())),
}
}
/// Compose the public `AppUser` shape from a `AppUserRow`. Roles
/// are always empty in commit 4 of v1.1.8; commit 8 (per-app
/// roles) replaces this stub with an actual `app_user_roles`
/// query.
fn to_app_user(row: AppUserRow, roles: Vec<String>) -> AppUser {
AppUser {
id: row.id,
app_id: row.app_id,
email: row.email,
display_name: row.display_name,
email_verified_at: row.email_verified_at,
last_login_at: row.last_login_at,
created_at: row.created_at,
updated_at: row.updated_at,
roles,
}
}
async fn fetch_roles(&self, _app_id: AppId, _user_id: AppUserId) -> Result<Vec<String>, UsersError> {
// Commit 8 (per-app roles, migration 0031) replaces this with
// a SELECT from app_user_roles.
Ok(Vec::new())
}
async fn emit(&self, cx: &SdkCallCx, op: &'static str, user_id: AppUserId) {
let _ = self
.events
.emit(
cx,
ServiceEvent {
source: "users",
op,
collection: None,
key: Some(user_id.to_string()),
payload: None,
old_payload: None,
},
)
.await;
}
}
fn validate_email(email: &str) -> Result<String, UsersError> {
let trimmed = email.trim();
if trimmed.is_empty() {
return Err(UsersError::Invalid("email must not be empty".into()));
}
if trimmed.len() > 254 {
return Err(UsersError::Invalid("email exceeds 254 characters".into()));
}
if !trimmed.contains('@') {
return Err(UsersError::Invalid("email must contain '@'".into()));
}
Ok(trimmed.to_string())
}
fn validate_password(password: &str) -> Result<(), UsersError> {
if password.chars().count() < 8 {
return Err(UsersError::Invalid(
"password must be at least 8 characters".into(),
));
}
if password.len() > 1024 {
// Guard against absurd inputs that would make Argon2id costly.
return Err(UsersError::Invalid("password exceeds 1024 bytes".into()));
}
Ok(())
}
fn validate_display_name(name: Option<&str>) -> Result<Option<String>, UsersError> {
let Some(raw) = name else { return Ok(None) };
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
if trimmed.chars().count() > 256 {
return Err(UsersError::Invalid(
"display_name exceeds 256 characters".into(),
));
}
Ok(Some(trimmed.to_string()))
}
impl From<AppUserRepositoryError> for UsersError {
fn from(e: AppUserRepositoryError) -> Self {
match e {
AppUserRepositoryError::NotFound(_) => UsersError::NotFound,
AppUserRepositoryError::DuplicateEmail(_) => UsersError::DuplicateEmail,
AppUserRepositoryError::Db(err) => UsersError::Backend(err.to_string()),
}
}
}
impl From<AppUserSessionRepositoryError> for UsersError {
fn from(e: AppUserSessionRepositoryError) -> Self {
match e {
AppUserSessionRepositoryError::Db(err) => UsersError::Backend(err.to_string()),
}
}
}
#[async_trait]
impl UsersService for UsersServiceImpl {
async fn create(
&self,
cx: &SdkCallCx,
input: CreateUserInput,
) -> Result<AppUser, UsersError> {
self.require(cx.principal.as_ref(), Capability::AppUsersWrite(cx.app_id))
.await?;
let email = validate_email(&input.email)?;
validate_password(&input.password)?;
let display_name = validate_display_name(input.display_name.as_deref())?;
let password_hash = hash_password(&input.password).map_err(|e| {
UsersError::Backend(format!("password hashing failed: {e}"))
})?;
let row = self
.users
.create(cx.app_id, &email, &password_hash, display_name.as_deref())
.await?;
let user_id = row.id;
let user = Self::to_app_user(row, Vec::new());
self.emit(cx, "create", user_id).await;
Ok(user)
}
async fn get(&self, cx: &SdkCallCx, id: AppUserId) -> Result<Option<AppUser>, UsersError> {
self.require(cx.principal.as_ref(), Capability::AppUsersRead(cx.app_id))
.await?;
let Some(row) = self.users.get(cx.app_id, id).await? else {
return Ok(None);
};
let roles = self.fetch_roles(cx.app_id, row.id).await?;
Ok(Some(Self::to_app_user(row, roles)))
}
async fn find_by_email(
&self,
cx: &SdkCallCx,
email: &str,
) -> Result<Option<AppUser>, UsersError> {
self.require(cx.principal.as_ref(), Capability::AppUsersRead(cx.app_id))
.await?;
let normalized = validate_email(email)?;
let Some(row) = self.users.find_by_email(cx.app_id, &normalized).await? else {
return Ok(None);
};
let roles = self.fetch_roles(cx.app_id, row.id).await?;
Ok(Some(Self::to_app_user(row, roles)))
}
async fn update(
&self,
cx: &SdkCallCx,
id: AppUserId,
patch: UpdateUserInput,
) -> Result<AppUser, UsersError> {
self.require(cx.principal.as_ref(), Capability::AppUsersWrite(cx.app_id))
.await?;
let mut row_opt = self.users.get(cx.app_id, id).await?;
if let Some(display) = patch.display_name {
let normalized = match display {
Some(s) => validate_display_name(Some(s.as_str()))?,
None => None,
};
row_opt = Some(
self.users
.update_display_name(cx.app_id, id, normalized.as_deref())
.await?,
);
}
let row = row_opt.ok_or(UsersError::NotFound)?;
let roles = self.fetch_roles(cx.app_id, row.id).await?;
let user = Self::to_app_user(row, roles);
self.emit(cx, "update", user.id).await;
Ok(user)
}
async fn delete(&self, cx: &SdkCallCx, id: AppUserId) -> Result<bool, UsersError> {
self.require(cx.principal.as_ref(), Capability::AppUsersWrite(cx.app_id))
.await?;
let deleted = self.users.delete(cx.app_id, id).await?;
if deleted {
self.emit(cx, "delete", id).await;
}
Ok(deleted)
}
async fn list(
&self,
cx: &SdkCallCx,
opts: UsersListOpts,
) -> Result<UsersListPage, UsersError> {
self.require(cx.principal.as_ref(), Capability::AppUsersRead(cx.app_id))
.await?;
let limit = opts.limit.unwrap_or(50).clamp(1, 500);
let page = self
.users
.list(
cx.app_id,
RepoListOpts {
cursor: opts.cursor,
limit,
},
)
.await?;
let mut items = Vec::with_capacity(page.items.len());
for row in page.items {
let roles = self.fetch_roles(cx.app_id, row.id).await?;
items.push(Self::to_app_user(row, roles));
}
Ok(UsersListPage {
items,
next_cursor: page.next_cursor,
})
}
async fn login(
&self,
cx: &SdkCallCx,
email: &str,
password: &str,
) -> Result<Option<GeneratedSession>, UsersError> {
self.require(cx.principal.as_ref(), Capability::AppUsersWrite(cx.app_id))
.await?;
// Both branches normalize the email so an attacker can't probe
// existence via "" / "x" / very-long-string differences.
let normalized = match validate_email(email) {
Ok(e) => e,
Err(_) => {
// Still run a dummy verify so the timing of an invalid
// email shape matches the timing of a legitimate miss.
let _ = verify_password(TIMING_FLAT_DUMMY_HASH, password);
return Ok(None);
}
};
let creds = self
.users
.get_credentials_by_email(cx.app_id, &normalized)
.await?;
let (hash, user_id, app_id) = match creds {
Some(c) => (c.password_hash, Some(c.id), Some(c.app_id)),
None => (TIMING_FLAT_DUMMY_HASH.to_string(), None, None),
};
let password_ok = verify_password(&hash, password);
let (Some(user_id), Some(app_id)) = (user_id, app_id) else {
return Ok(None);
};
if !password_ok {
return Ok(None);
}
// Cross-app isolation: a credentials row for a different app
// shouldn't be reachable by find/get_credentials with a fixed
// app_id, but defense-in-depth here.
if app_id != cx.app_id {
return Ok(None);
}
let now = Utc::now();
let absolute = now
+ chrono::Duration::from_std(self.config.session_absolute_ttl)
.map_err(|e| UsersError::Backend(format!("absolute ttl: {e}")))?;
let sliding = now
+ chrono::Duration::from_std(self.config.session_ttl)
.map_err(|e| UsersError::Backend(format!("sliding ttl: {e}")))?;
let expires_at = sliding.min(absolute);
let token = generate_session_token();
self.sessions
.create(cx.app_id, user_id, &token.hash, expires_at, absolute)
.await?;
self.users.touch_last_login(cx.app_id, user_id).await?;
let row = self
.users
.get(cx.app_id, user_id)
.await?
.ok_or(UsersError::NotFound)?;
let roles = self.fetch_roles(cx.app_id, row.id).await?;
let user = Self::to_app_user(row, roles);
Ok(Some(GeneratedSession {
token: token.raw,
user,
expires_at,
}))
}
async fn verify(
&self,
cx: &SdkCallCx,
token: &str,
) -> Result<Option<AppUser>, UsersError> {
self.require(cx.principal.as_ref(), Capability::AppUsersRead(cx.app_id))
.await?;
self.resolve_session(cx.app_id, token).await
}
async fn logout(&self, cx: &SdkCallCx, token: &str) -> Result<(), UsersError> {
self.require(cx.principal.as_ref(), Capability::AppUsersWrite(cx.app_id))
.await?;
let token_hash = hash_token(token);
// Only revoke when the row belongs to this app — otherwise the
// script could prove existence of cross-app sessions by trying
// arbitrary tokens. Lookup is the cross-app filter here.
if let Some(lookup) = self.sessions.lookup(&token_hash).await? {
if lookup.app_id != cx.app_id {
return Ok(());
}
self.sessions.revoke(&token_hash).await?;
}
Ok(())
}
async fn verify_session_for_realtime(
&self,
app_id: AppId,
token: &str,
) -> Result<Option<AppUser>, UsersError> {
self.resolve_session(app_id, token).await
}
async fn send_verification_email(
&self,
_cx: &SdkCallCx,
_user_id: AppUserId,
_opts: EmailTemplateOpts,
) -> Result<(), UsersError> {
Err(UsersError::Backend(NOT_YET_IMPL.into()))
}
async fn verify_email(
&self,
_cx: &SdkCallCx,
_token: &str,
) -> Result<Option<AppUser>, UsersError> {
Err(UsersError::Backend(NOT_YET_IMPL.into()))
}
async fn request_password_reset(
&self,
_cx: &SdkCallCx,
_email: &str,
_opts: EmailTemplateOpts,
) -> Result<(), UsersError> {
Err(UsersError::Backend(NOT_YET_IMPL.into()))
}
async fn complete_password_reset(
&self,
_cx: &SdkCallCx,
_token: &str,
_new_password: &str,
) -> Result<Option<AppUser>, UsersError> {
Err(UsersError::Backend(NOT_YET_IMPL.into()))
}
async fn invite(
&self,
_cx: &SdkCallCx,
_email: &str,
_opts: InviteOpts,
) -> Result<(), UsersError> {
Err(UsersError::Backend(NOT_YET_IMPL.into()))
}
async fn accept_invite(
&self,
_cx: &SdkCallCx,
_token: &str,
_password: &str,
_display_name: Option<String>,
) -> Result<Option<GeneratedAccept>, UsersError> {
Err(UsersError::Backend(NOT_YET_IMPL.into()))
}
async fn add_role(
&self,
_cx: &SdkCallCx,
_user_id: AppUserId,
_role: &str,
) -> Result<(), UsersError> {
Err(UsersError::Backend(NOT_YET_IMPL.into()))
}
async fn remove_role(
&self,
_cx: &SdkCallCx,
_user_id: AppUserId,
_role: &str,
) -> Result<bool, UsersError> {
Err(UsersError::Backend(NOT_YET_IMPL.into()))
}
async fn has_role(
&self,
_cx: &SdkCallCx,
_user_id: AppUserId,
_role: &str,
) -> Result<bool, UsersError> {
Err(UsersError::Backend(NOT_YET_IMPL.into()))
}
async fn admin_reset_password_token(
&self,
_principal: &Principal,
_app_id: AppId,
_user_id: AppUserId,
) -> Result<String, UsersError> {
Err(UsersError::Backend(NOT_YET_IMPL.into()))
}
async fn admin_revoke_all_sessions(
&self,
_principal: &Principal,
_app_id: AppId,
_user_id: AppUserId,
) -> Result<u64, UsersError> {
Err(UsersError::Backend(NOT_YET_IMPL.into()))
}
async fn list_invitations(
&self,
_principal: &Principal,
_app_id: AppId,
) -> Result<Vec<Invitation>, UsersError> {
Err(UsersError::Backend(NOT_YET_IMPL.into()))
}
async fn revoke_invitation(
&self,
_principal: &Principal,
_app_id: AppId,
_invite_id: InvitationId,
) -> Result<bool, UsersError> {
Err(UsersError::Backend(NOT_YET_IMPL.into()))
}
}
impl UsersServiceImpl {
/// Shared lookup logic for `verify` and `verify_session_for_realtime`.
/// Honors the absolute cap and cross-app isolation, then bumps the
/// sliding window (capped at the absolute cap) on success.
async fn resolve_session(
&self,
app_id: AppId,
token: &str,
) -> Result<Option<AppUser>, UsersError> {
let token_hash = hash_token(token);
let Some(lookup) = self.sessions.lookup(&token_hash).await? else {
return Ok(None);
};
// Cross-app isolation: a token issued for app A cannot
// authorize a request into app B.
if lookup.app_id != app_id {
return Ok(None);
}
let now = Utc::now();
let sliding = now
+ chrono::Duration::from_std(self.config.session_ttl)
.map_err(|e| UsersError::Backend(format!("sliding ttl: {e}")))?;
let new_expires = sliding.min(lookup.absolute_expires_at);
self.sessions.touch(&token_hash, new_expires).await?;
let row = self
.users
.get(lookup.app_id, lookup.user_id)
.await?
.ok_or(UsersError::NotFound)?;
let roles = self.fetch_roles(lookup.app_id, row.id).await?;
Ok(Some(Self::to_app_user(row, roles)))
}
}
// Silence unused warnings on the public `auth` re-import so the file
// stays clippy-clean while the password-reset commit prepares to use
// the dummy hash directly from this module.
#[allow(dead_code)]
fn _ensure_auth_in_scope() {
let _ = auth::hash_token;
}