# KeenAuthPermissions
A comprehensive Elixir library for authentication and authorization, extending [keen_auth](https://hexdocs.pm/keen_auth/readme.html) with PostgreSQL-backed permission management. Built entirely on top of [postgresql-permissions-model](https://github.com/KeenMate/postgresql-permissions-model), this library serves as an Elixir wrapper around the stored procedures defined in that PostgreSQL module.
## Features
- **Database-First Architecture**: Business logic implemented as PostgreSQL stored procedures with Elixir wrappers
- **Multi-Tenant Support**: Full tenant isolation for permissions, groups, and users
- **User Management**: Registration, authentication, user profiles, and identity management
- **Group Management**: User groups with membership and permission inheritance
- **Permission System**: Hierarchical permissions with short codes, permission sets, and source tracking
- **Permissions Map**: In-memory GenServer cache for fast full_code/short_code translation and permission checks
- **API Key Management**: Create and manage API keys with granular permissions
- **Audit Trail**: Unified audit trail and security event queries with data purge support
- **Token Management**: Secure token creation, validation, and lifecycle management
- **Event Logging**: User events with IP, user agent, and origin tracking
- **SSE Event Classification**: Tiered real-time event handling (hard/medium/soft) for LiveView apps — see [docs/sse-event-handling.md](docs/sse-event-handling.md)
- **PostgreSQL NOTIFY Listener**: Real-time event broadcasting from database triggers via PgListener
- **Service Accounts**: Purpose-specific accounts (system, registrator, authenticator, etc.) for meaningful audit trails
- **Email Authentication**: Built-in support for email/password authentication with Pbkdf2 hashing
## Installation
Add `keen_auth_permissions` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:keen_auth_permissions, "~> 1.0.0-rc.1"}
]
end
```
## Configuration
### Database context
Create a database module in your application and point the config to it:
```elixir
defmodule MyApp.Database do
use KeenAuthPermissions.Database, repo: MyApp.Repo
end
```
```elixir
# config/config.exs
config :keen_auth_permissions,
db_context: MyApp.Database
```
### Full config reference
```elixir
# config/config.exs
config :keen_auth_permissions,
db_context: MyApp.Database,
tenant: 1, # default tenant ID
password_hasher: Pbkdf2, # or Argon2, Bcrypt, etc.
user_extra_fields: [:employee_number, :department], # extend the User struct
context_extra_fields: [:tenant_code, :session_id], # extend RequestContext
notifier: [
enabled: true,
pubsub: MyApp.PubSub
],
pg_listener: [
enabled: true,
repo: MyApp.Repo,
pubsub: MyApp.PubSub,
channels: ["auth_events"],
debounce_interval: 200
]
```
### Extending the User Struct
The `%KeenAuthPermissions.User{}` struct ships with standard fields (`user_id`, `code`, `uuid`, `username`, `email`, `display_name`, `groups`, `permissions`). Consuming applications can add custom fields:
```elixir
# config/config.exs
config :keen_auth_permissions,
user_extra_fields: [:employee_number, :department, :phone]
```
Extra fields default to `nil` and work like any other struct field — dot access, pattern matching, and compile-time validation:
```elixir
# In your processor
user = %KeenAuthPermissions.User{
user_id: db_user.user_id,
# ... standard fields ...
employee_number: "EMP-1234",
department: "engineering"
}
# In your code
user.employee_number
%User{department: dept} = current_user
```
### Extending the Request Context
The `%KeenAuthPermissions.RequestContext{}` struct ships with standard context fields (`ip`, `user_agent`, `origin`, `language_code`, `request_id`). Consuming applications can add custom context fields that will be included in the JSONB context parameter passed to stored procedures:
```elixir
# config/config.exs
config :keen_auth_permissions,
context_extra_fields: [:tenant_code, :session_id, :device_id]
```
Extra fields default to `nil`, can be set via `new/2` opts or `with_field/3`, and are automatically included in `to_context_map/1`:
```elixir
alias KeenAuthPermissions.RequestContext
# Pass extra fields when creating context
ctx = RequestContext.new(user,
ip: "10.0.0.1",
tenant_code: "acme",
session_id: "sess-789"
)
# Or set them later
ctx = RequestContext.with_field(ctx, :device_id, "dev-001")
# Serialize for the JSONB parameter (used internally by facade modules)
RequestContext.to_context_map(ctx)
# => %{"ip" => "10.0.0.1", "request_id" => "req-123",
# "tenant_code" => "acme", "session_id" => "sess-789", "device_id" => "dev-001"}
```
> **Note:** `request_id` is a built-in field (not an extra field). It is passed as the `correlation_id`
> parameter on every stored procedure call, and also included in `to_context_map/1` for the JSONB parameter.
## Application Setup
On application start, ensure required seed data exists. This is idempotent and safe to run on every boot:
```elixir
defmodule MyApp.Application do
use Application
require Logger
def start(_type, _args) do
children = [
MyApp.Repo,
{Phoenix.PubSub, name: MyApp.PubSub},
# Permissions full_code <-> short_code cache
KeenAuthPermissions.PermissionsMap,
# PostgreSQL LISTEN/NOTIFY -> SSE bridge (optional)
KeenAuthPermissions.PgListener,
MyAppWeb.Endpoint
]
result = Supervisor.start_link(children, strategy: :one_for_one)
# Ensure seed data after supervisor starts
ensure_providers()
ensure_token_types()
ensure_group_mappings()
result
end
defp ensure_providers do
db = KeenAuthPermissions.DbContext.get_global_db_context()
for {code, name} <- [{"email", "Email"}, {"entra", "Microsoft Entra ID"}] do
case db.auth_ensure_provider("system", 1, "app-startup", code, name, true) do
{:ok, [%{is_new: true}]} -> Logger.info("Created provider: #{code}")
{:ok, [%{is_new: false}]} -> :ok
{:error, reason} -> Logger.warning("Failed to ensure provider '#{code}': #{inspect(reason)}")
end
end
end
defp ensure_token_types do
alias KeenAuthPermissions.{TokenTypes, RequestContext}
ctx = RequestContext.system_ctx()
# 86400 seconds = 24 hours, tenant_id 1
case TokenTypes.ensure_exists(ctx, "email_confirmation", 86400, 1) do
{:ok, :exists} -> :ok
{:ok, _created} -> Logger.info("Created token type: email_confirmation")
{:error, reason} -> Logger.warning("Failed to ensure token type: #{inspect(reason)}")
end
end
defp ensure_group_mappings do
db = KeenAuthPermissions.DbContext.get_global_db_context()
# Map external provider roles to user groups for SSO
mappings = [
%{group_code: "full_admins", provider: "entra", role: "Admins.FullAdmin"}
]
for m <- mappings do
case db.auth_ensure_user_group_mapping("system", 1, "app-startup", m.group_code, m.provider, m.role) do
{:ok, _} -> :ok
{:error, reason} -> Logger.warning("Failed to ensure group mapping: #{inspect(reason)}")
end
end
end
end
```
## Router Pipelines
Set up three authentication pipelines in your Phoenix router.
> **Note:** The path prefixes (e.g., `/auth`, `/auth/email`) are just standard Phoenix `scope` blocks — you can change them to any path that fits your application (e.g., `/identity`, `/api/v1/auth`).
```elixir
defmodule MyAppWeb.Router do
use MyAppWeb, :router
# KeenAuth setup — stores config in conn + auth session cookie
pipeline :authentication do
plug KeenAuth.Plug, otp_app: :my_app
plug KeenAuth.Plug.AuthSession, secure: false, same_site: "Lax"
end
# Optional auth — fetch user if logged in, don't require it
pipeline :maybe_auth do
plug KeenAuth.Plug.FetchUser
plug KeenAuthPermissions.Plug.RevalidateSession,
on_invalid: &KeenAuthPermissions.Plug.RevalidateSession.clear_user/2
end
# Required auth — redirect to login if not authenticated
pipeline :require_auth do
plug KeenAuth.Plug.FetchUser
plug KeenAuthPermissions.Plug.RevalidateSession
plug KeenAuth.Plug.RequireAuthenticated, redirect: "/login"
end
# Public pages (show user info if logged in)
scope "/", MyAppWeb do
pipe_through [:browser, :authentication, :maybe_auth]
get "/", PageController, :home
end
# Login/registration pages
scope "/", MyAppWeb do
pipe_through [:browser, :authentication]
get "/login", PageController, :login
post "/register", PageController, :create_registration
end
# Email auth routes (with CSRF)
scope "/auth/email" do
pipe_through [:browser, :authentication]
post "/new", KeenAuth.EmailAuthenticationController, :new
end
# OAuth routes (no CSRF — callbacks come from external providers)
scope "/auth" do
pipe_through [:browser_no_csrf, :authentication]
get "/:provider/new", KeenAuth.AuthenticationController, :new
get "/:provider/callback", KeenAuth.AuthenticationController, :callback
post "/:provider/callback", KeenAuth.AuthenticationController, :callback
end
# Protected pages
scope "/", MyAppWeb do
pipe_through [:browser, :authentication, :require_auth]
get "/dashboard", PageController, :dashboard
live "/users", UsersLive
end
end
```
## OAuth / External Provider Authentication
This library handles the **database side** of OAuth authentication — registering providers, storing user identities, syncing groups/roles from the provider, and tracking login events. The actual OAuth flow (redirects, token exchange, user info mapping) is handled by [keen_auth](https://hexdocs.pm/keen_auth/readme.html). See the keen_auth documentation for configuring OAuth strategies (Azure AD/Entra, Google, etc.).
### Registering providers
Each OAuth provider must be registered in the database before users can authenticate through it. The recommended approach is to call `auth.ensure_provider` on every application start — it's idempotent (creates the provider if missing, returns the existing one otherwise):
```elixir
# In your Application.start/2 or a startup task
db = KeenAuthPermissions.DbContext.get_global_db_context()
for {code, name} <- [{"email", "Email"}, {"entra", "Microsoft Entra ID"}] do
case db.auth_ensure_provider("system", 1, "app-startup", code, name, true) do
{:ok, [%{is_new: true}]} -> Logger.info("Created provider: #{code}")
{:ok, [%{is_new: false}]} -> :ok
{:error, reason} -> Logger.error("Failed to ensure provider #{code}: #{inspect(reason)}")
end
end
```
For manual provider management, use the facade functions:
```elixir
alias KeenAuthPermissions.Auth
ctx = KeenAuthPermissions.RequestContext.system_ctx()
# Create a provider (fails if already exists)
Auth.create_provider(ctx, "entra", "Microsoft Entra ID", true)
# Manage providers
Auth.enable_provider(ctx, "entra")
Auth.disable_provider(ctx, "entra")
```
### Processor
When a user authenticates via an OAuth provider, keen_auth calls a **processor** module that bridges the OAuth response to the permissions model. This library ships with a built-in Azure AD/Entra processor (`KeenAuthPermissions.Processor.AzureAD`) that:
1. Calls `auth.ensure_user_from_provider` — creates or updates the user identity linked to the provider
2. Calls `auth.ensure_groups_and_permissions` — syncs the user's provider groups/roles and returns their permissions
3. Returns a `%KeenAuthPermissions.User{}` struct with populated `groups` and `permissions`
```elixir
# config/config.exs — configure keen_auth to use the processor
config :keen_auth, :processors, %{
entra: KeenAuthPermissions.Processor.AzureAD
}
```
For custom OAuth providers, implement the `KeenAuth.Processor` behaviour and use the same database functions. See the `KeenAuthPermissions.Processor.AzureAD` source for a complete example.
### Provider management functions
- `Auth.create_provider/4` — register a new provider
- `Auth.update_provider/5` — update provider name/status
- `Auth.enable_provider/2` / `Auth.disable_provider/2` — toggle provider
- `Auth.delete_provider/2` — remove a provider
- `Auth.validate_provider_is_active/1` — check if provider is active
- `Auth.list_provider_users/2` — list users from a provider
## Email Authentication
The library includes built-in email/password authentication. By default it uses `Pbkdf2` for password hashing, but you can configure a different algorithm:
```elixir
# config/config.exs
config :keen_auth_permissions,
password_hasher: Argon2 # any module that implements hash_pwd_salt/1, verify_pass/2, no_user_verify/0
```
If not configured, `Pbkdf2` is used (pure Elixir, no C compiler required).
The convenience functions `Auth.authenticate_by_email/3` and `Auth.register_user/3` use this config internally. If you need full control over hashing, use the lower-level `Auth.register/5` and pass your own pre-hashed password.
```elixir
alias KeenAuthPermissions.Auth
# Email authentication
case Auth.authenticate_by_email("user@example.com", "password") do
{:ok, user} -> # Authentication successful
{:error, :invalid_credentials} -> # Invalid email or password
end
# Register a new user
Auth.register_user("user@example.com", "password", "Display Name")
```
To integrate with keen_auth, implement the `KeenAuth.EmailAuthenticationHandler` behaviour and a processor:
```elixir
# Email handler — validates credentials
defmodule MyApp.Auth.EmailHandler do
@behaviour KeenAuth.EmailAuthenticationHandler
alias KeenAuthPermissions.Auth
@impl true
def authenticate(_conn, %{"email" => email, "password" => password}) do
case Auth.authenticate_by_email(email, password) do
{:ok, user} ->
{:ok, %{"sub" => to_string(user.user_id), "email" => user.email,
"name" => user.display_name, "preferred_username" => user.username}}
{:error, _} ->
{:error, :invalid_credentials}
end
end
@impl true
def handle_authenticated(conn, _user), do: conn
@impl true
def handle_unauthenticated(conn, params, _error) do
conn
|> Phoenix.Controller.put_flash(:error, "Invalid email or password")
|> Phoenix.Controller.redirect(to: params["redirect_to"] || "/login")
end
end
```
```elixir
# Email processor — loads user from DB after authentication
defmodule MyApp.Auth.Processor do
@behaviour KeenAuth.Processor
alias KeenAuthPermissions.{DbContext, User}
@impl true
def process(conn, :email, mapped_user, response) do
db = DbContext.current_db_context!(conn)
user_id = mapped_user |> Map.get("sub") |> String.to_integer()
{:ok, [db_user]} = db.auth_get_user_by_id(user_id, nil)
{groups, permissions} = load_groups_and_permissions(db, db_user.user_id)
user = %User{
user_id: db_user.user_id, code: db_user.code, uuid: db_user.uuid,
username: db_user.username, email: db_user.email,
display_name: db_user.display_name,
groups: groups, permissions: permissions
}
{:ok, conn, user, response}
end
@impl true
def sign_out(conn, _provider, params) do
storage = KeenAuth.Storage.current_storage(conn)
conn |> storage.delete() |> Phoenix.Controller.redirect(to: params["redirect_to"] || "/")
end
defp load_groups_and_permissions(db, user_id) do
case db.auth_ensure_groups_and_permissions("system", 1, "email-login", user_id, "email", [], []) do
{:ok, [%{groups: groups, short_code_permissions: perms}]} -> {groups, perms}
_ -> {[], []}
end
end
end
```
```elixir
# config/config.exs — wire it up in keen_auth strategies
config :my_app, :keen_auth,
strategies: [
email: [
label: "Email",
authentication_handler: MyApp.Auth.EmailHandler,
mapper: KeenAuth.Mapper.Default,
processor: MyApp.Auth.Processor
]
]
```
## Request Context
Use `RequestContext` to pass user and request metadata through your application. All context fields (ip, user_agent, origin, request_id, language_code, plus any configured extra fields) are serialized into a single JSONB map for stored procedures:
```elixir
alias KeenAuthPermissions.RequestContext
# Create context from authenticated user
ctx = RequestContext.new(user,
request_id: "req-123",
ip: "192.168.1.1",
user_agent: "Mozilla/5.0...",
origin: "https://example.com"
)
# Set fields on an existing context
ctx = RequestContext.with_field(ctx, :language_code, "cs")
# System context for background jobs
ctx = RequestContext.system_ctx()
# Serialize context metadata for JSONB (used internally by facades)
RequestContext.to_context_map(ctx)
# => %{"ip" => "192.168.1.1", "request_id" => "req-123", "user_agent" => "Mozilla/5.0...", ...}
```
## Service Accounts
For automated operations, use purpose-specific service accounts instead of the generic system account:
```elixir
alias KeenAuthPermissions.RequestContext
# Service account for registration operations
ctx = RequestContext.service_ctx(:registrator)
# Service account for authentication operations
ctx = RequestContext.service_ctx(:authenticator)
# Built-in accounts: :system, :registrator, :authenticator,
# :token_manager, :api_gateway, :group_syncer, :data_processor
```
You can also define custom service accounts via config:
```elixir
# config/config.exs
config :keen_auth_permissions, :service_accounts, %{
my_importer: %{user_id: 900, username: "svc_importer", display_name: "Importer", email: "svc_importer@localhost"}
}
# then use it like any built-in account
ctx = RequestContext.service_ctx(:my_importer)
```
## Permission Helpers
```elixir
alias KeenAuthPermissions.PermissionHelpers
# Boolean checks
PermissionHelpers.has_any?(user, ["admin.read", "super.admin"])
PermissionHelpers.has_all?(user, ["users.read", "users.write"])
PermissionHelpers.in_any_group?(user, ["admins", "moderators"])
# Result-based checks (for with blocks)
with {:ok, :authorized} <- PermissionHelpers.require_any(ctx, ["admin.read"]),
{:ok, data} <- fetch_data(ctx) do
{:ok, data}
end
# Function wrappers
PermissionHelpers.with_permission(ctx, ["admin.delete"], fn ->
delete_record(id)
end)
```
## Facade Modules
The library provides high-level facade modules for common operations:
- `KeenAuthPermissions.Auth` - Authentication, registration, tokens
- `KeenAuthPermissions.Users` - User management and search
- `KeenAuthPermissions.UserGroups` - Group management and membership
- `KeenAuthPermissions.Permissions` - Permission CRUD, search, assignment, and checking
- `KeenAuthPermissions.Tenants` - Multi-tenant operations
- `KeenAuthPermissions.PermSets` - Permission set management
- `KeenAuthPermissions.ApiKeys` - API key management
- `KeenAuthPermissions.Audit` - Audit trail, security events, journal search
- `KeenAuthPermissions.SysParams` - Database-level system parameters (setup only)
- `KeenAuthPermissions.PermissionsMap` - In-memory permission code translation (GenServer)
## Session Revalidation
The `RevalidateSession` plug (used in the router pipelines above) periodically checks the database to ensure the session user is still valid (not deleted, disabled, or locked).
Options: `:interval` (seconds, default 300), `:redirect` (default `"/login"`), `:on_invalid` (custom callback), `:validate_fn` (custom validation function).
**Note:** `current_user` must be a `%KeenAuthPermissions.User{}` struct. Processors that return plain maps will cause revalidation to be skipped.
## LiveView Integration
In LiveViews, build a `RequestContext` from the session user and subscribe to SSE events for real-time updates:
```elixir
defmodule MyAppWeb.UsersLive do
use MyAppWeb, :live_view
alias KeenAuthPermissions.{Users, RequestContext}
alias MyAppWeb.AuthEventHandler
@impl true
def mount(_params, session, socket) do
user = session["current_user"]
if connected?(socket) do
# Subscribe to auth events for this user
Phoenix.PubSub.subscribe(MyApp.PubSub, "keen_auth:user:#{user.user_id}")
end
ctx = RequestContext.new(user)
{:ok,
socket
|> assign(user: user, ctx: ctx, users: [], loading: true)
|> load_users()}
end
# Handle SSE auth events (permission changes, lockouts, etc.)
@impl true
def handle_info({:sse_event, event, payload}, socket) do
{:noreply, AuthEventHandler.handle_sse_event(socket, event, payload, &load_users/1)}
end
defp load_users(socket) do
case Users.search(socket.assigns.ctx, nil, nil, nil, nil, 1, 50, 1) do
{:ok, users} -> assign(socket, users: users, loading: false)
_ -> assign(socket, users: [], loading: false)
end
end
end
```
The `AuthEventHandler` classifies SSE events using `KeenAuthPermissions.EventClassification` into tiers:
- **`:hard`** (user disabled/deleted/locked) — block the UI, force session clear
- **`:medium`** (permissions/groups changed) — show a warning banner
- **`:soft`** (data changes) — silently reload data
See [docs/sse-event-handling.md](docs/sse-event-handling.md) for the full event classification documentation.
## System Parameters
The [postgresql-permissions-model](https://github.com/KeenMate/postgresql-permissions-model) uses `auth.sys_param` entries to control database-level behavior. These are set once during deployment and rarely changed. Use `KeenAuthPermissions.SysParams` to read/update them (update requires user_id 1).
### Seeded parameters
| group_code | code | default | type | description |
|--------------|------------------|------------|-----------------|----------------------------------------------------------------------------------------------------------|
| `journal` | `level` | `"update"` | text | Journal logging verbosity. `"all"` = log everything including reads, `"update"` = state-changing only, `"none"` = disable |
| `journal` | `retention_days` | `"365"` | text (cast int) | Days of journal entries to keep. Used by `unsecure.purge_journal()` |
| `journal` | `storage_mode` | `"local"` | text | Where journal data goes. `"local"` = INSERT only, `"notify"` = pg_notify only, `"both"` = INSERT + pg_notify |
| `user_event` | `retention_days` | `"365"` | text (cast int) | Days of user events to keep. Used by `unsecure.purge_user_events()` |
| `user_event` | `storage_mode` | `"local"` | text | Where user event data goes. Same modes as journal |
| `partition` | `months_ahead` | `3` | number | Future monthly partitions to pre-create for journal and user_event tables. Used by `unsecure.ensure_audit_partitions()` |
### Not seeded but read by code
| group_code | code | default | type | description |
|------------|---------------------------|----------------------------|--------|--------------------------------------------------------------------------|
| `auth` | `perm_cache_timeout_in_s` | `300` (hardcoded fallback) | number | Permission cache TTL in seconds. Used by `unsecure.recalculate_user_permissions()` |
```elixir
# Read a parameter
{:ok, param} = KeenAuthPermissions.SysParams.get("journal", "level")
param.text_value # => "update"
# Update a parameter (only user_id 1 can do this)
KeenAuthPermissions.SysParams.update("journal", "level", "all")
KeenAuthPermissions.SysParams.update("partition", "months_ahead", nil, 6)
```
## Database Requirements
This library requires the [postgresql-permissions-model](https://github.com/KeenMate/postgresql-permissions-model) database schema to be installed.
## Code Generation
The library includes a code generation system (`db-gen`) that automatically creates Elixir modules from PostgreSQL stored procedures. See `db-gen/README.md` for details.
## Documentation
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc):
```bash
mix docs
```
## License
See LICENSE file for details.