Extending AshCookieConsent
View SourceThis guide shows you how to extend and customize AshCookieConsent for advanced use cases.
Table of Contents
- Complete File Structure
- Adding User Relationships
- Implementing Database Sync
- Custom Cookie Groups
- Custom Modal Styling
- Custom Storage Backend
- Audit Trail Implementation
Complete File Structure
After following the "Adding User Relationships" section, your project will have these files:
my_app/
├── lib/
│ └── my_app/
│ ├── consent/
│ │ ├── consent.ex # Domain (Step 2)
│ │ └── consent_settings.ex # Resource (Step 1)
│ └── accounts/
│ └── user.ex # Updated with relationship (Step 6)
│
├── config/
│ └── config.exs # Updated with ash_domains (Step 3)
│
└── priv/
└── repo/
└── migrations/
└── YYYYMMDDHHMMSS_add_user_to_consent_settings.exs # Generated (Step 4)Key files you'll create or modify:
- lib/my_app/consent/consent_settings.ex - Your consent resource with user relationship
- lib/my_app/consent/consent.ex - Domain with code_interface definitions
- config/config.exs - Add
MyApp.Consenttoash_domainslist - Migration file - Generated by
mix ash.codegen - lib/my_app/accounts/user.ex - Add
has_many :consent_settingsrelationship
Adding User Relationships
The default ConsentSettings resource doesn't include a user relationship. Here's how to add it.
Step 1: Define User Relationship
defmodule MyApp.Consent.ConsentSettings do
use Ash.Resource,
domain: MyApp.Consent,
data_layer: AshPostgres.DataLayer
postgres do
table "consent_settings"
repo MyApp.Repo
end
attributes do
uuid_primary_key :id
attribute :terms, :string do
allow_nil? false
end
attribute :groups, {:array, :string} do
default []
end
attribute :consented_at, :utc_datetime
attribute :expires_at, :utc_datetime
timestamps()
end
relationships do
# Add user relationship
belongs_to :user, MyApp.Accounts.User do
allow_nil? true # IMPORTANT: Allow nil for anonymous users
description "Optional link to authenticated user. Nil for anonymous consent."
attribute_writable? true
end
end
actions do
defaults [:read]
create :create do
primary? true
accept [:terms, :groups, :consented_at, :expires_at, :user_id] # Include user_id!
end
update :update do
primary? true
accept [:terms, :groups, :consented_at, :expires_at]
end
# Add action to find consent by user
read :for_user do
argument :user_id, :uuid do
allow_nil? false
end
filter expr(user_id == ^arg(:user_id))
end
# Add action to get latest consent for user
read :latest_for_user do
argument :user_id, :uuid do
allow_nil? false
end
filter expr(user_id == ^arg(:user_id))
prepare build(sort: [consented_at: :desc], limit: 1)
end
end
identities do
# Ensure only one active consent per user
identity :unique_user_consent, [:user_id], pre_check_with: MyApp.Consent
end
# IMPORTANT: Add code_interface for convenient function calls
code_interface do
define :list_consent_settings, action: :read
define :get_consent_settings, action: :read, get_by: [:id]
define :get_latest_for_user, action: :latest_for_user, args: [:user_id]
define :create_consent_settings, action: :create
define :update_consent_settings, action: :update
end
end⚠️ Critical Configuration Notes
1. User Relationship Must Allow Nil
Set allow_nil? true on the user relationship. This allows:
- Anonymous users to save consent to cookies (no user_id)
- Authenticated users to get database persistence (with user_id)
If you set allow_nil? false, anonymous users cannot use the consent system.
2. Include user_id in Accept List
Add :user_id to the :accept list in your :create action:
accept [:terms, :groups, :consented_at, :expires_at, :user_id]Without this, Ash will reject the user_id attribute when the Storage module tries to create records for authenticated users, causing silent failures.
Step 2: Configure Domain
CRITICAL: Your domain must define unique function names for the code interface. Duplicate names (like multiple create/2) will cause compilation errors.
# lib/my_app/consent.ex
defmodule MyApp.Consent do
use Ash.Domain
resources do
resource MyApp.Consent.ConsentSettings do
# ✅ CORRECT - Each define has a unique name
define :list_consent_settings, action: :read
define :get_consent_settings, action: :read, get_by: [:id]
define :get_latest_for_user, action: :latest_for_user, args: [:user_id]
define :create_consent_settings, action: :create
define :update_consent_settings, action: :update
end
end
endCommon Mistakes to Avoid:
# ❌ WRONG - Causes "def create/2 defines defaults multiple times"
resources do
resource MyApp.Consent.ConsentSettings do
define :create, action: :create # ❌ Generic name
define :read, action: :read # ❌ Generic name
define :update, action: :update # ❌ Generic name
end
end
# ✅ CORRECT - Use descriptive, unique names
resources do
resource MyApp.Consent.ConsentSettings do
define :create_consent_settings, action: :create
define :list_consent_settings, action: :read
define :update_consent_settings, action: :update
end
endNaming Convention: Use prefixes that describe what you're doing:
list_for reading multiple recordsget_for reading a single recordcreate_for creating recordsupdate_for updating recordsdelete_for deleting records
This prevents conflicts and makes your API self-documenting.
⚠️ Next Step Required: After creating your domain, you MUST register it in config/config.exs (Step 3 below), or migrations will fail with "no domains configured".
Step 3: Register Domain in Configuration
CRITICAL: After creating your domain, you must register it in your application config. Without this, Ash won't recognize your domain and migrations won't work.
Add your domain to config/config.exs:
# config/config.exs
config :my_app,
ash_domains: [
MyApp.Consent, # ← Add your new consent domain
MyApp.Accounts, # Your existing domains
# ... other domains
]If you skip this step, you'll get errors like:
- "no domains configured" when running migrations
- Domain not found errors at runtime
- Ash.Query failures
⚠️ Prerequisite Check: Before proceeding to Step 4, verify you completed Steps 1-3. The migration generator requires your resource (Step 1), domain (Step 2), and domain registration (Step 3) to be in place.
Step 4: Generate and Run Migrations
After creating your resource and configuring your domain, use Ash's migration tools to update your database.
Generate Migration
mix ash.codegen --name add_user_to_consent_settings
This will:
- Analyze your resource definitions
- Compare with existing database schema
- Generate migration files in
priv/repo/migrations/
Review Generated Migration
Ash will generate something like:
# priv/repo/migrations/XXXXXX_add_user_to_consent_settings.exs
defmodule MyApp.Repo.Migrations.AddUserToConsentSettings do
use Ecto.Migration
def up do
alter table(:consent_settings) do
add :user_id, :uuid
end
create index(:consent_settings, [:user_id])
create unique_index(:consent_settings, [:user_id], name: :unique_user_consent)
alter table(:consent_settings) do
modify :user_id, references(:users, type: :uuid, on_delete: :delete_all)
end
end
def down do
drop constraint(:consent_settings, "consent_settings_user_id_fkey")
drop index(:consent_settings, [:user_id], name: :unique_user_consent)
drop index(:consent_settings, [:user_id])
alter table(:consent_settings) do
remove :user_id
end
end
endRun Migration
IMPORTANT: Use mix ash.migrate, NOT mix ecto.migrate:
mix ash.migrate
⚠️ Critical Distinction: Many developers instinctively run mix ecto.migrate out of habit. This will cause errors!
Why ash.migrate instead of ecto.migrate?
ash.migrateruns migrations AND updates Ash's internal schema cacheecto.migrateonly runs migrations, leaving Ash unaware of schema changes- This can cause mysterious "column does not exist" errors even though the column exists in your database
Migration Workflow Summary
# 1. Make changes to your resource
# 2. Generate migration
mix ash.codegen --name descriptive_migration_name
# 3. Review generated files in priv/repo/migrations/
# 4. Run migration
mix ash.migrate
# 5. If something goes wrong, rollback
mix ash.rollback
Resource Snapshots
Important: The migration process also creates resource snapshot files in priv/resource_snapshots/repo/.
priv/
└── resource_snapshots/
└── repo/
├── consent_settings/
│ └── YYYYMMDDHHMMSS.json
└── users/
└── YYYYMMDDHHMMSS.jsonWhat are snapshots?
- JSON files tracking your resource schema at each migration point
- Used by Ash to detect schema changes for future migrations
- Similar to how Ecto tracks schema_migrations
Should you commit them?
- ✅ YES - Commit snapshots to version control
- They're essential for team collaboration
- Without them, other developers can't generate correct migrations
- They enable Ash to diff current schema vs. previous state
⚠️ Don't Forget: When you commit your migration file, also commit the corresponding snapshot files in priv/resource_snapshots/. Both are needed for the migration to work for other developers.
What's in a snapshot?
{
"attributes": [
{"name": "id", "type": "uuid", ...},
{"name": "terms", "type": "string", ...},
{"name": "groups", "type": "array", ...}
],
"table": "consent_settings",
"repo": "Elixir.MyApp.Repo",
...
}Step 5: Verify Resource Works
After running migrations, test your resource to ensure everything is configured correctly.
Test in IEx Console
Start your console and create a test consent record:
iex -S mix
# Create a consent record
{:ok, consent} = MyApp.Consent.create_consent_settings(%{
terms: "v1.0",
groups: ["essential", "analytics"],
user_id: "some-uuid-here" # Use a valid user ID from your database
})
IO.inspect(consent, label: "Created consent")Or Use Mix Run for Quick Tests
mix run -e '
alias MyApp.Consent
{:ok, consent} = Consent.create_consent_settings(%{
terms: "v1.0",
groups: ["essential", "analytics"],
user_id: "USER_UUID_HERE"
})
IO.inspect(consent, label: "Created consent")
'
What to Look For
Your output should show:
Created consent: %MyApp.Consent.ConsentSettings{
__meta__: #Ecto.Schema.Metadata<:loaded, "consent_settings">,
id: "generated-uuid",
terms: "v1.0",
groups: ["essential", "analytics"],
user_id: "USER_UUID_HERE",
consented_at: ~U[2024-01-15 10:30:00Z], # Automatic timestamp
expires_at: ~U[2025-01-15 10:30:00Z], # 365 days later
inserted_at: ~U[2024-01-15 10:30:00Z],
updated_at: ~U[2024-01-15 10:30:00Z]
}✅ Success indicators:
- Record created without errors
consented_atautomatically set to current timeexpires_atset to 365 days in the futuregroupsarray properly storeduser_idforeign key relationship works
❌ Common errors:
# Error: Domain not configured
** (RuntimeError) no domains configured
# Fix: Add domain to config/config.exs (see Step 3)
# Error: Column doesn't exist
** (Postgrex.Error) column "user_id" does not exist
# Fix: Run mix ash.migrate (not ecto.migrate)
# Error: Function undefined
** (UndefinedFunctionError) function MyApp.Consent.create_consent_settings/1 is undefined
# Fix: Check domain code_interface defines function (see Step 2)Test Querying Records
# List all consent records
MyApp.Consent.list_consent_settings!()
# Get specific consent by ID
MyApp.Consent.get_consent_settings!("consent-uuid")
# Get latest consent for a user
MyApp.Consent.get_latest_for_user!("user-uuid")Test Database Sync via Storage Module
After implementing your custom Storage module (see "Implementing Database Sync" section below), test the full sync flow:
# 1. Create a mock conn with authenticated user
conn = %Plug.Conn{
assigns: %{
current_user: %MyApp.Accounts.User{id: "550e8400-e29b-41d4-a716-446655440000"}
}
}
# 2. Simulate saving consent
consent = %{
"terms" => "v1.0",
"groups" => ["essential", "analytics"],
"consented_at" => DateTime.utc_now(),
"expires_at" => DateTime.add(DateTime.utc_now(), 365, :day)
}
MyApp.Consent.Storage.put_consent(conn, consent, [
resource: MyApp.Consent.ConsentSettings,
user_id_key: :current_user
])
# 3. Check database
consents = MyApp.Consent.list_consent_settings!()
IO.inspect(consents, label: "Database consents")
# Should show the consent record with user_id set
# 4. Simulate loading consent
loaded = MyApp.Consent.Storage.get_consent(conn, [
resource: MyApp.Consent.ConsentSettings,
user_id_key: :current_user
])
IO.inspect(loaded, label: "Loaded consent")
# Should match the saved consentWhat to verify:
- ✅ Consent record created in database with correct user_id
- ✅
get_consent/2returns the same data that was saved - ✅ Anonymous users (no current_user) don't cause errors
- ✅ "Newer wins" logic works (if implemented)
If all these work, your resource is properly configured! 🎉
Step 6: Update User Resource
defmodule MyApp.Accounts.User do
use Ash.Resource,
domain: MyApp.Accounts,
data_layer: AshPostgres.DataLayer
# ... existing attributes
relationships do
# ... existing relationships
has_many :consent_settings, MyApp.Consent.ConsentSettings do
destination_attribute :user_id
end
end
endImplementing Database Sync
Now implement the stubbed database functions in the Storage module.
Option 1: Extend Storage Module
Create a custom storage module that wraps the default one:
defmodule MyApp.Consent.Storage do
@moduledoc """
Custom storage implementation with database sync for authenticated users.
"""
alias AshCookieConsent.Storage, as: BaseStorage
alias MyApp.Consent.ConsentSettings
@doc """
Get consent with "newer wins" logic across cookie and database storage.
This ensures that the most recent consent is used, preventing stale
cookie data from overwriting newer database changes made on other devices.
"""
def get_consent(conn, opts \\\\ []) do
# Load consent from cookie/session
cookie_consent = BaseStorage.get_consent(conn, opts)
# Load from database for authenticated users
db_consent = case get_user_id(conn, opts) do
nil -> nil
user_id -> load_from_database(user_id)
end
# Return whichever is newer (or only available one)
case {cookie_consent, db_consent} do
{nil, nil} -> nil
{nil, db} -> db
{cookie, nil} -> cookie
{cookie, db} -> if newer?(db, cookie), do: db, else: cookie
end
end
@doc """
Save consent to all tiers including database for authenticated users.
"""
def put_consent(conn, consent, opts \\\\ []) do
# Save to base storage (assigns/session/cookie)
conn = BaseStorage.put_consent(conn, consent, opts)
# Also save to database if authenticated
case get_user_id(conn, opts) do
nil ->
conn
user_id ->
save_to_database(user_id, consent)
conn
end
end
defp get_user_id(conn, opts) do
user_id_key = Keyword.get(opts, :user_id_key, :current_user)
# Get the user from assigns (could be struct or nil)
case Map.get(conn.assigns, user_id_key) do
nil -> nil
# If user is a struct with .id field (most common case)
%{id: id} when is_binary(id) or is_integer(id) -> id
# If user_id_key points directly to an ID
id when is_binary(id) or is_integer(id) -> id
_ -> nil
end
end
defp load_from_database(user_id) do
case ConsentSettings
|> Ash.Query.for_action(:latest_for_user, %{user_id: user_id})
|> Ash.read_one() do
{:ok, nil} ->
nil
{:ok, consent_record} ->
%{
"terms" => consent_record.terms,
"groups" => consent_record.groups,
"consented_at" => consent_record.consented_at,
"expires_at" => consent_record.expires_at
}
{:error, _} ->
nil
end
end
defp save_to_database(user_id, consent) do
# Create or update consent record
attrs = %{
user_id: user_id,
terms: consent["terms"] || consent[:terms],
groups: consent["groups"] || consent[:groups] || [],
consented_at: consent["consented_at"] || consent[:consented_at],
expires_at: consent["expires_at"] || consent[:expires_at]
}
ConsentSettings
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create()
end
# Helper to compare timestamps - returns true if consent1 is newer
defp newer?(consent1, consent2) do
time1 = get_timestamp(consent1, "consented_at")
time2 = get_timestamp(consent2, "consented_at")
cond do
is_nil(time1) -> false
is_nil(time2) -> true
true -> DateTime.compare(time1, time2) == :gt
end
end
defp get_timestamp(consent, field) do
case consent[field] || consent[String.to_atom(field)] do
%DateTime{} = dt -> dt
_ -> nil
end
end
end🔍 Understanding User ID Extraction
The user_id_key option (defaults to :current_user) typically points to a user struct in conn.assigns, not a raw ID. For example:
conn.assigns.current_user = %MyApp.Accounts.User{
id: "550e8400-e29b-41d4-a716-446655440000",
email: "user@example.com",
...
}The get_user_id/2 function extracts the .id field from this struct:
- Checks if the value is nil (anonymous user)
- Checks if it's a struct with an
.idfield (most common) - Checks if it's already an ID (string or integer)
- Returns nil for anything else
If your authentication library sets a different assign (like :user_id), configure it:
plug MyApp.Consent.Plug,
resource: MyApp.Consent.ConsentSettings,
user_id_key: :user_id # Points directly to ID, not struct🔄 "Newer Wins" Logic
The get_consent/2 function implements conflict resolution:
- Scenario: User changes preferences on Device B (saved to DB)
- Problem: Device A still has old cookie with previous preferences
- Solution: Compare
consented_attimestamps, return newer consent
This prevents stale cookies from overwriting recent changes made on other devices.
Option 2: Custom Plug
Create a custom plug that uses your storage implementation:
defmodule MyApp.Consent.Plug do
@moduledoc """
Custom consent plug with database sync.
"""
import Plug.Conn
alias MyApp.Consent.Storage
def init(opts), do: AshCookieConsent.Plug.init(opts)
def call(conn, config) do
# Use custom storage
storage_opts = [
resource: config.resource,
cookie_name: config.cookie_name,
session_key: config.session_key,
user_id_key: config.user_id_key
]
consent = Storage.get_consent(conn, storage_opts)
show_modal = should_show_modal?(consent)
conn
|> assign(:consent, consent)
|> assign(:show_consent_modal, show_modal)
|> assign(:cookie_groups, AshCookieConsent.cookie_groups())
end
defp should_show_modal?(nil), do: true
defp should_show_modal?(consent) do
groups = consent["groups"] || consent[:groups]
is_nil(groups) || groups == [] || is_expired?(consent)
end
defp is_expired?(consent) do
expires_at = consent["expires_at"] || consent[:expires_at]
case expires_at do
%DateTime{} = dt -> DateTime.compare(DateTime.utc_now(), dt) == :gt
_ -> false
end
end
endThen use your custom plug in the router:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_cookies
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
# Authentication plug MUST come before consent plug
plug :load_current_user
# Consent plug reads current_user from assigns
plug MyApp.Consent.Plug,
resource: MyApp.Consent.ConsentSettings,
user_id_key: :current_user,
skip_session_cache: true # Prevents session conflicts with auth
end⚠️ Critical Plug Configuration
1. Plug Order
Place the consent plug AFTER your authentication plug (e.g., :load_current_user). The consent plug needs access to conn.assigns.current_user to determine if the user is authenticated.
# ✅ CORRECT ORDER
plug :load_current_user # Sets conn.assigns.current_user
plug MyApp.Consent.Plug # Reads conn.assigns.current_user
# ❌ WRONG ORDER
plug MyApp.Consent.Plug # Can't find current_user yet!
plug :load_current_user # Too late2. Session Conflicts
Use skip_session_cache: true to prevent the consent plug from interfering with authentication session management:
plug MyApp.Consent.Plug,
resource: MyApp.Consent.ConsentSettings,
skip_session_cache: true # ← CRITICAL for auth compatibilityWithout this, you may experience:
FunctionClauseErrorin session handling- Authentication failures in LiveView contexts
- Session data corruption
- Lost user authentication state
The skip_session_cache option makes the plug read from cookies only, avoiding any put_session/3 calls that could conflict with your authentication library.
Handling Non-Atomic Operations
If you use custom validation functions or changesets in your actions, you may see warnings during compilation:
warning: actions -> update : cannot be done atomically, so it cannot be done in bulk.
warning: actions -> revoke_consent : cannot be done atomically, so it cannot be done in bulk.Understanding the Warning
Ash prefers "atomic" operations that can be executed directly at the database level for performance and safety. Non-atomic operations require:
- Loading the record into memory
- Running Elixir validation logic
- Saving changes back to the database
When It's Safe to Ignore
For consent management, this warning is safe to ignore because:
- User-specific records: Each consent record belongs to one user, eliminating race conditions
- Simple operations: Consent updates are straightforward (accept/reject/update groups)
- Minimal performance impact: Consent changes are infrequent user actions, not high-throughput operations
- No bulk operations: You're not updating thousands of consent records at once
Suppressing the Warning
If you want to explicitly acknowledge this is intentional, add require_atomic? false to your actions:
defmodule MyApp.Consent.ConsentSettings do
# ... other code
actions do
update :update do
accept [:terms, :groups, :expires_at]
require_atomic? false # Explicit: this action doesn't need to be atomic
end
update :revoke_consent do
accept [:groups]
change fn changeset, _ ->
Ash.Changeset.change_attribute(changeset, :groups, ["essential"])
end
require_atomic? false # Custom logic prevents atomic execution
end
end
endWhen to Use Atomic Operations
You should make operations atomic when:
- Processing bulk updates (updating many records at once)
- High-frequency writes (thousands per second)
- Critical race condition prevention (inventory, financial transactions)
For consent management, the non-atomic approach is perfectly acceptable and more flexible.
Custom Cookie Groups
Define your own cookie categories to match your application's needs.
Basic Custom Groups
# config/config.exs
config :ash_cookie_consent,
cookie_groups: [
%{
id: "essential",
label: "Essential Cookies",
description: "Required for basic site functionality",
required: true
},
%{
id: "analytics",
label: "Analytics & Performance",
description: "Help us understand how you use our site",
required: false
},
%{
id: "marketing",
label: "Marketing & Advertising",
description: "Used to show you relevant advertisements",
required: false
},
%{
id: "social",
label: "Social Media",
description: "Enable social sharing features",
required: false
},
%{
id: "preferences",
label: "Preference Cookies",
description: "Remember your settings and preferences",
required: false
}
]Groups with Examples
config :ash_cookie_consent,
cookie_groups: [
%{
id: "essential",
label: "Essential Cookies",
description: "Required for the website to function properly",
required: true,
examples: [
"Session cookies",
"CSRF protection",
"Load balancing"
]
},
%{
id: "analytics",
label: "Analytics Cookies",
description: "Help us improve our website",
required: false,
examples: [
"Google Analytics",
"Plausible Analytics",
"Page view tracking"
]
}
]Custom Modal Styling
Customize the appearance of the consent modal.
Override Modal Classes
<.consent_modal
current_consent={@consent}
cookie_groups={@cookie_groups}
modal_class="bg-white/95 backdrop-blur-sm"
button_class="bg-purple-600 hover:bg-purple-700"
title="🍪 Cookie Settings"
description="We value your privacy. Choose which cookies work for you."
/>Complete Custom Modal
If you need full control, create your own modal component:
defmodule MyAppWeb.CustomConsentModal do
use Phoenix.Component
import Phoenix.HTML.Form
def custom_modal(assigns) do
~H"""
<div
x-data={"{ showModal: #{@show_modal}, selectedGroups: #{Jason.encode!(@selected_groups)} }"}
x-show="showModal"
class="your-custom-classes"
>
<!-- Your custom modal HTML -->
<form phx-submit="save_consent">
<%= for group <- @cookie_groups do %>
<input
type="checkbox"
name="groups[]"
value={group.id}
checked={group.id in @selected_groups}
/>
<%= group.label %>
<% end %>
<button type="submit">Save My Choices</button>
</form>
</div>
"""
end
endCustom Storage Backend
Implement a completely custom storage backend.
defmodule MyApp.CustomStorage do
@behaviour MyApp.ConsentStorageBehaviour
@impl true
def get_consent(conn, _opts) do
# Your custom logic
# Could use Redis, Memcached, etc.
end
@impl true
def put_consent(conn, consent, _opts) do
# Your custom logic
conn
end
@impl true
def delete_consent(conn, _opts) do
# Your custom logic
conn
end
endAudit Trail Implementation
Track consent changes over time for compliance.
Create Consent History Resource
defmodule MyApp.Consent.ConsentHistory do
use Ash.Resource,
domain: MyApp.Consent,
data_layer: AshPostgres.DataLayer
postgres do
table "consent_history"
repo MyApp.Repo
end
attributes do
uuid_primary_key :id
attribute :terms, :string, allow_nil?: false
attribute :groups, {:array, :string}, default: []
attribute :action, :atom, constraints: [one_of: [:granted, :revoked, :updated]]
attribute :ip_address, :string
attribute :user_agent, :string
attribute :consented_at, :utc_datetime
timestamps()
end
relationships do
belongs_to :user, MyApp.Accounts.User
end
actions do
defaults [:read]
create :create do
primary? true
end
read :for_user do
argument :user_id, :uuid, allow_nil?: false
filter expr(user_id == ^arg(:user_id))
prepare build(sort: [consented_at: :desc])
end
end
endTrack Consent Changes
defmodule MyApp.Consent.Tracker do
alias MyApp.Consent.ConsentHistory
def track_consent_change(user_id, consent, action, conn) do
attrs = %{
user_id: user_id,
terms: consent["terms"],
groups: consent["groups"],
action: action,
ip_address: get_ip_address(conn),
user_agent: get_user_agent(conn),
consented_at: DateTime.utc_now()
}
ConsentHistory
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create()
end
defp get_ip_address(conn) do
conn.remote_ip
|> Tuple.to_list()
|> Enum.join(".")
end
defp get_user_agent(conn) do
case Plug.Conn.get_req_header(conn, "user-agent") do
[ua | _] -> ua
[] -> "Unknown"
end
end
endUse in Custom Storage
def put_consent(conn, consent, opts) do
conn = BaseStorage.put_consent(conn, consent, opts)
if user_id = get_user_id(conn, opts) do
MyApp.Consent.Tracker.track_consent_change(
user_id,
consent,
:updated,
conn
)
end
conn
endAdvanced Customization
Custom Expiration Logic
defmodule MyApp.Consent.Expiration do
def calculate_expiration(consent_type) do
base_date = DateTime.utc_now()
days =
case consent_type do
:full_consent -> 365 # 1 year
:partial_consent -> 180 # 6 months
:minimal_consent -> 90 # 3 months
end
DateTime.add(base_date, days, :day)
end
endGeo-specific Compliance
defmodule MyApp.Consent.GeoCompliance do
def required_groups_for_region(region) do
case region do
:eu -> ["essential"] # GDPR - explicit consent required
:us -> [] # No mandatory groups
:uk -> ["essential"] # UK GDPR
_ -> ["essential"]
end
end
def consent_expiration_for_region(region) do
case region do
:eu -> 365 # 1 year
:california -> 365 # CCPA
_ -> 730 # 2 years default
end
end
endNext Steps
- Review Examples for implementation patterns
- Check Troubleshooting if you encounter issues
- Contribute your extensions back to the project!