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

@@ -19,6 +19,7 @@ pub mod app_repo;
pub mod app_secrets_repo; pub mod app_secrets_repo;
pub mod app_user_repo; pub mod app_user_repo;
pub mod app_user_session_repo; pub mod app_user_session_repo;
pub mod users_service;
pub mod apps_api; pub mod apps_api;
pub mod auth; pub mod auth;
pub mod auth_api; pub mod auth_api;
@@ -86,6 +87,7 @@ pub use app_user_session_repo::{
AppUserSessionLookup, AppUserSessionRepository, AppUserSessionRepositoryError, AppUserSessionLookup, AppUserSessionRepository, AppUserSessionRepositoryError,
PostgresAppUserSessionRepository, PostgresAppUserSessionRepository,
}; };
pub use users_service::{UsersServiceConfig, UsersServiceImpl};
pub use admin_users_api::{admins_router, AdminsState}; pub use admin_users_api::{admins_router, AdminsState};
pub use api::{admin_router, AdminState}; pub use api::{admin_router, AdminState};
pub use api_key_repo::{ pub use api_key_repo::{

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;
}

View File

@@ -21,14 +21,15 @@ use picloud_manager_core::{
FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter, FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter,
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
PostgresAppRepository, PostgresAppSecretsRepo, PostgresDeadLetterRepo, PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserRepository,
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository, PostgresAppUserSessionRepository, PostgresDeadLetterRepo, PostgresDeadLetterService,
PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresDocsRepo, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo,
PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository,
PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo, PrincipalResolver,
RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository,
SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState,
TriggerRepo, TriggersState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState,
UsersServiceConfig, UsersServiceImpl,
}; };
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
@@ -40,7 +41,7 @@ use picloud_shared::{
DeadLetterService, DocsService, EmailService, ExecutionLogSink, FilesService, HttpService, DeadLetterService, DocsService, EmailService, ExecutionLogSink, FilesService, HttpService,
InboxResolver, KvService, MasterKey, OutboxWriter, PubsubService, RealtimeAuthority, InboxResolver, KvService, MasterKey, OutboxWriter, PubsubService, RealtimeAuthority,
RealtimeBroadcaster, ScriptValidator, SecretsService, ServiceEventEmitter, Services, 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::postgres::PgPoolOptions;
use sqlx::PgPool; use sqlx::PgPool;
@@ -244,6 +245,18 @@ pub async fn build_app(
// v1.1.7 outbound email. Builds a lettre SMTP transport from // v1.1.7 outbound email. Builds a lettre SMTP transport from
// PICLOUD_SMTP_* env (disabled mode + warning if unconfigured). // PICLOUD_SMTP_* env (disabled mode + warning if unconfigured).
let email: Arc<dyn EmailService> = Arc::new(EmailServiceImpl::from_env(authz.clone())); let email: Arc<dyn EmailService> = 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<dyn UsersService> = Arc::new(UsersServiceImpl::new(
app_users_repo.clone(),
app_user_sessions_repo.clone(),
authz.clone(),
events.clone(),
UsersServiceConfig::from_env(),
));
let services = Services::new( let services = Services::new(
kv, kv,
docs, docs,
@@ -255,6 +268,7 @@ pub async fn build_app(
pubsub, pubsub,
secrets, secrets,
email, email,
users,
); );
let engine = Arc::new(Engine::new(Limits::default(), services)); let engine = Arc::new(Engine::new(Limits::default(), services));

View File

@@ -33,6 +33,7 @@ pub mod secrets;
pub mod services; pub mod services;
pub mod subscriber_token; pub mod subscriber_token;
pub mod trigger_event; pub mod trigger_event;
pub mod users;
pub mod validator; pub mod validator;
pub mod version; pub mod version;
@@ -79,5 +80,10 @@ pub use services::Services;
pub use trigger_event::{ pub use trigger_event::{
DeadLetterEventDetail, DocsEventOp, FilesEventOp, KvEventOp, TriggerEvent, 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 validator::{ScriptValidator, ValidatedScript, ValidationError};
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION}; pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};

View File

@@ -23,7 +23,8 @@ use crate::{
DeadLetterService, DocsService, EmailService, FilesService, HttpService, KvService, DeadLetterService, DocsService, EmailService, FilesService, HttpService, KvService,
ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService, NoopEventEmitter, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService, NoopEventEmitter,
NoopFilesService, NoopHttpService, NoopKvService, NoopModuleSource, NoopPubsubService, 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 /// 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 /// `NoopEmailService` (always `NotConfigured`) in tests that don't
/// send mail. /// send mail.
pub email: Arc<dyn EmailService>, pub email: Arc<dyn EmailService>,
/// 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<dyn UsersService>,
} }
impl Services { impl Services {
@@ -105,6 +114,7 @@ impl Services {
pubsub: Arc<dyn PubsubService>, pubsub: Arc<dyn PubsubService>,
secrets: Arc<dyn SecretsService>, secrets: Arc<dyn SecretsService>,
email: Arc<dyn EmailService>, email: Arc<dyn EmailService>,
users: Arc<dyn UsersService>,
) -> Self { ) -> Self {
Self { Self {
kv, kv,
@@ -117,6 +127,7 @@ impl Services {
pubsub, pubsub,
secrets, secrets,
email, email,
users,
} }
} }
@@ -138,6 +149,7 @@ impl Services {
Arc::new(NoopPubsubService), Arc::new(NoopPubsubService),
Arc::new(NoopSecretsService), Arc::new(NoopSecretsService),
Arc::new(NoopEmailService), Arc::new(NoopEmailService),
Arc::new(NoopUsersService),
) )
} }
} }

