BreakGlass (BreakGlassEx v0.1.0)

Copy Markdown View Source

Public façade for the break_glass_ex emergency access library.

This module is the primary entry point for all host-application controllers. Internal modules (BreakGlass.RateLimiter, BreakGlass.OtpStore, etc.) are considered private to the library.

Purpose

break_glass_ex provides an in-process emergency access subsystem for Elixir and Phoenix applications. When all normal admin accounts are locked out, a pre-configured break-glass credential can be used to regain access through a two-factor authentication flow (password → emailed OTP).

Installation

Add break_glass_ex to your mix.exs dependencies:

{:break_glass_ex, "~> 0.1"}

Configuration

All configuration lives under config :break_glass_ex in your runtime.exs:

config :break_glass_ex,
  email:         System.fetch_env!("BREAK_GLASS_EMAIL"),
  password_hash: System.fetch_env!("BREAK_GLASS_PASSWORD_HASH"),
  user_provider: MyApp.BreakGlassUserProvider,
  allowed_ips:   ~w[10.0.0.0/8 127.0.0.1],
  alert_emails:  ["security@example.com"],
  mailer:        MyApp.Mailer

See the Configuration Reference in README.md for the full list of keys.

Supervision Tree Setup

Add BreakGlass.Supervisor to your application's supervision tree before your endpoint or router:

children = [
  # ... other children ...
  {BreakGlass.Supervisor, []},
  MyAppWeb.Endpoint
]

UserProvider Behaviour

Implement BreakGlass.UserProvider in your host application to return your own user struct on successful break-glass authentication:

defmodule MyApp.BreakGlassUserProvider do
  @behaviour BreakGlass.UserProvider

  def build_user(attrs) do
    %MyApp.User{
      id:          attrs.sentinel_id,
      email:       attrs.email,
      break_glass: true
    }
  end
end

Security Considerations

  • Physical IP only. Always pass conn.remote_ip (formatted via :inet.ntoa/1) and never derive the IP from x-forwarded-for or any proxy header.
  • x-forwarded-for WARNING. The library has no way to enforce IP origin. It is a documented contract that the host controller MUST use the physical IP.
  • Password hash storage. Never store the plaintext break-glass password in source control. Use mix break_glass.gen_hash <password> to generate a hash and store it in an environment variable or secrets manager.
  • Lockout defaults. The default rate limit is 5 attempts with a 900-second (15 minute) lockout window. Configure :max_attempts and :lockout_seconds to suit your security policy.

Summary

Functions

Returns true if the given user term has break_glass: true.

Step 1 of the two-factor authentication flow: verify email, IP, and password.

Returns a list containing the configured break-glass email address.

Returns true if ip is in the configured IP whitelist.

Returns true if ip is currently locked out due to too many failed attempts.

Returns the number of remaining authentication attempts for ip.

Returns the configured sentinel ID (default: 0).

Step 2 of the two-factor authentication flow: verify the OTP code.

Functions

active?(arg1)

@spec active?(user :: term()) :: boolean()

Returns true if the given user term has break_glass: true.

Handles both plain maps with atom keys and structs that implement the break_glass field.

iex> BreakGlass.active?(%{break_glass: true})
true

iex> BreakGlass.active?(%{break_glass: false})
false

iex> BreakGlass.active?(%{})
false

authenticate(email, password, ip)

@spec authenticate(email :: String.t(), password :: String.t(), ip :: String.t()) ::
  {:ok, :otp_required}
  | {:error, :ip_not_allowed}
  | {:error, :rate_limited}
  | :error

Step 1 of the two-factor authentication flow: verify email, IP, and password.

Arguments

  • email — the email address submitted by the operator
  • password — the plaintext password to verify against the configured bcrypt hash
  • ip — the physical source IP address (from conn.remote_ip, never from headers)

Returns

  • {:ok, :otp_required} — credentials correct; OTP sent; host should redirect to OTP form
  • {:error, :rate_limited} — IP is currently locked out after too many failed attempts
  • {:error, :ip_not_allowed} — IP is not in the configured whitelist
  • :error — email or password did not match (intentionally ambiguous)

All rejection paths call Bcrypt.no_user_verify/0 to maintain constant-time response latency regardless of which check fails.

emails()

@spec emails() :: [String.t()]

Returns a list containing the configured break-glass email address.

Returns [email] where email is read from config :break_glass_ex, :email.

ip_allowed?(ip)

@spec ip_allowed?(ip :: String.t()) :: boolean()

Returns true if ip is in the configured IP whitelist.

The whitelist is read from config :break_glass_ex, :allowed_ips and defaults to ["127.0.0.1", "::1"] if the key is absent. Each entry may be an exact IP address or a CIDR range (e.g. "10.0.0.0/8").

Host-app controllers can call this function directly before invoking authenticate/3 to short-circuit a request early and return a proper HTTP response.

locked_out?(ip)

@spec locked_out?(ip :: String.t()) :: boolean()

Returns true if ip is currently locked out due to too many failed attempts.

Delegates to BreakGlass.RateLimiter.locked_out?/1.

remaining_attempts(ip)

@spec remaining_attempts(ip :: String.t()) :: non_neg_integer()

Returns the number of remaining authentication attempts for ip.

Delegates to BreakGlass.RateLimiter.remaining_attempts/1.

sentinel_id()

@spec sentinel_id() :: non_neg_integer()

Returns the configured sentinel ID (default: 0).

The sentinel ID is a value that can never match a real database primary key, used as the break-glass user's identifier to prevent accidental DB writes.

verify_otp(code, ip)

@spec verify_otp(code :: String.t(), ip :: String.t()) ::
  {:ok, user :: term()} | {:error, :invalid_otp}

Step 2 of the two-factor authentication flow: verify the OTP code.

Arguments

  • code — the 6-digit OTP string submitted by the operator
  • ip — the physical source IP address (must match the IP used in step 1)

Returns

  • {:ok, user} — OTP verified; user is the term returned by the configured UserProvider.build_user/1 callback
  • {:error, :invalid_otp} — code did not match, had expired, or no OTP was pending

On success, the rate limit counter for ip is reset and a Logger.warning is emitted. On failure, a Logger.warning is emitted with the IP and failure reason.