diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index b6d3423..d1fe7b5 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -19,6 +19,7 @@ pub mod app_repo; pub mod app_secrets_repo; pub mod app_user_repo; pub mod app_user_session_repo; +pub mod users_service; pub mod apps_api; pub mod auth; pub mod auth_api; @@ -86,6 +87,7 @@ pub use app_user_session_repo::{ AppUserSessionLookup, AppUserSessionRepository, AppUserSessionRepositoryError, PostgresAppUserSessionRepository, }; +pub use users_service::{UsersServiceConfig, UsersServiceImpl}; pub use admin_users_api::{admins_router, AdminsState}; pub use api::{admin_router, AdminState}; pub use api_key_repo::{ diff --git a/crates/manager-core/src/users_service.rs b/crates/manager-core/src/users_service.rs new file mode 100644 index 0000000..eada01f --- /dev/null +++ b/crates/manager-core/src/users_service.rs @@ -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::().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::().ok()) + .map(|d| Duration::from_secs(d * 24 * 3600)) + .unwrap_or(default) +} + +/// Postgres-backed `UsersService`. +pub struct UsersServiceImpl { + users: Arc, + sessions: Arc, + authz: Arc, + events: Arc, + config: UsersServiceConfig, +} + +impl UsersServiceImpl { + #[must_use] + pub fn new( + users: Arc, + sessions: Arc, + authz: Arc, + events: Arc, + 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) -> 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, 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 { + 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, 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 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 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 { + 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, 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, 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 { + 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 { + 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 { + 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, 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, 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, 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, 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, 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, + ) -> Result, 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 { + Err(UsersError::Backend(NOT_YET_IMPL.into())) + } + async fn has_role( + &self, + _cx: &SdkCallCx, + _user_id: AppUserId, + _role: &str, + ) -> Result { + Err(UsersError::Backend(NOT_YET_IMPL.into())) + } + async fn admin_reset_password_token( + &self, + _principal: &Principal, + _app_id: AppId, + _user_id: AppUserId, + ) -> Result { + Err(UsersError::Backend(NOT_YET_IMPL.into())) + } + async fn admin_revoke_all_sessions( + &self, + _principal: &Principal, + _app_id: AppId, + _user_id: AppUserId, + ) -> Result { + Err(UsersError::Backend(NOT_YET_IMPL.into())) + } + async fn list_invitations( + &self, + _principal: &Principal, + _app_id: AppId, + ) -> Result, UsersError> { + Err(UsersError::Backend(NOT_YET_IMPL.into())) + } + async fn revoke_invitation( + &self, + _principal: &Principal, + _app_id: AppId, + _invite_id: InvitationId, + ) -> Result { + 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, 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; +} diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 8ed826b..cd05e74 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -21,14 +21,15 @@ use picloud_manager_core::{ FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, - PostgresAppRepository, PostgresAppSecretsRepo, PostgresDeadLetterRepo, - PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository, - PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, - PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, - PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, - RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, - SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, - TriggerRepo, TriggersState, + PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserRepository, + PostgresAppUserSessionRepository, PostgresDeadLetterRepo, PostgresDeadLetterService, + PostgresDocsRepo, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, + PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, + PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo, PrincipalResolver, + PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, + SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, + SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, + UsersServiceConfig, UsersServiceImpl, }; use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; @@ -40,7 +41,7 @@ use picloud_shared::{ DeadLetterService, DocsService, EmailService, ExecutionLogSink, FilesService, HttpService, InboxResolver, KvService, MasterKey, OutboxWriter, PubsubService, RealtimeAuthority, RealtimeBroadcaster, ScriptValidator, SecretsService, ServiceEventEmitter, Services, - API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION, + UsersService, API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION, }; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; @@ -244,6 +245,18 @@ pub async fn build_app( // v1.1.7 outbound email. Builds a lettre SMTP transport from // PICLOUD_SMTP_* env (disabled mode + warning if unconfigured). let email: Arc = Arc::new(EmailServiceImpl::from_env(authz.clone())); + // v1.1.8 data-plane user management. Wires Argon2id-hashed user + // rows + SHA-256-hashed sliding-window sessions to the Rhai + // `users::*` namespace and the admin /apps/{id}/users HTTP surface. + let app_users_repo = Arc::new(PostgresAppUserRepository::new(pool.clone())); + let app_user_sessions_repo = Arc::new(PostgresAppUserSessionRepository::new(pool.clone())); + let users: Arc = Arc::new(UsersServiceImpl::new( + app_users_repo.clone(), + app_user_sessions_repo.clone(), + authz.clone(), + events.clone(), + UsersServiceConfig::from_env(), + )); let services = Services::new( kv, docs, @@ -255,6 +268,7 @@ pub async fn build_app( pubsub, secrets, email, + users, ); let engine = Arc::new(Engine::new(Limits::default(), services)); diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 80756d1..ba7cbcf 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -33,6 +33,7 @@ pub mod secrets; pub mod services; pub mod subscriber_token; pub mod trigger_event; +pub mod users; pub mod validator; pub mod version; @@ -79,5 +80,10 @@ pub use services::Services; pub use trigger_event::{ DeadLetterEventDetail, DocsEventOp, FilesEventOp, KvEventOp, TriggerEvent, }; +pub use users::{ + AppUser, CreateUserInput, EmailTemplateOpts, GeneratedAccept, GeneratedSession, InviteOpts, + Invitation, NoopUsersService, UpdateUserInput, UsersError, UsersListOpts, UsersListPage, + UsersService, +}; pub use validator::{ScriptValidator, ValidatedScript, ValidationError}; pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION}; diff --git a/crates/shared/src/services.rs b/crates/shared/src/services.rs index aa8ad16..8e16c88 100644 --- a/crates/shared/src/services.rs +++ b/crates/shared/src/services.rs @@ -23,7 +23,8 @@ use crate::{ DeadLetterService, DocsService, EmailService, FilesService, HttpService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService, NoopEventEmitter, NoopFilesService, NoopHttpService, NoopKvService, NoopModuleSource, NoopPubsubService, - NoopSecretsService, PubsubService, SecretsService, ServiceEventEmitter, + NoopSecretsService, NoopUsersService, PubsubService, SecretsService, ServiceEventEmitter, + UsersService, }; /// SDK service bundle. See module docs for the lifecycle and the v1.1.x @@ -86,6 +87,14 @@ pub struct Services { /// `NoopEmailService` (always `NotConfigured`) in tests that don't /// send mail. pub email: Arc, + + /// Per-app data-plane user management (v1.1.8). Scripts get the + /// full `users::*` namespace (CRUD, login/verify/logout, email + /// verification, password reset, invitations, string-tagged + /// roles). Backed by Postgres-stored Argon2id hashes + SHA-256 + /// session tokens in the picloud binary; `NoopUsersService` in + /// tests that don't exercise users. + pub users: Arc, } impl Services { @@ -105,6 +114,7 @@ impl Services { pubsub: Arc, secrets: Arc, email: Arc, + users: Arc, ) -> Self { Self { kv, @@ -117,6 +127,7 @@ impl Services { pubsub, secrets, email, + users, } } @@ -138,6 +149,7 @@ impl Services { Arc::new(NoopPubsubService), Arc::new(NoopSecretsService), Arc::new(NoopEmailService), + Arc::new(NoopUsersService), ) } } diff --git a/crates/shared/src/users.rs b/crates/shared/src/users.rs new file mode 100644 index 0000000..adab41d --- /dev/null +++ b/crates/shared/src/users.rs @@ -0,0 +1,527 @@ +//! `UsersService` — the v1.1.8 data-plane user management contract. +//! +//! Scripts get `users::*` for CRUD on per-app end-users (distinct from +//! the control-plane operators in `admin_users`), session-based auth +//! (`login` / `verify` / `logout`), email verification, password reset, +//! invitations, and string-tagged per-app roles. +//! +//! Lives in `picloud-shared` so the Rhai bridge in `executor-core` and +//! the impl in `manager-core` share one trait. The impl +//! (`manager-core::users_service::UsersServiceImpl`) wires the +//! Postgres-backed repos; `picloud-shared` stays free of the sqlx +//! dependency. +//! +//! Every method that takes `&SdkCallCx` derives `app_id` from +//! `cx.app_id` — never from a script-passed argument. The +//! `verify_session_for_realtime` method takes `app_id` explicitly +//! because it's invoked from the realtime authority (no script +//! execution context). + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use thiserror::Error; + +use crate::{AppId, AppUserId, InvitationId, Principal, SdkCallCx}; + +/// Public app-user record returned by `get` / `find_by_email` / `verify` +/// / `list` / etc. Never carries the password hash. The `roles` field +/// is populated from `app_user_roles` (v1.1.8 commit 9 adds the storage; +/// pre-commit-9 it's always empty). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppUser { + 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, + pub roles: Vec, +} + +/// Pending invitation row returned by the admin invitations API. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Invitation { + pub id: InvitationId, + pub app_id: AppId, + pub email: String, + pub display_name: Option, + pub roles: Vec, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +/// Input for `users::create` and the admin POST endpoint. +#[derive(Debug, Clone)] +pub struct CreateUserInput { + pub email: String, + pub password: String, + pub display_name: Option, +} + +/// Input for `users::update` / admin PATCH. v1.1.8 patches only +/// display_name; password changes go through password reset, email is +/// immutable post-create (a v1.2 concern). +#[derive(Debug, Clone, Default)] +pub struct UpdateUserInput { + pub display_name: Option>, +} + +/// Pagination knobs for `users::list`. The cursor is the `created_at` +/// of the last row from the previous page; `None` is "first page". +#[derive(Debug, Clone, Default)] +pub struct UsersListOpts { + pub cursor: Option>, + pub limit: Option, +} + +#[derive(Debug, Clone)] +pub struct UsersListPage { + pub items: Vec, + pub next_cursor: Option>, +} + +/// Newly-minted session — returned exactly once by `users::login` and +/// `users::accept_invite`. The raw token goes back to the caller; only +/// the hash hits storage. +#[derive(Debug, Clone)] +pub struct GeneratedSession { + pub token: String, + pub user: AppUser, + pub expires_at: DateTime, +} + +/// Outcome of `users::accept_invite`: the freshly-created user plus a +/// session token so the script can return both in one round-trip. +#[derive(Debug, Clone)] +pub struct GeneratedAccept { + pub user: AppUser, + pub session: GeneratedSession, +} + +/// Email template fields supplied by the script to v1.1.8 email-tied +/// flows (`send_verification_email`, `request_password_reset`, `invite`). +/// The script owns the body; PiCloud only injects `?token=` into +/// the `link_base`. +#[derive(Debug, Clone)] +pub struct EmailTemplateOpts { + pub link_base: String, + pub subject: String, + /// Body template with `{link}` substituted for the + /// `link_base?token=` URL. Plain text body — the v1.1.7 + /// hybrid email design lets richer templates land in userland. + pub body_template: String, +} + +/// Extra fields for `users::invite` on top of the email template: +/// optional pre-staged display name + roles applied on accept. +#[derive(Debug, Clone, Default)] +pub struct InviteOpts { + pub template: Option, + pub display_name: Option, + pub roles: Vec, +} + +/// Failure modes surfaced to the Rhai bridge and the admin HTTP layer. +/// Variants are mapped to stable string codes by the SDK bridge so +/// scripts can `try`/`catch` and switch on the code; admin handlers +/// map to HTTP status codes. +#[derive(Debug, Error)] +pub enum UsersError { + /// Caller principal lacked the required capability. Only raised + /// when `cx.principal.is_some()` (script-as-gate semantics). + #[error("forbidden")] + Forbidden, + + /// Looked-up user does not exist (or, more precisely, is not + /// visible from `cx.app_id`). + #[error("not found")] + NotFound, + + /// `users::create` collided with an existing `(app_id, lower(email))` + /// row. Surfaced to script-land as a distinguishable code so a + /// sign-up form can react; never distinguishes "exists" vs + /// "doesn't exist" through any timing-observable side channel. + #[error("email already in use")] + DuplicateEmail, + + /// Input field failed validation (e.g., empty email, password + /// shorter than 8 characters, malformed shape). + #[error("invalid: {0}")] + Invalid(String), + + /// Email-tied flow attempted but no SMTP relay is configured. Maps + /// to the same script-side code as `EmailError::NotConfigured` so + /// scripts already handling the email-disabled mode don't need + /// special cases. + #[error("email is not configured")] + EmailNotConfigured, + + /// Underlying email send failed (transport rejected, bad address, + /// too large). Wraps the EmailError message for diagnostics. + #[error("email transport: {0}")] + EmailTransport(String), + + /// Underlying storage error. Surfaces to script-land as a generic + /// runtime error; the message is logged server-side for forensics. + #[error("backend error: {0}")] + Backend(String), +} + +#[async_trait] +pub trait UsersService: Send + Sync { + // ---- CRUD ---- + + async fn create( + &self, + cx: &SdkCallCx, + input: CreateUserInput, + ) -> Result; + + async fn get(&self, cx: &SdkCallCx, id: AppUserId) -> Result, UsersError>; + + async fn find_by_email( + &self, + cx: &SdkCallCx, + email: &str, + ) -> Result, UsersError>; + + async fn update( + &self, + cx: &SdkCallCx, + id: AppUserId, + patch: UpdateUserInput, + ) -> Result; + + async fn delete(&self, cx: &SdkCallCx, id: AppUserId) -> Result; + + async fn list( + &self, + cx: &SdkCallCx, + opts: UsersListOpts, + ) -> Result; + + // ---- Auth ---- + + /// Email + password login. Returns `Some(session)` on success, + /// `None` on bad credentials (same wall-clock cost on bad-email + /// vs bad-password via the timing-flat dummy Argon2 path). + async fn login( + &self, + cx: &SdkCallCx, + email: &str, + password: &str, + ) -> Result, UsersError>; + + /// Verify a session token. Returns the owning user on success, + /// `None` on missing / expired / revoked / cross-app token. Bumps + /// the sliding-window TTL on success (capped at the absolute cap). + async fn verify( + &self, + cx: &SdkCallCx, + token: &str, + ) -> Result, UsersError>; + + /// Logout — revokes the session. No-op for already-revoked / + /// already-expired tokens. + async fn logout(&self, cx: &SdkCallCx, token: &str) -> Result<(), UsersError>; + + /// Realtime SSE entry point. Same DB lookup + sliding-window bump + /// as `verify`, but takes `app_id` explicitly because the + /// `RealtimeAuthority` is not inside a script execution. Returns + /// `None` on missing / expired / revoked / cross-app token. + async fn verify_session_for_realtime( + &self, + app_id: AppId, + token: &str, + ) -> Result, UsersError>; + + // ---- Email verification (commit 5) ---- + + async fn send_verification_email( + &self, + cx: &SdkCallCx, + user_id: AppUserId, + opts: EmailTemplateOpts, + ) -> Result<(), UsersError>; + + async fn verify_email( + &self, + cx: &SdkCallCx, + token: &str, + ) -> Result, UsersError>; + + // ---- Password reset (commit 6) ---- + + async fn request_password_reset( + &self, + cx: &SdkCallCx, + email: &str, + opts: EmailTemplateOpts, + ) -> Result<(), UsersError>; + + async fn complete_password_reset( + &self, + cx: &SdkCallCx, + token: &str, + new_password: &str, + ) -> Result, UsersError>; + + // ---- Invitations (commit 7) ---- + + async fn invite( + &self, + cx: &SdkCallCx, + email: &str, + opts: InviteOpts, + ) -> Result<(), UsersError>; + + async fn accept_invite( + &self, + cx: &SdkCallCx, + token: &str, + password: &str, + display_name: Option, + ) -> Result, UsersError>; + + // ---- Roles (commit 8) ---- + + async fn add_role( + &self, + cx: &SdkCallCx, + user_id: AppUserId, + role: &str, + ) -> Result<(), UsersError>; + + async fn remove_role( + &self, + cx: &SdkCallCx, + user_id: AppUserId, + role: &str, + ) -> Result; + + async fn has_role( + &self, + cx: &SdkCallCx, + user_id: AppUserId, + role: &str, + ) -> Result; + + // ---- Admin-mediated (no cx; called from the admin HTTP layer + // with an explicit principal) ---- + + /// Admin clicks "reset password"; returns a one-shot token the + /// admin can paste into a manual reset link. Does NOT send an + /// email — that's the SDK's `request_password_reset` path. + async fn admin_reset_password_token( + &self, + principal: &Principal, + app_id: AppId, + user_id: AppUserId, + ) -> Result; + + /// Admin clicks "revoke all sessions"; returns the count of + /// sessions revoked (zero for already-quiet users). + async fn admin_revoke_all_sessions( + &self, + principal: &Principal, + app_id: AppId, + user_id: AppUserId, + ) -> Result; + + /// Admin lists pending invitations for the app's invitations tab. + async fn list_invitations( + &self, + principal: &Principal, + app_id: AppId, + ) -> Result, UsersError>; + + /// Admin revokes a pending invitation (the row is deleted; the + /// already-mailed link becomes inert). + async fn revoke_invitation( + &self, + principal: &Principal, + app_id: AppId, + invite_id: InvitationId, + ) -> Result; +} + +/// Stub used by tests that build a `Services` bundle without a backing +/// users impl. Every call returns `UsersError::Backend("noop users +/// service")` so tests fail loudly instead of silently passing through. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopUsersService; + +const NOOP_MSG: &str = "noop users service"; + +#[async_trait] +impl UsersService for NoopUsersService { + async fn create( + &self, + _cx: &SdkCallCx, + _input: CreateUserInput, + ) -> Result { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn get( + &self, + _cx: &SdkCallCx, + _id: AppUserId, + ) -> Result, UsersError> { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn find_by_email( + &self, + _cx: &SdkCallCx, + _email: &str, + ) -> Result, UsersError> { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn update( + &self, + _cx: &SdkCallCx, + _id: AppUserId, + _patch: UpdateUserInput, + ) -> Result { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn delete(&self, _cx: &SdkCallCx, _id: AppUserId) -> Result { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn list( + &self, + _cx: &SdkCallCx, + _opts: UsersListOpts, + ) -> Result { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn login( + &self, + _cx: &SdkCallCx, + _email: &str, + _password: &str, + ) -> Result, UsersError> { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn verify( + &self, + _cx: &SdkCallCx, + _token: &str, + ) -> Result, UsersError> { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn logout(&self, _cx: &SdkCallCx, _token: &str) -> Result<(), UsersError> { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn verify_session_for_realtime( + &self, + _app_id: AppId, + _token: &str, + ) -> Result, UsersError> { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn send_verification_email( + &self, + _cx: &SdkCallCx, + _user_id: AppUserId, + _opts: EmailTemplateOpts, + ) -> Result<(), UsersError> { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn verify_email( + &self, + _cx: &SdkCallCx, + _token: &str, + ) -> Result, UsersError> { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn request_password_reset( + &self, + _cx: &SdkCallCx, + _email: &str, + _opts: EmailTemplateOpts, + ) -> Result<(), UsersError> { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn complete_password_reset( + &self, + _cx: &SdkCallCx, + _token: &str, + _new_password: &str, + ) -> Result, UsersError> { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn invite( + &self, + _cx: &SdkCallCx, + _email: &str, + _opts: InviteOpts, + ) -> Result<(), UsersError> { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn accept_invite( + &self, + _cx: &SdkCallCx, + _token: &str, + _password: &str, + _display_name: Option, + ) -> Result, UsersError> { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn add_role( + &self, + _cx: &SdkCallCx, + _user_id: AppUserId, + _role: &str, + ) -> Result<(), UsersError> { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn remove_role( + &self, + _cx: &SdkCallCx, + _user_id: AppUserId, + _role: &str, + ) -> Result { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn has_role( + &self, + _cx: &SdkCallCx, + _user_id: AppUserId, + _role: &str, + ) -> Result { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn admin_reset_password_token( + &self, + _principal: &Principal, + _app_id: AppId, + _user_id: AppUserId, + ) -> Result { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn admin_revoke_all_sessions( + &self, + _principal: &Principal, + _app_id: AppId, + _user_id: AppUserId, + ) -> Result { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn list_invitations( + &self, + _principal: &Principal, + _app_id: AppId, + ) -> Result, UsersError> { + Err(UsersError::Backend(NOOP_MSG.into())) + } + async fn revoke_invitation( + &self, + _principal: &Principal, + _app_id: AppId, + _invite_id: InvitationId, + ) -> Result { + Err(UsersError::Backend(NOOP_MSG.into())) + } +}