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:
@@ -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::{
|
||||
|
||||
627
crates/manager-core/src/users_service.rs
Normal file
627
crates/manager-core/src/users_service.rs
Normal 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;
|
||||
}
|
||||
@@ -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<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(
|
||||
kv,
|
||||
docs,
|
||||
@@ -255,6 +268,7 @@ pub async fn build_app(
|
||||
pubsub,
|
||||
secrets,
|
||||
email,
|
||||
users,
|
||||
);
|
||||
let engine = Arc::new(Engine::new(Limits::default(), services));
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<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 {
|
||||
@@ -105,6 +114,7 @@ impl Services {
|
||||
pubsub: Arc<dyn PubsubService>,
|
||||
secrets: Arc<dyn SecretsService>,
|
||||
email: Arc<dyn EmailService>,
|
||||
users: Arc<dyn UsersService>,
|
||||
) -> 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
527
crates/shared/src/users.rs
Normal file
527
crates/shared/src/users.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user