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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user