Packages

Modern, enterprise-grade HashiCorp Vault client for Elixir.

Current section

Files

Jump to
vaultx lib vaultx auth approle.ex
Raw

lib/vaultx/auth/approle.ex

defmodule Vaultx.Auth.AppRole do
@moduledoc """
AppRole authentication method for HashiCorp Vault.
This module implements the AppRole authentication method for Vault, providing
secure machine-to-machine authentication with comprehensive support for
role-based access control, secret management, and enterprise features.
## Features
- Machine Authentication: Role ID and Secret ID-based authentication
- Role Management: Support for role creation, updates, and deletion
- Secret ID Management: Generate, list, and revoke secret IDs
- CIDR Restrictions: IP address-based access control
- Token Policies: Flexible policy assignment and management
- Audit Integration: Comprehensive audit logging for security compliance
- Enterprise Ready: Supports all Vault enterprise features
## API Compliance
Fully implements HashiCorp Vault AppRole authentication:
- [AppRole Auth Method](https://developer.hashicorp.com/vault/api-docs/auth/approle)
- [AppRole Auth Configuration](https://developer.hashicorp.com/vault/docs/auth/approle)
## Usage Examples
### Basic Authentication
{:ok, auth_response} = Vaultx.Auth.AppRole.authenticate(%{
role_id: "59d6d1ca-47bb-4e7e-a40b-8be3bc5a0ba8",
secret_id: "84896a0c-1347-aa90-a4f6-aca8b7558780"
})
### Authentication with Custom Mount Path
{:ok, auth_response} = Vaultx.Auth.AppRole.authenticate(%{
role_id: "59d6d1ca-47bb-4e7e-a40b-8be3bc5a0ba8",
secret_id: "84896a0c-1347-aa90-a4f6-aca8b7558780"
}, mount_path: "custom-approle")
### Authentication with Additional Options
{:ok, auth_response} = Vaultx.Auth.AppRole.authenticate(%{
role_id: "59d6d1ca-47bb-4e7e-a40b-8be3bc5a0ba8",
secret_id: "84896a0c-1347-aa90-a4f6-aca8b7558780"
}, [
mount_path: "approle",
timeout: 30_000,
retry_attempts: 3
])
### Authentication without Secret ID (if bind_secret_id is false)
{:ok, auth_response} = Vaultx.Auth.AppRole.authenticate(%{
role_id: "59d6d1ca-47bb-4e7e-a40b-8be3bc5a0ba8"
})
## Vault Configuration
Before using this authentication method, configure it in Vault:
# Enable AppRole auth method
vault auth enable approle
# Create an AppRole
vault write auth/approle/role/my-role \\
token_policies="default,my-policy" \\
token_ttl=1h \\
token_max_ttl=4h \\
bind_secret_id=true
# Get the Role ID
vault read auth/approle/role/my-role/role-id
# Generate a Secret ID
vault write -f auth/approle/role/my-role/secret-id
## Security Considerations
- Store Role IDs and Secret IDs securely and separately
- Use CIDR restrictions to limit access from specific IP ranges
- Implement proper secret ID rotation policies
- Monitor authentication events in Vault audit logs
- Use least privilege principle for policy assignments
- Consider using response wrapping for secret ID distribution
- Regularly audit and rotate credentials
"""
@behaviour Vaultx.Auth.Behaviour
alias Vaultx.Base.{Error, Logger, Security, Telemetry}
alias Vaultx.Transport.HTTP
# Default mount path for AppRole auth method
@default_mount_path "approle"
@impl true
def authenticate(credentials, opts \\ []) do
with :ok <- validate_credentials(credentials),
:ok <- Security.audit_log(:authentication, :attempt, %{method: :approle}) do
mount_path = Keyword.get(opts, :mount_path, @default_mount_path)
path = "auth/#{mount_path}/login"
request_body = build_request_body(credentials)
metadata = %{
method: :approle,
mount_path: mount_path,
role_id: Map.get(credentials, :role_id),
has_secret_id: Map.has_key?(credentials, :secret_id)
}
Logger.debug("Attempting AppRole authentication", metadata)
Telemetry.auth_start(metadata)
start_time = System.monotonic_time()
case HTTP.request(:post, path, request_body, [], opts) do
{:ok, %{body: %{"auth" => auth_info}}} ->
duration = System.monotonic_time() - start_time
auth_response = build_auth_response(auth_info, Map.get(credentials, :role_id))
Logger.info(
"AppRole authentication successful",
Map.merge(metadata, %{
policies: auth_info["policies"],
lease_duration: auth_info["lease_duration"],
renewable: auth_info["renewable"]
})
)
Telemetry.auth_success(duration, metadata)
Security.audit_log(:authentication, :success, metadata)
{:ok, auth_response}
{:ok, %{body: response}} ->
duration = System.monotonic_time() - start_time
error =
Error.from_http_response(
Map.get(response, "status", 401),
response,
details: %{method: :approle, role_id: Map.get(credentials, :role_id)}
)
Logger.error("AppRole authentication failed", Map.put(metadata, :error, error))
Telemetry.auth_failure(duration, Map.put(metadata, :error, error))
Security.audit_log(:authentication, :failure, Map.put(metadata, :error, error.type))
{:error, error}
{:error, error} ->
duration = System.monotonic_time() - start_time
Logger.error("AppRole authentication error", Map.put(metadata, :error, error))
Telemetry.auth_failure(duration, Map.put(metadata, :error, error))
Security.audit_log(:authentication, :failure, Map.put(metadata, :error, error.type))
{:error, error}
end
end
end
@impl true
def refresh_token(_token, _opts) do
{:error,
Error.new(:unsupported_operation, "AppRole auth method does not support token refresh")}
end
@impl true
def revoke_token(_token, _opts) do
{:error,
Error.new(:unsupported_operation, "AppRole auth method does not support token revocation")}
end
@impl true
def validate_credentials(credentials) do
with :ok <- validate_required_fields(credentials),
:ok <- validate_field_types(credentials),
:ok <- validate_field_formats(credentials) do
:ok
end
end
@impl true
def metadata do
%{
name: "approle",
supports_refresh: false,
supports_revocation: false,
required_fields: [:role_id],
optional_fields: [:secret_id],
description: "Machine-to-machine authentication using Role ID and Secret ID"
}
end
# Private helper functions
defp build_request_body(credentials) do
base_body = %{role_id: Map.get(credentials, :role_id)}
case Map.get(credentials, :secret_id) do
nil -> base_body
secret_id -> Map.put(base_body, :secret_id, secret_id)
end
end
defp validate_required_fields(credentials) do
required_fields = [:role_id]
missing_fields =
Enum.filter(required_fields, fn field ->
case Map.get(credentials, field) do
nil -> true
_ -> false
end
end)
case missing_fields do
[] ->
:ok
fields ->
field_names = Enum.map(fields, &to_string/1)
message = "Missing required fields: #{Enum.join(field_names, ", ")}"
{:error, Error.new(:invalid_request, message)}
end
end
defp validate_field_types(credentials) do
type_validations = [
{:role_id, :string},
{:secret_id, :string}
]
Enum.reduce_while(type_validations, :ok, fn {field, expected_type}, _acc ->
case Map.get(credentials, field) do
nil ->
{:cont, :ok}
value ->
if valid_type?(value, expected_type) do
{:cont, :ok}
else
message = "Field '#{field}' must be a #{expected_type}"
{:halt, {:error, Error.new(:invalid_request, message)}}
end
end
end)
end
defp validate_field_formats(credentials) do
with :ok <- validate_role_id_format(Map.get(credentials, :role_id)),
:ok <- validate_secret_id_format(Map.get(credentials, :secret_id)) do
:ok
end
end
# coveralls-ignore-start
# This handles nil role_id in format validation, which is defensive programming.
# In normal flow, nil role_ids are caught by required field validation first.
defp validate_role_id_format(nil), do: :ok
# coveralls-ignore-stop
defp validate_role_id_format(""),
do: {:error, Error.new(:invalid_request, "Role ID cannot be empty")}
defp validate_role_id_format(role_id) when is_binary(role_id) do
cond do
byte_size(role_id) > 4096 ->
{:error, Error.new(:invalid_request, "Role ID too long (max 4096 characters)")}
not String.valid?(role_id) ->
{:error, Error.new(:invalid_request, "Role ID contains invalid UTF-8 characters")}
true ->
:ok
end
end
# coveralls-ignore-start
# This handles non-string role_id types that aren't caught by type validation.
# It's defensive programming for edge cases.
defp validate_role_id_format(_),
do: {:error, Error.new(:invalid_request, "Role ID must be a string")}
# coveralls-ignore-stop
# coveralls-ignore-start
# This handles nil secret_id in format validation, which is defensive programming.
# In normal flow, nil secret_ids are valid (optional field).
defp validate_secret_id_format(nil), do: :ok
# coveralls-ignore-stop
defp validate_secret_id_format(""),
do: {:error, Error.new(:invalid_request, "Secret ID cannot be empty")}
defp validate_secret_id_format(secret_id) when is_binary(secret_id) do
cond do
byte_size(secret_id) > 4096 ->
{:error, Error.new(:invalid_request, "Secret ID too long (max 4096 characters)")}
not String.valid?(secret_id) ->
{:error, Error.new(:invalid_request, "Secret ID contains invalid UTF-8 characters")}
true ->
:ok
end
end
# coveralls-ignore-start
# This handles non-string secret_id types that aren't caught by type validation.
# It's defensive programming for edge cases.
defp validate_secret_id_format(_),
do: {:error, Error.new(:invalid_request, "Secret ID must be a string")}
# coveralls-ignore-stop
defp valid_type?(value, :string), do: is_binary(value)
# coveralls-ignore-start
# This handles unknown types in validation. It's defensive programming
# to ensure type safety for any future type additions.
defp valid_type?(_value, _type), do: false
# coveralls-ignore-stop
defp build_auth_response(auth_info, role_id) do
%{
client_token: auth_info["client_token"],
accessor: auth_info["accessor"],
policies: auth_info["policies"] || [],
token_policies: auth_info["token_policies"] || auth_info["policies"] || [],
metadata: %{
auth_method: "approle",
role_id: role_id
},
lease_duration: auth_info["lease_duration"] || 0,
renewable: auth_info["renewable"] || false,
entity_id: auth_info["entity_id"],
token_type: auth_info["token_type"] || "service",
orphan: auth_info["orphan"] || false,
num_uses: auth_info["num_uses"] || 0
}
end
end