Skip to content

KeenMate/keen-auth-permissions

Repository files navigation

KeenAuthPermissions

A comprehensive Elixir library for authentication and authorization, extending keen_auth with PostgreSQL-backed permission management. Built entirely on top of 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
  • 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:

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:

defmodule MyApp.Database do
  use KeenAuthPermissions.Database, repo: MyApp.Repo
end
# config/config.exs
config :keen_auth_permissions,
  db_context: MyApp.Database

Full config reference

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

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

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

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

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:

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

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. 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):

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

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

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

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:

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

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:

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:

# 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

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:

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 for the full event classification documentation.

System Parameters

The 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()
# 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 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:

mix docs

License

See LICENSE file for details.

About

Elixir authentication & authorization library with a database-first approach. PostgreSQL-backed permissions, multi-tenant support, user groups, API keys, token management, and auto-generated Elixir wrappers for stored procedures.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages