Extending AshCookieConsent

View Source

This guide shows you how to extend and customize AshCookieConsent for advanced use cases.

Table of Contents

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:

  1. lib/my_app/consent/consent_settings.ex - Your consent resource with user relationship
  2. lib/my_app/consent/consent.ex - Domain with code_interface definitions
  3. config/config.exs - Add MyApp.Consent to ash_domains list
  4. Migration file - Generated by mix ash.codegen
  5. lib/my_app/accounts/user.ex - Add has_many :consent_settings relationship

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
end

Common 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
end

Naming Convention: Use prefixes that describe what you're doing:

  • list_ for reading multiple records
  • get_ for reading a single record
  • create_ for creating records
  • update_ for updating records
  • delete_ 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:

  1. Analyze your resource definitions
  2. Compare with existing database schema
  3. 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
end

Run 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.migrate runs migrations AND updates Ash's internal schema cache
  • ecto.migrate only 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.json

What 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_at automatically set to current time
  • expires_at set to 365 days in the future
  • groups array properly stored
  • user_id foreign 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 consent

What to verify:

  • ✅ Consent record created in database with correct user_id
  • get_consent/2 returns 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
end

Implementing 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:

  1. Checks if the value is nil (anonymous user)
  2. Checks if it's a struct with an .id field (most common)
  3. Checks if it's already an ID (string or integer)
  4. 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_at timestamps, 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
end

Then 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 late

2. 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 compatibility

Without this, you may experience:

  • FunctionClauseError in 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:

  1. Loading the record into memory
  2. Running Elixir validation logic
  3. 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
end

When 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.

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
end

Custom 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
end

Audit Trail Implementation

Track consent changes over time for compliance.

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
end
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
end

Use 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
end

Advanced 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
end

Geo-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
end

Next Steps

  • Review Examples for implementation patterns
  • Check Troubleshooting if you encounter issues
  • Contribute your extensions back to the project!