feat(external users): email sanity + mock SMTP
- SMTP has a mock to enable end to end test and validate the whole path
(via OXICLOUD_SMTP_MOCK)
- add email normalisation ( including punicode)
- api to share to external user
This commit is contained in:
Generated
+1
@@ -3729,6 +3729,7 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"id3",
|
||||
"idna",
|
||||
"image",
|
||||
"infer 0.19.0",
|
||||
"jsonwebtoken",
|
||||
|
||||
@@ -70,6 +70,7 @@ lru = "0.16.4"
|
||||
fastcdc = "4.0.0"
|
||||
memmap2 = "0.9.10"
|
||||
lettre = { version = "0.11.18", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "rustls-native-certs", "builder"] }
|
||||
idna = "1.1"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -185,6 +185,8 @@ Used by the magic-link invitation flow and the login-via-email flow. When `OXICL
|
||||
| `OXICLOUD_SMTP_FROM` | — | `From:` mailbox; bare address or RFC 5322 name-address (`OxiCloud <noreply@example.com>`) |
|
||||
| `OXICLOUD_SMTP_TLS` | `starttls` | Transport encryption: `starttls`, `tls`, or `none` (emits startup WARN) |
|
||||
|
||||
There is also `OXICLOUD_SMTP_MOCK` (false by default), this is for test purpose only, do not activate it
|
||||
|
||||
### Reliability and retries
|
||||
|
||||
OxiCloud does **not** spool mail. Each `send()` is a single attempt: if the remote SMTP server is unreachable, slow, or temporarily refusing the message, the send fails and the error is logged — there is no in-process retry, queue, or dead-letter handling. This keeps the HTTP path fast and the binary small at the cost of durability guarantees during a relay outage.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Drop `external` from `storage.access_grants.subject_type` CHECK
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- The federated-identity case is now folded into `Subject::User(uuid)` with
|
||||
-- `auth.users.is_external = TRUE` (PR 2 of the external-users work). Nothing
|
||||
-- in code ever minted a grant row with subject_type='external', so there
|
||||
-- are no live rows to migrate — this migration just tightens the CHECK
|
||||
-- so a stale piece of code can't accidentally start producing them.
|
||||
--
|
||||
-- The original CHECK in `20260520000000_rebac_access_grants.sql` was an
|
||||
-- inline anonymous constraint, so we discover its auto-generated name via
|
||||
-- pg_constraint before dropping it.
|
||||
|
||||
DO $BODY$
|
||||
DECLARE
|
||||
cname TEXT;
|
||||
BEGIN
|
||||
SELECT c.conname INTO cname
|
||||
FROM pg_constraint c
|
||||
JOIN pg_namespace n ON n.oid = c.connamespace
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
WHERE n.nspname = 'storage'
|
||||
AND t.relname = 'access_grants'
|
||||
AND c.contype = 'c'
|
||||
AND pg_get_constraintdef(c.oid) ILIKE '%subject_type%external%';
|
||||
|
||||
IF cname IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE storage.access_grants DROP CONSTRAINT '
|
||||
|| quote_ident(cname);
|
||||
END IF;
|
||||
END $BODY$;
|
||||
|
||||
ALTER TABLE storage.access_grants
|
||||
ADD CONSTRAINT access_grants_subject_type_check
|
||||
CHECK (subject_type IN ('user', 'group', 'token'));
|
||||
@@ -23,7 +23,6 @@ pub enum SubjectTypeDto {
|
||||
User,
|
||||
Group,
|
||||
Token,
|
||||
External,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
@@ -39,7 +38,6 @@ impl From<SubjectDto> for Subject {
|
||||
SubjectTypeDto::User => Subject::User(dto.id),
|
||||
SubjectTypeDto::Group => Subject::Group(dto.id),
|
||||
SubjectTypeDto::Token => Subject::Token(dto.id),
|
||||
SubjectTypeDto::External => Subject::External(dto.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +48,6 @@ impl From<Subject> for SubjectDto {
|
||||
Subject::User(id) => (SubjectTypeDto::User, id),
|
||||
Subject::Group(id) => (SubjectTypeDto::Group, id),
|
||||
Subject::Token(id) => (SubjectTypeDto::Token, id),
|
||||
Subject::External(id) => (SubjectTypeDto::External, id),
|
||||
};
|
||||
SubjectDto { kind, id }
|
||||
}
|
||||
@@ -181,11 +178,39 @@ impl Role {
|
||||
// Request DTOs
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Subject shape accepted by `POST /api/grants`. Internally-tagged enum
|
||||
/// so the existing `{type:"user", id:"..."}` payload keeps working
|
||||
/// alongside the new `{type:"email", email:"..."}` variant that feeds
|
||||
/// the invite-by-email flow. The response-side [`SubjectDto`] stays
|
||||
/// unchanged — externals resolve to `Subject::User(uuid)` with
|
||||
/// `is_external = TRUE` on the user row, never a distinct subject type.
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum SubjectInputDto {
|
||||
User {
|
||||
id: Uuid,
|
||||
},
|
||||
Group {
|
||||
id: Uuid,
|
||||
},
|
||||
Token {
|
||||
id: Uuid,
|
||||
},
|
||||
/// Invite-by-email. Lazily provisions an external user with the
|
||||
/// normalised address as both username and email when no match
|
||||
/// exists; otherwise reuses the existing user. Triggers a magic-link
|
||||
/// invitation email when the resolved user has no other login
|
||||
/// credential.
|
||||
Email {
|
||||
email: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// `POST /api/grants` — accepts either `permissions` (explicit) or `role`.
|
||||
/// Server-side validation requires exactly one of the two to be present.
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateGrantDto {
|
||||
pub subject: SubjectDto,
|
||||
pub subject: SubjectInputDto,
|
||||
pub resource: ResourceDto,
|
||||
#[serde(default)]
|
||||
pub permissions: Option<Vec<PermissionDto>>,
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Invite-by-email orchestration for `POST /api/grants` with
|
||||
//! `subject.type = "email"`.
|
||||
//!
|
||||
//! Two-step API (kept separate so the handler can interleave the standard
|
||||
//! grant-creation step in between):
|
||||
//!
|
||||
//! 1. [`resolve_or_create_recipient`] — normalise the email, apply the
|
||||
//! allowlist + kill-switch checks, then look up or lazily provision
|
||||
//! an external user. Returns the resolved [`User`] entity.
|
||||
//! 2. [`issue_invitation`] — mint a magic-link token targeting the
|
||||
//! shared resource, build the `/magic/v1/{token}` URL, and send the
|
||||
//! invitation email through the wired `EmailSender`.
|
||||
//!
|
||||
//! Step 2 is only called when the resolved user has no other login
|
||||
//! credential (`!user.has_login_credential()`) — internal users with
|
||||
//! passwords / OIDC see the grant appear in their normal
|
||||
//! "Shared with me" view and do not get a clickable magic link.
|
||||
//!
|
||||
//! # Enumeration defense
|
||||
//!
|
||||
//! v1 awaits the SMTP send synchronously. A malicious caller can in
|
||||
//! theory measure response times to distinguish "new external user
|
||||
//! provisioned + mail sent" from "existing internal user, no mail" —
|
||||
//! a single-bit oracle. The plan defers full constant-time defense
|
||||
//! (fire-and-forget spawn, dummy SMTP latency on no-op paths) to PR 12.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::application::ports::email_sender::{EmailMessage, EmailSender};
|
||||
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||
use crate::common::config::MagicLinkConfig;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkToken};
|
||||
use crate::domain::entities::user::User;
|
||||
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
|
||||
use crate::domain::repositories::user_repository::{UserRepository, UserRepositoryError};
|
||||
use crate::domain::services::authorization::{Resource, ResourceKind};
|
||||
use crate::domain::services::email_normalize::normalize_email;
|
||||
use crate::infrastructure::repositories::pg::UserPgRepository;
|
||||
|
||||
pub struct MagicLinkInviteService {
|
||||
user_storage: Arc<UserPgRepository>,
|
||||
magic_link_repo: Arc<dyn MagicLinkTokenRepository>,
|
||||
email_sender: Arc<dyn EmailSender>,
|
||||
user_lifecycle: Arc<UserLifecycleService>,
|
||||
magic_link_cfg: MagicLinkConfig,
|
||||
/// Public base URL of this OxiCloud instance — used to build the
|
||||
/// `/magic/v1/{token}` invitation link. Sourced from
|
||||
/// `AppConfig::base_url()` at DI time.
|
||||
public_base_url: String,
|
||||
}
|
||||
|
||||
impl MagicLinkInviteService {
|
||||
pub fn new(
|
||||
user_storage: Arc<UserPgRepository>,
|
||||
magic_link_repo: Arc<dyn MagicLinkTokenRepository>,
|
||||
email_sender: Arc<dyn EmailSender>,
|
||||
user_lifecycle: Arc<UserLifecycleService>,
|
||||
magic_link_cfg: MagicLinkConfig,
|
||||
public_base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
user_storage,
|
||||
magic_link_repo,
|
||||
email_sender,
|
||||
user_lifecycle,
|
||||
magic_link_cfg,
|
||||
public_base_url,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the email to an existing user, or lazily provision a new
|
||||
/// external user. Returns the resolved [`User`].
|
||||
///
|
||||
/// Errors:
|
||||
/// - `InvalidInput` — email failed normalisation (malformed / too long).
|
||||
/// - `AccessDenied` — email-grant kill switch is off
|
||||
/// (`OXICLOUD_ALLOW_EXTERNAL_USERS=false`) and no matching user
|
||||
/// exists, OR the email's domain isn't in the allowlist.
|
||||
/// - any propagated repo error.
|
||||
pub async fn resolve_or_create_recipient(&self, raw_email: &str) -> Result<User, DomainError> {
|
||||
let normalised = normalize_email(raw_email).map_err(|e| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "MagicLinkInvite", format!("{}", e))
|
||||
})?;
|
||||
|
||||
// Fast path: existing user with this email — works for both
|
||||
// internal (was previously created via normal registration) and
|
||||
// external (previous invitation re-sharing) cases.
|
||||
match UserRepository::get_user_by_email(&*self.user_storage, &normalised).await {
|
||||
Ok(user) => Ok(user),
|
||||
Err(UserRepositoryError::NotFound(_)) => self.create_external_user(&normalised).await,
|
||||
Err(e) => Err(DomainError::from(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazy provisioning path. Runs the two policy guards (kill switch
|
||||
/// and per-domain allowlist) before touching the DB.
|
||||
async fn create_external_user(&self, normalised_email: &str) -> Result<User, DomainError> {
|
||||
if !self.magic_link_cfg.allow_external_users {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"MagicLinkInvite",
|
||||
"Creating external users is disabled on this server \
|
||||
(OXICLOUD_ALLOW_EXTERNAL_USERS=false)"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if !self.magic_link_cfg.is_email_allowed(normalised_email) {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"MagicLinkInvite",
|
||||
format!(
|
||||
"Email domain is not in the allowlist (OXICLOUD_EXTERNAL_EMAIL_DOMAINS); \
|
||||
refusing to invite {}",
|
||||
normalised_email,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// username == normalised email for external users. The user
|
||||
// entity's `validate_username` was widened to 254 chars + email
|
||||
// shape in PR 6 specifically to allow this.
|
||||
let user = User::new_external(normalised_email.to_string(), normalised_email.to_string())
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"MagicLinkInvite",
|
||||
format!("invalid external user data: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let saved = UserRepository::create_user(&*self.user_storage, user.clone())
|
||||
.await
|
||||
.map_err(DomainError::from)?;
|
||||
|
||||
// Fire the user-lifecycle dispatcher — `on_user_created` lights
|
||||
// up audit + future external-identity provenance bookkeeping.
|
||||
// Errors are logged-and-continued by the dispatcher's
|
||||
// `dispatch_created` per the lifecycle contract.
|
||||
self.user_lifecycle.dispatch_created(&saved).await;
|
||||
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
/// Mint a magic-link token targeting the resource and email the
|
||||
/// invitation link. Caller is expected to have already created the
|
||||
/// grant rows.
|
||||
///
|
||||
/// `inviter_username` is interpolated into the subject line as a
|
||||
/// trust signal ("Alice shared with you on OxiCloud"). The message
|
||||
/// body is plain text only in v1; HTML templating is out of scope
|
||||
/// (see plan "Out of scope" → "Email template engine").
|
||||
pub async fn issue_invitation(
|
||||
&self,
|
||||
recipient: &User,
|
||||
inviter_username: &str,
|
||||
resource: Resource,
|
||||
) -> Result<(), DomainError> {
|
||||
// The grant is in place either way; only mint a magic link when
|
||||
// the recipient has no other way to authenticate (the auto-auth
|
||||
// mailbox-as-2FA-bypass is only acceptable when the recipient
|
||||
// has nothing else). Internal users with passwords / OIDC
|
||||
// simply see the grant in their normal "Shared with me" view.
|
||||
if recipient.has_login_credential() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (kind, resource_id) = match resource {
|
||||
Resource::Folder(id) => (MagicLinkResourceKind::Folder, id),
|
||||
Resource::File(id) => (MagicLinkResourceKind::File, id),
|
||||
};
|
||||
let token = MagicLinkToken::new(
|
||||
recipient.id(),
|
||||
self.magic_link_cfg.ttl_hours,
|
||||
Some((kind, resource_id)),
|
||||
);
|
||||
self.magic_link_repo.create(&token).await?;
|
||||
|
||||
let link = format!(
|
||||
"{}/magic/v1/{}",
|
||||
self.public_base_url.trim_end_matches('/'),
|
||||
token.token(),
|
||||
);
|
||||
|
||||
let kind_label = match resource {
|
||||
Resource::Folder(_) => "folder",
|
||||
Resource::File(_) => "file",
|
||||
};
|
||||
let subject = format!(
|
||||
"{} shared a {} with you on OxiCloud",
|
||||
inviter_username, kind_label
|
||||
);
|
||||
let text_body = format!(
|
||||
"{inviter} shared a {kind} with you on OxiCloud.\n\
|
||||
\n\
|
||||
Open it by clicking the link below:\n\
|
||||
{link}\n\
|
||||
\n\
|
||||
The link works once and expires in {ttl} hours.\n\
|
||||
If you didn't expect this invitation, you can safely ignore this message.\n\
|
||||
\n\
|
||||
— OxiCloud, {now}\n",
|
||||
inviter = inviter_username,
|
||||
kind = kind_label,
|
||||
link = link,
|
||||
ttl = self.magic_link_cfg.ttl_hours,
|
||||
now = Utc::now().to_rfc3339(),
|
||||
);
|
||||
|
||||
let message = EmailMessage {
|
||||
to: recipient.email().to_string(),
|
||||
subject,
|
||||
text_body,
|
||||
html_body: None,
|
||||
};
|
||||
|
||||
// Synchronous send — see module docs for the enumeration-defense
|
||||
// trade-off. PR 12 promotes this to fire-and-forget when the
|
||||
// hardening pass lands.
|
||||
match self.email_sender.send(message).await {
|
||||
Ok(outcome) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "magic_link.invitation_sent",
|
||||
recipient_user_id = %recipient.id(),
|
||||
recipient_email = %recipient.email(),
|
||||
resource = ?resource,
|
||||
smtp_code = outcome.code,
|
||||
smtp_message = %outcome.message,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// The grant already exists — log the SMTP failure but
|
||||
// don't propagate it as a fatal error, so the API client
|
||||
// still gets `201 Created` with the GrantDto. Recipient
|
||||
// can re-trigger via the future `POST /api/auth/magic-link/send`
|
||||
// endpoint once login-via-email lands.
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "magic_link.invitation_send_failed",
|
||||
recipient_user_id = %recipient.id(),
|
||||
recipient_email = %recipient.email(),
|
||||
error = %e.message,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight conversion so the grant handler can derive a
|
||||
/// [`MagicLinkResourceKind`] from the already-parsed [`ResourceKind`]
|
||||
/// without re-importing match arms.
|
||||
impl From<ResourceKind> for MagicLinkResourceKind {
|
||||
fn from(kind: ResourceKind) -> Self {
|
||||
match kind {
|
||||
ResourceKind::Folder => Self::Folder,
|
||||
ResourceKind::File => Self::File,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod file_upload_service;
|
||||
pub mod file_use_case_factory;
|
||||
pub mod folder_service;
|
||||
pub mod i18n_application_service;
|
||||
pub mod magic_link_invite_service;
|
||||
pub mod music_service;
|
||||
pub mod nextcloud_file_id_service;
|
||||
pub mod nextcloud_login_flow_service;
|
||||
|
||||
+102
-7
@@ -678,6 +678,15 @@ impl AppServiceFactory {
|
||||
let storage_usage_service: Option<Arc<StorageUsageService>>;
|
||||
let mut auth_services: Option<crate::common::di::AuthServices> = None;
|
||||
let mut nextcloud_services: Option<NextcloudServices> = None;
|
||||
// Lifted out of the database-services block so PR 9's invite
|
||||
// orchestrator (built at AppState-assembly time below) can share
|
||||
// the same lifecycle dispatcher. The inner block at line ~682
|
||||
// is unconditional and always assigns; the `#[allow]` silences
|
||||
// the rustc warning that the `None` initialiser is never read.
|
||||
#[allow(unused_assignments)]
|
||||
let mut user_lifecycle_handle: Option<
|
||||
Arc<crate::application::services::user_lifecycle_service::UserLifecycleService>,
|
||||
> = None;
|
||||
|
||||
{
|
||||
let favs = self.create_favorites_service(&pool);
|
||||
@@ -792,6 +801,8 @@ impl AppServiceFactory {
|
||||
tracing::info!("Authentication services initialized successfully");
|
||||
auth_services = Some(services);
|
||||
}
|
||||
|
||||
user_lifecycle_handle = Some(user_lifecycle);
|
||||
}
|
||||
|
||||
// Shared App Password service — created once, used by both NC routes and native API
|
||||
@@ -911,8 +922,41 @@ impl AppServiceFactory {
|
||||
),
|
||||
),
|
||||
)),
|
||||
email_sender: build_email_sender(&self.config.smtp),
|
||||
email_sender: None, // populated below
|
||||
mock_email_sender: None, // populated below
|
||||
magic_link_invite_service: None, // populated below
|
||||
};
|
||||
let email_bundle = build_email_sender(&self.config.smtp);
|
||||
app_state.email_sender = email_bundle.sender;
|
||||
app_state.mock_email_sender = email_bundle.mock;
|
||||
|
||||
// Magic-link invite orchestrator: only when SMTP wired AND the
|
||||
// user-lifecycle dispatcher exists (i.e. auth is enabled).
|
||||
if let (Some(email_sender), Some(lifecycle)) = (
|
||||
app_state.email_sender.clone(),
|
||||
user_lifecycle_handle.clone(),
|
||||
) {
|
||||
let invite_user_storage = Arc::new(
|
||||
crate::infrastructure::repositories::pg::UserPgRepository::new(pool.clone()),
|
||||
);
|
||||
let invite_magic_link_repo: Arc<
|
||||
dyn crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository,
|
||||
> = Arc::new(
|
||||
crate::infrastructure::repositories::pg::MagicLinkTokenPgRepository::new(
|
||||
pool.clone(),
|
||||
),
|
||||
);
|
||||
app_state.magic_link_invite_service = Some(Arc::new(
|
||||
crate::application::services::magic_link_invite_service::MagicLinkInviteService::new(
|
||||
invite_user_storage,
|
||||
invite_magic_link_repo,
|
||||
email_sender,
|
||||
lifecycle,
|
||||
self.config.magic_link.clone(),
|
||||
self.config.base_url(),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// 9b. Wire admin settings service when auth is available
|
||||
if let Some(auth_svc) = &app_state.auth_service {
|
||||
@@ -1246,6 +1290,18 @@ pub struct AppState {
|
||||
/// must return 503 when this is `None` rather than silently dropping
|
||||
/// the message.
|
||||
pub email_sender: Option<Arc<dyn crate::application::ports::email_sender::EmailSender>>,
|
||||
/// Set alongside `email_sender` when the test harness flag
|
||||
/// `OXICLOUD_SMTP_MOCK=true` is on. Used by the
|
||||
/// `GET /api/admin/smtp/test/captured` test-only endpoint to look up
|
||||
/// recently captured messages. Always `None` in production.
|
||||
pub mock_email_sender:
|
||||
Option<Arc<crate::infrastructure::services::mock_email_sender::MockEmailSender>>,
|
||||
/// Invite-by-email orchestrator — `None` when SMTP isn't configured
|
||||
/// (no `email_sender`). `POST /api/grants` with `subject.type=email`
|
||||
/// returns 503 when this is `None`.
|
||||
pub magic_link_invite_service: Option<
|
||||
Arc<crate::application::services::magic_link_invite_service::MagicLinkInviteService>,
|
||||
>,
|
||||
}
|
||||
|
||||
// All AppState construction is done via struct literal in build_app_state().
|
||||
@@ -1276,19 +1332,52 @@ fn build_authorization_engine(
|
||||
Arc::new(PgAclEngine::new(pool, folder_repo, file_repo, group_repo))
|
||||
}
|
||||
|
||||
/// Pair returned by [`build_email_sender`] when wiring DI: the
|
||||
/// `EmailSender` trait object used by the rest of the application, plus
|
||||
/// (in mock mode only) a typed handle to the same `MockEmailSender` so
|
||||
/// the test-only capture endpoint can introspect it without downcasting.
|
||||
struct EmailSenderBundle {
|
||||
sender: Option<Arc<dyn crate::application::ports::email_sender::EmailSender>>,
|
||||
mock: Option<Arc<crate::infrastructure::services::mock_email_sender::MockEmailSender>>,
|
||||
}
|
||||
|
||||
/// Construct the SMTP email sender from config, or return `None` when
|
||||
/// SMTP is disabled (`OXICLOUD_SMTP_HOST` empty). Construction errors
|
||||
/// (unparseable `From:` mailbox, bad TLS settings) downgrade to `None`
|
||||
/// with a `WARN` log — the server still starts, but every magic-link
|
||||
/// endpoint will return 503 until the operator fixes the config.
|
||||
fn build_email_sender(
|
||||
cfg: &crate::common::config::SmtpConfig,
|
||||
) -> Option<Arc<dyn crate::application::ports::email_sender::EmailSender>> {
|
||||
///
|
||||
/// When `OXICLOUD_SMTP_MOCK=true` (test harness only — never in
|
||||
/// production), construction returns an in-process `MockEmailSender`
|
||||
/// that captures every message instead of sending it. The harness
|
||||
/// retrieves captured messages via `GET /api/admin/smtp/test/captured`.
|
||||
fn build_email_sender(cfg: &crate::common::config::SmtpConfig) -> EmailSenderBundle {
|
||||
if std::env::var("OXICLOUD_SMTP_MOCK")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "oxicloud",
|
||||
event = "smtp.mock_enabled",
|
||||
"OXICLOUD_SMTP_MOCK=true — outbound mail is being captured in-process. \
|
||||
Test harness only; never set this in production.",
|
||||
);
|
||||
let mock =
|
||||
Arc::new(crate::infrastructure::services::mock_email_sender::MockEmailSender::new());
|
||||
return EmailSenderBundle {
|
||||
sender: Some(mock.clone()),
|
||||
mock: Some(mock),
|
||||
};
|
||||
}
|
||||
|
||||
if !cfg.is_enabled() {
|
||||
tracing::info!(
|
||||
"SMTP disabled (OXICLOUD_SMTP_HOST empty); magic-link endpoints will return 503"
|
||||
);
|
||||
return None;
|
||||
return EmailSenderBundle {
|
||||
sender: None,
|
||||
mock: None,
|
||||
};
|
||||
}
|
||||
match crate::infrastructure::services::smtp_email_sender::SmtpEmailSender::new(cfg) {
|
||||
Ok(sender) => {
|
||||
@@ -1302,7 +1391,10 @@ fn build_email_sender(
|
||||
user = if cfg.user.is_empty() { "<anon>" } else { "<set>" },
|
||||
"SMTP sender configured",
|
||||
);
|
||||
Some(Arc::new(sender))
|
||||
EmailSenderBundle {
|
||||
sender: Some(Arc::new(sender)),
|
||||
mock: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
@@ -1311,7 +1403,10 @@ fn build_email_sender(
|
||||
error = %e,
|
||||
"SMTP configuration is invalid; magic-link endpoints will return 503",
|
||||
);
|
||||
None
|
||||
EmailSenderBundle {
|
||||
sender: None,
|
||||
mock: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@ pub enum Subject {
|
||||
Group(Uuid),
|
||||
/// An anonymous share token (`storage.shares.id`).
|
||||
Token(Uuid),
|
||||
/// A federated identity from another server — Open Cloud Mesh, external
|
||||
/// OIDC, etc. Refers to `auth.external_subjects.id` (future table).
|
||||
External(Uuid),
|
||||
}
|
||||
|
||||
impl Subject {
|
||||
@@ -37,26 +34,27 @@ impl Subject {
|
||||
Subject::User(_) => "user",
|
||||
Subject::Group(_) => "group",
|
||||
Subject::Token(_) => "token",
|
||||
Subject::External(_) => "external",
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw UUID regardless of variant.
|
||||
pub fn id(&self) -> Uuid {
|
||||
match self {
|
||||
Subject::User(id) | Subject::Group(id) | Subject::Token(id) | Subject::External(id) => {
|
||||
*id
|
||||
}
|
||||
Subject::User(id) | Subject::Group(id) | Subject::Token(id) => *id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct from a SQL row's `(subject_type, subject_id)` pair.
|
||||
///
|
||||
/// `"external"` is no longer accepted: PR-2 of the external-users
|
||||
/// work folded the federated-identity case into `Subject::User(uuid)`
|
||||
/// with `auth.users.is_external = TRUE`. The DB CHECK constraint
|
||||
/// on `storage.access_grants.subject_type` was narrowed to match.
|
||||
pub fn from_parts(subject_type: &str, id: Uuid) -> Option<Self> {
|
||||
match subject_type {
|
||||
"user" => Some(Subject::User(id)),
|
||||
"group" => Some(Subject::Group(id)),
|
||||
"token" => Some(Subject::Token(id)),
|
||||
"external" => Some(Subject::External(id)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -350,17 +348,14 @@ mod tests {
|
||||
#[test]
|
||||
fn subject_roundtrip() {
|
||||
let id = Uuid::new_v4();
|
||||
let cases = [
|
||||
Subject::User(id),
|
||||
Subject::Group(id),
|
||||
Subject::Token(id),
|
||||
Subject::External(id),
|
||||
];
|
||||
let cases = [Subject::User(id), Subject::Group(id), Subject::Token(id)];
|
||||
for s in cases {
|
||||
let back = Subject::from_parts(s.type_str(), s.id()).unwrap();
|
||||
assert_eq!(s, back);
|
||||
}
|
||||
assert!(Subject::from_parts("unknown", id).is_none());
|
||||
// `external` is no longer a valid subject_type — folded into `user`.
|
||||
assert!(Subject::from_parts("external", id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Email address normalization.
|
||||
//!
|
||||
//! Every email coming through the magic-link invitation path is funneled
|
||||
//! through [`normalize_email`] before it is compared against existing
|
||||
//! users or persisted in `auth.users.email`. Two addresses that differ
|
||||
//! only in case or in the IDN encoding of the domain MUST collapse to
|
||||
//! the same stored form — otherwise the same recipient would be invited
|
||||
//! twice and end up with two `is_external` accounts.
|
||||
//!
|
||||
//! # Rules
|
||||
//!
|
||||
//! - The local-part (before the `@`) is lower-cased. We treat the local
|
||||
//! part as opaque: Gmail-style `+tag` aliases and dot-insensitivity are
|
||||
//! NOT special-cased. Each `alice+invoices@example.com` and
|
||||
//! `alice@example.com` is a distinct identity from our perspective.
|
||||
//! - The domain (after the `@`) is lower-cased, then run through
|
||||
//! `idna::domain_to_ascii` so internationalised domains land as
|
||||
//! punycode (`münchen.de` → `xn--mnchen-3ya.de`). This keeps the
|
||||
//! stored form ASCII; UIs that want to display the unicode original
|
||||
//! can reverse it with `idna::domain_to_unicode`.
|
||||
//! - Exactly one `@` separator is required. Whitespace around the input
|
||||
//! is trimmed. Empty local-part or empty domain is rejected. Overall
|
||||
//! length must fit in the 254-char RFC 5321 envelope cap.
|
||||
//!
|
||||
//! # What this is NOT
|
||||
//!
|
||||
//! - Not a deliverability check. No MX lookup, no syntax validation
|
||||
//! beyond the basics above. A normalized string that comes out of
|
||||
//! here can still fail at SMTP-send time.
|
||||
//! - Not a sanitiser against XSS / SQL injection. Callers must still
|
||||
//! treat the output as untrusted text.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EmailNormalizeError {
|
||||
/// Missing `@` separator, or more than one (we require exactly one
|
||||
/// post-trim, splitting on the last `@`).
|
||||
Malformed,
|
||||
/// Local-part is empty after lowercasing / trimming.
|
||||
EmptyLocal,
|
||||
/// Domain is empty or punycode conversion failed.
|
||||
InvalidDomain,
|
||||
/// Normalised form exceeds RFC 5321's 254-char ceiling.
|
||||
TooLong,
|
||||
}
|
||||
|
||||
impl fmt::Display for EmailNormalizeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Malformed => write!(f, "email is missing '@' separator"),
|
||||
Self::EmptyLocal => write!(f, "email local-part is empty"),
|
||||
Self::InvalidDomain => write!(f, "email domain is empty or invalid"),
|
||||
Self::TooLong => write!(f, "email exceeds 254 characters"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EmailNormalizeError {}
|
||||
|
||||
/// Normalise a raw email address. See module docs for the rules.
|
||||
pub fn normalize_email(raw: &str) -> Result<String, EmailNormalizeError> {
|
||||
let trimmed = raw.trim();
|
||||
let (local, domain) = trimmed
|
||||
.rsplit_once('@')
|
||||
.ok_or(EmailNormalizeError::Malformed)?;
|
||||
|
||||
let local_lc = local.to_ascii_lowercase();
|
||||
if local_lc.is_empty() {
|
||||
return Err(EmailNormalizeError::EmptyLocal);
|
||||
}
|
||||
|
||||
let domain_lc = domain.to_ascii_lowercase();
|
||||
if domain_lc.is_empty() {
|
||||
return Err(EmailNormalizeError::InvalidDomain);
|
||||
}
|
||||
let domain_ascii =
|
||||
idna::domain_to_ascii(&domain_lc).map_err(|_| EmailNormalizeError::InvalidDomain)?;
|
||||
if domain_ascii.is_empty() {
|
||||
return Err(EmailNormalizeError::InvalidDomain);
|
||||
}
|
||||
|
||||
let out = format!("{}@{}", local_lc, domain_ascii);
|
||||
if out.len() > 254 {
|
||||
return Err(EmailNormalizeError::TooLong);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ascii_passthrough_lowercases_local_and_domain() {
|
||||
assert_eq!(
|
||||
normalize_email("Alice@Example.COM").unwrap(),
|
||||
"alice@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_surrounding_whitespace() {
|
||||
assert_eq!(
|
||||
normalize_email(" bob@example.com \n").unwrap(),
|
||||
"bob@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idn_domain_is_punycoded() {
|
||||
// u-umlaut in München.
|
||||
assert_eq!(
|
||||
normalize_email("user@münchen.de").unwrap(),
|
||||
"user@xn--mnchen-3ya.de"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plus_tag_local_part_is_preserved() {
|
||||
// No Gmail-style folding.
|
||||
assert_eq!(
|
||||
normalize_email("alice+invoices@example.com").unwrap(),
|
||||
"alice+invoices@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_at_is_rejected() {
|
||||
assert_eq!(
|
||||
normalize_email("not-an-email").unwrap_err(),
|
||||
EmailNormalizeError::Malformed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_local_is_rejected() {
|
||||
assert_eq!(
|
||||
normalize_email("@example.com").unwrap_err(),
|
||||
EmailNormalizeError::EmptyLocal
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_domain_is_rejected() {
|
||||
assert_eq!(
|
||||
normalize_email("alice@").unwrap_err(),
|
||||
EmailNormalizeError::InvalidDomain
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_at_uses_last_separator() {
|
||||
// `rsplit_once('@')` splits at the rightmost `@`. The local-part
|
||||
// can legally contain `@` if quoted; we don't fully parse
|
||||
// RFC 5321, so we let the resulting local-part through and rely
|
||||
// on the caller's email regex / SMTP server to reject malformed
|
||||
// local-parts the relay will refuse.
|
||||
assert_eq!(
|
||||
normalize_email("WeIrD@local@example.com").unwrap(),
|
||||
"weird@local@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_254_chars_is_rejected() {
|
||||
let long_local = "a".repeat(250);
|
||||
let raw = format!("{}@x.io", long_local);
|
||||
assert_eq!(
|
||||
normalize_email(&raw).unwrap_err(),
|
||||
EmailNormalizeError::TooLong
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod authorization;
|
||||
pub mod email_normalize;
|
||||
pub mod i18n_service;
|
||||
pub mod path_service;
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! In-process `EmailSender` for integration tests.
|
||||
//!
|
||||
//! Captures every sent message in a moka cache keyed by normalised
|
||||
//! recipient address. The test harness retrieves the captured payload
|
||||
//! via the `GET /api/admin/smtp/test/captured` endpoint (only mounted
|
||||
//! when `OXICLOUD_SMTP_MOCK=true`), parses the magic-link URL out of
|
||||
//! the body, and follows it.
|
||||
//!
|
||||
//! # NOT for production
|
||||
//!
|
||||
//! The capture endpoint is admin-only AND only mounted in mock mode —
|
||||
//! but even then, exposing inbox-style storage over HTTP is a leak
|
||||
//! waiting to happen. The mock sender refuses to construct unless the
|
||||
//! `OXICLOUD_SMTP_MOCK` env var is `true` at startup, so a misconfigured
|
||||
//! prod deployment can't silently end up here.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use moka::future::Cache;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::application::ports::email_sender::{EmailMessage, EmailSendOutcome, EmailSender};
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
/// Snapshot of one captured outbound message. Hands a copy to the
|
||||
/// capture endpoint so the test runner can extract the magic-link URL,
|
||||
/// verify subject, etc.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct CapturedEmail {
|
||||
pub to: String,
|
||||
pub subject: String,
|
||||
pub text_body: String,
|
||||
pub html_body: Option<String>,
|
||||
pub captured_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
pub struct MockEmailSender {
|
||||
/// Keyed by lowercased recipient address; only the most-recent
|
||||
/// message is kept. Tests that need a full history can extend
|
||||
/// this — for the magic-link flow one-per-recipient is enough.
|
||||
captured: Cache<String, Arc<CapturedEmail>>,
|
||||
}
|
||||
|
||||
impl MockEmailSender {
|
||||
/// Construct a sender with a generous 10-minute capture TTL — long
|
||||
/// enough for a Hurl test suite to retrieve the message at leisure
|
||||
/// without the entry getting evicted out from under it.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
captured: Cache::builder()
|
||||
.max_capacity(10_000)
|
||||
.time_to_live(Duration::from_secs(600))
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the most recent captured message for `recipient` (matched
|
||||
/// case-insensitively on the address). Returns `None` if no message
|
||||
/// was ever sent to that recipient (or it expired).
|
||||
pub async fn last_for(&self, recipient: &str) -> Option<Arc<CapturedEmail>> {
|
||||
self.captured.get(&recipient.to_ascii_lowercase()).await
|
||||
}
|
||||
|
||||
/// Clear every captured message. Intended for between-test isolation.
|
||||
pub async fn clear(&self) {
|
||||
self.captured.invalidate_all();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MockEmailSender {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmailSender for MockEmailSender {
|
||||
async fn send(&self, message: EmailMessage) -> Result<EmailSendOutcome, DomainError> {
|
||||
let key = message.to.to_ascii_lowercase();
|
||||
let entry = CapturedEmail {
|
||||
to: message.to.clone(),
|
||||
subject: message.subject,
|
||||
text_body: message.text_body,
|
||||
html_body: message.html_body,
|
||||
captured_at: chrono::Utc::now(),
|
||||
};
|
||||
self.captured.insert(key, Arc::new(entry)).await;
|
||||
|
||||
// Mimic a healthy relay's response so callers that surface the
|
||||
// SMTP code (admin "test email" page) see a realistic value.
|
||||
Ok(EmailSendOutcome {
|
||||
code: 250,
|
||||
message: "2.0.0 Mock OK".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub mod local_blob_backend;
|
||||
pub mod login_lockout_service;
|
||||
pub mod migration_blob_backend;
|
||||
pub mod migration_job;
|
||||
pub mod mock_email_sender;
|
||||
pub mod nextcloud_chunked_upload_service;
|
||||
pub mod oidc_service;
|
||||
pub mod password_hasher;
|
||||
|
||||
@@ -61,6 +61,10 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
// SMTP diagnostics
|
||||
.route("/smtp/info", get(get_smtp_info))
|
||||
.route("/smtp/test", post(send_smtp_test))
|
||||
// Test-only capture endpoint. The handler short-circuits to 404
|
||||
// when `OXICLOUD_SMTP_MOCK` is off, so production deployments
|
||||
// can route the path freely without leaking inboxes.
|
||||
.route("/smtp/test/captured", get(get_captured_email))
|
||||
}
|
||||
|
||||
/// Validate JWT and require admin role. Returns (user_id, role).
|
||||
@@ -1264,6 +1268,52 @@ async fn get_smtp_info(
|
||||
Ok(Json(info))
|
||||
}
|
||||
|
||||
/// GET /api/admin/smtp/test/captured?to=<email> — test-only inbox lookup.
|
||||
///
|
||||
/// Returns the most recently captured outbound message for `to` when
|
||||
/// `OXICLOUD_SMTP_MOCK=true`. In production / non-mock mode this
|
||||
/// returns 404 to keep the endpoint inert.
|
||||
async fn get_captured_email(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<CapturedEmailQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
if !std::env::var("OXICLOUD_SMTP_MOCK")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(AppError::not_found(
|
||||
"Capture endpoint is only available when OXICLOUD_SMTP_MOCK=true",
|
||||
));
|
||||
}
|
||||
|
||||
let recipient = params.to.trim();
|
||||
if recipient.is_empty() {
|
||||
return Err(AppError::bad_request("`to` query parameter is required"));
|
||||
}
|
||||
|
||||
let Some(mock) = state.mock_email_sender.as_ref() else {
|
||||
return Err(AppError::not_found(
|
||||
"Mock sender is not active (set OXICLOUD_SMTP_MOCK=true)",
|
||||
));
|
||||
};
|
||||
|
||||
match mock.last_for(recipient).await {
|
||||
Some(captured) => Ok(Json((*captured).clone())),
|
||||
None => Err(AppError::not_found(format!(
|
||||
"No captured message for '{}'",
|
||||
recipient
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct CapturedEmailQuery {
|
||||
to: String,
|
||||
}
|
||||
|
||||
/// POST /api/admin/smtp/test — send a diagnostic email to `dto.to`.
|
||||
///
|
||||
/// Returns 200 regardless of SMTP outcome; the body's `success` flag
|
||||
|
||||
@@ -22,7 +22,8 @@ use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::grant_dto::{
|
||||
CreateGrantDto, GrantDto, MySharesDto, OutgoingResourceGrantDto, OutgoingResourceItemDto,
|
||||
PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto, SharedWithMeDto,
|
||||
SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, role_from_permissions,
|
||||
SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, SubjectInputDto, UpdateRoleDto,
|
||||
role_from_permissions,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
@@ -86,7 +87,6 @@ pub async fn create_grant(
|
||||
}
|
||||
};
|
||||
|
||||
let subject: Subject = dto.subject.into();
|
||||
let resource: Resource = dto.resource.into();
|
||||
let expires_at = dto.expires_at;
|
||||
|
||||
@@ -98,6 +98,31 @@ pub async fn create_grant(
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
|
||||
// Resolve the subject. For the email variant this lazily provisions
|
||||
// an external user (or reuses an existing match) and remembers the
|
||||
// resolved User so the invitation email can be sent after the grant
|
||||
// rows land.
|
||||
let (subject, invite_recipient) = match dto.subject {
|
||||
SubjectInputDto::User { id } => (Subject::User(id), None),
|
||||
SubjectInputDto::Group { id } => (Subject::Group(id), None),
|
||||
SubjectInputDto::Token { id } => (Subject::Token(id), None),
|
||||
SubjectInputDto::Email { email } => {
|
||||
let Some(invite_svc) = state.magic_link_invite_service.as_ref() else {
|
||||
return AppError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Magic-link invitations are not configured on this server \
|
||||
(set OXICLOUD_SMTP_HOST in .env to enable)",
|
||||
"ServiceUnavailable",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
match invite_svc.resolve_or_create_recipient(&email).await {
|
||||
Ok(user) => (Subject::User(user.id()), Some(user)),
|
||||
Err(e) => return AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
|
||||
for perm in permissions {
|
||||
match authz
|
||||
@@ -118,6 +143,28 @@ pub async fn create_grant(
|
||||
resource,
|
||||
caller_id
|
||||
);
|
||||
|
||||
// Fire the invitation email AFTER the grant rows are in place so a
|
||||
// failed SMTP send can't leave the recipient with mail-but-no-access.
|
||||
// The service swallows SMTP errors (logs only) — the API response
|
||||
// stays 201 Created either way, matching the plan's "201 always
|
||||
// when grants land; mail is best-effort" contract.
|
||||
if let Some(recipient) = invite_recipient
|
||||
&& let Some(invite_svc) = state.magic_link_invite_service.as_ref()
|
||||
{
|
||||
let inviter_name = auth_user.username.clone();
|
||||
if let Err(e) = invite_svc
|
||||
.issue_invitation(&recipient, &inviter_name, resource)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"invitation issuance failed for {} (grants already created): {}",
|
||||
recipient.email(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
(StatusCode::CREATED, Json(results)).into_response()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# =============================================================
|
||||
# OxiCloud — invite-by-email + magic-link redemption (PR 9)
|
||||
# =============================================================
|
||||
# End-to-end: Alice shares a folder with bob@externalcompany.com,
|
||||
# the server lazily provisions bob as an external user, sends the
|
||||
# invitation through MockEmailSender, and bob redeems the magic
|
||||
# link to land authenticated on the resource.
|
||||
#
|
||||
# Requires `OXICLOUD_SMTP_MOCK=true` in tests/common/server.env so
|
||||
# the in-process capture endpoint at /api/admin/smtp/test/captured
|
||||
# is mounted. The .hurl file would error on a real SMTP setup
|
||||
# because the magic link wouldn't be retrievable.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Alice logs in (admin) and grabs her home folder id.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_token: jsonpath "$.access_token"
|
||||
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_home_id: jsonpath "$[0].id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — Alice creates a folder she's about to share by email.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "ext-share", "parent_id": "{{alice_home_id}}" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
ext_folder_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — Alice shares with bob@externalcompany.com via the new
|
||||
# subject.type=email payload. Server lazily provisions
|
||||
# bob as an external user.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "email", "email": "bob@externalcompany.com" },
|
||||
"resource": { "type": "folder", "id": "{{ext_folder_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
# The response carries the resolved subject as a regular user UUID —
|
||||
# externals never surface as a distinct subject_type post-PR-9.3a.
|
||||
[Asserts]
|
||||
jsonpath "$[0].subject.type" == "user"
|
||||
jsonpath "$[0].resource.id" == "{{ext_folder_id}}"
|
||||
[Captures]
|
||||
bob_user_id: jsonpath "$[0].subject.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — Alice's /grants/outgoing lists bob as a grantee.
|
||||
# The endpoint groups by resource and exposes the
|
||||
# subject display string (here: bob's email-as-username).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/grants/outgoing/resources
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
# Hurl's `includes` predicate type-mismatches when JSONPath returns a
|
||||
# scalar (single-grantee case) instead of an array, so we assert on
|
||||
# the raw body — robust regardless of result count + ordering.
|
||||
[Asserts]
|
||||
body contains "bob@externalcompany.com"
|
||||
body contains "{{bob_user_id}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — Defense gap #2: bob must NOT appear in the system
|
||||
# address book. The contacts handler filters externals
|
||||
# via `include_external = false` (PR 6).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/address-books/system/contacts
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body not contains "bob@externalcompany.com"
|
||||
body not contains "{{bob_user_id}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — Retrieve the invitation email captured by the mock
|
||||
# sender BEFORE issuing any further mail (the mock only
|
||||
# remembers the latest message per recipient), then
|
||||
# extract the magic-link URL out of the plain-text body.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/admin/smtp/test/captured?to=bob@externalcompany.com
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.to" == "bob@externalcompany.com"
|
||||
jsonpath "$.subject" contains "shared a folder with you"
|
||||
jsonpath "$.text_body" matches "/magic/v1/[A-Za-z0-9_-]+"
|
||||
[Captures]
|
||||
magic_url: jsonpath "$.text_body" regex "(https?://[^\\s]+/magic/v1/[A-Za-z0-9_-]+)"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — Re-sharing the same email reuses bob — no second
|
||||
# external user gets created. The response carries the
|
||||
# same user_id captured in Step 3.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "ext-share-2", "parent_id": "{{alice_home_id}}" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
ext_folder_id_2: jsonpath "$.id"
|
||||
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "email", "email": "bob@externalcompany.com" },
|
||||
"resource": { "type": "folder", "id": "{{ext_folder_id_2}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Asserts]
|
||||
jsonpath "$[0].subject.id" == "{{bob_user_id}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Redeem the magic link. The handler 302s to the SPA
|
||||
# hash-route for the shared folder and sets the auth
|
||||
# cookies. Hurl follows-mode is OFF by default; we want
|
||||
# to inspect the Location header AND the Set-Cookie.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{magic_url}}
|
||||
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Location" == "/#/files/folder/{{ext_folder_id}}"
|
||||
[Captures]
|
||||
bob_access_token: cookie "oxicloud_access"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Bob (now carrying the cookie-issued JWT as bearer)
|
||||
# can read the shared folder. Without the magic-link
|
||||
# grant this would be 404 anti-enumeration.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/folders/{{ext_folder_id}}
|
||||
Authorization: Bearer {{bob_access_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.id" == "{{ext_folder_id}}"
|
||||
jsonpath "$.name" == "ext-share"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — Bob sees the shared folder in his /grants/incoming.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/grants/incoming/resources
|
||||
Authorization: Bearer {{bob_access_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "{{ext_folder_id}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — Second redemption of the same token is rejected.
|
||||
# single-use is enforced by the SQL UPDATE in
|
||||
# magic_link_token_pg_repository::mark_used.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{magic_url}}
|
||||
|
||||
HTTP 410
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 12 — Cleanup. Alice trashes the two test folders and
|
||||
# deletes bob via the admin API so the suite's
|
||||
# storage-check sweep at run.sh end sees a clean DB.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/folders/{{ext_folder_id_2}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/folders/{{ext_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/admin/users/{{bob_user_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP *
|
||||
+2
-1
@@ -101,7 +101,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/permissions.hurl" \
|
||||
"$API_DIR/grants.hurl" \
|
||||
"$API_DIR/subject_groups.hurl" \
|
||||
"$API_DIR/grants_nested_groups.hurl"
|
||||
"$API_DIR/grants_nested_groups.hurl" \
|
||||
"$API_DIR/external_users.hurl"
|
||||
|
||||
#bash "$API_DIR/dedup_bulk_upload.sh"
|
||||
|
||||
|
||||
@@ -22,3 +22,15 @@ RUST_LOG=warn
|
||||
# grow up limits for tests
|
||||
OXICLOUD_RATE_LIMIT_REFRESH_MAX=360
|
||||
OXICLOUD_RATE_LIMIT_LOGIN_MAX=360
|
||||
|
||||
# Magic-link / external-users flow (PR 9). The mock SMTP captures every
|
||||
# outbound message in-process so external_users.hurl can retrieve the
|
||||
# invitation body and follow the magic-link URL. The `SMTP_FROM` value
|
||||
# is required so the mock can build a valid Message; host/port are
|
||||
# irrelevant in mock mode but kept set for completeness.
|
||||
OXICLOUD_SMTP_MOCK=true
|
||||
OXICLOUD_SMTP_HOST=localhost
|
||||
OXICLOUD_SMTP_PORT=25
|
||||
OXICLOUD_SMTP_FROM='OxiCloud Tests <test@oxicloud.local>'
|
||||
OXICLOUD_SMTP_TLS=none
|
||||
OXICLOUD_ALLOW_EXTERNAL_USERS=true
|
||||
|
||||
Reference in New Issue
Block a user