Current section
Files
Jump to
Current section
Files
lib/ex_open_directory.ex
defmodule ExOpenDirectory do
@moduledoc """
Elixir bindings to macOS OpenDirectory.framework.
OpenDirectory is Apple's native directory services framework, providing
access to local accounts, LDAP servers, and Active Directory domains.
It communicates with the `opendirectoryd` daemon via private IPC —
there is no alternative to using this framework on macOS.
## Nodes
All operations start by connecting to a node. A node represents a
directory source:
# Local directory (users on this Mac)
{:ok, node} = ExOpenDirectory.connect(:local)
# Search across all configured directories
{:ok, node} = ExOpenDirectory.connect(:search)
# Specific LDAP/AD server
{:ok, node} = ExOpenDirectory.connect({:server, "ldap://ad.example.com"})
# Authenticated connection
{:ok, node} = ExOpenDirectory.connect({:server, "ldap://ad.example.com"},
username: "admin", password: "secret")
## User Lookups
{:ok, node} = ExOpenDirectory.connect(:search)
# Find a user by name
{:ok, record} = ExOpenDirectory.find_user(node, "jsmith")
# Get specific attributes
{:ok, attrs} = ExOpenDirectory.get_attributes(record,
["dsAttrTypeStandard:RealName", "dsAttrTypeStandard:EMailAddress"])
# Find all users matching a query
{:ok, users} = ExOpenDirectory.query_users(node, "smi",
match: :begins_with, limit: 50)
## Group Lookups
{:ok, groups} = ExOpenDirectory.get_groups(node, "jsmith")
## Authentication
:ok = ExOpenDirectory.authenticate(node, "jsmith", "password123")
## Password Operations
:ok = ExOpenDirectory.change_password(node, "jsmith", "old_pw", "new_pw")
## Platform Requirements
macOS only. The `opendirectoryd` daemon must be running (it always is
on a standard macOS install). For Active Directory operations, the Mac
must be bound to an AD domain or the LDAP server must be reachable.
"""
use Rustler,
otp_app: :ex_open_directory,
crate: "ex_open_directory"
# ── Node / Connection Management ────────────────────────────────────
@type node_ref :: reference()
@type record_ref :: reference()
@type node_type :: :local | :search | {:server, String.t()}
@doc """
Connect to an OpenDirectory node.
## Node Types
* `:local` - The local directory (this Mac's users/groups)
* `:search` - Search across all configured directories
* `{:server, url}` - A specific LDAP or AD server
## Options (for `:server` type)
* `:username` - DN or username for authenticated bind
* `:password` - Password for authenticated bind
Returns `{:ok, node_ref}` on success.
"""
@spec connect(node_type(), keyword()) :: {:ok, node_ref()} | {:error, String.t()}
def connect(_node_type, _opts \\ []),
do: :erlang.nif_error(:nif_not_loaded)
@doc """
Close a connection to an OpenDirectory node.
"""
@spec disconnect(node_ref()) :: :ok
def disconnect(_node),
do: :erlang.nif_error(:nif_not_loaded)
# ── User Operations ─────────────────────────────────────────────────
@doc """
Find a user record by account name (short name).
Returns `{:ok, record_ref}` or `{:error, reason}`.
"""
@spec find_user(node_ref(), String.t()) :: {:ok, record_ref()} | {:error, String.t()}
def find_user(_node, _username),
do: :erlang.nif_error(:nif_not_loaded)
@doc """
Query users matching a pattern.
## Options
* `:match` - Match type: `:exact`, `:begins_with`, `:contains`, `:ends_with`
(default: `:begins_with`)
* `:attribute` - Attribute to search (default: `"dsAttrTypeStandard:RecordName"`)
* `:limit` - Maximum results (default: 100)
* `:return_attributes` - List of attribute names to return
(default: standard attributes)
Returns a list of maps with the requested attributes.
"""
@spec query_users(node_ref(), String.t(), keyword()) ::
{:ok, [map()]} | {:error, String.t()}
def query_users(_node, _query, _opts \\ []),
do: :erlang.nif_error(:nif_not_loaded)
@doc """
Get attributes from a record reference.
Pass a list of attribute type strings like:
* `"dsAttrTypeStandard:RealName"` - Display name
* `"dsAttrTypeStandard:EMailAddress"` - Email
* `"dsAttrTypeStandard:UniqueID"` - UID number
* `"dsAttrTypeStandard:PrimaryGroupID"` - Primary GID
* `"dsAttrTypeStandard:NFSHomeDirectory"` - Home directory
* `"dsAttrTypeStandard:UserShell"` - Login shell
For AD-bound Macs, AD attributes are also available:
* `"dsAttrTypeStandard:SMBHomeDrive"` - AD home drive letter
* `"dsAttrTypeStandard:SMBProfilePath"` - AD roaming profile path
"""
@spec get_attributes(record_ref(), [String.t()]) :: {:ok, map()} | {:error, String.t()}
def get_attributes(_record, _attribute_names),
do: :erlang.nif_error(:nif_not_loaded)
# ── Group Operations ────────────────────────────────────────────────
@doc """
Get the list of group names a user belongs to.
Uses OpenDirectory's native nested group resolution, which handles
AD nested groups correctly.
"""
@spec get_groups(node_ref(), String.t()) :: {:ok, [String.t()]} | {:error, String.t()}
def get_groups(_node, _username),
do: :erlang.nif_error(:nif_not_loaded)
@doc """
Check if a user is a member of a specific group.
Handles nested group membership (AD and OD).
"""
@spec member?(node_ref(), String.t(), String.t()) :: boolean()
def member?(_node, _username, _group),
do: :erlang.nif_error(:nif_not_loaded)
# ── Authentication ──────────────────────────────────────────────────
@doc """
Authenticate a user against the directory.
This performs a real authentication check via OpenDirectory, which
may trigger Kerberos authentication on AD-bound nodes.
Returns `:ok` on success or `{:error, reason}` on failure.
"""
@spec authenticate(node_ref(), String.t(), String.t()) :: :ok | {:error, String.t()}
def authenticate(_node, _username, _password),
do: :erlang.nif_error(:nif_not_loaded)
# ── Password Operations ─────────────────────────────────────────────
@doc """
Change a user's password.
Requires knowing the current password. For AD users, this performs
an AD password change which enforces AD password policy.
"""
@spec change_password(node_ref(), String.t(), String.t(), String.t()) ::
:ok | {:error, String.t()}
def change_password(_node, _username, _old_password, _new_password),
do: :erlang.nif_error(:nif_not_loaded)
@doc """
Get password policy information for a user.
Returns a map that may include:
* `:expires_at` - DateTime when password expires (nil if never)
* `:days_until_expiry` - Integer days remaining
* `:min_length` - Minimum password length
* `:requires_alpha` - Whether alpha characters are required
* `:requires_numeric` - Whether numeric characters are required
* `:history_count` - Number of remembered passwords
For AD users, this reads the `msDS-UserPasswordExpiryTimeComputed`
and related attributes.
"""
@spec password_policy(node_ref(), String.t()) :: {:ok, map()} | {:error, String.t()}
def password_policy(_node, _username),
do: :erlang.nif_error(:nif_not_loaded)
end