feat(privacy): harden logs, admin access, federation, and backups
All checks were successful
Deploy Docker Images / Build, push, and deploy (push) Successful in 25m2s

Redact admin bodies, limit moderation to open reports, reject inbound
ActivityPub DMs, federate admin deletes, shorten media cache, Tor-null
and retain audit logs, scrub JSON logs, and add backup/Haraka ops scripts.
This commit is contained in:
maxfield 2026-08-05 15:42:15 -04:00
parent 2198031b95
commit 01217824bb
26 changed files with 1065 additions and 145 deletions

View file

@ -56,7 +56,8 @@ defmodule ArblargWeb.Admin.ChatMessagesController do
|> offset(^offset)
|> preload([_m, c, s], conversation: c, sender: s)
|> Repo.all()
|> Messaging.ChatMessage.decrypt_messages()
# Never decrypt chat bodies for admin — metadata only.
|> ContentAccess.redact_chat_messages()
|> Enum.map(&Map.put(&1, :protocol_kind, protocol_kind(&1)))
stats = %{
@ -172,17 +173,29 @@ defmodule ArblargWeb.Admin.ChatMessagesController do
defp maybe_search(query, ""), do: query
defp maybe_search(query, search_query) do
# Metadata only — never search plaintext body content.
search_term = "%#{search_query}%"
from([m, c, s] in query,
where:
ilike(fragment("COALESCE(?, '')", m.content), ^search_term) or
ilike(fragment("COALESCE(?, '')", s.username), ^search_term) or
metadata_filter =
dynamic(
[m, c, s],
ilike(fragment("COALESCE(?, '')", s.username), ^search_term) or
ilike(fragment("COALESCE(?, '')", s.handle), ^search_term) or
ilike(fragment("COALESCE(?, '')", c.name), ^search_term) or
ilike(fragment("COALESCE(?, '')", m.federated_source), ^search_term) or
ilike(fragment("COALESCE(?, '')", m.origin_domain), ^search_term)
)
)
filter =
case Integer.parse(String.trim(search_query)) do
{id, ""} ->
dynamic([m, _c, _s], m.id == ^id or ^metadata_filter)
_ ->
metadata_filter
end
from(q in query, where: ^filter)
end
defp maybe_filter_conversation_type(query, "dm"), do: where(query, [_m, c, _s], c.type == "dm")
@ -303,7 +316,7 @@ defmodule ArblargWeb.Admin.ChatMessagesController do
end
defp prepare_admin_chat_message(message) do
%{message | content: ContentAccess.chat_redacted_notice(), encrypted_content: nil}
ContentAccess.redact_chat_message(message)
end
defp get_remote_ip(conn) do

View file

@ -84,8 +84,10 @@ defmodule Elektrine.ActivityPub.MRF do
def get_policies do
configured = Application.get_env(:elektrine, :mrf, [])[:policies] || []
# Always include these core policies at the end
# Always include these core policies at the end.
# RejectNonPublicPolicy blocks inbound ActivityPub DMs (direct) by default.
core_policies = [
Elektrine.ActivityPub.MRF.RejectNonPublicPolicy,
Elektrine.ActivityPub.MRF.AntiFollowbotPolicy,
Elektrine.ActivityPub.MRF.NoPlaceholderTextPolicy,
Elektrine.ActivityPub.MRF.NoEmptyPolicy,

View file

@ -1,12 +1,80 @@
defmodule Elektrine.Admin.ContentAccess do
@moduledoc """
Admin UIs show metadata only. Message and chat bodies are never decrypted
for operators.
Admin UIs show metadata only. Message, chat, and social bodies are never
decrypted or exposed for operators.
"""
@email_redacted "[Message body is not available in admin.]"
@chat_redacted "[Chat message body is not available in admin.]"
@social_redacted "[Post content is not available in admin.]"
def email_redacted_notice, do: @email_redacted
def chat_redacted_notice, do: @chat_redacted
def social_redacted_notice, do: @social_redacted
@doc """
Strip body and encrypted payload fields from an email message for admin UI.
Keeps headers/metadata (subject, from, to, status, dates).
"""
def redact_email_message(message) when is_map(message) do
message
|> Map.put(:text_body, @email_redacted)
|> Map.put(:html_body, nil)
|> Map.put(:encrypted_text_body, nil)
|> Map.put(:encrypted_html_body, nil)
|> Map.put(:encrypted_raw_source, nil)
|> Map.put(:raw_source, nil)
|> Map.put(:client_encrypted_payload, nil)
end
def redact_email_message(message), do: message
@doc """
Strip body, media, and encrypted payload fields from a chat message for admin UI.
Keeps conversation/sender/protocol metadata.
"""
def redact_chat_message(message) when is_map(message) do
message
|> Map.put(:content, @chat_redacted)
|> Map.put(:encrypted_content, nil)
|> Map.put(:client_encrypted_payload, nil)
|> Map.put(:media_urls, [])
|> Map.put(:media_metadata, nil)
|> Map.put(:search_index, [])
end
def redact_chat_message(message), do: message
@doc """
Redact a list of chat messages for admin list views.
"""
def redact_chat_messages(messages) when is_list(messages) do
Enum.map(messages, &redact_chat_message/1)
end
def redact_chat_messages(messages), do: messages
@doc """
Strip body, media, and encrypted payload fields from a social/timeline/community
post for admin UI. Keeps title, author, vote counts, and timestamps.
"""
def redact_social_message(message) when is_map(message) do
message
|> Map.put(:content, @social_redacted)
|> Map.put(:encrypted_content, nil)
|> Map.put(:media_urls, [])
|> Map.put(:media_metadata, %{})
|> Map.put(:search_index, [])
end
def redact_social_message(message), do: message
@doc """
Redact a list of social messages for admin list views.
"""
def redact_social_messages(messages) when is_list(messages) do
Enum.map(messages, &redact_social_message/1)
end
def redact_social_messages(messages), do: messages
end

View file

@ -1,12 +1,21 @@
defmodule Elektrine.AuditLog do
@moduledoc false
@moduledoc """
Append-only admin audit log.
IP and user-agent are Tor-nulled via `Elektrine.Privacy.Network` when
`via_tor: true` is passed. IPs are also nullified after the IP retention
window; entire rows older than audit retention are purged.
"""
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias Elektrine.Accounts.User
alias Elektrine.Privacy.Network
alias Elektrine.Repo
@default_retention_days 90
schema "audit_logs" do
field :action, :string
field :resource_type, :string
@ -23,17 +32,24 @@ defmodule Elektrine.AuditLog do
@doc """
Creates an audit log entry.
Options:
- `:target_user_id`, `:resource_id`, `:details`
- `:ip_address`, `:user_agent` scrubbed with `via_tor:`
- `:via_tor` when true, IP and UA are not stored
"""
def log(admin_id, action, resource_type, opts \\ []) do
via_tor = Keyword.get(opts, :via_tor, false)
attrs = %{
admin_id: admin_id,
action: action,
resource_type: resource_type,
target_user_id: opts[:target_user_id],
resource_id: opts[:resource_id],
details: opts[:details] || %{},
ip_address: opts[:ip_address],
user_agent: opts[:user_agent]
details: sanitize_details(opts[:details] || %{}),
ip_address: Network.login_ip(opts[:ip_address], via_tor: via_tor),
user_agent: Network.analytics_user_agent(opts[:user_agent], via_tor: via_tor)
}
%__MODULE__{}
@ -41,6 +57,45 @@ defmodule Elektrine.AuditLog do
|> Repo.insert()
end
@doc """
Configured retention for full audit rows (default 90 days).
"""
def retention_days do
Application.get_env(:elektrine, :audit_log, [])
|> Keyword.get(:retention_days, @default_retention_days)
|> max(1)
end
@doc """
Deletes audit log rows older than the retention window.
"""
def purge_older_than(opts \\ []) do
days = Keyword.get(opts, :retention_days, retention_days())
now = Keyword.get(opts, :now, DateTime.utc_now() |> DateTime.truncate(:second))
cutoff = DateTime.add(now, -days, :day)
batch_size = Keyword.get(opts, :batch_size, 5_000) |> max(1)
max_batches = Keyword.get(opts, :max_batches, 100) |> max(1)
Enum.reduce_while(1..max_batches, 0, fn _batch, total ->
ids =
from(a in __MODULE__,
where: a.inserted_at < ^cutoff,
select: a.id,
limit: ^batch_size
)
|> Repo.all()
case ids do
[] ->
{:halt, total}
ids ->
{count, _} = from(a in __MODULE__, where: a.id in ^ids) |> Repo.delete_all()
{:cont, total + count}
end
end)
end
@doc """
Gets audit logs with pagination and filtering.
"""
@ -88,4 +143,35 @@ defmodule Elektrine.AuditLog do
|> validate_length(:action, max: 100)
|> validate_length(:resource_type, max: 100)
end
defp sanitize_details(details) when is_map(details) do
Map.new(details, fn {key, value} ->
key_s = to_string(key)
cond do
String.contains?(String.downcase(key_s), "email") and is_binary(value) ->
{key, redact_email(value)}
String.contains?(String.downcase(key_s), "password") ->
{key, "[redacted]"}
true ->
{key, value}
end
end)
end
defp sanitize_details(details), do: details
defp redact_email(email) when is_binary(email) do
case String.split(email, "@") do
[local, domain] when local != "" and domain != "" ->
"#{String.slice(local, 0, 1)}…@#{domain}"
_ ->
"[redacted]"
end
end
defp redact_email(_), do: "[redacted]"
end

View file

@ -0,0 +1,15 @@
defmodule Elektrine.Jobs.AuditLogRetentionWorker do
@moduledoc """
Purges audit log rows older than the configured retention window.
"""
use Oban.Worker, queue: :default, max_attempts: 3
alias Elektrine.AuditLog
@impl Oban.Worker
def perform(%Oban.Job{}) do
deleted = AuditLog.purge_older_than()
{:ok, %{deleted: deleted}}
end
end

View file

@ -539,17 +539,83 @@ defmodule Elektrine.Reports do
end
def build_metadata("message", message_id) do
# Add logic to fetch message details when needed
%{"message_id" => message_id}
# Metadata + content hash only — never store plaintext body for operators.
snapshot_message_metadata(message_id)
end
def build_metadata("chat_message", message_id) do
snapshot_chat_message_metadata(message_id)
end
def build_metadata("conversation", conversation_id) do
# Add logic to fetch conversation details when needed
%{"conversation_id" => conversation_id}
%{"conversation_id" => conversation_id, "snapshot_at" => snapshot_timestamp()}
end
def build_metadata(_, _), do: %{}
defp snapshot_message_metadata(message_id) do
case Repo.get(Elektrine.Social.Message, message_id) do
nil ->
%{"message_id" => message_id, "snapshot_at" => snapshot_timestamp()}
message ->
%{
"message_id" => message.id,
"conversation_id" => message.conversation_id,
"sender_id" => message.sender_id,
"message_type" => message.message_type,
"post_type" => message.post_type,
"visibility" => message.visibility,
"title" => message.title,
"content_sha256" => content_fingerprint(message),
"media_count" => length(message.media_urls || []),
"inserted_at" => datetime_iso(message.inserted_at),
"snapshot_at" => snapshot_timestamp()
}
end
rescue
_ -> %{"message_id" => message_id, "snapshot_at" => snapshot_timestamp()}
end
defp snapshot_chat_message_metadata(message_id) do
case Repo.get(Elektrine.Messaging.ChatMessage, message_id) do
nil ->
%{"message_id" => message_id, "snapshot_at" => snapshot_timestamp()}
message ->
%{
"message_id" => message.id,
"conversation_id" => message.conversation_id,
"sender_id" => message.sender_id,
"message_type" => message.message_type,
"content_sha256" => content_fingerprint(message),
"media_count" => length(message.media_urls || []),
"inserted_at" => datetime_iso(message.inserted_at),
"snapshot_at" => snapshot_timestamp()
}
end
rescue
_ -> %{"message_id" => message_id, "snapshot_at" => snapshot_timestamp()}
end
defp content_fingerprint(%{encrypted_content: enc}) when is_map(enc) and map_size(enc) > 0 do
:crypto.hash(:sha256, :erlang.term_to_binary(enc)) |> Base.encode16(case: :lower)
end
defp content_fingerprint(%{content: content}) when is_binary(content) and content != "" do
:crypto.hash(:sha256, content) |> Base.encode16(case: :lower)
end
defp content_fingerprint(_), do: nil
defp snapshot_timestamp do
DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
end
defp datetime_iso(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp datetime_iso(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp datetime_iso(_), do: nil
defp maybe_record_report_creation(%Report{} = report) do
if local_reporter?(report) do
TrustLevel.increment_stat(report.reporter_id, :flags_given)

View file

@ -456,12 +456,16 @@ defmodule Elektrine.Social.Messages do
|> Repo.update()
|> case do
{:ok, deleted_message} ->
media_urls = deleted_message.media_urls || []
_ =
Elektrine.Social.AttachmentCleanupWorker.enqueue(
deleted_message.id,
deleted_message.media_urls || []
media_urls
)
_ = Elektrine.MediaProxy.purge(media_urls)
maybe_record_moderated_delete(deleted_message, user_id, is_admin, is_mod)
broadcast_message_delete(deleted_message)
notify_home_feed_message_deleted(deleted_message)
@ -502,14 +506,28 @@ defmodule Elektrine.Social.Messages do
|> Repo.update()
|> case do
{:ok, deleted_message} ->
media_urls = deleted_message.media_urls || []
_ =
Elektrine.Social.AttachmentCleanupWorker.enqueue(
deleted_message.id,
deleted_message.media_urls || []
media_urls
)
# Invalidate media-proxy state for remote attachments.
_ = Elektrine.MediaProxy.purge(media_urls)
maybe_record_moderated_delete(deleted_message, admin_user.id, true, true)
broadcast_message_delete(deleted_message)
notify_home_feed_message_deleted(deleted_message)
# Same federation path as user deletes so remote copies get Delete/Tombstone.
Elektrine.Async.start(fn ->
preloaded = Repo.preload(deleted_message, :sender)
Elektrine.ActivityPub.Outbox.federate_delete(preloaded)
_ = Elektrine.Bluesky.OutboundWorker.enqueue_post_delete(deleted_message.id)
end)
{:ok, deleted_message}
error ->

View file

@ -3,10 +3,113 @@ defmodule Elektrine.Admin.ContentAccessTest do
alias Elektrine.Admin.ContentAccess
test "redaction notices are stable non-empty strings" do
assert is_binary(ContentAccess.email_redacted_notice())
assert ContentAccess.email_redacted_notice() != ""
assert is_binary(ContentAccess.chat_redacted_notice())
assert ContentAccess.chat_redacted_notice() != ""
describe "notices" do
test "email, chat, and social notices are non-empty binaries" do
assert is_binary(ContentAccess.email_redacted_notice())
assert ContentAccess.email_redacted_notice() != ""
assert is_binary(ContentAccess.chat_redacted_notice())
assert ContentAccess.chat_redacted_notice() != ""
assert is_binary(ContentAccess.social_redacted_notice())
assert ContentAccess.social_redacted_notice() != ""
end
end
describe "redact_email_message/1" do
test "replaces body fields and clears encrypted payloads" do
message = %{
subject: "Hello",
from: "a@example.com",
text_body: "SECRET BODY",
html_body: "<p>SECRET</p>",
encrypted_text_body: %{"c" => "x"},
encrypted_html_body: %{"c" => "y"},
encrypted_raw_source: %{"c" => "z"},
raw_source: "raw",
client_encrypted_payload: %{"p" => 1}
}
redacted = ContentAccess.redact_email_message(message)
assert redacted.subject == "Hello"
assert redacted.from == "a@example.com"
assert redacted.text_body == ContentAccess.email_redacted_notice()
refute redacted.text_body =~ "SECRET"
assert is_nil(redacted.html_body)
assert is_nil(redacted.encrypted_text_body)
assert is_nil(redacted.encrypted_html_body)
assert is_nil(redacted.encrypted_raw_source)
assert is_nil(redacted.raw_source)
assert is_nil(redacted.client_encrypted_payload)
end
end
describe "redact_chat_message/1" do
test "replaces content and clears media and encrypted payloads" do
message = %{
id: 1,
content: "SECRET CHAT",
encrypted_content: %{"c" => "x"},
client_encrypted_payload: %{"p" => 1},
media_urls: ["https://example.com/secret.jpg"],
media_metadata: %{w: 1},
search_index: ["secret"],
sender_id: 9
}
redacted = ContentAccess.redact_chat_message(message)
assert redacted.id == 1
assert redacted.sender_id == 9
assert redacted.content == ContentAccess.chat_redacted_notice()
refute redacted.content =~ "SECRET"
assert is_nil(redacted.encrypted_content)
assert is_nil(redacted.client_encrypted_payload)
assert redacted.media_urls == []
assert is_nil(redacted.media_metadata)
assert redacted.search_index == []
end
test "redacts a list of messages" do
messages = [%{content: "a", media_urls: ["u"]}, %{content: "b", media_urls: ["v"]}]
assert Enum.all?(ContentAccess.redact_chat_messages(messages), fn m ->
m.content == ContentAccess.chat_redacted_notice() and m.media_urls == []
end)
end
end
describe "redact_social_message/1" do
test "replaces content and clears media and encrypted payloads" do
message = %{
id: 2,
title: "Public title",
content: "SECRET POST",
encrypted_content: %{"c" => "x"},
media_urls: ["https://example.com/post.jpg"],
media_metadata: %{w: 1},
search_index: ["secret"],
sender_id: 3
}
redacted = ContentAccess.redact_social_message(message)
assert redacted.id == 2
assert redacted.title == "Public title"
assert redacted.sender_id == 3
assert redacted.content == ContentAccess.social_redacted_notice()
refute redacted.content =~ "SECRET"
assert is_nil(redacted.encrypted_content)
assert redacted.media_urls == []
assert redacted.media_metadata == %{}
assert redacted.search_index == []
end
test "redacts a list of social messages" do
messages = [%{content: "a", media_urls: ["u"]}, %{content: "b", media_urls: ["v"]}]
assert Enum.all?(ContentAccess.redact_social_messages(messages), fn m ->
m.content == ContentAccess.social_redacted_notice() and m.media_urls == []
end)
end
end
end

View file

@ -0,0 +1,54 @@
defmodule Elektrine.AuditLogTest do
use Elektrine.DataCase, async: true
alias Elektrine.AccountsFixtures
alias Elektrine.AuditLog
alias Elektrine.Repo
test "nulls IP and UA when via_tor is true" do
admin = AccountsFixtures.user_fixture() |> make_admin()
{:ok, log} =
AuditLog.log(admin.id, "elevate", "admin_session",
ip_address: "1.2.3.4",
user_agent: "TorBrowser",
via_tor: true
)
assert is_nil(log.ip_address)
assert is_nil(log.user_agent)
end
test "redacts email fields in details" do
admin = AccountsFixtures.user_fixture() |> make_admin()
{:ok, log} =
AuditLog.log(admin.id, "delete", "alias",
details: %{alias_email: "secret@example.com", username: "bob"}
)
assert log.details["username"] == "bob" or log.details[:username] == "bob"
email = log.details["alias_email"] || log.details[:alias_email]
refute email == "secret@example.com"
assert is_binary(email)
end
test "purge_older_than removes aged rows" do
admin = AccountsFixtures.user_fixture() |> make_admin()
{:ok, old} = AuditLog.log(admin.id, "old_action", "user")
past = DateTime.add(DateTime.utc_now(), -100, :day) |> DateTime.truncate(:second)
from_q = from(a in AuditLog, where: a.id == ^old.id)
Repo.update_all(from_q, set: [inserted_at: past])
deleted = AuditLog.purge_older_than(retention_days: 90)
assert deleted >= 1
assert is_nil(Repo.get(AuditLog, old.id))
end
defp make_admin(user) do
{:ok, admin} = Elektrine.Accounts.admin_update_user(user, %{is_admin: true})
admin
end
end

View file

@ -226,11 +226,7 @@ defmodule ElektrineEmailWeb.Admin.MessagesController do
# Private helper functions
defp prepare_admin_email(message) do
%{
message
| text_body: ContentAccess.email_redacted_notice(),
html_body: nil
}
ContentAccess.redact_email_message(message)
end
defp require_message_read_grant(conn, _opts) do

View file

@ -18,7 +18,8 @@ defmodule ElektrineSocialWeb.MediaProxyController do
@max_redirects 5
@keep_req_headers ~w(range if-range if-modified-since if-none-match accept accept-encoding)
@keep_resp_headers ~w(content-type content-length content-range accept-ranges etag last-modified date content-encoding vary)
@cache_control "public, max-age=1209600, immutable"
# Short browser cache (1 hour). Avoid multi-week retention of remote media.
@cache_control "public, max-age=3600"
@valid_status [200, 206, 304]
@conn_key :media_proxy_conn

View file

@ -2,6 +2,7 @@ defmodule ElektrineWeb.Admin.CommunitiesController do
use ElektrineWeb, :controller
alias Elektrine.{Accounts, Repo}
alias Elektrine.Admin.ContentAccess
import Ecto.Query
plug :put_layout, html: {ElektrineWeb.Layouts, :admin}
@ -237,7 +238,8 @@ defmodule ElektrineWeb.Admin.CommunitiesController do
|> offset(^((posts_page - 1) * posts_per_page))
|> preload(:sender)
|> Repo.all()
|> Elektrine.Social.Message.decrypt_messages()
# Never decrypt post bodies for admin.
|> ContentAccess.redact_social_messages()
|> Enum.map(fn m ->
%{
id: m.id,

View file

@ -1,7 +1,16 @@
defmodule ElektrineWeb.Admin.ModerationController do
@moduledoc """
Report-only content moderation.
Lists only content that has an open (pending/reviewing) report not a
full-history browser. Bodies are always redacted via ContentAccess.
"""
use ElektrineWeb, :controller
alias Elektrine.{Accounts, Repo}
alias Elektrine.Admin.ContentAccess
alias Elektrine.Reports.Report
alias Elektrine.Social.Messages
alias Elektrine.Utils.SafeConvert
alias ElektrineWeb.Platform.Integrations
@ -16,7 +25,8 @@ defmodule ElektrineWeb.Admin.ModerationController do
offset = (page - 1) * per_page
search_query = params["search"] || ""
# Build query based on content type
reported_ids = open_reported_ids(content_type)
base_query =
case content_type do
"timeline" ->
@ -24,61 +34,42 @@ defmodule ElektrineWeb.Admin.ModerationController do
join: c in Elektrine.Social.Conversation,
on: m.conversation_id == c.id,
where: c.type == "timeline",
where: is_nil(m.deleted_at)
where: is_nil(m.deleted_at),
where: m.id in ^reported_ids
"discussions" ->
from m in Elektrine.Social.Message,
join: c in Elektrine.Social.Conversation,
on: m.conversation_id == c.id,
where: c.type == "community",
where: is_nil(m.deleted_at)
where: is_nil(m.deleted_at),
where: m.id in ^reported_ids
"chat" ->
from m in Elektrine.Messaging.ChatMessage,
join: c in Elektrine.Messaging.ChatConversation,
on: m.conversation_id == c.id,
where: c.type in ["group", "dm", "channel"],
where: is_nil(m.deleted_at)
where: is_nil(m.deleted_at),
where: m.id in ^reported_ids
_ ->
from m in Elektrine.Social.Message,
join: c in Elektrine.Social.Conversation,
on: m.conversation_id == c.id,
where: c.type == "timeline",
where: is_nil(m.deleted_at)
where: is_nil(m.deleted_at),
where: m.id in ^reported_ids
end
# Add search filter if provided
# Empty reported set → empty list (Ecto `in []` can be awkward).
query =
if search_query != "" do
search_pattern = "%#{search_query}%"
if content_type == "chat" do
from [m, c] in base_query,
left_join: u in Accounts.User,
on: m.sender_id == u.id,
where:
ilike(fragment("COALESCE(?, '')", m.content), ^search_pattern) or
ilike(fragment("COALESCE(?, '')", u.username), ^search_pattern) or
ilike(fragment("COALESCE(?, '')", u.handle), ^search_pattern) or
ilike(fragment("COALESCE(?, '')", c.name), ^search_pattern) or
ilike(fragment("COALESCE(?, '')", m.federated_source), ^search_pattern) or
ilike(fragment("COALESCE(?, '')", m.origin_domain), ^search_pattern)
else
from [m, c] in base_query,
join: u in Accounts.User,
on: m.sender_id == u.id,
where:
ilike(m.content, ^search_pattern) or
ilike(u.username, ^search_pattern) or
ilike(u.handle, ^search_pattern) or
ilike(c.name, ^search_pattern)
end
if reported_ids == [] do
from(m in base_query, where: false)
else
base_query
maybe_filter_search(base_query, content_type, search_query)
end
# Get content
content =
query
|> order_by([m], desc: m.inserted_at)
@ -86,40 +77,15 @@ defmodule ElektrineWeb.Admin.ModerationController do
|> offset(^offset)
|> preload([:sender, conversation: []])
|> Repo.all()
|> then(fn messages ->
if content_type == "chat" do
Elektrine.Messaging.ChatMessage.decrypt_messages(messages)
else
Elektrine.Social.Message.decrypt_messages(messages)
end
end)
|> redact_admin_messages(content_type)
# Get counts for all types
counts = %{
timeline:
from(m in Elektrine.Social.Message,
join: c in Elektrine.Social.Conversation,
on: m.conversation_id == c.id,
where: c.type == "timeline" and is_nil(m.deleted_at)
)
|> Repo.aggregate(:count),
discussions:
from(m in Elektrine.Social.Message,
join: c in Elektrine.Social.Conversation,
on: m.conversation_id == c.id,
where: c.type == "community" and is_nil(m.deleted_at)
)
|> Repo.aggregate(:count),
chat:
from(m in Elektrine.Messaging.ChatMessage,
join: c in Elektrine.Messaging.ChatConversation,
on: m.conversation_id == c.id,
where: c.type in ["group", "dm", "channel"] and is_nil(m.deleted_at)
)
|> Repo.aggregate(:count)
timeline: count_open_reports(["message", "social_message"]),
discussions: count_open_reports(["message", "social_message"]),
chat: count_open_reports(["chat_message", "message"])
}
total_count = query |> Repo.aggregate(:count)
total_count = if reported_ids == [], do: 0, else: query |> Repo.aggregate(:count)
total_pages = ceil(total_count / per_page)
page_range = pagination_range(page, total_pages)
@ -137,7 +103,8 @@ defmodule ElektrineWeb.Admin.ModerationController do
total_count: total_count,
page_range: page_range,
timezone: timezone,
time_format: time_format
time_format: time_format,
report_only: true
)
end
@ -151,12 +118,20 @@ defmodule ElektrineWeb.Admin.ModerationController do
end
with {:ok, content_id} <- SafeConvert.parse_id(content_id),
true <- reported_open?(content_type, content_id),
{:ok, _message} <-
Messages.admin_delete_message(content_id, conn.assigns.current_user) do
conn
|> put_flash(:info, "#{content_name} deleted successfully.")
|> redirect(to: ~p"/pripyat/content-moderation?type=#{content_type}")
else
false ->
redirect_delete_error(
conn,
"Only reported content can be moderated here.",
content_type
)
{:error, :already_deleted} ->
redirect_delete_error(conn, "#{content_name} was already deleted.", content_type)
@ -181,6 +156,95 @@ defmodule ElektrineWeb.Admin.ModerationController do
|> redirect(to: ~p"/pripyat/content-moderation?type=#{content_type}")
end
defp open_reported_ids(content_type) do
types = reportable_types(content_type)
from(r in Report,
where: r.reportable_type in ^types and r.status in ["pending", "reviewing"],
select: r.reportable_id,
distinct: true
)
|> Repo.all()
end
defp reported_open?(content_type, content_id) do
types = reportable_types(content_type)
from(r in Report,
where:
r.reportable_type in ^types and r.reportable_id == ^content_id and
r.status in ["pending", "reviewing"]
)
|> Repo.exists?()
end
defp reportable_types("chat"), do: ["chat_message", "message"]
defp reportable_types(_), do: ["message", "social_message"]
defp count_open_reports(types) do
from(r in Report,
where: r.reportable_type in ^types and r.status in ["pending", "reviewing"]
)
|> Repo.aggregate(:count, :id)
end
defp redact_admin_messages(messages, "chat"), do: ContentAccess.redact_chat_messages(messages)
defp redact_admin_messages(messages, _), do: ContentAccess.redact_social_messages(messages)
defp maybe_filter_search(query, _content_type, search_query)
when search_query in [nil, ""],
do: query
defp maybe_filter_search(query, "chat", search_query) do
search_pattern = "%#{search_query}%"
id_match = parse_numeric_id(search_query)
filter =
dynamic(
[m, c, u],
ilike(fragment("COALESCE(?, '')", u.username), ^search_pattern) or
ilike(fragment("COALESCE(?, '')", u.handle), ^search_pattern) or
ilike(fragment("COALESCE(?, '')", c.name), ^search_pattern) or
ilike(fragment("COALESCE(?, '')", m.federated_source), ^search_pattern) or
ilike(fragment("COALESCE(?, '')", m.origin_domain), ^search_pattern) or
(^id_match != 0 and m.id == ^id_match)
)
from([m, c] in query,
left_join: u in Accounts.User,
on: m.sender_id == u.id,
where: ^filter
)
end
defp maybe_filter_search(query, _content_type, search_query) do
search_pattern = "%#{search_query}%"
id_match = parse_numeric_id(search_query)
filter =
dynamic(
[m, c, u],
ilike(u.username, ^search_pattern) or
ilike(u.handle, ^search_pattern) or
ilike(fragment("COALESCE(?, '')", c.name), ^search_pattern) or
ilike(fragment("COALESCE(?, '')", m.title), ^search_pattern) or
(^id_match != 0 and m.id == ^id_match)
)
from([m, c] in query,
join: u in Accounts.User,
on: m.sender_id == u.id,
where: ^filter
)
end
defp parse_numeric_id(search_query) do
case Integer.parse(String.trim(search_query)) do
{id, ""} when id > 0 -> id
_ -> 0
end
end
def unsubscribe_stats(conn, params) do
page = SafeConvert.parse_page(params)
per_page = 50
@ -196,7 +260,6 @@ defmodule ElektrineWeb.Admin.ModerationController do
)
end
# Helper for pagination
defp pagination_range(_current_page, total_pages) when total_pages <= 7 do
1..max(total_pages, 1) |> Enum.to_list()
end

View file

@ -55,6 +55,20 @@ defmodule ElektrineWeb.Admin.SecurityController do
{:ok, verified_user, credential} <-
Passkeys.verify_authentication_with_credential(challenge, assertion),
true <- verified_user.id == user.id do
_ =
Elektrine.AuditLog.log(
user.id,
"elevate",
"admin_session",
details: %{
return_to: return_to,
credential_id_prefix: credential_id_prefix(credential.credential_id)
},
ip_address: ElektrineWeb.ClientIP.client_ip(conn),
user_agent: get_req_header(conn, "user-agent") |> List.first(),
via_tor: conn.assigns[:via_tor] == true
)
conn
|> AdminSecurity.refresh_after_passkey(credential.credential_id)
|> json(%{
@ -79,6 +93,12 @@ defmodule ElektrineWeb.Admin.SecurityController do
end
end
defp credential_id_prefix(id) when is_binary(id) and byte_size(id) > 8 do
String.slice(id, 0, 8) <> ""
end
defp credential_id_prefix(_), do: nil
def start_action(conn, %{"method" => method, "path" => path}) do
user = conn.assigns.current_user

View file

@ -148,7 +148,7 @@ defmodule ElektrineWeb.Admin.UsersController do
trust_level_locked: user.trust_level_locked
},
ip_address: get_remote_ip(conn),
user_agent: get_req_header(conn, "user-agent") |> List.first()
user_agent: get_user_agent(conn)
)
conn
@ -291,7 +291,7 @@ defmodule ElektrineWeb.Admin.UsersController do
target_user_id: user.id,
details: %{username: user.username, reason: ban_params["reason"]},
ip_address: get_remote_ip(conn),
user_agent: get_req_header(conn, "user-agent") |> List.first()
user_agent: get_user_agent(conn)
)
conn
@ -319,7 +319,7 @@ defmodule ElektrineWeb.Admin.UsersController do
target_user_id: user.id,
details: %{username: user.username},
ip_address: get_remote_ip(conn),
user_agent: get_req_header(conn, "user-agent") |> List.first()
user_agent: get_user_agent(conn)
)
# Determine redirect based on where the request came from
@ -390,7 +390,7 @@ defmodule ElektrineWeb.Admin.UsersController do
reason: suspend_params["suspension_reason"]
},
ip_address: get_remote_ip(conn),
user_agent: get_req_header(conn, "user-agent") |> List.first()
user_agent: get_user_agent(conn)
)
conn
@ -423,7 +423,7 @@ defmodule ElektrineWeb.Admin.UsersController do
target_user_id: user.id,
details: %{username: user.username},
ip_address: get_remote_ip(conn),
user_agent: get_req_header(conn, "user-agent") |> List.first()
user_agent: get_user_agent(conn)
)
conn
@ -459,7 +459,7 @@ defmodule ElektrineWeb.Admin.UsersController do
admin_username: admin_user.username
},
ip_address: get_remote_ip(conn),
user_agent: get_req_header(conn, "user-agent") |> List.first()
user_agent: get_user_agent(conn)
)
# Clear impersonation session and restore admin user
@ -500,7 +500,7 @@ defmodule ElektrineWeb.Admin.UsersController do
"user",
details: %{username: user.username, user_id: user.id},
ip_address: get_remote_ip(conn),
user_agent: get_req_header(conn, "user-agent") |> List.first()
user_agent: get_user_agent(conn)
)
conn
@ -532,7 +532,7 @@ defmodule ElektrineWeb.Admin.UsersController do
reset_by_admin: admin.username
},
ip_address: get_remote_ip(conn),
user_agent: get_req_header(conn, "user-agent") |> List.first()
user_agent: get_user_agent(conn)
)
conn
@ -571,7 +571,7 @@ defmodule ElektrineWeb.Admin.UsersController do
username: user.username
},
ip_address: get_remote_ip(conn),
user_agent: get_req_header(conn, "user-agent") |> List.first()
user_agent: get_user_agent(conn)
)
conn
@ -626,7 +626,7 @@ defmodule ElektrineWeb.Admin.UsersController do
exact_match: is_exact_match
},
ip_address: get_remote_ip(conn),
user_agent: get_req_header(conn, "user-agent") |> List.first()
user_agent: get_user_agent(conn)
)
search_results =
@ -667,7 +667,7 @@ defmodule ElektrineWeb.Admin.UsersController do
reset_by_admin: admin.username
},
ip_address: get_remote_ip(conn),
user_agent: get_req_header(conn, "user-agent") |> List.first()
user_agent: get_user_agent(conn)
)
conn
@ -965,6 +965,16 @@ defmodule ElektrineWeb.Admin.UsersController do
end
defp get_remote_ip(conn) do
ElektrineWeb.ClientIP.client_ip(conn)
Elektrine.Privacy.Network.login_ip(
ElektrineWeb.ClientIP.client_ip(conn),
via_tor: conn.assigns[:via_tor] == true
)
end
defp get_user_agent(conn) do
Elektrine.Privacy.Network.analytics_user_agent(
get_req_header(conn, "user-agent") |> List.first(),
via_tor: conn.assigns[:via_tor] == true
)
end
end

