defmodule Paddle do @moduledoc ~S""" Module handling ldap requests and translate them to the `:eldap` syntax. ## Configuration The configuration should be in the dev.secret.exs or prod.secret.exs depending on the environment you're working on. Here's an example config: config :paddle, Paddle, host: "ldap.my-organisation.org", base: "dc=myorganisation,dc=org", ssl: true, port: 636, account_subdn: "ou=People", group_subdn: "ou=Group", account_class: "inetOrgPerson", group_class: "posixGroup" - `:host` - The host of the LDAP server. Mandatory - `:base` - The base DN. - `:ssl` - When set to `true`, use SSL to connect to the LDAP server. Defaults to `false`. - `:port` - The port the LDAP server listen to. Defaults to `389`. - `:account_subdn` - The DN (without the base) where the accounts are located. Defaults to `"ou=People"`. - `:group_subdn` - The DN (without the base) where the groups are located. Defaults to `"ou=Group"`. - `:account_class` - The class (objectClass) of all your user entries. Defaults to `"posixAccount"` - `:group_class` - The class (objectClass) of all your group entries. Defaults to `"posixGroup"` ## Usage To check a user's credentials and/or authenticate the connection, simply do: Paddle.authenticate("username", "password") You can also specify the partial DN like so: Paddle.authenticate([cn: "admin"], "adminpassword") Many functions support passing both a base and a filter via a keyword list like so: Paddle.get(filter: [uid: "testuser"], base: [ou: "People"]) But you can also use structs which implements the `Paddle.Class` protocol (called [class objects](#module-class-objects)). Some are already defined: Paddle.get %Paddle.PosixAccount{uid: "user"} The previous example will return every accounts which are in a given subDN (defined in the `Paddle.Class` protocol), which have the right objectClass (also defined in the same protocol), and have an uid of "user". You can also specify an additional [filter](#module-filters) as second argument. You are also provided with some "user" functions that will automatically get the information from the right subDN and check that the entry have the right objectClass, see [Configuration](#module-configuration). Example: Paddle.users(filter: [givenName: "User"]) ## Class objects A class object is simply a struct implementing the `Paddle.Class` protocol. Some "classes" are already defined and implemented (see `Paddle.PosixAccount`, and `Paddle.PosixGroup`) For more informations, see the `Paddle.Class` module documentation. ## Filters A filter in Paddle is a Keyword list and the atom corresponding to the key must have a value strictly equal to, well the given value. When multiple keywords are provided, the result must match all criteria. Example: [uid: "user", cn: "User", homeDirectory: "/home/user"] If you are missing some filtering capabilities, you can always pass as argument an `:eldap` filter like so: Paddle.get(filter: :eldap.substrings('uid', initial: 'b')) For more informations and examples, see `Paddle.Filters.construct_filter/1` ## Bases A base in Paddle can be a Keyword list that will be converted to a charlist to be passed on to the `:eldap` module. A direct string can also be passed. For more informations and examples, see `Paddle.Filters.construct_dn/2` """ use GenServer require Logger alias Paddle.Parsing alias Paddle.Filters alias Paddle.Attributes @type ldap_conn :: :eldap.handle @type ldap_entry :: %{required(binary) => binary} @type auth_status :: :ok | {:error, atom} @type eldap_dn :: charlist @type eldap_entry :: {:eldap_entry, eldap_dn, [{charlist, [charlist]}]} unless Application.get_env(:paddle, __MODULE__) do raise """ Please configure the LDAP in the config files See the `Paddle` module documentation. """ end def start(_type, _args) do __MODULE__.start_link end @spec start_link() :: Genserver.on_start @doc ~S""" Start the LDAP process. This function is called by the supervisor handled by the main application in `MyApplication.start/2`. """ def start_link() do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) end @spec init(:ok) :: {:ok, ldap_conn} @doc ~S""" Init the LDAP connection. This is called by the `GenServer.start_link/3` function. GenServer will then handle and keep the state, which is in this case the ldap connection, and pass it we we need it. """ def init(:ok) do ssl = config(:ssl) host = config(:host) port = config(:port) Logger.info("Connecting to ldap#{if ssl, do: "s"}://#{host}:#{port}") {:ok, ldap_conn} = :eldap.open([host], ssl: ssl, port: port) :eldap.controlling_process(ldap_conn, self()) Logger.info("Connected to LDAP") {:ok, ldap_conn} end @type reason :: :normal | :shutdown | {:shutdown, term} | term @spec terminate(reason, ldap_conn) :: :ok @doc ~S""" Terminate the LDAP connection. Called by GenServer when the process is stopped. """ def terminate(_reason, ldap_conn) do :eldap.close(ldap_conn) Logger.info("Stopped LDAP") end def handle_call({:authenticate, dn, password}, _from, ldap_conn) do Logger.debug "Checking credentials with dn: #{dn}" status = :eldap.simple_bind(ldap_conn, dn, password) case status do :ok -> {:reply, status, ldap_conn} {:error, :invalidCredentials} -> {:reply, status, ldap_conn} {:error, :anonymous_auth} -> {:reply, status, ldap_conn} end end def handle_call({:get, filter, kwdn, base}, _from, ldap_conn) do dn = Parsing.construct_dn(kwdn, config(base)) filter = Filters.construct_filter(filter) Logger.debug("Getting entries with dn: #{dn} and filter: #{inspect filter}") {:reply, :eldap.search(ldap_conn, base: dn, filter: filter) |> clean_eldap_search_results, ldap_conn} end def handle_call({:get_single, filter, kwdn, base}, _from, ldap_conn) do dn = Parsing.construct_dn(kwdn, config(base)) filter = Filters.construct_filter(filter) Logger.debug("Getting single entry with dn: #{dn} and filter: #{inspect filter}") {:reply, :eldap.search(ldap_conn, base: dn, scope: :eldap.baseObject, filter: filter) |> clean_eldap_search_results |> ensure_single_result, ldap_conn} end def handle_call({:add, kwdn, attributes, base}, _from, ldap_conn) do dn = Parsing.construct_dn(kwdn, config(base)) Logger.info("Adding entry with dn: #{dn}") attributes = attributes |> Enum.filter_map(fn {_key, value} -> value != nil end, fn {key, value} -> {'#{key}', Parsing.list_wrap value} end) {:reply, :eldap.add(ldap_conn, dn, attributes), ldap_conn} end def handle_call({:delete, kwdn, base}, _from, ldap_conn) do dn = Parsing.construct_dn(kwdn, config(base)) {:reply, :eldap.delete(ldap_conn, dn), ldap_conn} end def handle_call({:modify, kwdn, base, mods}, _from, ldap_conn) do dn = Parsing.construct_dn(kwdn, config(base)) Logger.info("Modifying entry: \"#{dn}\" with mods: #{inspect mods}") mods = mods |> Enum.map(&Parsing.mod_convert/1) Logger.debug("Mods translated in :eldap form: #{inspect mods}") {:reply, :eldap.modify(ldap_conn, dn, mods), ldap_conn} end @spec authenticate(keyword | binary, binary) :: boolean @doc ~S""" Check the given credentials. The user id can be given through a binary, which will expand to `uid=,,`, or through a keyword list if you want to specify the whole DN (still without the base). Example: iex> Paddle.authenticate("testuser", "test") :ok iex> Paddle.authenticate("testuser", "wrong password") {:error, :invalidCredentials} iex> Paddle.authenticate([cn: "admin"], "test") :ok """ def authenticate(kwdn, password) when is_list(kwdn) do dn = Parsing.construct_dn(kwdn, config(:base)) GenServer.call(Paddle, {:authenticate, dn, String.to_charlist(password)}) end def authenticate(username, password) do dn = Parsing.construct_dn([uid: username], config(:account_base)) GenServer.call(Paddle, {:authenticate, dn, String.to_charlist(password)}) end @spec get_dn(struct) :: {:ok, binary} | {:error, :missing_unique_identifier} @doc ~S""" Get the DN of an entry. Example: iex> Paddle.get_dn(%Paddle.PosixAccount{uid: "testuser"}) {:ok, "uid=testuser,ou=People"} """ def get_dn(object) do subdn = Paddle.Class.location(object) id_field = Paddle.Class.unique_identifier(object) id_value = Map.get(object, id_field) if id_value do id_value = Paddle.Parsing.ldap_escape id_value {:ok, "#{id_field}=#{id_value},#{subdn}"} else {:error, :missing_unique_identifier} end end # ============= # == Getting == # ============= @type search_ldap_error :: :noSuchObject | :sizeLimitExceeded | :timeLimitExceeded | :undefinedAttributeType | :insufficientAccessRights @spec get(keyword) :: {:ok, [ldap_entry]} | {:error, search_ldap_error} @doc ~S""" Get one or more LDAP entries given a keyword list. Example: iex> Paddle.get(base: [uid: "testuser", ou: "People"]) {:ok, [%{"cn" => ["Test User"], "dn" => "uid=testuser,ou=People,dc=test,dc=com", "gecos" => ["Test User,,,,"], "gidNumber" => ["120"], "homeDirectory" => ["/home/testuser"], "loginShell" => ["/bin/bash"], "objectClass" => ["account", "posixAccount", "top"], "uid" => ["testuser"], "uidNumber" => ["500"], "userPassword" => ["{SSHA}AIzygLSXlArhAMzddUriXQxf7UlkqopP"]}]} iex> Paddle.get(base: "uid=testuser,ou=People") {:ok, [%{"cn" => ["Test User"], "dn" => "uid=testuser,ou=People,dc=test,dc=com", "gecos" => ["Test User,,,,"], "gidNumber" => ["120"], "homeDirectory" => ["/home/testuser"], "loginShell" => ["/bin/bash"], "objectClass" => ["account", "posixAccount", "top"], "uid" => ["testuser"], "uidNumber" => ["500"], "userPassword" => ["{SSHA}AIzygLSXlArhAMzddUriXQxf7UlkqopP"]}]} iex> Paddle.get(base: [uid: "nothing"]) {:error, :noSuchObject} iex> Paddle.get(filter: [uid: "testuser"], base: [ou: "People"]) {:ok, [%{"cn" => ["Test User"], "dn" => "uid=testuser,ou=People,dc=test,dc=com", "gecos" => ["Test User,,,,"], "gidNumber" => ["120"], "homeDirectory" => ["/home/testuser"], "loginShell" => ["/bin/bash"], "objectClass" => ["account", "posixAccount", "top"], "uid" => ["testuser"], "uidNumber" => ["500"], "userPassword" => ["{SSHA}AIzygLSXlArhAMzddUriXQxf7UlkqopP"]}]} """ def get(kwdn) when is_list(kwdn) do GenServer.call(Paddle, {:get, Keyword.get(kwdn, :filter), Keyword.get(kwdn, :base), :base}) end @spec get(Paddle.Class.t, [tuple]) :: {:ok, [Paddle.Class.t]} | {:error, search_ldap_error} @doc ~S""" Get an entry in the LDAP given a class object. You can specify an optional additional filter as second argument. Example: iex> Paddle.get(%Paddle.PosixAccount{}) {:ok, [%Paddle.PosixAccount{cn: ["Test User"], description: nil, gecos: ["Test User,,,,"], gidNumber: ["120"], homeDirectory: ["/home/testuser"], host: nil, l: nil, loginShell: ["/bin/bash"], o: nil, ou: nil, seeAlso: nil, uid: ["testuser"], uidNumber: ["500"], userPassword: ["{SSHA}AIzygLSXlArhAMzddUriXQxf7UlkqopP"]}]} iex> Paddle.get(%Paddle.PosixGroup{cn: "users"}) {:ok, [%Paddle.PosixGroup{cn: ["users"], description: nil, gidNumber: ["2"], memberUid: ["testuser"], userPassword: nil}]} iex> Paddle.get(%Paddle.PosixGroup{}, :eldap.substrings('cn', initial: 'a')) {:ok, [%Paddle.PosixGroup{cn: ["adm"], description: nil, gidNumber: ["3"], memberUid: nil, userPassword: nil}]} """ def get(object, additional_filter \\ []) do fields_filter = object |> Map.from_struct |> Enum.filter(fn {_key, value} -> value != nil end) filter = Filters.class_filter(Paddle.Class.object_classes(object)) |> Filters.merge_filter(fields_filter) |> Filters.merge_filter(additional_filter) location = Paddle.Class.location(object) with {:ok, result} <- GenServer.call(Paddle, {:get, filter, location, :base}) do {:ok, result |> Enum.map(&entry_to_struct(&1, object))} end end @spec get!(keyword) :: [ldap_entry] @doc ~S""" Same as `get/1` but throws in case of an error. """ def get!(kwdn) when is_list(kwdn) do {:ok, result} = get(kwdn) result end @spec get!(Paddle.Class.t, [tuple]) :: [Paddle.Class.t] @doc ~S""" Same as `get/2` but throws in case of an error. """ def get!(object, additional_filter \\ []) do {:ok, result} = get(object, additional_filter) result end @spec get_single(keyword) :: {:ok, ldap_entry} | {:error, search_ldap_error} @doc ~S""" Get a single LDAP entry given a keyword list. Example: iex> Paddle.get_single(base: [ou: "People"]) {:ok, %{"dn" => "ou=People,dc=test,dc=com", "objectClass" => ["top", "organizationalUnit"], "ou" => ["People"]}} iex> Paddle.get_single(filter: [uid: "nothing"]) {:error, :noSuchObject} """ def get_single(kwdn) do GenServer.call(Paddle, {:get_single, Keyword.get(kwdn, :filter), Keyword.get(kwdn, :base), :base}) end # ============ # == Adding == # ============ @type attributes :: keyword | %{required(binary) => binary} | [{binary, binary}] @type add_ldap_error :: :undefinedAttributeType | :objectClassViolation | :invalidAttributeSyntax | :noSuchObject | :insufficientAccessRights | :entryAlreadyExists @spec add(keyword, attributes) :: :ok | {:error, add_ldap_error} @doc ~S""" Add an entry to the LDAP given a DN and a list of attributes. The first argument is the DN given as a string or keyword list as usual. The second argument is the list of attributes in the new entry as a keyword list like so: [objectClass: ["account", "posixAccount"], cn: "User", loginShell: "/bin/bash", homeDirectory: "/home/user", uidNumber: 501, gidNumber: 100] Please note that due to the limitation of char lists you cannot pass directly a char list as an attribute value. But, you can wrap it in an array like this: `homeDirectory: ['/home/user']` """ def add(kwdn, attributes), do: GenServer.call(Paddle, {:add, kwdn, attributes, :base}) @spec add(Paddle.Class.t) :: :ok | {:error, :missing_unique_identifier} | {:error, :missing_req_attributes, [atom]} | {:error, add_ldap_error} @doc ~S""" Add an entry to the LDAP given a class object. Example: Paddle.add(%Paddle.PosixAccount{uid: "myUser", cn: "My User", gidNumber: "501", homeDirectory: "/home/myUser"}) """ def add(class_object) do with {:ok, dn} <- get_dn(class_object), {:ok, attributes} <- Attributes.get(class_object) do add(dn, attributes) end end # ============== # == Deleting == # ============== @type delete_ldap_error :: :noSuchObject | :notAllowedOnNonLeaf | :insufficientAccessRights @spec delete(Paddle.Class.t | keyword) :: :ok | {:error, delete_ldap_error} @doc ~S""" Delete a LDAP entry given a DN or a class object. Examples: Paddle.delete("uid=testuser,ou=People") Paddle.delete([uid: "testuser", ou: "People"]) Paddle.delete(%Paddle.PosixAccount{uid: "testuser"}) The three examples above do exactly the same thing (provided that the `Paddle.PosixAccount` is configured appropriately). """ def delete(kwdn) when is_list(kwdn) or is_binary(kwdn) do GenServer.call(Paddle, {:delete, kwdn, :base}) end def delete(class_object) when is_map(class_object) do with {:ok, dn} <- get_dn(class_object) do GenServer.call(Paddle, {:delete, dn, :base}) end end # =============== # == Modifying == # =============== @type mod :: {:add, {binary | atom, binary | [binary]}} | {:delete, binary} | {:replace, {binary | atom, binary | [binary]}} @type modify_ldap_error :: :noSuchObject | :undefinedAttributeType | :namingViolation | :attributeOrValueExists | :invalidAttributeSyntax | :notAllowedOnRDN | :objectClassViolation | :objectClassModsProhibited | :insufficientAccessRights @spec modify(Paddle.Class.t | keyword, mod) :: :ok | {:error, modify_ldap_error} @doc ~S""" Modify an LDAP entry given a DN or a class object and a list of modifications. A modification is specified like so: {action, {parameters...}} For example, adding a "description" field: {:add, {"description", "This is a description"}} This allows you to do things like this: Paddle.modify([uid: "testuser", ou: "People"], add: {"description", "This is a description"}, delete: "gecos", replace: {"o", ["Club *Nix", "Linux Foundation"]}) """ def modify(kwdn, mods) when is_list(kwdn) or is_binary(kwdn) do GenServer.call(Paddle, {:modify, kwdn, :base, mods}) end def modify(class_object, mods) when is_map(class_object) do with {:ok, dn} <- get_dn(class_object) do GenServer.call(Paddle, {:modify, dn, :base, mods}) end end # ======================= # == Private Utilities == # ======================= @spec config :: keyword @doc ~S""" Get the environment whole configuration of the Paddle application. """ def config, do: Application.get_env(:paddle, Paddle) @spec config(atom) :: any @doc ~S""" Get the environment configuration of the Paddle application under a certain key. """ def config(:host), do: Keyword.get(config(), :host) |> String.to_charlist def config(:ssl), do: config(:ssl, false) def config(:port), do: config(:port, 389) def config(:base), do: config(:base, "") |> String.to_charlist def config(:account_base), do: config(:account_subdn) ++ ',' ++ config(:base) def config(:group_base), do: config(:group_subdn) ++ ',' ++ config(:base) def config(:account_subdn), do: config(:account_subdn, "ou=People") |> String.to_charlist def config(:group_subdn), do: config(:group_subdn, "ou=Group") |> String.to_charlist def config(:account_class), do: config(:account_class, "posixAccount") |> String.to_charlist def config(:group_class), do: config(:group_class, "posixGroup") |> String.to_charlist @spec config(atom, any) :: any @doc ~S""" Same as `config/1` but allows you to specify a default value. """ def config(key, default), do: Keyword.get(config(), key, default) @spec clean_eldap_search_results({:ok, {:eldap_search_result, [eldap_entry]}} | {:error, atom}) :: {:ok, [ldap_entry]} | {:error, :noSuchObject} defp clean_eldap_search_results({:error, error}) do case error do :noSuchObject -> {:error, :noSuchObject} end end defp clean_eldap_search_results({:ok, {:eldap_search_result, [], []}}) do {:error, :noSuchObject} end defp clean_eldap_search_results({:ok, {:eldap_search_result, entries, []}}) do {:ok, Parsing.clean_entries(entries)} end defp entry_to_struct(entry, target) do entry = entry |> Map.drop(["dn", "objectClass"]) |> Enum.map(fn {key, value} -> {String.to_atom(key), value} end) |> Enum.into(%{}) Map.merge(target, entry) end @spec ensure_single_result({:ok, [ldap_entry]} | {:error, atom}) :: {:ok, ldap_entry} | {:error, :noSuchObject} defp ensure_single_result({:error, error}) do case error do :noSuchObject -> {:error, :noSuchObject} end end defp ensure_single_result({:ok, []}), do: {:error, :noSuchObject} defp ensure_single_result({:ok, [result]}), do: {:ok, result} end