527
crates/shared/src/users.rs Normal file
View File

@@ -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<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>,
pub roles: Vec<String>,
}
/// 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<String>,
pub roles: Vec<String>,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
/// 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<String>,
}
/// 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<Option<String>>,
}
/// 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<DateTime<Utc>>,
pub limit: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct UsersListPage {
pub items: Vec<AppUser>,
pub next_cursor: Option<DateTime<Utc>>,
}
/// 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<Utc>,
}
/// 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=<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=<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<EmailTemplateOpts>,
pub display_name: Option<String>,
pub roles: Vec<String>,
}
/// 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<AppUser, UsersError>;
async fn get(&self, cx: &SdkCallCx, id: AppUserId) -> Result<Option<AppUser>, UsersError>;
async fn find_by_email(
&self,
cx: &SdkCallCx,
email: &str,
) -> Result<Option<AppUser>, UsersError>;
async fn update(
&self,
cx: &SdkCallCx,
id: AppUserId,
patch: UpdateUserInput,
) -> Result<AppUser, UsersError>;
async fn delete(&self, cx: &SdkCallCx, id: AppUserId) -> Result<bool, UsersError>;
async fn list(
&self,
cx: &SdkCallCx,
opts: UsersListOpts,
) -> Result<UsersListPage, UsersError>;
// ---- 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<Option<GeneratedSession>, 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<Option<AppUser>, 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<Option<AppUser>, 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<Option<AppUser>, 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<Option<AppUser>, 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<String>,
) -> Result<Option<GeneratedAccept>, 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<bool, UsersError>;
async fn has_role(
&self,
cx: &SdkCallCx,
user_id: AppUserId,
role: &str,
) -> Result<bool, UsersError>;
// ---- 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<String, UsersError>;
/// 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<u64, UsersError>;
/// Admin lists pending invitations for the app's invitations tab.
async fn list_invitations(
&self,
principal: &Principal,
app_id: AppId,
) -> Result<Vec<Invitation>, 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<bool, UsersError>;
}
/// 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<AppUser, UsersError> {
Err(UsersError::Backend(NOOP_MSG.into()))
}
async fn get(
&self,
_cx: &SdkCallCx,
_id: AppUserId,
) -> Result<Option<AppUser>, UsersError> {
Err(UsersError::Backend(NOOP_MSG.into()))
}
async fn find_by_email(
&self,
_cx: &SdkCallCx,
_email: &str,
) -> Result<Option<AppUser>, UsersError> {
Err(UsersError::Backend(NOOP_MSG.into()))
}
async fn update(
&self,
_cx: &SdkCallCx,
_id: AppUserId,
_patch: UpdateUserInput,
) -> Result<AppUser, UsersError> {
Err(UsersError::Backend(NOOP_MSG.into()))
}
async fn delete(&self, _cx: &SdkCallCx, _id: AppUserId) -> Result<bool, UsersError> {
Err(UsersError::Backend(NOOP_MSG.into()))
}
async fn list(
&self,
_cx: &SdkCallCx,
_opts: UsersListOpts,
) -> Result<UsersListPage, UsersError> {
Err(UsersError::Backend(NOOP_MSG.into()))
}
async fn login(
&self,
_cx: &SdkCallCx,
_email: &str,
_password: &str,
) -> Result<Option<GeneratedSession>, UsersError> {
Err(UsersError::Backend(NOOP_MSG.into()))
}
async fn verify(
&self,
_cx: &SdkCallCx,
_token: &str,
) -> Result<Option<AppUser>, 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<Option<AppUser>, 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<Option<AppUser>, 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<Option<AppUser>, 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<String>,
) -> Result<Option<GeneratedAccept>, 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<bool, UsersError> {
Err(UsersError::Backend(NOOP_MSG.into()))
}
async fn has_role(
&self,
_cx: &SdkCallCx,
_user_id: AppUserId,
_role: &str,
) -> Result<bool, UsersError> {
Err(UsersError::Backend(NOOP_MSG.into()))
}
async fn admin_reset_password_token(
&self,
_principal: &Principal,
_app_id: AppId,
_user_id: AppUserId,
) -> Result<String, UsersError> {
Err(UsersError::Backend(NOOP_MSG.into()))
}
async fn admin_revoke_all_sessions(
&self,
_principal: &Principal,
_app_id: AppId,
_user_id: AppUserId,
) -> Result<u64, UsersError> {
Err(UsersError::Backend(NOOP_MSG.into()))
}
async fn list_invitations(
&self,
_principal: &Principal,
_app_id: AppId,
) -> Result<Vec<Invitation>, UsersError> {
Err(UsersError::Backend(NOOP_MSG.into()))
}
async fn revoke_invitation(
&self,
_principal: &Principal,
_app_id: AppId,
_invite_id: InvitationId,
) -> Result<bool, UsersError> {
Err(UsersError::Backend(NOOP_MSG.into()))
}
}