Packages
ash_cookie_consent
0.1.0
GDPR-compliant cookie consent management for Ash Framework applications. Provides Phoenix Components, LiveView integration, and database persistence for tracking user consent with full audit trail.
Current section
Files
Jump to
Current section
Files
ash_cookie_consent
usage-rules.md
usage-rules.md
# AshCookieConsent Usage Rules
**Version**: 0.1.0
**Purpose**: Guide AI assistants in correctly implementing GDPR-compliant cookie consent management
## Quick Integration Checklist
**For a basic cookie-only setup (no database), complete these 3 steps:**
1. **Add Plug to Router** (after `:fetch_session` and `:fetch_cookies`)
```elixir
plug AshCookieConsent.Plug, skip_session_cache: true
```
2. **Add Modal to root.html.heex**
```heex
<AshCookieConsent.Components.ConsentModal.consent_modal
current_consent={assigns[:consent]}
cookie_groups={assigns[:cookie_groups]}
/>
```
3. **Add LiveView Hook** (if using LiveView - in router, NOT web.ex)
```elixir
# In router.ex - use live_session for public routes ONLY
live_session :public,
on_mount: [{AshCookieConsent.LiveView.Hook, :load_consent}] do
live "/", HomeLive
end
```
4. **Add Consent Controller** (for form submission - in router)
```elixir
post "/consent", ConsentController, :create
```
**⚠️ Critical**: If using authentication libraries (like AshAuthentication), use `skip_session_cache: true` to avoid session conflicts.
**⚠️ ANTI-PATTERN**: Do NOT add hooks to your `live_view` macro in `lib/my_app_web.ex`. This applies hooks globally to ALL LiveViews (including admin routes). Use router-level `live_session` instead for conditional application.
See [Getting Started Guide](guides/getting-started.html) for complete setup.
---
## Understanding AshCookieConsent
AshCookieConsent is a lightweight, Ash-native library for Phoenix applications that provides:
- GDPR-compliant cookie consent management
- Three-tier storage (assigns → session → cookie → database)
- Phoenix Plug and LiveView Hook integration
- Configurable cookie groups (essential, analytics, marketing, etc.)
- Conditional script loading based on consent
- Audit trail support through Ash resources
**Core Philosophy**: Minimal overhead, maximum flexibility. Store consent in browser cookies for anonymous users, with optional database persistence for authenticated users.
## Project Setup
### Adding the Dependency
```elixir
# mix.exs
def deps do
[
{:ash_cookie_consent, "~> 0.1"}
]
end
```
### Creating the ConsentSettings Resource
The ConsentSettings resource tracks consent decisions. It MUST use these attribute names:
```elixir
# GOOD: Standard ConsentSettings resource
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
# REQUIRED attributes with exact names
attribute :terms, :string, allow_nil?: false
attribute :groups, {:array, :string}, default: []
attribute :consented_at, :utc_datetime
attribute :expires_at, :utc_datetime
timestamps()
end
# REQUIRED actions
actions do
defaults [:read, :destroy]
create :create do
primary? true
accept [:terms, :groups, :consented_at, :expires_at]
# Auto-set timestamps
change fn changeset, _context ->
now = DateTime.utc_now() |> DateTime.truncate(:second)
expires = DateTime.add(now, 365, :day) |> DateTime.truncate(:second)
changeset
|> Ash.Changeset.change_attribute(:consented_at, now)
|> Ash.Changeset.change_attribute(:expires_at, expires)
end
end
update :update do
primary? true
accept [:terms, :groups, :expires_at]
end
end
end
```
**WRONG**: Don't rename core attributes or omit required actions:
```elixir
# WRONG: Using different attribute names
attribute :policy_version, :string # Should be :terms
attribute :categories, {:array, :string} # Should be :groups
attribute :accepted_at, :utc_datetime # Should be :consented_at
```
### Adding User Relationships (Optional)
For authenticated users, add a `belongs_to :user` relationship:
```elixir
# GOOD: Optional user relationship
relationships do
belongs_to :user, MyApp.Accounts.User do
allow_nil? true # Allow anonymous consent
attribute_writable? true
end
end
actions do
create :create do
accept [:terms, :groups, :consented_at, :expires_at, :user_id] # Add user_id
end
end
```
## Phoenix Integration
### Router Configuration
The consent Plug MUST come after `:fetch_session`:
```elixir
# GOOD: Correct plug order
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session # MUST come first
plug :fetch_live_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
# Add consent plug after session
plug AshCookieConsent.Plug, resource: MyApp.Consent.ConsentSettings
end
```
**WRONG**: Plug before session fetch will fail:
```elixir
# WRONG: Consent plug before fetch_session
plug AshCookieConsent.Plug, resource: MyApp.Consent.ConsentSettings
plug :fetch_session # Too late - plug needs session
```
### LiveView Integration
**❌ ANTI-PATTERN - Do NOT add hooks to `live_view` macro:**
```elixir
# ❌ WRONG: Applies to ALL LiveViews globally (admin, internal tools, etc.)
# lib/my_app_web.ex
def live_view do
quote do
use Phoenix.LiveView, layout: {MyAppWeb.Layouts, :app}
# ❌ DON'T DO THIS - applies globally
on_mount {AshCookieConsent.LiveView.Hook, :load_consent}
end
end
```
**Why this is wrong:**
- Applies consent to admin routes that don't need it
- Causes authentication conflicts
- Violates separation of concerns (GDPR is for public-facing pages)
**✅ CORRECT: Use router-level `live_session` for conditional application:**
```elixir
# ✅ GOOD: Router-level conditional application
# lib/my_app_web/router.ex
live_session :public,
on_mount: [{AshCookieConsent.LiveView.Hook, :load_consent}] do
live "/", HomeLive
live "/about", AboutLive
# Only public routes that need consent tracking
end
live_session :admin,
on_mount: [YourApp.AdminAuthHook] do # No consent for admin
live "/admin", AdminDashboardLive
end
```
**Import components in web.ex:**
```elixir
# lib/my_app_web.ex
defp html_helpers do
quote do
# Import consent components for use in templates
import AshCookieConsent.Components.ConsentModal
import AshCookieConsent.Components.ConsentScript
end
end
```
### ⚠️ CRITICAL: live_session Replaces on_mount from live_view Macro
**Phoenix Behavior**: When you specify `on_mount` in a `live_session`, it **completely replaces** any `on_mount` from your `live_view` macro. This is a common source of bugs.
```elixir
# ❌ WRONG: Only AshAuth hook runs, consent hook is LOST
# lib/my_app_web.ex
def live_view do
quote do
on_mount {AshCookieConsent.LiveView.Hook, :load_consent} # Won't run!
end
end
# lib/my_app_web/router.ex
live_session :admin,
on_mount: AshAuthentication.Phoenix.LiveSession do
# Consent hook from live_view macro is REPLACED, not combined!
live "/admin", AdminDashboardLive
end
# ✅ CORRECT: Explicitly list ALL hooks in live_session
live_session :admin,
on_mount: [
AshAuthentication.Phoenix.LiveSession, # First
{AshCookieConsent.LiveView.Hook, :load_consent} # Second
] do
live "/admin", AdminDashboardLive
end
# 💡 BETTER: Skip consent hook for admin routes (prevents session conflicts)
live_session :admin,
on_mount: [AshAuthentication.Phoenix.LiveSession] do
live "/admin", AdminDashboardLive
# No consent hook needed - Plug already set assigns, no modal on admin pages
end
```
**Rule of Thumb**:
- If you use `on_mount` in **both** `live_view` macro AND `live_session`, you MUST list ALL hooks in the `live_session`
- The `live_session` hooks take precedence and replace the macro hooks
- Missing this causes silent failures where assigns like `@consent` or `@current_user` are nil
**When to Skip the Consent Hook**:
- ✅ **Include**: Public-facing pages that show the consent modal
- ❌ **Skip**: Admin panels, authenticated dashboards (prevents session interference, see troubleshooting guide)
**Debugging**:
```elixir
# Add to your mount/3 to check which hooks ran
def mount(_params, _session, socket) do
IO.inspect(Map.keys(socket.assigns), label: "Available assigns")
# Should see: [:consent, :cookie_groups, :show_consent_modal, :current_user, ...]
end
```
## Layout Configuration
### Adding the Consent Modal
The modal MUST be in `root.html.heex`, not `app.html.heex`:
```heex
<!-- GOOD: In root.html.heex -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- ... other head elements ... -->
</head>
<body>
<%= @inner_content %>
<!-- Consent Modal -->
<.consent_modal
current_consent={assigns[:consent]}
cookie_groups={assigns[:cookie_groups] || AshCookieConsent.cookie_groups()}
privacy_url="/privacy"
/>
<!-- LiveView Cookie Update Handler -->
<script>
window.addEventListener("phx:update-consent-cookie", (e) => {
const consent = e.detail.consent;
const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString();
document.cookie = `_consent=${encodeURIComponent(consent)}; expires=${expires}; path=/; SameSite=Lax`;
});
</script>
</body>
</html>
```
**WRONG**: Don't put in app.html.heex (won't show on first load):
```heex
<!-- WRONG: In app.html.heex -->
<main>
<%= @inner_content %>
<.consent_modal ... /> <!-- Won't show properly -->
</main>
```
### Conditional Script Loading
Use `ConsentScript` component for analytics/marketing scripts:
```heex
<!-- GOOD: Conditional script loading -->
<head>
<!-- Only loads if analytics consent given -->
<.consent_script
consent={assigns[:consent]}
group="analytics"
src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
async={true}
/>
<!-- Inline script with consent check -->
<.consent_script consent={assigns[:consent]} group="analytics">
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'GA_MEASUREMENT_ID');
</.consent_script>
</head>
```
**WRONG**: Don't use regular script tags (they load regardless of consent):
```heex
<!-- WRONG: Loads without checking consent -->
<script src="https://www.googletagmanager.com/gtag/js?id=GA_ID"></script>
```
## Helper Functions
### Checking Consent
Use the helper functions to check consent status:
```elixir
# GOOD: Check specific group consent
def home(conn, _params) do
if AshCookieConsent.consent_given?(conn, "analytics") do
# Load analytics
end
render(conn, :home)
end
# GOOD: In LiveView
def mount(_params, _session, socket) do
analytics_enabled = AshCookieConsent.consent_given?(socket, "analytics")
{:ok, assign(socket, analytics_enabled: analytics_enabled)}
end
```
**Available Helper Functions**:
- `consent_given?(conn_or_socket, group)` - Check if consent given for group
- `get_consent(conn_or_socket)` - Get full consent map
- `has_consent?(conn_or_socket)` - Check if any consent exists
- `consent_expired?(consent)` - Check if consent has expired
- `needs_consent?(conn_or_socket)` - Check if consent modal should show
- `cookie_groups()` - Get configured cookie groups
### Consent Data Structure
Consent data is a map with these keys (can be atom or string keys):
```elixir
%{
"terms" => "v1.0", # Policy version
"groups" => ["essential", "analytics"], # Consented groups
"consented_at" => ~U[2024-01-01 12:00:00Z],
"expires_at" => ~U[2025-01-01 12:00:00Z]
}
# Or with atom keys:
%{
terms: "v1.0",
groups: ["essential", "analytics"],
consented_at: ~U[2024-01-01 12:00:00Z],
expires_at: ~U[2025-01-01 12:00:00Z]
}
```
**IMPORTANT**: Helper functions handle both atom and string keys automatically.
## Configuration
### Cookie Groups
Configure cookie categories in your application config:
```elixir
# config/config.exs
config :ash_cookie_consent,
cookie_groups: [
%{
id: "essential",
label: "Essential Cookies",
description: "Required for basic site functionality",
required: true # Cannot be disabled
},
%{
id: "analytics",
label: "Analytics Cookies",
description: "Help us understand how you use our site",
required: false
},
%{
id: "marketing",
label: "Marketing Cookies",
description: "Used to show you relevant advertisements",
required: false
}
]
```
**IMPORTANT**: The `id` field MUST match the group name used in `consent_given?/2` and `ConsentScript` components.
### Cookie Settings
Customize cookie behavior:
```elixir
# config/config.exs
config :ash_cookie_consent,
cookie_name: "_my_app_consent", # Default: "_consent"
cookie_max_age: 31_536_000, # Default: 1 year in seconds
cookie_secure: true, # Default: false (use true in production)
cookie_http_only: false, # Default: false (JS needs access)
cookie_same_site: "Lax" # Default: "Lax"
```
## Frontend Requirements
### AlpineJS
The consent modal requires AlpineJS:
```bash
cd assets
npm install alpinejs --save
```
```javascript
// assets/js/app.js
import Alpine from 'alpinejs'
window.Alpine = Alpine
Alpine.start()
```
**WRONG**: Modal won't work without AlpineJS:
```javascript
// WRONG: Not imported
// Alpine features won't work
```
### Tailwind CSS
Add the library path to Tailwind config:
```javascript
// assets/tailwind.config.js
module.exports = {
content: [
'./js/**/*.js',
'../lib/*_web.ex',
'../lib/*_web/**/*.*ex',
'../deps/ash_cookie_consent/lib/**/*.ex' // IMPORTANT: Add this
],
// ...
}
```
**WRONG**: Without this, modal will have no styling:
```javascript
// WRONG: Library path not included
content: [
'./js/**/*.js',
'../lib/*_web/**/*.*ex'
// Missing: '../deps/ash_cookie_consent/lib/**/*.ex'
]
```
## Common Patterns
### Handling Consent Updates in LiveView
```elixir
# GOOD: Handle consent update event
defmodule MyAppWeb.SettingsLive do
use MyAppWeb, :live_view
@impl true
def handle_event("update_consent", params, socket) do
# Use the hook's helper
AshCookieConsent.LiveView.Hook.handle_consent_update(
socket,
params,
resource: MyApp.Consent.ConsentSettings
)
end
@impl true
def handle_event("show_consent_modal", _params, socket) do
{:noreply, AshCookieConsent.LiveView.Hook.show_modal(socket)}
end
end
```
### Custom Consent Controller (Traditional Phoenix)
```elixir
# GOOD: Traditional form submission
defmodule MyAppWeb.ConsentController do
use MyAppWeb, :controller
def update(conn, params) do
groups = parse_groups(params)
consent = %{
"terms" => params["terms"] || "v1.0",
"groups" => groups,
"consented_at" => DateTime.utc_now() |> DateTime.truncate(:second),
"expires_at" => DateTime.add(DateTime.utc_now(), 365, :day) |> DateTime.truncate(:second)
}
conn =
AshCookieConsent.Storage.put_consent(
conn,
consent,
resource: MyApp.Consent.ConsentSettings
)
conn
|> put_flash(:info, "Consent preferences saved")
|> redirect(to: "/")
end
defp parse_groups(%{"groups" => groups}) when is_list(groups), do: groups
defp parse_groups(_), do: ["essential"]
end
```
### Accept All / Reject All Shortcuts
```elixir
# GOOD: Quick accept/reject actions
def handle_event("accept_all", _params, socket) do
all_groups =
AshCookieConsent.cookie_groups()
|> Enum.map(& &1.id)
params = %{
"terms" => "v1.0",
"groups" => all_groups
}
AshCookieConsent.LiveView.Hook.handle_consent_update(
socket,
params,
resource: MyApp.Consent.ConsentSettings
)
end
def handle_event("reject_all", _params, socket) do
params = %{
"terms" => "v1.0",
"groups" => ["essential"] # Only essential
}
AshCookieConsent.LiveView.Hook.handle_consent_update(
socket,
params,
resource: MyApp.Consent.ConsentSettings
)
end
```
## Advanced: Database Sync for Authenticated Users
### Custom Storage Module
Create custom storage to sync consent to database for logged-in users:
```elixir
# GOOD: Custom storage with DB sync
defmodule MyApp.Consent.Storage do
alias AshCookieConsent.Storage, as: BaseStorage
alias MyApp.Consent.ConsentSettings
def get_consent(conn, opts \\ []) do
# Try base storage first
case BaseStorage.get_consent(conn, opts) do
nil ->
# Check database for authenticated users
case get_user_id(conn, opts) do
nil -> nil
user_id -> load_from_database(user_id)
end
consent ->
consent
end
end
def put_consent(conn, consent, opts \\ []) do
# Save to base storage (cookie/session)
conn = BaseStorage.put_consent(conn, consent, opts)
# Also save to database if authenticated
if user_id = get_user_id(conn, opts) do
save_to_database(user_id, consent)
end
conn
end
defp get_user_id(conn, opts) do
user_id_key = Keyword.get(opts, :user_id_key, :current_user_id)
Map.get(conn.assigns, user_id_key)
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, record} ->
%{
"terms" => record.terms,
"groups" => record.groups,
"consented_at" => record.consented_at,
"expires_at" => record.expires_at
}
{:error, _} -> nil
end
end
defp save_to_database(user_id, consent) do
ConsentSettings
|> Ash.Changeset.for_create(:create, %{
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]
})
|> Ash.create()
end
end
```
Then use a custom plug that calls your storage module.
## Common Mistakes
### 1. Plug Order
**WRONG**: Consent plug before `:fetch_session`
```elixir
plug AshCookieConsent.Plug, resource: MyApp.Consent.ConsentSettings
plug :fetch_session # Too late!
```
**GOOD**: Session must be fetched first
```elixir
plug :fetch_session
plug AshCookieConsent.Plug, resource: MyApp.Consent.ConsentSettings
```
### 2. Modal Placement
**WRONG**: Modal in `app.html.heex`
```heex
<!-- app.html.heex -->
<main>
<.consent_modal ... /> <!-- Won't work -->
</main>
```
**GOOD**: Modal in `root.html.heex`
```heex
<!-- root.html.heex -->
<body>
<%= @inner_content %>
<.consent_modal ... /> <!-- Correct -->
</body>
```
### 3. Script Loading
**WRONG**: Regular script tag (loads regardless of consent)
```heex
<script src="https://analytics.example.com/script.js"></script>
```
**GOOD**: Conditional loading with ConsentScript
```heex
<.consent_script
consent={@consent}
group="analytics"
src="https://analytics.example.com/script.js"
/>
```
### 4. Attribute Names
**WRONG**: Custom attribute names
```elixir
attribute :policy_version, :string # Should be :terms
attribute :categories, {:array, :string} # Should be :groups
```
**GOOD**: Standard attribute names
```elixir
attribute :terms, :string
attribute :groups, {:array, :string}
```
### 5. Missing AlpineJS
**WRONG**: Modal doesn't work without AlpineJS
```javascript
// No Alpine import
```
**GOOD**: AlpineJS properly imported
```javascript
import Alpine from 'alpinejs'
window.Alpine = Alpine
Alpine.start()
```
### 6. Tailwind Configuration
**WRONG**: Library path not in Tailwind content
```javascript
content: [
'../lib/*_web/**/*.*ex'
// Missing library path
]
```
**GOOD**: Include library in content array
```javascript
content: [
'../lib/*_web/**/*.*ex',
'../deps/ash_cookie_consent/lib/**/*.ex'
]
```
## Testing
### Unit Tests
```elixir
# GOOD: Test consent checking
test "consent_given?/2 returns true for consented groups", %{conn: conn} do
conn = Plug.Conn.assign(conn, :consent, %{
"groups" => ["essential", "analytics"]
})
assert AshCookieConsent.consent_given?(conn, "analytics")
refute AshCookieConsent.consent_given?(conn, "marketing")
end
```
### Integration Tests
```elixir
# GOOD: Test full consent flow
test "consent persists across requests", %{conn: conn} do
# Grant consent
conn = post(conn, "/consent", %{
"terms" => "v1.0",
"groups" => ["essential", "analytics"]
})
# Check cookie was set
assert conn.resp_cookies["_consent"]
# Make new request
conn = get(conn, "/")
assert conn.assigns.consent["groups"] == ["essential", "analytics"]
end
```
## Performance Considerations
- **Plug Overhead**: Should be <1ms per request (consent checked from assigns/session/cookie)
- **Cookie Size**: Consent cookie is typically <500 bytes
- **Database Sync**: Only occurs on consent change, not every request
- **Session Caching**: Consent cached in session after first load
## Migration Notes
When adding to an existing application:
1. **Check Plug Order**: Consent plug MUST come after `:fetch_session`
2. **Update Layouts**: Add modal to `root.html.heex`
3. **Install AlpineJS**: Required for modal interactivity
4. **Configure Tailwind**: Add library path to content array
5. **Run Migrations**: Generate and run `consent_settings` table migration
6. **Test Integration**: Verify modal appears and consent persists
## Security Considerations
- **HttpOnly**: Cookie is NOT HttpOnly (JavaScript needs access for client-side consent checks)
- **Secure**: Set `cookie_secure: true` in production (HTTPS only)
- **SameSite**: Default is "Lax" (appropriate for consent cookies)
- **XSS Protection**: ConsentScript component escapes all user input
- **Cookie Size**: Consent data is minimal (<1KB), reducing attack surface
## GDPR Compliance
AshCookieConsent helps with GDPR compliance by:
1. **Explicit Consent**: Users must actively grant consent (no pre-checked boxes)
2. **Granular Control**: Users can consent to specific cookie categories
3. **Easy Withdrawal**: Users can revoke consent at any time
4. **Audit Trail**: Database persistence provides consent history
5. **Expiration**: Consent expires after 365 days (configurable)
**Note**: GDPR compliance requires more than just technical implementation. Ensure your privacy policy and consent text meet legal requirements.
## Quick Reference
### Essential Files Checklist
- [ ] ConsentSettings resource created with required attributes
- [ ] Plug added to router (after `:fetch_session`)
- [ ] LiveView hook added (if using LiveView)
- [ ] Modal added to `root.html.heex`
- [ ] AlpineJS installed and imported
- [ ] Tailwind config updated with library path
- [ ] Cookie update handler script added
- [ ] Database migration run
### Required Attributes
- `terms` (string) - Policy version
- `groups` (array of strings) - Consented categories
- `consented_at` (utc_datetime) - When consent given
- `expires_at` (utc_datetime) - When consent expires
### Required Actions
- `create` (primary) - Create consent record
- `update` (primary) - Update consent
- `read` - Read consent records
- `destroy` - Delete consent
### Helper Functions
- `consent_given?(conn_or_socket, group)` - Check consent
- `get_consent(conn_or_socket)` - Get full consent
- `has_consent?(conn_or_socket)` - Check if any consent
- `needs_consent?(conn_or_socket)` - Should show modal?
- `cookie_groups()` - Get configured groups
### Component Usage
```heex
<!-- Modal -->
<.consent_modal
current_consent={@consent}
cookie_groups={AshCookieConsent.cookie_groups()}
privacy_url="/privacy"
/>
<!-- Conditional Script -->
<.consent_script
consent={@consent}
group="analytics"
src="https://analytics.example.com/script.js"
/>
```
## Support
For issues or questions:
- GitHub: https://github.com/shotleybuilder/ash_cookie_consent
- Documentation: https://hexdocs.pm/ash_cookie_consent
- Ash Discord: #libraries channel