Current section

Files

Jump to
ash_authentication lib ash_authentication add_ons audit_log ip_privacy.ex
Raw

lib/ash_authentication/add_ons/audit_log/ip_privacy.ex

# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT
defmodule AshAuthentication.AddOn.AuditLog.IpPrivacy do
@moduledoc """
Provides IP address privacy transformations for audit logging.
This module handles transforming IP addresses according to privacy settings
to help comply with privacy regulations like GDPR.
"""
import Bitwise
@doc """
Apply privacy transformation to an IP address string.
## Options
- `:mode` - The privacy mode (`:none`, `:hash`, `:truncate`, `:exclude`)
- `:truncation_masks` - Map with `:ipv4` and `:ipv6` keys for truncation bits
"""
@spec apply_privacy(String.t() | nil, atom(), map()) :: String.t() | nil
def apply_privacy(nil, _mode, _opts), do: nil
def apply_privacy(_ip, :exclude, _opts), do: nil
def apply_privacy(ip, :none, _opts), do: ip
def apply_privacy(ip, :hash, _opts) when is_binary(ip) do
hash_ip(ip)
end
def apply_privacy(ip, :truncate, opts) when is_binary(ip) do
truncate_ip(ip, opts[:truncation_masks] || %{ipv4: 24, ipv6: 48})
end
def apply_privacy(ip, _mode, _opts), do: ip
@doc """
Apply privacy transformation to request data containing IP addresses.
Transforms the following fields:
- `remote_ip`
- `x_forwarded_for` (list of IPs)
- `forwarded` (list of forwarded headers)
"""
@spec apply_to_request(map(), atom(), map()) :: map()
def apply_to_request(request, mode, opts) when is_map(request) do
request
|> transform_remote_ip(mode, opts)
|> transform_x_forwarded_for(mode, opts)
|> transform_forwarded(mode, opts)
end
def apply_to_request(request, _mode, _opts), do: request
# Private functions
defp transform_remote_ip(request, mode, opts) do
case Map.get(request, :remote_ip) do
nil -> request
ip -> Map.put(request, :remote_ip, apply_privacy(ip, mode, opts))
end
end
defp transform_x_forwarded_for(request, mode, opts) do
case Map.get(request, :x_forwarded_for) do
nil ->
request
[] ->
request
headers when is_list(headers) ->
transformed =
Enum.map(headers, fn header ->
header
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.map(&apply_privacy(&1, mode, opts))
|> Enum.reject(&is_nil/1)
|> Enum.join(", ")
end)
Map.put(request, :x_forwarded_for, transformed)
_ ->
request
end
end
defp transform_forwarded(request, mode, opts) do
case Map.get(request, :forwarded) do
nil ->
request
[] ->
request
headers when is_list(headers) ->
transformed = Enum.map(headers, &transform_forwarded_header(&1, mode, opts))
Map.put(request, :forwarded, transformed)
_ ->
request
end
end
defp transform_forwarded_header(header, mode, opts) when is_binary(header) do
# Parse the Forwarded header format: for=ip;proto=http;by=ip
header
|> String.split(";")
|> Enum.map(&transform_forwarded_param(&1, mode, opts))
|> Enum.reject(&is_nil/1)
|> Enum.join(";")
end
defp transform_forwarded_header(header, _mode, _opts), do: header
defp transform_forwarded_param(param, mode, opts) do
param = String.trim(param)
case String.split(param, "=", parts: 2) do
["for", value] ->
# Remove quotes and port if present
ip =
value
|> String.trim("\"")
|> extract_ip_from_forwarded()
|> apply_privacy(mode, opts)
if ip, do: "for=#{maybe_quote_forwarded(ip)}", else: nil
["by", value] ->
# Remove quotes and port if present
ip =
value
|> String.trim("\"")
|> extract_ip_from_forwarded()
|> apply_privacy(mode, opts)
if ip, do: "by=#{maybe_quote_forwarded(ip)}", else: nil
_ ->
param
end
end
defp extract_ip_from_forwarded(value) do
# Handle [IPv6]:port or IPv4:port or just IP
cond do
String.starts_with?(value, "[") ->
# IPv6 with possible port
value
|> String.split("]")
|> List.first()
|> String.trim_leading("[")
String.contains?(value, ":") and not String.contains?(value, "::") ->
# IPv4 with port
value
|> String.split(":")
|> List.first()
true ->
# Just an IP
value
end
end
defp maybe_quote_forwarded(ip) do
cond do
# Hashed IPs don't need quoting
String.starts_with?(ip, "hashed:") ->
ip
# IPv6 addresses and truncated IPv6 need quoting
String.contains?(ip, ":") ->
"\"[#{ip}]\""
# IPv4 and others don't need quoting
true ->
ip
end
end
@doc """
Hash an IP address using SHA256.
Uses the application's secret key base as salt for consistent hashing.
"""
@spec hash_ip(String.t()) :: String.t()
def hash_ip(ip) when is_binary(ip) do
# Get a salt from application config or use a default
salt = get_hash_salt()
:crypto.hash(:sha256, salt <> ip)
|> Base.encode16(case: :lower)
# Use first 16 chars for readability
|> String.slice(0..15)
|> then(&"hashed:#{&1}")
end
def hash_ip(_), do: nil
defp get_hash_salt do
# Try to get from application config
case Application.get_env(:ash_authentication, :audit_log_ip_salt) do
nil ->
# Fall back to secret_key_base if available
case Application.get_env(:ash_authentication, :secret) do
nil -> "default-salt-change-in-production"
secret when is_binary(secret) -> secret
{module, fun, args} -> apply(module, fun, args)
end
salt when is_binary(salt) ->
salt
end
end
@doc """
Truncate an IP address to a network prefix.
For IPv4: Applies a subnet mask (e.g., /24 keeps first 3 octets)
For IPv6: Applies a prefix length (e.g., /48 keeps first 3 hextets)
"""
@spec truncate_ip(String.t(), map()) :: String.t() | nil
def truncate_ip(ip, masks) when is_binary(ip) and is_map(masks) do
case parse_ip_address(ip) do
{:ipv4, parsed} ->
truncate_ipv4(parsed, Map.get(masks, :ipv4, 24))
{:ipv6, parsed} ->
truncate_ipv6(parsed, Map.get(masks, :ipv6, 48))
:error ->
# If we can't parse it, return a placeholder
"invalid-ip"
end
end
def truncate_ip(_, _), do: nil
defp parse_ip_address(ip) do
# Try IPv4 first
case :inet.parse_ipv4_address(String.to_charlist(ip)) do
{:ok, addr} ->
{:ipv4, addr}
{:error, _} ->
# Try IPv6
case :inet.parse_ipv6_address(String.to_charlist(ip)) do
{:ok, addr} ->
{:ipv6, addr}
{:error, _} ->
:error
end
end
end
defp truncate_ipv4(addr, mask) when mask >= 0 and mask <= 32 do
# addr is a 4-tuple like {192, 168, 1, 100}
{a, b, c, d} = addr
# Convert tuple to 32-bit integer
ip_int = (a <<< 24) + (b <<< 16) + (c <<< 8) + d
# Create mask: all 1s for the prefix, all 0s for the rest
# For mask=24: 0xFFFFFF00
mask_bits = if mask == 0, do: 0, else: Bitwise.bnot((1 <<< (32 - mask)) - 1)
# Apply mask
masked_int = ip_int &&& mask_bits
# Convert back to octets
masked_a = masked_int >>> 24 &&& 0xFF
masked_b = masked_int >>> 16 &&& 0xFF
masked_c = masked_int >>> 8 &&& 0xFF
masked_d = masked_int &&& 0xFF
"#{masked_a}.#{masked_b}.#{masked_c}.#{masked_d}/#{mask}"
end
defp truncate_ipv4(_, _), do: "invalid-ipv4"
defp truncate_ipv6(addr, mask) when mask >= 0 and mask <= 128 do
# addr is an 8-tuple of 16-bit integers
{a, b, c, d, e, f, g, h} = addr
# Convert tuple to 128-bit integer
ip_int =
(a <<< 112) + (b <<< 96) + (c <<< 80) + (d <<< 64) +
(e <<< 48) + (f <<< 32) + (g <<< 16) + h
# Create mask: all 1s for the prefix, all 0s for the rest
mask_bits = if mask == 0, do: 0, else: Bitwise.bnot((1 <<< (128 - mask)) - 1)
# Apply mask
masked_int = ip_int &&& mask_bits
# Convert back to 8 segments
masked_a = masked_int >>> 112 &&& 0xFFFF
masked_b = masked_int >>> 96 &&& 0xFFFF
masked_c = masked_int >>> 80 &&& 0xFFFF
masked_d = masked_int >>> 64 &&& 0xFFFF
masked_e = masked_int >>> 48 &&& 0xFFFF
masked_f = masked_int >>> 32 &&& 0xFFFF
masked_g = masked_int >>> 16 &&& 0xFFFF
masked_h = masked_int &&& 0xFFFF
# Format as IPv6 string
formatted =
[masked_a, masked_b, masked_c, masked_d, masked_e, masked_f, masked_g, masked_h]
|> Enum.map_join(":", &Integer.to_string(&1, 16))
|> String.downcase()
"#{formatted}/#{mask}"
end
defp truncate_ipv6(_, _), do: "invalid-ipv6"
end