Packages

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
ash_cookie_consent usage-rules.md
Raw

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