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:
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