View file

@ -230,14 +230,16 @@
class="mx-auto mb-3 h-10 w-10 text-base-content/30"
/>
<h3 class="text-lg font-medium">
No {String.downcase(content_label)} content found
No reported {String.downcase(content_label)} content
</h3>
<p class="mt-1 text-sm text-base-content/55">
<%= if search_active do %>
Try changing your query or clearing filters.
<% else %>
New content will appear here as users post.
Only items with an open report appear here. Open the
<.link href={~p"/pripyat/reports"} class="link link-primary">reports queue</.link>
to triage new reports.
<% end %>
</p>
</div>

View file

@ -2,8 +2,22 @@ defmodule ElektrineWeb.JsonLogFormatter do
@moduledoc """
JSON log formatter for production environments.
Outputs logs in JSON format so log pipelines can parse levels reliably.
Sensitive metadata keys (tokens, passwords, email addresses, subjects, bodies)
are redacted so host log retention cannot reconstruct private content.
"""
@sensitive_exact ~w(
authorization cookie set-cookie token secret password api_key
email subject from to cc bcc text_body html_body body content
message message_body raw_source recovery_email
)
@sensitive_substrings ~w(
token secret password api_key authorization cookie
email subject text_body html_body raw_source
)
@doc """
Formats a log message as JSON.
"""
@ -12,17 +26,17 @@ defmodule ElektrineWeb.JsonLogFormatter do
json = %{
level: level,
message: IO.chardata_to_string(message),
message: redact_message_text(IO.chardata_to_string(message)),
timestamp: format_timestamp(date, hour, minute, second),
metadata: format_metadata(metadata)
}
case Jason.encode(json) do
{:ok, encoded} -> [encoded, "\n"]
{:error, _} -> ["[#{level}] #{message}\n"]
{:error, _} -> ["[#{level}] #{redact_message_text(message)}\n"]
end
rescue
_ -> ["[#{level}] #{message}\n"]
_ -> ["[#{level}] log format error\n"]
end
defp format_timestamp({year, month, day}, hour, minute, second) do
@ -45,20 +59,36 @@ defmodule ElektrineWeb.JsonLogFormatter do
end
defp redact_metadata(key, value) do
key
|> to_string()
|> String.downcase()
|> case do
name
when name in ["authorization", "cookie", "set-cookie", "token", "secret", "password"] ->
name = key |> to_string() |> String.downcase()
cond do
name in @sensitive_exact ->
"[redacted]"
name ->
if String.contains?(name, ["token", "secret", "password", "api_key"]) do
"[redacted]"
else
value
end
Enum.any?(@sensitive_substrings, &String.contains?(name, &1)) ->
"[redacted]"
is_binary(value) and looks_like_email?(value) ->
"[redacted]"
true ->
value
end
end
defp redact_message_text(message) when is_binary(message) do
message
|> String.replace(~r/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/, "[redacted-email]")
|> String.replace(
~r/(?i)(password|token|secret|authorization)\s*[:=]\s*\S+/,
"\\1=[redacted]"
)
end
defp redact_message_text(message), do: to_string(message)
defp looks_like_email?(value) do
String.contains?(value, "@") and
String.match?(value, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
end
end

View file

@ -8,16 +8,20 @@ defmodule ArblargWeb.Admin.ChatMessagesControllerTest do
alias Elektrine.Messaging.{ChatConversation, ChatConversationMember, ChatMessages}
describe "admin arblarg chat message views" do
test "renders message console and logs list view", %{conn: conn} do
%{admin: admin} = admin_chat_message_fixture()
test "renders message console, redacts list bodies, and logs list view", %{conn: conn} do
secret = "SECRET_CHAT_BODY_#{System.unique_integer([:positive])}"
%{admin: admin} = admin_chat_message_fixture(secret)
conn =
html =
conn
|> with_elektrine_host()
|> log_in_as(admin)
|> get("/pripyat/arblarg/messages")
|> html_response(200)
assert html_response(conn, 200) =~ "Chat Message Console"
assert html =~ "Chat Message Console"
assert html =~ "not available in admin"
refute html =~ secret
log = latest_list_log(admin.id)
assert log.action == "view_chat_messages"
@ -26,18 +30,20 @@ defmodule ArblargWeb.Admin.ChatMessagesControllerTest do
assert log.details["result_count"] >= 1
end
test "renders message detail and logs view", %{conn: conn} do
%{admin: admin, message: message} = admin_chat_message_fixture()
test "renders message detail redacted and logs view", %{conn: conn} do
secret = "SECRET_CHAT_BODY_#{System.unique_integer([:positive])}"
%{admin: admin, message: message} = admin_chat_message_fixture(secret)
conn =
response =
conn
|> with_elektrine_host()
|> log_in_as(admin)
|> get("/pripyat/arblarg/messages/#{message.id}/view")
|> html_response(200)
response = html_response(conn, 200)
assert response =~ "Chat Message"
assert response =~ message.content
assert response =~ "not available in admin"
refute response =~ secret
log = latest_message_log(admin.id, message.id)
assert log.action == "view_chat_message"
@ -45,25 +51,54 @@ defmodule ArblargWeb.Admin.ChatMessagesControllerTest do
assert log.details["view_format"] == "html"
end
test "renders raw message and logs raw view", %{conn: conn} do
%{admin: admin, message: message} = admin_chat_message_fixture()
test "renders raw message redacted and logs raw view", %{conn: conn} do
secret = "SECRET_CHAT_BODY_#{System.unique_integer([:positive])}"
%{admin: admin, message: message} = admin_chat_message_fixture(secret)
conn =
body =
conn
|> with_elektrine_host()
|> log_in_as(admin)
|> get("/pripyat/arblarg/messages/#{message.id}/raw")
|> response(200)
assert response(conn, 200) =~ "ARBLARG CHAT MESSAGE"
assert body =~ "not available in admin"
refute body =~ secret
log = latest_message_log(admin.id, message.id)
assert log.action == "view_chat_message"
assert log.resource_type == "chat_message"
assert log.details["view_format"] == "raw"
end
test "search does not match body text", %{conn: conn} do
secret = "UNIQUE_BODY_TOKEN_#{System.unique_integer([:positive])}"
%{admin: admin, message: message, sender: sender} = admin_chat_message_fixture(secret)
html =
conn
|> with_elektrine_host()
|> log_in_as(admin)
|> get("/pripyat/arblarg/messages", %{"search" => secret})
|> html_response(200)
# Body text must not find the message (search box may echo the query).
refute html =~ ~s(/pripyat/arblarg/messages/#{message.id}/view)
assert html =~ "No chat messages found"
# Username search still works (metadata).
html_user =
conn
|> with_elektrine_host()
|> log_in_as(admin)
|> get("/pripyat/arblarg/messages", %{"search" => sender.username})
|> html_response(200)
assert html_user =~ ~s(/pripyat/arblarg/messages/#{message.id}/view)
end
end
defp admin_chat_message_fixture do
defp admin_chat_message_fixture(content) do
admin = AccountsFixtures.user_fixture() |> make_admin()
sender = AccountsFixtures.user_fixture()
@ -88,7 +123,7 @@ defmodule ArblargWeb.Admin.ChatMessagesControllerTest do
ChatMessages.create_text_message(
conversation.id,
sender.id,
"Arblarg test message #{System.unique_integer([:positive])}"
content
)
%{admin: admin, sender: sender, conversation: conversation, message: message}

View file

@ -5,6 +5,7 @@ defmodule ElektrineWeb.Admin.CommunitiesControllerTest do
alias Elektrine.AccountsFixtures
alias Elektrine.Repo
alias Elektrine.Social.Conversation
alias Elektrine.SocialFixtures
describe "GET /pripyat/communities" do
test "renders with fallback timezone when admin timezone is nil", %{conn: conn} do
@ -31,6 +32,32 @@ defmodule ElektrineWeb.Admin.CommunitiesControllerTest do
end
end
describe "GET /pripyat/communities/:id" do
test "redacts discussion post bodies on community show", %{conn: conn} do
admin = AccountsFixtures.user_fixture() |> make_admin()
author = AccountsFixtures.user_fixture()
secret = "SECRET_COMMUNITY_POST_#{System.unique_integer([:positive])}"
community = SocialFixtures.community_conversation_fixture(author)
_post =
SocialFixtures.discussion_post_fixture(%{
user: author,
community: community,
content: secret
})
html =
conn
|> with_elektrine_host()
|> log_in_as(admin)
|> get("/pripyat/communities/#{community.id}")
|> html_response(200)
assert html =~ "not available in admin"
refute html =~ secret
end
end
defp make_admin(user) do
{:ok, admin_user} = Accounts.admin_update_user(user, %{is_admin: true})
admin_user

View file

@ -10,8 +10,13 @@ defmodule ElektrineEmailWeb.Admin.MessagesControllerTest do
alias ElektrineWeb.AdminSecurity
describe "admin email view logging" do
test "logs standard admin message view", %{conn: conn} do
%{admin: admin, owner: owner, message: message} = admin_message_fixture()
test "logs standard admin message view and redacts body", %{conn: conn} do
%{admin: admin, owner: owner, message: message} =
admin_message_fixture(%{
text_body: "EMAIL CONTENT SECRET",
html_body: "<p>EMAIL CONTENT SECRET</p>"
})
request_path = "/pripyat/messages/#{message.id}/view"
conn =
@ -19,12 +24,16 @@ defmodule ElektrineEmailWeb.Admin.MessagesControllerTest do
|> with_elektrine_host()
|> log_in_as(admin)
conn =
get(conn, request_path, %{
html =
conn
|> get(request_path, %{
"_admin_action_grant" => grant_read_access(conn, admin, request_path)
})
|> html_response(200)
assert html_response(conn, 200) =~ "Message Details"
assert html =~ "Message Details"
assert html =~ "not available in admin"
refute html =~ "EMAIL CONTENT SECRET"
log = latest_view_email_log(admin.id, message.id)
assert log.resource_type == "email_message"

View file

@ -3,8 +3,95 @@ defmodule ElektrineWeb.Admin.ModerationControllerTest do
alias Elektrine.Accounts
alias Elektrine.AccountsFixtures
alias Elektrine.Messaging.{ChatConversation, ChatConversationMember, ChatMessages}
alias Elektrine.Repo
alias Elektrine.SocialFixtures
alias ElektrineWeb.AdminSecurity
describe "GET /pripyat/content-moderation" do
test "is report-only: unreported posts do not appear", %{conn: conn} do
admin = admin_user_fixture()
secret = "SECRET_TIMELINE_BODY_#{System.unique_integer([:positive])}"
author = AccountsFixtures.user_fixture()
_post = SocialFixtures.post_fixture(%{user: author, content: secret})
html =
conn
|> with_elektrine_host()
|> log_in_as(admin)
|> get("/pripyat/content-moderation", %{"type" => "timeline"})
|> html_response(200)
assert html =~ "No reported"
refute html =~ secret
end
test "shows reported content with redacted body", %{conn: conn} do
admin = admin_user_fixture()
secret = "SECRET_REPORTED_BODY_#{System.unique_integer([:positive])}"
author = AccountsFixtures.user_fixture()
reporter = AccountsFixtures.user_fixture()
post = SocialFixtures.post_fixture(%{user: author, content: secret})
{:ok, _report} =
Elektrine.Reports.create_report(%{
reporter_id: reporter.id,
reportable_type: "message",
reportable_id: post.id,
reason: "spam",
description: "looks like spam",
metadata: Elektrine.Reports.build_metadata("message", post.id)
})
html =
conn
|> with_elektrine_host()
|> log_in_as(admin)
|> get("/pripyat/content-moderation", %{"type" => "timeline"})
|> html_response(200)
assert html =~ "not available in admin"
refute html =~ secret
assert html =~ author.username or html =~ author.handle
end
test "unreported chat messages do not appear", %{conn: conn} do
admin = admin_user_fixture()
secret = "SECRET_CHAT_MOD_#{System.unique_integer([:positive])}"
sender = AccountsFixtures.user_fixture()
conversation =
%ChatConversation{}
|> ChatConversation.group_changeset(%{
creator_id: sender.id,
name: "mod-chat-#{System.unique_integer([:positive])}",
description: "moderation chat test"
})
|> Repo.insert!()
%ChatConversationMember{}
|> ChatConversationMember.changeset(%{
conversation_id: conversation.id,
user_id: sender.id,
role: "admin"
})
|> Repo.insert!()
{:ok, _message} =
ChatMessages.create_text_message(conversation.id, sender.id, secret)
html =
conn
|> with_elektrine_host()
|> log_in_as(admin)
|> get("/pripyat/content-moderation", %{"type" => "chat"})
|> html_response(200)
assert html =~ "No reported"
refute html =~ secret
end
end
describe "POST /pripyat/content-moderation/delete" do
test "redirects with an error for malformed content ids", %{conn: conn} do
admin = admin_user_fixture()

View file

@ -0,0 +1,28 @@
defmodule ElektrineWeb.JsonLogFormatterTest do
use ExUnit.Case, async: true
alias ElektrineWeb.JsonLogFormatter
test "redacts sensitive metadata keys and email-like values" do
ts = {{2026, 1, 2}, {3, 4, 5, 0}}
out =
JsonLogFormatter.format(
:info,
"user alice@example.com logged in",
ts,
email: "alice@example.com",
subject: "Secret subject",
token: "abc",
request_id: "rid-1"
)
|> IO.iodata_to_binary()
assert out =~ "rid-1"
assert out =~ "[redacted]"
refute out =~ "alice@example.com"
refute out =~ "Secret subject"
refute out =~ "abc"
assert out =~ "[redacted-email]"
end
end

View file

@ -185,6 +185,8 @@ config :elektrine, Oban,
{"50 3 * * *", Elektrine.Profiles.AnalyticsRetentionWorker},
# Null stored client IPs after retention window (default 14 days)
{"55 3 * * *", Elektrine.Jobs.IpRetentionWorker},
# Purge old admin audit rows (default 90 days; IPs null earlier via IpRetention)
{"0 4 * * *", Elektrine.Jobs.AuditLogRetentionWorker},
# Prune DNS query rollups (dns_query_stats only; not profile/site analytics)
{"5 4 * * *", Elektrine.DNS.QueryStatsRetentionWorker},
# Keep public site analytics rollups fresh for fast domain analytics pages
@ -638,6 +640,14 @@ config :elektrine, :media_proxy,
whitelist: [],
blocklist: []
# Inbound ActivityPub: reject DMs (direct) by default. Followers-only allowed.
config :elektrine, :mrf_reject_non_public,
allow_direct: false,
allow_followers_only: true
# Admin audit log row retention (IPs null earlier via :ip_retention)
config :elektrine, :audit_log, retention_days: 90
# Optional per-domain MRF subchains (prepended before the global policy list)
config :elektrine, :mrf_subchains, %{}

View file

@ -19,13 +19,18 @@ App AES protects a stolen disk when secrets stay elsewhere. It does not hide mai
| Item | Default |
|------|---------|
| Admin mail and chat bodies | Not shown |
| Admin content moderation | Report-only (open reports) |
| Admin impersonation | Off |
| Inbound ActivityPub DMs | Rejected (`mrf_reject_non_public.allow_direct: false`) |
| `EMAIL_RAW_SOURCE_MAX_BYTES` | `0` (no second RFC822 copy) |
| `EMAIL_STORE_INBOUND_REMOTE_IP` | `false` |
| `EMAIL_TRASH_RETENTION_DAYS` | `30` |
| `EMAIL_SPAM_RETENTION_DAYS` | `30` |
| `EMAIL_INBOX_RETENTION_DAYS` | `0` (keep. Set e.g. `365` to cap age) |
| `VPN_DURABLE_SESSION_LOGS` | `false` |
| Audit log row retention | 90 days |
| IP fields (incl. audit IP) | Null after 14 days |
| Media proxy browser cache | 1 hour when proxy enabled |
## Secrets
@ -87,6 +92,26 @@ ELEKTRINE_RELEASE_MODULES=chat,social,nerve docker compose build app
3. `HARAKA_DLQ_STORE_PAYLOAD=false`.
4. Optional: `docker-compose.privacy.yml` for Redis without AOF/RDB.
5. After triage run `scripts/purge-dlq.sh`.
6. Assert defaults: `scripts/ops/check_haraka_privacy.sh` (set `HARAKA_DEPLOY_DIR`).
## Logs that forget
1. Prod JSON logs redact tokens, passwords, email-like fields, subjects, bodies.
2. Prefer host journald retention of a few days; do not ship full mail subjects to log drains.
3. Keep `VPN_DURABLE_SESSION_LOGS=false` so connect events are not stored in Postgres.
## Federation
1. User and admin deletes federate Delete/Tombstone for public social posts.
2. Chat DMs are not federated; inbound ActivityPub directs are rejected by default.
3. Post delete purges media-proxy failure/ban state for attachment URLs.
## Backups
1. Use `scripts/ops/pg_backup.sh` with `AGE_RECIPIENT` or `GPG_RECIPIENT`.
2. Default retention 14 days (`RETENTION_DAYS`) — match or beat IP retention.
3. Encrypt dumps. Keep dump keys off the dump store.
4. Never store `.env` / `ELEKTRINE_MASTER_SECRET` next to dumps (script refuses unsafe dirs).
## Content that stays private from the operator
@ -94,6 +119,7 @@ ELEKTRINE_RELEASE_MODULES=chat,social,nerve docker compose build app
2. PGP for high-value mail.
3. Client E2E chat when available.
4. Shorter trash and spam retention. Optional inbox age cap.
5. Admin moderation is report-only; bodies stay redacted.
Retention deletes rows. Until delete, app secrets still decrypt normal mail.

View file

@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Assert Haraka privacy defaults on a deployment (log level, no remote queue store, no DLQ payloads).
#
# Usage:
# HARAKA_DEPLOY_DIR=/opt/elektrine-haraka scripts/ops/check_haraka_privacy.sh
#
# Exit 0 if checks pass or Haraka dir is absent (skip). Exit 1 if privacy defaults are wrong.
set -euo pipefail
HARAKA_DEPLOY_DIR="${HARAKA_DEPLOY_DIR:-/opt/elektrine-haraka}"
if [[ ! -d "${HARAKA_DEPLOY_DIR}" ]]; then
echo "Haraka deploy dir not found (${HARAKA_DEPLOY_DIR}); skipping."
exit 0
fi
fail=0
check_env() {
local key="$1"
local expected="$2"
local file
file="$(find "${HARAKA_DEPLOY_DIR}" -maxdepth 3 \( -name '.env' -o -name '*.env' -o -name 'compose*.yml' \) 2>/dev/null | head -20)"
local found=""
local f
for f in ${file}; do
if grep -E "(^|[[:space:]])${key}=${expected}" "$f" >/dev/null 2>&1 ||
grep -E "\"${key}\"[[:space:]]*:[[:space:]]*\"?${expected}\"?" "$f" >/dev/null 2>&1; then
found=1
break
fi
done
if [[ -z "${found}" ]]; then
# Also accept absence of true for false defaults
if [[ "${expected}" == "false" ]]; then
if ! grep -R --include='*.env' --include='*.yml' -E "${key}=true" "${HARAKA_DEPLOY_DIR}" >/dev/null 2>&1; then
echo "OK (default): ${key} is not true"
return
fi
fi
echo "FAIL: expected ${key}=${expected} under ${HARAKA_DEPLOY_DIR}" >&2
fail=1
else
echo "OK: ${key}=${expected}"
fi
}
check_env "HARAKA_QUEUE_STORE_REMOTE" "false"
check_env "HARAKA_DLQ_STORE_PAYLOAD" "false"
# Log level warn preferred
if grep -R --include='*.ini' --include='*.yml' --include='*.env' -E 'loglevel[[:space:]]*=[[:space:]]*warn|LOG_LEVEL=warn|log_level:[[:space:]]*warn' \
"${HARAKA_DEPLOY_DIR}" >/dev/null 2>&1; then
echo "OK: log level warn configured"
else
echo "WARN: could not confirm Haraka log level=warn (check config by hand)" >&2
fi
if [[ "${fail}" -ne 0 ]]; then
echo "Haraka privacy checks failed." >&2
exit 1
fi
echo "Haraka privacy checks passed."

85
scripts/ops/pg_backup.sh Executable file
View file

@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Encrypted Postgres dump with short retention. Secrets never go in the dump.
#
# Usage:
# DATABASE_URL=postgres://... BACKUP_DIR=/var/backups/elektrine \
# RETENTION_DAYS=14 ENCRYPTION_KEY_FILE=/root/elektrine-backup.key \
# scripts/ops/pg_backup.sh
#
# Env:
# DATABASE_URL or PG* vars — required for pg_dump
# BACKUP_DIR — dump destination (default: ./_backups)
# RETENTION_DAYS — delete dumps older than N days (default: 14)
# ENCRYPTION_KEY_FILE — age recipient key or gpg --recipient optional
# AGE_RECIPIENT — if set, encrypt with age -r
# GPG_RECIPIENT — if set, encrypt with gpg -r
#
# Refuses to write next to .env.production or into a path containing secrets.
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-./_backups}"
RETENTION_DAYS="${RETENTION_DAYS:-14}"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
BASENAME="elektrine-pg-${STAMP}.sql"
PLAIN="${BACKUP_DIR}/${BASENAME}"
if [[ -f "${BACKUP_DIR}/.env.production" ]] || [[ -f "${BACKUP_DIR}/.env" ]]; then
echo "Refusing to write backups next to env files in ${BACKUP_DIR}" >&2
exit 1
fi
case "${BACKUP_DIR}" in
*/.env*|*/secrets*|*/.ssh*)
echo "Refusing unsafe BACKUP_DIR: ${BACKUP_DIR}" >&2
exit 1
;;
esac
mkdir -p "${BACKUP_DIR}"
chmod 700 "${BACKUP_DIR}"
echo "Dumping database to ${PLAIN}"
if [[ -n "${DATABASE_URL:-}" ]]; then
pg_dump --no-owner --no-acl --format=plain --file="${PLAIN}" "${DATABASE_URL}"
else
pg_dump --no-owner --no-acl --format=plain --file="${PLAIN}"
fi
chmod 600 "${PLAIN}"
FINAL="${PLAIN}"
if [[ -n "${AGE_RECIPIENT:-}" ]] && command -v age >/dev/null 2>&1; then
FINAL="${PLAIN}.age"
age -r "${AGE_RECIPIENT}" -o "${FINAL}" "${PLAIN}"
shred -u "${PLAIN}" 2>/dev/null || rm -f "${PLAIN}"
chmod 600 "${FINAL}"
echo "Encrypted with age -> ${FINAL}"
elif [[ -n "${GPG_RECIPIENT:-}" ]] && command -v gpg >/dev/null 2>&1; then
FINAL="${PLAIN}.gpg"
gpg --batch --yes -o "${FINAL}" -r "${GPG_RECIPIENT}" --encrypt "${PLAIN}"
shred -u "${PLAIN}" 2>/dev/null || rm -f "${PLAIN}"
chmod 600 "${FINAL}"
echo "Encrypted with gpg -> ${FINAL}"
elif [[ -n "${ENCRYPTION_KEY_FILE:-}" ]] && command -v age >/dev/null 2>&1; then
FINAL="${PLAIN}.age"
age -R "${ENCRYPTION_KEY_FILE}" -o "${FINAL}" "${PLAIN}"
shred -u "${PLAIN}" 2>/dev/null || rm -f "${PLAIN}"
chmod 600 "${FINAL}"
echo "Encrypted with age key file -> ${FINAL}"
else
echo "WARNING: dump is plaintext. Set AGE_RECIPIENT or GPG_RECIPIENT." >&2
fi
# Never pack app secrets into the backup tree
for f in .env .env.production ELEKTRINE_MASTER_SECRET; do
if [[ -e "${BACKUP_DIR}/${f}" ]]; then
echo "Removing accidental secret file from backup dir: ${f}" >&2
rm -f "${BACKUP_DIR}/${f}"
fi
done
# Retention: match or beat IP retention so backups do not outlive privacy policy
find "${BACKUP_DIR}" -type f \( -name 'elektrine-pg-*.sql' -o -name 'elektrine-pg-*.sql.age' -o -name 'elektrine-pg-*.sql.gpg' \) \
-mtime "+${RETENTION_DAYS}" -print -delete 2>/dev/null || true
echo "Backup complete: ${FINAL} (retention ${RETENTION_DAYS}d)"