diff --git a/crates/common/src/audit.rs b/crates/common/src/audit.rs index 488154b..129b0c9 100644 --- a/crates/common/src/audit.rs +++ b/crates/common/src/audit.rs @@ -445,6 +445,58 @@ impl Server { } } + /// MA-D0a: a message sent from an address that isn't the sender's own: + /// a group's, today. The message itself only says `From:` the group, so + /// the audit log is where the person who sent it is named. A delegate's + /// send is AL-9's record, not this one. + pub async fn audit_send_as( + &self, + token: &AccessToken, + submission_account_id: u32, + submission_id: u32, + address: &str, + ) { + let Ok(Some(as_account_id)) = self.account_id_from_email(address, true).await else { + return; + }; + if as_account_id == token.account_id() || token.delegation(as_account_id).is_some() { + return; + } + let actor = self.audit_actor(token).await; + let tenant_id = self + .account(as_account_id) + .await + .ok() + .and_then(|account| account.id_tenant); + let details = if submission_account_id == as_account_id { + format!("Sent as {address}") + } else { + format!( + "Sent as {address}, from {}", + self.audit_account_name(submission_account_id).await + ) + }; + self.audit_note(Record { + at: ms(), + actor, + via: token.origin().cloned(), + remote_ip: None, + action: Action::Create, + target: Target { + kind: "EmailSubmission".into(), + id: Some(Id::from(submission_id).to_string()), + name: Some(address.to_string()), + account_id: Some(as_account_id), + tenant_id, + }, + changes: vec![], + details: Some(details), + reason: None, + outcome: Outcome::success(), + }) + .await; + } + /// AU-7: removes entries past the retention period. pub async fn audit_purge(&self) -> trc::Result { let settings = log::settings(self.store()).await?; diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index bff61e1..3a144f2 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -783,7 +783,7 @@ impl RequestHandler for Server { // inbuxa: AL-8: a delegate may send as a locked account access_token.assert_can_send(req.account_id)?; - self.email_submission_set(*req, &session.instance, next_call) + self.email_submission_set(*req, access_token, &session.instance, next_call) .await? .into() } diff --git a/crates/jmap/src/submission/set.rs b/crates/jmap/src/submission/set.rs index 2f83dfe..ce187f7 100644 --- a/crates/jmap/src/submission/set.rs +++ b/crates/jmap/src/submission/set.rs @@ -8,6 +8,7 @@ use common::{ Server, + auth::AccessToken, config::smtp::queue::QueueName, network::{ServerInstance, stream::NullIo}, storage::index::ObjectIndexBuilder, @@ -49,6 +50,7 @@ pub trait EmailSubmissionSet: Sync + Send { fn email_submission_set<'x>( &self, request: SetRequest<'x, email_submission::EmailSubmission>, + access_token: &AccessToken, instance: &Arc, next_call: &mut Option>>, ) -> impl Future>> + Send; @@ -68,6 +70,7 @@ impl EmailSubmissionSet for Server { async fn email_submission_set<'x>( &self, mut request: SetRequest<'x, email_submission::EmailSubmission>, + access_token: &AccessToken, instance: &Arc, next_call: &mut Option>>, ) -> trc::Result> { @@ -110,6 +113,15 @@ impl EmailSubmissionSet for Server { .assign_document_ids(account_id, Collection::EmailSubmission, 1) .await .caused_by(trc::location!())?; + + // inbuxa: MA-D0a: who sent it, when it went out as someone else + self.audit_send_as( + access_token, + account_id, + document_id, + &submission.envelope.mail_from.email, + ) + .await; batch .with_account_id(account_id) .with_collection(Collection::EmailSubmission) diff --git a/tests/src/system/audit.rs b/tests/src/system/audit.rs index bb776ea..33d8471 100644 --- a/tests/src/system/audit.rs +++ b/tests/src/system/audit.rs @@ -540,10 +540,110 @@ pub async fn test(test: &mut TestServer) { "AU-7: continued" ); + // MA-D0a: a group member sending as the group is named in the log; the + // same person sending as themselves isn't recorded + let group = admin + .create_group_account("helpdesk@send-as.example.org", "Help desk", &[]) + .await; + let agent = admin + .create_user_account( + "agent@send-as.example.org", + "agent-secret-for-send-as", + "Agent", + &[], + vec![], + ) + .await; + admin + .registry_update_object( + ObjectType::Account, + agent.id(), + json!({"memberGroupIds": {group.id_string(): true}}), + ) + .await; + agent.send_as("helpdesk@send-as.example.org").await; + agent.send_as("agent@send-as.example.org").await; + let sends = admin + .audit(json!({ + "targetKind": "EmailSubmission", + "actorId": agent.id_string(), + })) + .await; + assert_eq!(sends.len(), 1, "MA-D0a: {sends:?}"); + assert_eq!(sends[0]["target"]["name"], "helpdesk@send-as.example.org"); + assert_eq!( + sends[0]["target"]["accountId"], + group.id_string(), + "MA-D0a: {}", + sends[0] + ); + assert_eq!( + sends[0]["details"], + "Sent as helpdesk@send-as.example.org, from agent@send-as.example.org" + ); + // Clean up what later suites could trip over admin.registry_destroy_all(ObjectType::BlockedIp).await; } +impl Account { + /// Sends one message to itself from its own account, as `from`. + async fn send_as(&self, from: &str) { + const USING: &[&str] = &[ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail", + "urn:ietf:params:jmap:submission", + ]; + let account_id = self.id_string(); + let response = self + .jmap_request( + USING, + json!([ + ["Identity/get", {"accountId": account_id}, "i"], + ["Mailbox/get", {"accountId": account_id, "properties": ["role"]}, "m"] + ]), + ) + .await; + let identity = response + .0 + .pointer("/methodResponses/0/1/list") + .and_then(Value::as_array) + .and_then(|list| list.iter().find(|identity| identity["email"] == from)) + .unwrap_or_else(|| panic!("no identity for {from}: {}", response.0))["id"] + .clone(); + let drafts = response + .0 + .pointer("/methodResponses/1/1/list") + .and_then(Value::as_array) + .and_then(|list| list.iter().find(|mailbox| mailbox["role"] == "drafts")) + .unwrap_or_else(|| panic!("no drafts: {}", response.0))["id"] + .clone(); + let response = self + .jmap_request( + USING, + json!([ + ["Email/set", {"accountId": account_id, "create": {"m": { + "mailboxIds": {drafts.as_str().unwrap(): true}, + "from": [{"email": from}], + "to": [{"email": self.name()}], + "subject": format!("Sent as {from}"), + "bodyValues": {"t": {"value": "MA-D0a"}}, + "textBody": [{"partId": "t", "type": "text/plain"}] + }}}, "e"], + ["EmailSubmission/set", {"accountId": account_id, "create": {"s": { + "identityId": identity, "emailId": "#m" + }}}, "s"] + ]), + ) + .await; + assert!( + response.0.pointer("/methodResponses/1/1/created/s").is_some(), + "send as {from}: {}", + response.0 + ); + } +} + /// Runs these tests alone: `cargo test -p tests audit_log_tests -- --ignored`. #[ignore] #[tokio::test(flavor = "multi_thread")]