ORDER BY created_at DESC, id DESC but cursor was `WHERE created_at <
$2` — when two users were created at the same instant, pagination
could skip the second row at a page boundary or return it twice.
- Add ListCursor { created_at, id } with `<rfc3339>_<uuid>` encode /
decode helpers.
- Change WHERE predicate to `(created_at, id) < ($2, $3)` matching the
ORDER BY.
- Surface the opaque cursor string through UsersListOpts /
UsersListPage / users_admin_api ListUsersResponse instead of the raw
DateTime — keeps the wire format stable while the id half stops the
boundary bug.
Same shape as F-P-005 (execution_logs).
AUDIT.md anchor: F-P-012.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both methods short-circuit the authz gate when cx.principal == None.
An attacker hitting any public route could trigger unbounded outbound
email per call to arbitrary or attacker-supplied addresses — exhausting
SMTP-relay quota, getting the relay blacklisted, or burning provider
credits.
Add an in-memory EmailRateLimiter on UsersServiceImpl with two scopes:
- Per-(app, recipient): 5 calls per rolling 1h window
- Per-app daily cap: 200 calls per rolling 24h window
Both windows reset lazily. Token-bucket counts increment only after
both checks pass — a denied recipient doesn't consume the app counter.
Wired:
- send_verification_email: returns UsersError::EmailRateLimited (→ 429
on the admin HTTP surface).
- request_password_reset: returns Ok(()) silently on rate-limit (would
otherwise enable an existence-leak side channel).
UsersError::EmailRateLimited variant added; AppUsersApiError gains the
same variant + 429 mapping. NoopUsersError trait stub unaffected.
AUDIT.md anchor: F-S-002.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8 files needed re-wrapping after the F2 clippy-fix pass landed
un-fmt'd. Pure cosmetic — no behavioral change. Re-runs of
`cargo fmt --all -- --check` now exit clean.
Reviewer-supplied per REVIEW.md §7; folded onto the branch
to avoid a NEEDS-CHANGES round-trip on a purely mechanical fix.
users_admin_api router merged into the guarded admin tree at
/api/v1/admin/apps/{id_or_slug}/users/* + /invitations/*.
Endpoints (capability gates applied via the service layer):
GET /users AppUsersRead
GET /users/{user_id} AppUsersRead
POST /users AppUsersWrite
PATCH /users/{user_id} AppUsersWrite
DELETE /users/{user_id} AppUsersWrite (admins satisfy implicitly)
POST /users/{user_id}/reset-password AppUsersAdmin -> one-shot token
POST /users/{user_id}/revoke-sessions AppUsersAdmin
GET /invitations AppUsersAdmin
POST /invitations AppUsersAdmin
DELETE /invitations/{invite_id} AppUsersAdmin
DTOs never include password_hash, session tokens, or unrotated
reset tokens. The reset-password endpoint returns the raw one-shot
token exactly once in the response body so an admin can paste it
into a manual reset link (TTL 1h by default).
Synth_cx() factors the admin-side cx construction into one place
(marked) so the SDK and admin code paths share the service's authz
fan-out. Admin-mediated trait methods (admin_create_invitation,
admin_reset_password_token, admin_revoke_all_sessions,
list_invitations, revoke_invitation) take &Principal directly,
not the synthesized cx.
UsersServiceImpl: removed the NOT_YET_IMPL constant + unused
auth alias (every method has a real implementation now). Added
Serialize+Deserialize on the AppUser / Invitation shared DTOs so
the admin HTTP layer doesn't need a parallel set of types.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
migration 0028: app_user_email_verifications table — token_hash PK,
app_id + user_id FKs cascading, expires_at, consumed_at. Single-use
via atomic UPDATE WHERE consumed_at IS NULL.
UsersServiceImpl gains:
* verifications: Arc<dyn AppUserVerificationRepo>
* email: Arc<dyn EmailService>
users::send_verification_email(user_id, opts) mints a 32-byte token,
stores SHA-256(token), and calls EmailService::send with the body
template's {link} placeholder substituted by link_base + ?token=raw.
EmailError::NotConfigured propagates as UsersError::EmailNotConfigured
so scripts already handling email-disabled mode (v1.1.7 email::send)
don't need new branches.
users::verify_email(token) atomically consumes the one-shot token via
the verifications repo, marks the user's email_verified_at = NOW(),
and emits a "users::email_verified" event for future triggers.
Internal email send uses a synthesized SdkCallCx with principal=None
so the email-service AppEmailSend authz check is skipped (the
users::* surface has already gated on AppUsersWrite — the internal
hop isn't the script's direct call). Documented in HANDBACK §7.
EmailTemplateOpts now requires `from` (the v1.1.7 email service needs
an envelope sender). The brief example omitted it; deviation logged
in HANDBACK §7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>