├── .gitignore ├── .iex.exs ├── .travis.yml ├── README.md ├── config ├── config.exs ├── dev.exs ├── docs.exs ├── prod.exs └── test.exs ├── lib └── ecto_ldap │ ├── adapter.ex │ └── adapter │ ├── converter.ex │ ├── dumpers.ex │ ├── loaders.ex │ └── sandbox.ex ├── mix.exs ├── mix.lock └── test ├── ecto_ldap_test.exs ├── support ├── test_repo.ex └── test_user.ex └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /doc 5 | erl_crash.dump 6 | *.ez 7 | .mix_tasks 8 | -------------------------------------------------------------------------------- /.iex.exs: -------------------------------------------------------------------------------- 1 | # Helper script for iex testing 2 | # $ iex -S mix 3 | 4 | Code.require_file "test/support/test_repo.ex", __DIR__ 5 | Code.require_file "test/support/test_user.ex", __DIR__ 6 | 7 | require Ecto.Query 8 | 9 | alias Ecto.Ldap.Adapter 10 | alias Ecto.Ldap.TestRepo 11 | alias Ecto.Ldap.TestUser 12 | alias Ecto.Query 13 | 14 | TestRepo.start_link 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.3.0 4 | notifications: 5 | recipients: 6 | - travis@jeffweiss.org 7 | otp_release: 8 | - 18.2 9 | env: 10 | - MIX_ENV=test 11 | script: 12 | - "mix do deps.get, compile, coveralls.travis" 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EctoLdap 2 | [![Build Status](https://travis-ci.org/jeffweiss/ecto_ldap.svg?branch=master)](https://travis-ci.org/jeffweiss/ecto_ldap) 3 | [![Hex.pm Version](http://img.shields.io/hexpm/v/ecto_ldap.svg?style=flat)](https://hex.pm/packages/ecto_ldap) 4 | [![Coverage Status](https://coveralls.io/repos/github/jeffweiss/ecto_ldap/badge.svg?branch=master)](https://coveralls.io/github/jeffweiss/ecto_ldap?branch=master) 5 | 6 | **Ecto Adapter for LDAP** 7 | 8 | ## Installation 9 | 10 | [From Hex](https://hex.pm/docs/publish), the package can be installed as follows: 11 | 12 | 1. Add `ecto_ldap` to your list of dependencies in `mix.exs`: 13 | ```elixir 14 | def deps do 15 | [{:ecto_ldap, "~> 0.3"}] 16 | end 17 | ``` 18 | 19 | 2. Ensure `ecto_ldap` is started before your application: 20 | ```elixir 21 | def application do 22 | [applications: [:ecto_ldap]] 23 | end 24 | ``` 25 | 26 | 3. Specify `Ecto.Ldap.Adapter` as the adapter for your application's Repo: 27 | ```elixir 28 | config :my_app, MyApp.Repo, 29 | adapter: Ecto.Ldap.Adapter, 30 | hostname: "ldap.example.com", 31 | base: "dc=example,dc=com", 32 | port: 636, 33 | ssl: true, 34 | user_dn: "uid=sample_user,ou=users,dc=example,dc=com", 35 | password: "password", 36 | pool_size: 1 37 | ``` 38 | 39 | ## Usage 40 | 41 | Use the `ecto_ldap` adapter, just as you would any other Ecto backend. 42 | 43 | ### Example Schema 44 | 45 | 46 | ```elixir 47 | defmodule User do 48 | use Ecto.Schema 49 | import Ecto.Changeset 50 | 51 | @primary_key {:dn, :string, autogenerate: false} 52 | schema "users" do 53 | field :objectClass, {:array, :string} 54 | field :loginShell, :string 55 | field :mail, :string 56 | field :mobile, :string 57 | field :skills, {:array, :string} 58 | field :sn, :string 59 | field :st, :string 60 | field :startDate, Ecto.DateTime 61 | field :uid, :string 62 | field :jpegPhoto, :binary 63 | end 64 | 65 | def changeset(model, params \\ :empty) do 66 | model 67 | |> cast(params, ~w(dn), ~w(objectClass loginShell mail mobile skills sn uid)) 68 | |> unique_constraint(:dn) 69 | end 70 | 71 | end 72 | ``` 73 | 74 | ### Example Queries 75 | 76 | ```elixir 77 | Repo.get User, "uid=jeff.weiss,ou=users,dc=example,dc=com" 78 | 79 | Repo.get_by User, uid: "jeff.weiss" 80 | 81 | Repo.all User, st: "OR" 82 | 83 | Ecto.Query.from(u in User, where: like(u.mail, "%@example.com")) 84 | 85 | Ecto.Query.from(u in User, where: "inetOrgPerson" in u.objectClass and not is_nil(u.jpegPhoto), select: u.uid) 86 | ``` 87 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :ecto_ldap, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:ecto_ldap, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | config :ecto, primary_key_type: :binary_id 31 | import_config "#{Mix.env}.exs" 32 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :ecto, Ecto.Ldap.TestRepo, 4 | adapter: Ecto.Ldap.Adapter, 5 | hostname: "ldap.example.com", 6 | base: "dc=example,dc=com", 7 | port: 636, 8 | ssl: true, 9 | user_dn: "uid=sample_user,ou=users,dc=example,dc=com", 10 | password: "password", 11 | pool_size: 1 12 | -------------------------------------------------------------------------------- /config/docs.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :ecto, Ecto.Ldap.TestRepo, 4 | ldap_api: Ecto.Ldap.Adapter.Sandbox, 5 | adapter: Ecto.Ldap.Adapter, 6 | hostname: "ldap.example.com", 7 | base: "dc=example,dc=com", 8 | port: 636, 9 | ssl: true, 10 | user_dn: "uid=sample_user,ou=users,dc=example,dc=com", 11 | password: "password", 12 | pool_size: 1 13 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :ecto, Ecto.Ldap.TestRepo, 4 | ldap_api: Ecto.Ldap.Adapter.Sandbox, 5 | adapter: Ecto.Ldap.Adapter, 6 | hostname: "ldap.example.com", 7 | base: "dc=example,dc=com", 8 | port: 636, 9 | ssl: true, 10 | user_dn: "uid=sample_user,ou=users,dc=example,dc=com", 11 | password: "password", 12 | pool_size: 1 13 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :ecto, Ecto.Ldap.TestRepo, 4 | ldap_api: Ecto.Ldap.Adapter.Sandbox, 5 | adapter: Ecto.Ldap.Adapter, 6 | hostname: "ldap.example.com", 7 | base: "dc=example,dc=com", 8 | port: 636, 9 | ssl: true, 10 | user_dn: "uid=sample_user,ou=users,dc=example,dc=com", 11 | password: "password", 12 | pool_size: 1 13 | -------------------------------------------------------------------------------- /lib/ecto_ldap/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Ecto.Ldap.Adapter do 2 | use GenServer 3 | import Supervisor.Spec 4 | import Ecto.Ldap.Adapter.Converter 5 | 6 | @behaviour Ecto.Adapter 7 | @behaviour Ecto.Adapter.Storage 8 | 9 | @spec storage_up(any()) :: {:error, :already_up} 10 | def storage_up(_), do: {:error, :already_up} 11 | def storage_down(_), do: {:error, :already_down} 12 | 13 | defdelegate loaders(primitive, type), to: Ecto.Ldap.Adapter.Loaders 14 | defdelegate dumpers(primitive, type), to: Ecto.Ldap.Adapter.Dumpers 15 | 16 | @moduledoc """ 17 | Allows talking to an LDAP directory as an Ecto data store. 18 | 19 | ## Sample Configuration 20 | 21 | use Mix.Config 22 | 23 | config :my_app, MyApp.Repo, 24 | adapter: Ecto.Ldap.Adapter, 25 | hostname: "ldap.example.com", 26 | base: "dc=example,dc=com", 27 | port: 636, 28 | ssl: true, 29 | user_dn: "uid=sample_user,ou=users,dc=example,dc=com", 30 | password: "password", 31 | pool_size: 1 32 | 33 | Currently `ecto_ldap` does not support a `pool_size` larger than `1`. If this 34 | is a bottleneck for you, please [open an issue](https://github.com/jeffweiss/ecto_ldap/issues/new). 35 | 36 | ## Example schema 37 | 38 | 39 | defmodule TestUser do 40 | use Ecto.Schema 41 | import Ecto.Changeset 42 | 43 | @primary_key {:dn, :string, autogenerate: false} 44 | schema "users" do 45 | field :objectClass, {:array, :string} 46 | field :loginShell, :string 47 | field :mail, :string 48 | field :mobile, :string 49 | field :skills, {:array, :string} 50 | field :sn, :string 51 | field :st, :string 52 | field :startDate, Ecto.DateTime 53 | field :uid, :string 54 | field :jpegPhoto, :binary 55 | end 56 | 57 | def changeset(model, params \\ :empty) do 58 | model 59 | |> cast(params, ~w(dn), ~w(objectClass loginShell mail mobile skills sn uid)) 60 | |> unique_constraint(:dn) 61 | end 62 | 63 | end 64 | 65 | ## Example Usage 66 | 67 | iex> require Ecto.Query 68 | iex> Ecto.Query.from(u in TestUser, select: u.uid) |> TestRepo.all 69 | ["jeff.weiss", "manny"] 70 | 71 | iex> TestRepo.all(TestUser, uid: "jeff.weiss") |> Enum.count 72 | 1 73 | 74 | iex> TestRepo.get(TestUser, "uid=jeff.weiss,ou=users,dc=example,dc=com").mail 75 | "jeff.weiss@example.com" 76 | 77 | iex> TestRepo.get_by(TestUser, uid: "jeff.weiss").loginShell 78 | "/bin/zsh" 79 | 80 | iex> Ecto.Query.from(u in TestUser, where: u.st == "OR" and "elixir" in u.skills) |> TestRepo.all |> List.first |> Map.get(:uid) 81 | "jeff.weiss" 82 | 83 | iex> Ecto.Query.from(u in TestUser, where: like(u.sn, "%Weis%")) |> TestRepo.all |> List.first |> Map.get(:uid) 84 | "jeff.weiss" 85 | 86 | """ 87 | 88 | #### 89 | # 90 | # GenServer API 91 | # 92 | #### 93 | @doc false 94 | def start_link(_repo, opts) do 95 | GenServer.start_link(__MODULE__, opts, name: __MODULE__) 96 | end 97 | 98 | @doc false 99 | @spec child_spec(any, any) :: Supervisor.child_spec() 100 | def child_spec(repo, opts) do 101 | worker(__MODULE__, [repo, opts], name: __MODULE__) 102 | end 103 | 104 | @doc false 105 | def init(opts) do 106 | {:ok, opts} 107 | end 108 | 109 | #### 110 | # 111 | # Client API 112 | # 113 | #### 114 | @doc false 115 | def search(search_options) do 116 | GenServer.call(__MODULE__, {:search, search_options}) 117 | end 118 | 119 | @doc false 120 | def update(dn, modify_operations) do 121 | GenServer.call(__MODULE__, {:update, dn, modify_operations}) 122 | end 123 | 124 | @doc false 125 | def base do 126 | GenServer.call(__MODULE__, :base) 127 | end 128 | 129 | #### 130 | # 131 | # 132 | # GenServer server API 133 | # 134 | #### 135 | def handle_call({:search, search_options}, _from, state) do 136 | {:ok, handle} = ldap_connect(state) 137 | search_response = ldap_api(state).search(handle, search_options) 138 | ldap_api(state).close(handle) 139 | 140 | {:reply, search_response, state} 141 | end 142 | 143 | def handle_call({:update, dn, modify_operations}, _from, state) do 144 | {:ok, handle} = ldap_connect(state) 145 | update_response = ldap_api(state).modify(handle, dn, modify_operations) 146 | ldap_api(state).close(handle) 147 | 148 | {:reply, update_response, state} 149 | end 150 | 151 | def handle_call(:base, _from, state) do 152 | base = Keyword.get(state, :base) |> to_charlist() 153 | {:reply, base, state} 154 | end 155 | 156 | @spec ldap_api([{atom, any}]) :: :eldap | module 157 | defp ldap_api(state) do 158 | module = Keyword.get(state, :ldap_api, :eldap) 159 | Code.ensure_loaded(module) 160 | module 161 | end 162 | 163 | @spec ldap_connect([{atom, any}]) :: {:ok, pid} 164 | defp ldap_connect(state) do 165 | user_dn = Keyword.get(state, :user_dn) |> to_charlist() 166 | password = Keyword.get(state, :password) |> to_charlist() 167 | hostname = Keyword.get(state, :hostname) |> to_charlist() 168 | port = Keyword.get(state, :port, 636) 169 | use_ssl = Keyword.get(state, :ssl, true) 170 | 171 | {:ok, handle} = ldap_api(state).open([hostname], [{:port, port}, {:ssl, use_ssl}]) 172 | true = Kernel.is_pid(handle) 173 | ldap_api(state).simple_bind(handle, user_dn, password) 174 | {:ok, handle} 175 | end 176 | 177 | #### 178 | # 179 | # Ecto.Adapter.API 180 | # 181 | #### 182 | defmacro __before_compile__(_env) do 183 | quote do 184 | end 185 | end 186 | 187 | def prepare(:all, query) do 188 | query_metadata = 189 | [ 190 | :construct_filter, 191 | :construct_base, 192 | :construct_scope, 193 | :construct_attributes, 194 | ] 195 | |> Enum.map(&(apply(__MODULE__, &1, [query]))) 196 | |> Enum.filter(&(&1)) 197 | 198 | {:nocache, query_metadata} 199 | end 200 | 201 | def prepare(:update_all, _query), do: raise "Update is currently unsupported" 202 | def prepare(:delete_all, _query), do: raise "Delete is currently unsupported" 203 | 204 | @doc false 205 | def construct_filter(%{wheres: wheres}) when is_list(wheres) do 206 | filter_term = 207 | wheres 208 | |> Enum.map(&Map.get(&1, :expr)) 209 | {:filter, filter_term} 210 | end 211 | 212 | @doc false 213 | def construct_filter(wheres, params) when is_list(wheres) do 214 | filter_term = 215 | wheres 216 | |> Enum.map(&(translate_ecto_lisp_to_eldap_filter(&1, params))) 217 | |> :eldap.and 218 | {:filter, filter_term} 219 | end 220 | 221 | @doc false 222 | def construct_base(%{from: {from, _}}) do 223 | {:base, to_charlist("ou=" <> from <> "," <> to_string(base())) } 224 | end 225 | @doc false 226 | def constuct_base(_), do: {:base, base()} 227 | 228 | @doc false 229 | def construct_scope(_), do: {:scope, :eldap.wholeSubtree} 230 | 231 | @doc false 232 | def construct_attributes(%{select: select, sources: sources}) do 233 | case select.fields do 234 | [{:&, [], [0]}] -> 235 | { :attributes, 236 | sources 237 | |> ordered_fields 238 | |> List.flatten 239 | |> Enum.map(&convert_to_erlang/1) 240 | } 241 | attributes -> 242 | { 243 | :attributes, 244 | attributes 245 | |> Enum.map(&extract_select/1) 246 | |> List.flatten 247 | |> Enum.map(&convert_to_erlang/1) 248 | } 249 | end 250 | end 251 | 252 | defp extract_select({:&, _, [_, select, _]}), do: select 253 | defp extract_select({{:., _, [{:&, _, _}, select]}, _, _}), do: select 254 | 255 | defp translate_ecto_lisp_to_eldap_filter({:or, _, list_of_subexpressions}, params) do 256 | list_of_subexpressions 257 | |> Enum.map(&(translate_ecto_lisp_to_eldap_filter(&1, params))) 258 | |> :eldap.or 259 | end 260 | defp translate_ecto_lisp_to_eldap_filter({:and, _, list_of_subexpressions}, params) do 261 | list_of_subexpressions 262 | |> Enum.map(&(translate_ecto_lisp_to_eldap_filter(&1, params))) 263 | |> :eldap.and 264 | end 265 | defp translate_ecto_lisp_to_eldap_filter({:not, _, [subexpression]}, params) do 266 | :eldap.not(translate_ecto_lisp_to_eldap_filter(subexpression, params)) 267 | end 268 | # {:==, [], [{{:., [], [{:&, [], [0]}, :sn]}, [ecto_type: :string], []}, {:^, [], [0]}]}, ['Weiss', 'jeff.weiss@puppetlabs.com'] 269 | defp translate_ecto_lisp_to_eldap_filter({op, [], [value1, {:^, [], [idx]}]}, params) do 270 | translate_ecto_lisp_to_eldap_filter({op, [], [value1, Enum.at(params, idx)]}, params) 271 | end 272 | defp translate_ecto_lisp_to_eldap_filter({op, [], [value1, {:^, [], [idx,len]}]}, params) do 273 | translate_ecto_lisp_to_eldap_filter({op, [], [value1, Enum.slice(params, idx, len)]}, params) 274 | end 275 | # {:in, [], [{:^, [], [0]}, {{:., [], [{:&, [], [0]}, :uniqueMember]}, [], []}]}, ['uid=manny,ou=users,dc=puppetlabs,dc=com'] 276 | defp translate_ecto_lisp_to_eldap_filter({op, [], [{:^, [], [idx]}, value2]}, params) do 277 | translate_ecto_lisp_to_eldap_filter({op, [], [Enum.at(params, idx), value2]}, params) 278 | end 279 | 280 | defp translate_ecto_lisp_to_eldap_filter({:ilike, _, [value1, "%" <> value2]}, _) do 281 | like_with_leading_wildcard(value1, value2) 282 | end 283 | defp translate_ecto_lisp_to_eldap_filter({:ilike, _, [value1, [37|value2]]}, _) do 284 | like_with_leading_wildcard(value1, convert_from_erlang(value2)) 285 | end 286 | defp translate_ecto_lisp_to_eldap_filter({:ilike, _, [value1, value2]}, _) when is_list(value2) do 287 | like_without_leading_wildcard(value1, convert_from_erlang(value2)) 288 | end 289 | defp translate_ecto_lisp_to_eldap_filter({:ilike, _, [value1, value2]}, _) when is_binary(value2) do 290 | like_without_leading_wildcard(value1, value2) 291 | end 292 | defp translate_ecto_lisp_to_eldap_filter({:like, a, b}, params) do 293 | translate_ecto_lisp_to_eldap_filter({:ilike, a, b}, params) 294 | end 295 | defp translate_ecto_lisp_to_eldap_filter({:==, _, [value1, value2]}, _) do 296 | :eldap.equalityMatch(translate_value(value1), translate_value(value2)) 297 | end 298 | defp translate_ecto_lisp_to_eldap_filter({:!=, _, [value1, value2]}, _) do 299 | :eldap.not(:eldap.equalityMatch(translate_value(value1), translate_value(value2))) 300 | end 301 | defp translate_ecto_lisp_to_eldap_filter({:>=, _, [value1, value2]}, _) do 302 | :eldap.greaterOrEqual(translate_value(value1), translate_value(value2)) 303 | end 304 | defp translate_ecto_lisp_to_eldap_filter({:<=, _, [value1, value2]}, _) do 305 | :eldap.lessOrEqual(translate_value(value1), translate_value(value2)) 306 | end 307 | defp translate_ecto_lisp_to_eldap_filter({:in, _, [value1, value2]}, _) when is_list(value2) do 308 | for value <- value2 do 309 | :eldap.equalityMatch(translate_value(value1), translate_value(value)) 310 | end 311 | |> :eldap.or 312 | end 313 | defp translate_ecto_lisp_to_eldap_filter({:in, _, [value1, value2]}, _) do 314 | :eldap.equalityMatch(translate_value(value2), translate_value(value1)) 315 | end 316 | defp translate_ecto_lisp_to_eldap_filter({:is_nil, _, [value]}, _) do 317 | :eldap.not(:eldap.present(translate_value(value))) 318 | end 319 | 320 | defp like_with_leading_wildcard(value1, value2) do 321 | case String.last(value2) do 322 | "%" -> :eldap.substrings(translate_value(value1), [{:any, translate_value(String.slice(value2, 0..-2))}]) 323 | _ -> :eldap.substrings(translate_value(value1), [{:final, translate_value(value2)}]) 324 | end 325 | end 326 | defp like_without_leading_wildcard(value1, value2) do 327 | case String.last(value2) do 328 | "%" -> :eldap.substrings(translate_value(value1), [{:initial, translate_value(String.slice(value2, 0..-2))}]) 329 | _ -> :eldap.substrings(translate_value(value1), [{:any, translate_value(value2)}]) 330 | end 331 | end 332 | 333 | defp translate_value({{:., [], [{:&, [], [0]}, attribute]}, _ecto_type, []}) when is_atom(attribute) do 334 | translate_value(attribute) 335 | end 336 | defp translate_value(%Ecto.Query.Tagged{value: value}), do: value 337 | defp translate_value(atom) when is_atom(atom) do 338 | atom 339 | |> to_string() 340 | |> to_charlist() 341 | end 342 | defp translate_value(other), do: convert_to_erlang(other) 343 | 344 | def execute(_repo, query_metadata, {:nocache, prepared}, params, preprocess, options) do 345 | {:filter, filter} = construct_filter(Keyword.get(prepared, :filter), params) 346 | options_filter = :eldap.and(translate_options_to_filter(options)) 347 | full_filter = :eldap.and([filter, options_filter]) 348 | 349 | search_response = 350 | prepared 351 | |> Keyword.put(:filter, full_filter) 352 | |> replace_dn_search_with_objectclass_present 353 | |> merge_search_options(prepared) 354 | |> search 355 | 356 | fields = ordered_fields(query_metadata.sources) 357 | count = count_fields(query_metadata.select, query_metadata.sources) 358 | 359 | {:ok, {:eldap_search_result, results, []}} = search_response 360 | 361 | selected_fields = Keyword.get(prepared, :attributes, fields) 362 | |> Enum.map(fn text_field -> 363 | text_field 364 | |> convert_from_erlang 365 | |> String.to_atom 366 | end) 367 | 368 | result_set = 369 | for entry <- results do 370 | entry 371 | |> process_entry 372 | |> prune_attributes(fields, selected_fields) 373 | |> preprocess.() 374 | end 375 | 376 | {count, result_set} 377 | end 378 | 379 | defp translate_options_to_filter([]), do: [] 380 | defp translate_options_to_filter(list) when is_list(list) do 381 | for {attr, value} <- list do 382 | translate_ecto_lisp_to_eldap_filter({:==, [], [attr, convert_to_erlang(value)]}, []) 383 | end 384 | end 385 | 386 | defp merge_search_options({filter, []}, full_search_terms) do 387 | full_search_terms 388 | |> Keyword.put(:filter, filter) 389 | end 390 | defp merge_search_options({filter, [base: dn]}, full_search_terms) do 391 | full_search_terms 392 | |> Keyword.put(:filter, filter) 393 | |> Keyword.put(:base, dn) 394 | |> Keyword.put(:scope, :eldap.baseObject) 395 | end 396 | defp merge_search_options(_, _) do 397 | raise "Unable to search across multiple base DNs" 398 | end 399 | 400 | defp replace_dn_search_with_objectclass_present(search_options) when is_list(search_options)do 401 | {filter, dns} = 402 | search_options 403 | |> Keyword.get(:filter) 404 | |> replace_dn_filters 405 | {filter, dns |> List.flatten |> Enum.uniq} 406 | end 407 | 408 | defp replace_dn_filters([]), do: {[], []} 409 | defp replace_dn_filters([head|tail]) do 410 | {h, hdns} = replace_dn_filters(head) 411 | {t, tdns} = replace_dn_filters(tail) 412 | {[h|t], [hdns|tdns]} 413 | end 414 | defp replace_dn_filters({:equalityMatch, {:AttributeValueAssertion, 'dn', dn}}) do 415 | {:eldap.present('objectClass'), {:base, dn}} 416 | end 417 | defp replace_dn_filters({conjunction, list}) when is_list(list) do 418 | {l, dns} = replace_dn_filters(list) 419 | {{conjunction, l}, dns} 420 | end 421 | defp replace_dn_filters(other), do: {other, []} 422 | 423 | defp ordered_fields(sources) do 424 | {_, model} = elem(sources, 0) 425 | model.__schema__(:fields) 426 | end 427 | 428 | def count_fields(%{preprocess: [{:source, _, fields}]}, _), do: fields 429 | def count_fields(fields, sources) when is_list(fields), do: fields |> Enum.map(fn field -> count_fields(field, sources) end) |> List.flatten 430 | def count_fields({{_, _, fields}, _, _}, sources), do: fields |> extract_field_info(sources) 431 | def count_fields({:&, _, [_idx]} = field, sources), do: extract_field_info(field, sources) 432 | def count_fields(_field, sources), do: elem(sources, 0) 433 | 434 | defp extract_field_info({:&, _, [idx]} = field, sources) do 435 | {_source, model} = elem(sources, idx) 436 | [{field, length(model.__schema__(:fields))}] 437 | end 438 | defp extract_field_info(field, _sources) do 439 | [{field, 0}] 440 | end 441 | 442 | defp process_entry({:eldap_entry, dn, attributes}) when is_list(attributes) do 443 | List.flatten( 444 | [dn: dn], 445 | Enum.map(attributes, fn {key, value} -> 446 | {key |> to_string |> String.to_atom, value} 447 | end)) 448 | end 449 | 450 | defp prune_attributes(attributes, all_fields, [{{:&, [], [0]}, _}] = _selected_fields) do 451 | for field <- all_fields, do: Keyword.get(attributes, field) 452 | end 453 | defp prune_attributes(attributes, _all_fields, selected_fields) do 454 | selected_fields 455 | |> Enum.map(fn {field, _type} -> Keyword.get(attributes, field) 456 | field -> Keyword.get(attributes, field) end) 457 | end 458 | 459 | def update(_repo, schema_meta, fields, filters, _returning, _options) do 460 | dn = Keyword.get(filters, :dn) 461 | 462 | modify_operations = 463 | for {attribute, value} <- fields do 464 | type = schema_meta.schema.__schema__(:type, attribute) 465 | generate_modify_operation(attribute, value, type) 466 | end 467 | 468 | case update(dn, modify_operations) do 469 | :ok -> 470 | {:ok, []} 471 | {:error, reason} -> 472 | {:invalid, [reason]} 473 | end 474 | end 475 | 476 | defp generate_modify_operation(attribute, nil, _) do 477 | :eldap.mod_replace(convert_to_erlang(attribute), []) 478 | end 479 | defp generate_modify_operation(attribute, [], {:array, _}) do 480 | :eldap.mod_replace(convert_to_erlang(attribute), []) 481 | end 482 | defp generate_modify_operation(attribute, value, {:array, _}) do 483 | :eldap.mod_replace(convert_to_erlang(attribute), value) 484 | end 485 | defp generate_modify_operation(attribute, value, _) do 486 | :eldap.mod_replace(convert_to_erlang(attribute), [value]) 487 | end 488 | 489 | @spec autogenerate(any) :: no_return 490 | def autogenerate(_), do: raise ArgumentError, message: "autogenerate not supported" 491 | 492 | @spec delete(any, any, any, any) :: no_return 493 | def delete(_, _, _, _), do: raise ArgumentError, message: "delete not supported" 494 | 495 | @spec ensure_all_started(any, any) :: {:ok, []} 496 | def ensure_all_started(_, _), do: {:ok, []} 497 | 498 | @spec insert(any, any, any, any, any, any) :: no_return 499 | def insert(_, _, _, _, _, _), do: raise ArgumentError, message: "insert not supported" 500 | 501 | @spec insert_all(any, any, any, any, any, any, any) :: no_return 502 | def insert_all(_, _, _, _, _, _, _), do: raise ArgumentError, message: "insert_all not supported" 503 | end 504 | -------------------------------------------------------------------------------- /lib/ecto_ldap/adapter/converter.ex: -------------------------------------------------------------------------------- 1 | defmodule Ecto.Ldap.Adapter.Converter do 2 | 3 | @spec convert_from_erlang(any) :: any 4 | def convert_from_erlang(list=[head|_]) when is_list(head), do: Enum.map(list, &convert_from_erlang/1) 5 | def convert_from_erlang(string) when is_list(string), do: :binary.list_to_bin(string) 6 | def convert_from_erlang(other), do: other 7 | 8 | @spec convert_to_erlang(list | String.t | atom | number) :: list | number 9 | def convert_to_erlang(list) when is_list(list), do: Enum.map(list, &convert_to_erlang/1) 10 | def convert_to_erlang(string) when is_binary(string), do: :binary.bin_to_list(string) 11 | def convert_to_erlang(atom) when is_atom(atom), do: atom |> Atom.to_string |> convert_to_erlang 12 | def convert_to_erlang(num) when is_number(num), do: num 13 | 14 | @spec trim_converted(any) :: any 15 | def trim_converted(list) when is_list(list), do: hd(list) 16 | def trim_converted(other), do: other 17 | 18 | end 19 | -------------------------------------------------------------------------------- /lib/ecto_ldap/adapter/dumpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Ecto.Ldap.Adapter.Dumpers do 2 | import Ecto.Ldap.Adapter.Converter 3 | 4 | @doc """ 5 | 6 | Returns a function that can convert a value from the Ecto 7 | data format to a form that `:eldap` can interpret. 8 | 9 | The functions (or types) returned by these calls will be invoked 10 | by Ecto while performing the translation of values to valid `eldap` data. 11 | 12 | `nil`s are simply passed straight through regardless of Ecto datatype. 13 | Additionally, `:id`, `:binary`, and other unspecified datatypes are directly 14 | returned to Ecto for native translation. 15 | 16 | ### Examples 17 | 18 | iex> Ecto.Ldap.Adapter.dumpers(:id, :id) 19 | [:id] 20 | 21 | iex> Ecto.Ldap.Adapter.dumpers(:string, nil) 22 | { :ok, nil} 23 | 24 | iex> Ecto.Ldap.Adapter.dumpers(:binary, :binary) 25 | [:binary] 26 | 27 | iex> Ecto.Ldap.Adapter.dumpers(:woo, :woo) 28 | [:woo] 29 | 30 | iex> Ecto.Ldap.Adapter.dumpers(:integer, :integer) 31 | [:integer] 32 | 33 | 34 | Strings are converted to Erlang character lists. 35 | 36 | ### Examples 37 | 38 | iex> [conversion_function] = Ecto.Ldap.Adapter.dumpers({:in, :string}, {:in, :string}) 39 | iex> conversion_function.(["yes", "no"]) 40 | { :ok, {:in, ['yes', 'no']}} 41 | 42 | iex> [conversion_function] = Ecto.Ldap.Adapter.dumpers(:string, :string) 43 | iex> conversion_function.("bob") 44 | { :ok, 'bob'} 45 | iex> conversion_function.("Sören") 46 | { :ok, [83, 195, 182, 114, 101, 110]} 47 | iex> conversion_function.("José") 48 | { :ok, [74, 111, 115, 195, 169]} 49 | iex> conversion_function.(:atom) 50 | { :ok, 'atom'} 51 | 52 | iex> [conversion_function] = Ecto.Ldap.Adapter.dumpers({:array, :string}, {:array, :string}) 53 | iex> conversion_function.(["list", "of", "skills"]) 54 | { :ok, ['list', 'of', 'skills']} 55 | 56 | 57 | Ecto.DateTimes are converted to a stringified ASN.1 GeneralizedTime format in UTC. Currently, 58 | fractional seconds are truncated. 59 | 60 | ### Examples 61 | 62 | iex> [conversion_function] = Ecto.Ldap.Adapter.dumpers(:datetime, :datetime) 63 | iex> conversion_function.({{2016, 2, 2}, {0, 0, 0, 0}}) 64 | { :ok, '20160202000000Z'} 65 | 66 | iex> [conversion_function] = Ecto.Ldap.Adapter.dumpers(Ecto.DateTime, Ecto.DateTime) 67 | iex> conversion_function.({{2016, 4, 1}, {12, 34, 56, 789000}}) 68 | { :ok, '20160401123456Z'} 69 | iex> conversion_function.({{2016, 4, 1}, {12, 34, 56, 0}}) 70 | { :ok, '20160401123456Z'} 71 | 72 | """ 73 | @spec dumpers(Ecto.Type.primitive, Ecto.Type.t) :: [(term -> {:ok, term} | :error) | Ecto.Type.t] 74 | def dumpers(_, nil), do: {:ok, nil} 75 | def dumpers({:in, _type}, {:in, _}), do: [&dump_in/1] 76 | def dumpers(:string, _type), do: [&dump_string/1] 77 | def dumpers({:array, :string}, _type), do: [&dump_array/1] 78 | def dumpers(:datetime, _type), do: [&dump_date/1] 79 | def dumpers(Ecto.DateTime, _type), do: [&dump_date/1] 80 | def dumpers(_primitive, type), do: [type] 81 | 82 | def dump_in(value), do: {:ok, {:in, convert_to_erlang(value)}} 83 | def dump_string(value), do: {:ok, convert_to_erlang(value)} 84 | def dump_array(value) when is_list(value), do: {:ok, convert_to_erlang(value)} 85 | def dump_date(value) when is_tuple(value) do 86 | with {:ok, v} <- Timex.Ecto.DateTime.load(value), {:ok, d} <- Timex.format(v, "{ASN1:GeneralizedTime:Z}") do 87 | {:ok, convert_to_erlang(d)} 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/ecto_ldap/adapter/loaders.ex: -------------------------------------------------------------------------------- 1 | defmodule Ecto.Ldap.Adapter.Loaders do 2 | import Ecto.Ldap.Adapter.Converter 3 | 4 | @doc """ 5 | 6 | Retrieves a function that can convert a value from the LDAP 7 | data format to a form that Ecto can interpret. 8 | 9 | The functions (or types) returned by these calls will be invoked 10 | by Ecto while performing the translation of values for LDAP records. 11 | 12 | 13 | `:id` fields and unrecognized types are always returned unchanged. 14 | 15 | ### Examples 16 | 17 | iex> Ecto.Ldap.Adapter.loaders(:id, :id) 18 | [:id] 19 | 20 | iex> Ecto.Ldap.Adapter.loaders(:woo, :woo) 21 | [:woo] 22 | 23 | Given that LDAP uses ASN.1 GeneralizedTime for its datetime storage format, values 24 | where the type is `Ecto.DateTime` will be converted to a string and parsed as ASN.1 25 | GeneralizedTime, assuming UTC ( `"2016040112[34[56[.789]]]Z"` ) 26 | 27 | ### Examples 28 | 29 | iex> [conversion_function] = Ecto.Ldap.Adapter.loaders(:datetime, :datetime) 30 | iex> conversion_function.('20160202000000.000Z') 31 | { :ok, {{2016, 2, 2}, {0, 0, 0, 0}}} 32 | iex> conversion_function.('20160401123456.789Z') 33 | { :ok, {{2016, 4, 1}, {12, 34, 56, 789000}}} 34 | iex> conversion_function.('20160401123456Z') 35 | { :ok, {{2016, 4, 1}, {12, 34, 56, 0}}} 36 | iex> conversion_function.('201604011234Z') 37 | { :ok, {{2016, 4, 1}, {12, 34, 0, 0}}} 38 | iex> conversion_function.('2016040112Z') 39 | { :ok, {{2016, 4, 1}, {12, 0, 0, 0}}} 40 | 41 | iex> [conversion_function] = Ecto.Ldap.Adapter.loaders(Ecto.DateTime, Ecto.DateTime) 42 | iex> conversion_function.("20160202000000.000Z") 43 | { :ok, {{2016, 2, 2}, {0, 0, 0, 0}}} 44 | iex> conversion_function.('20160401123456.789Z') 45 | { :ok, {{2016, 4, 1}, {12, 34, 56, 789000}}} 46 | iex> conversion_function.('20160401123456Z') 47 | { :ok, {{2016, 4, 1}, {12, 34, 56, 0}}} 48 | iex> conversion_function.('201604011234Z') 49 | { :ok, {{2016, 4, 1}, {12, 34, 0, 0}}} 50 | iex> conversion_function.('2016040112Z') 51 | { :ok, {{2016, 4, 1}, {12, 0, 0, 0}}} 52 | 53 | 54 | String and binary types will take the first element if the underlying LDAP attribute 55 | supports multiple values. 56 | 57 | ### Examples 58 | 59 | iex> Ecto.Ldap.Adapter.loaders(:string, nil) 60 | [nil] 61 | 62 | iex> [conversion_function] = Ecto.Ldap.Adapter.loaders(:string, :string) 63 | iex> {:ok, "hello"} = conversion_function.("hello") 64 | { :ok, "hello"} 65 | iex> conversion_function.(nil) 66 | { :ok, nil} 67 | iex> conversion_function.([83, 195, 182, 114, 101, 110]) 68 | { :ok, "Sören"} 69 | iex> conversion_function.(['Home, home on the range', 'where the deer and the antelope play']) 70 | { :ok, "Home, home on the range"} 71 | 72 | iex> [conversion_function] = Ecto.Ldap.Adapter.loaders(:binary, :binary) 73 | iex> {:ok, "hello"} = conversion_function.("hello") 74 | { :ok, "hello"} 75 | iex> conversion_function.(nil) 76 | { :ok, nil} 77 | iex> conversion_function.([[1,2,3,4,5], [6,7,8,9,10]]) 78 | { :ok, <<1,2,3,4,5>>} 79 | 80 | Array values will be each be converted. 81 | 82 | ### Examples 83 | 84 | iex> [conversion_function] = Ecto.Ldap.Adapter.loaders({:array, :string}, {:array, :string}) 85 | iex> conversion_function.(["hello", "world"]) 86 | { :ok, ["hello", "world"]} 87 | 88 | 89 | """ 90 | @spec loaders(Ecto.Type.primitive, Ecto.Type.t) :: [(term -> {:ok, term} | :error) | Ecto.Type.t] 91 | def loaders(:id, type), do: [type] 92 | def loaders(:integer, _type), do: [&load_integer/1] 93 | def loaders(_primitive, nil), do: [nil] 94 | def loaders(:string, _type), do: [&load_string/1] 95 | def loaders(:binary, _type), do: [&load_string/1] 96 | def loaders(:datetime, _type), do: [&load_date/1] 97 | def loaders(:naive_datetime, _type), do: [&load_date/1] 98 | def loaders({:array, :string}, _type), do: [&load_array/1] 99 | def loaders(_primitive, type), do: [type] 100 | 101 | def load_integer(value) do 102 | {:ok, trim_converted(convert_from_erlang(value))} 103 | end 104 | 105 | def load_string(value) do 106 | {:ok, trim_converted(convert_from_erlang(value))} 107 | end 108 | 109 | def load_array(array) do 110 | {:ok, convert_from_erlang(array)} 111 | end 112 | 113 | def load_date(value) do 114 | value 115 | |> to_string 116 | |> Timex.parse!("{ASN1:GeneralizedTime:Z}") 117 | |> Timex.Ecto.DateTime.dump 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/ecto_ldap/adapter/sandbox.ex: -------------------------------------------------------------------------------- 1 | defmodule Ecto.Ldap.Adapter.Sandbox do 2 | use GenServer 3 | 4 | @moduledoc """ 5 | Fake LDAP server which returns realistic results for specific LDAP calls. 6 | 7 | Useful when using `ecto_ldap` in testing. 8 | 9 | 10 | use Mix.Config 11 | 12 | config :my_app, MyApp.Repo, 13 | ldap_api: Ecto.Ldap.Adapter.Sandbox, 14 | adapter: Ecto.Ldap.Adapter, 15 | hostname: "ldap.example.com", 16 | base: "dc=example,dc=com", 17 | port: 636, 18 | ssl: true, 19 | user_dn: "uid=sample_user,ou=users,dc=example,dc=com", 20 | password: "password", 21 | pool_size: 1 22 | 23 | """ 24 | 25 | @jeffweiss {:eldap_entry, 'uid=jeff.weiss,ou=users,dc=example,dc=com', [ 26 | {'cn', ['Jeff Weiss']}, 27 | {'displayName', ['Jeff Weiss']}, 28 | {'gidNumber', ['5000']}, 29 | {'givenName', ['Jeff']}, 30 | {'homeDirectory', ['/home/jeff.weiss']}, 31 | {'l', ['Portland']}, 32 | {'loginShell', ['/bin/zsh']}, 33 | {'mail', ['jeff.weiss@example.com', 'jeff.weiss@example.org']}, 34 | {'objectClass', ['posixAccount','shadowAccount', 'inetOrgPerson', 'ldapPublicKey', 'top']}, 35 | {'skills', ['dad jokes', 'being awesome', 'elixir']}, 36 | {'sn', ['Weiss']}, 37 | {'startDate', ['20120319100000.000Z']}, 38 | {'sshPublicKey', ['ssh-rsa AAAA/TOTALLY+FAKE/KEY jeff.weiss@example.com']}, 39 | {'st', ['OR']}, 40 | {'title', ['Principal Software Engineer']}, 41 | {'uid', ['jeff.weiss']}, 42 | {'uidNumber', ['5001']}, 43 | ]} 44 | 45 | @manny {:eldap_entry, 'uid=manny,ou=users,dc=example,dc=com', [ 46 | {'cn', ['Manny Batule']}, 47 | {'displayName', ['Manny Batule']}, 48 | {'gidNumber', ['5000']}, 49 | {'givenName', ['Manny']}, 50 | {'homeDirectory', ['/home/manny']}, 51 | {'l', ['Portland']}, 52 | {'loginShell', ['/bin/bash']}, 53 | {'mail', ['manny@example.com']}, 54 | {'objectClass', ['posixAccount','shadowAccount', 'inetOrgPerson', 'ldapPublicKey', 'top']}, 55 | {'skills', ['nunchuck', 'computer hacking', 'bowhunting']}, 56 | {'sn', ['Batule']}, 57 | {'startDate', ['20151214100000.000Z']}, 58 | {'sshPublicKey', ['ssh-rsa AAAA/TOTALLY+FAKE/KEY+2 manny@example.com']}, 59 | {'st', ['OR']}, 60 | {'title', ['Senior Software Engineer']}, 61 | {'uid', ['manny']}, 62 | {'uidNumber', ['5002']}, 63 | ]} 64 | 65 | @doc false 66 | def init(_) do 67 | {:ok, [@jeffweiss, @manny]} 68 | end 69 | 70 | @doc false 71 | def search(pid, search_options) when is_list(search_options) do 72 | GenServer.call(pid, {:search, Map.new(search_options)}) 73 | end 74 | @doc false 75 | def modify(pid, dn, modify_operations) do 76 | GenServer.call(pid, {:update, dn, modify_operations}) 77 | end 78 | def handle_call({:search, %{scope: :baseObject, base: 'uid=jeff.weiss,ou=users,dc=example,dc=com'}}, _from, state) do 79 | ldap_response = {:ok, {:eldap_search_result, [List.first(state)], []}} 80 | {:reply, ldap_response, state} 81 | end 82 | def handle_call({:search, %{scope: :baseObject, base: 'uid=manny,ou=users,dc=example,dc=com'}}, _from, state) do 83 | ldap_response = {:ok, {:eldap_search_result, [List.last(state)], []}} 84 | {:reply, ldap_response, state} 85 | end 86 | def handle_call({:search, %{scope: :baseObject}}, _from, state) do 87 | ldap_response = {:ok, {:eldap_search_result, [], []}} 88 | {:reply, ldap_response, state} 89 | end 90 | def handle_call({:search, %{base: 'ou=users,dc=example,dc=com', filter: {:and, [and: [equalityMatch: {:AttributeValueAssertion, 'uid', 'jeff.weiss'}], and: []]}}}, _from, state) do 91 | ldap_response = {:ok, {:eldap_search_result, [List.first(state)], []}} 92 | {:reply, ldap_response, state} 93 | end 94 | def handle_call({:search, %{base: 'ou=users,dc=example,dc=com', filter: {:and, [and: [], and: [equalityMatch: {:AttributeValueAssertion, 'uid', 'jeff.weiss'}]]}}}, _from, state) do 95 | ldap_response = {:ok, {:eldap_search_result, [List.first(state)], []}} 96 | {:reply, ldap_response, state} 97 | end 98 | def handle_call({:search, %{base: 'ou=users,dc=example,dc=com', filter: {:and, [and: [substrings: {:SubstringFilter, 'sn', [{:any, 'Weis'}]}], and: []]}}}, _from, state) do 99 | ldap_response = {:ok, {:eldap_search_result, [List.first(state)], []}} 100 | {:reply, ldap_response, state} 101 | end 102 | 103 | def handle_call({:search, %{base: 'ou=users,dc=example,dc=com', filter: {:and, [and: [substrings: {:SubstringFilter, 'uid', [{:initial, 'jeff'}]}], and: []]}}}, _from, state) do 104 | ldap_response = {:ok, {:eldap_search_result, [List.first(state)], []}} 105 | {:reply, ldap_response, state} 106 | end 107 | def handle_call({:search, %{base: 'ou=users,dc=example,dc=com', filter: {:and, [and: [substrings: {:SubstringFilter, 'sn', [final: 'eiss']}], and: []]}}}, _from, state) do 108 | ldap_response = {:ok, {:eldap_search_result, [List.first(state)], []}} 109 | {:reply, ldap_response, state} 110 | end 111 | def handle_call({:search, %{base: 'ou=users,dc=example,dc=com', filter: {:and, [and: [and: [equalityMatch: {:AttributeValueAssertion, 'st', 'OR'}, equalityMatch: {:AttributeValueAssertion, 'skills', 'elixir'}]], and: []]}}}, _from, state) do 112 | ldap_response = {:ok, {:eldap_search_result, [List.first(state)], []}} 113 | {:reply, ldap_response, state} 114 | end 115 | def handle_call({:search, %{base: 'ou=users,dc=example,dc=com', filter: {:and, [and: [or: [equalityMatch: {:AttributeValueAssertion, 'st', 'OR'}, not: {:not, {:present, 'skills'}}]], and: []]}}}, _from, state) do 116 | ldap_response = {:ok, {:eldap_search_result, state, []}} 117 | {:reply, ldap_response, state} 118 | end 119 | def handle_call({:search, %{base: 'ou=users,dc=example,dc=com', filter: {:and, [and: [or: [equalityMatch: {:AttributeValueAssertion, 'uid', 'jeff.weiss'}, equalityMatch: {:AttributeValueAssertion, 'uid', 'jeff'}]], and: []]}}}, _from, state) do 120 | ldap_response = {:ok, {:eldap_search_result, [List.first(state)], []}} 121 | {:reply, ldap_response, state} 122 | end 123 | def handle_call({:search, %{base: 'ou=users,dc=example,dc=com', filter: {:and, [and: [not: {:equalityMatch, {:AttributeValueAssertion, 'uid', 'jeff.weiss'}}], and: []]}}}, _from, state) do 124 | ldap_response = {:ok, {:eldap_search_result, [List.first(state)], []}} 125 | {:reply, ldap_response, state} 126 | end 127 | def handle_call({:search, %{base: 'ou=users,dc=example,dc=com', filter: {:and, [and: [greaterOrEqual: {:AttributeValueAssertion, 'uidNumber', 5002}], and: []]}}}, _from, state) do 128 | ldap_response = {:ok, {:eldap_search_result, [List.first(state)], []}} 129 | {:reply, ldap_response, state} 130 | end 131 | def handle_call({:search, %{base: 'ou=users,dc=example,dc=com', filter: {:and, [and: [lessOrEqual: {:AttributeValueAssertion, 'uidNumber', 5001}], and: []]}}}, _from, state) do 132 | ldap_response = {:ok, {:eldap_search_result, [List.first(state)], []}} 133 | {:reply, ldap_response, state} 134 | end 135 | def handle_call({:search, %{base: 'ou=users,dc=example,dc=com'} = _options}, _from, state) do 136 | ldap_response = {:ok, {:eldap_search_result, state, []}} 137 | {:reply, ldap_response, state} 138 | end 139 | def handle_call({:update, 'uid=manny,ou=users,dc=example,dc=com', modify_operations}, _from, state) do 140 | {:eldap_entry, dn, attributes} = List.last(state) 141 | 142 | updated_attributes = 143 | modify_operations 144 | |> Enum.reduce( 145 | Enum.into(attributes, %{}), 146 | &replace_value_in_attribute_map/2) 147 | |> Enum.to_list 148 | 149 | updated_state = [List.first(state), {:eldap_entry, dn, updated_attributes}] 150 | 151 | {:reply, :ok, updated_state} 152 | end 153 | 154 | defp replace_value_in_attribute_map({_, :replace, {_, attribute, []}}, attribute_map) do 155 | Map.put(attribute_map, attribute, nil) 156 | end 157 | defp replace_value_in_attribute_map({_, :replace, {_, attribute, value}}, attribute_map) do 158 | Map.put(attribute_map, attribute, value) 159 | end 160 | 161 | 162 | @doc false 163 | def open(_hosts, _options) do 164 | __MODULE__ 165 | |> Process.whereis 166 | |> case do 167 | nil -> GenServer.start_link(__MODULE__, [], name: __MODULE__) 168 | pid -> {:ok, pid} 169 | end 170 | end 171 | 172 | @doc false 173 | def simple_bind(_pid, 'uid=sample_user,ou=users,dc=example,dc=com', 'password'), do: :ok 174 | def simple_bind(_, _, _), do: {:error, :invalidCredentials} 175 | 176 | @doc false 177 | def close(_pid) do 178 | :ok 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoLdap.Mixfile do 2 | use Mix.Project 3 | @description """ 4 | An Ecto adapter for LDAP 5 | """ 6 | 7 | def project() do 8 | [app: :ecto_ldap, 9 | version: "0.4.0", 10 | elixir: "~> 1.3", 11 | name: "ecto_ldap", 12 | description: @description, 13 | package: package(), 14 | build_embedded: Mix.env == :prod, 15 | start_permanent: Mix.env == :prod, 16 | test_coverage: [tool: ExCoveralls], 17 | preferred_cli_env: [ 18 | "coveralls": :test, 19 | "coveralls.detail": :test, 20 | "coveralls.html": :test, 21 | "coveralls.post": :test, 22 | "docs": :docs, 23 | "hex.docs": :docs, 24 | ], 25 | dialyzer: [ 26 | plt_add_apps: [:ecto, :timex_ecto, :timex, :eldap], 27 | flags: [ 28 | "-Wunmatched_returns", 29 | "-Werror_handling", 30 | "-Wrace_conditions", 31 | "-Wunderspecs", 32 | "-Wunknown", 33 | "-Woverspecs", 34 | "-Wspecdiffs", 35 | ] 36 | ], 37 | deps: deps()] 38 | end 39 | 40 | # Configuration for the OTP application 41 | # 42 | # Type "mix help compile.app" for more information 43 | def application() do 44 | [applications: [:logger, :ecto, :timex, :timex_ecto]] 45 | end 46 | 47 | # Dependencies can be Hex packages: 48 | # 49 | # {:mydep, "~> 0.3.0"} 50 | # 51 | # Or git/path repositories: 52 | # 53 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 54 | # 55 | # Type "mix help deps" for more examples and options 56 | defp deps() do 57 | [ 58 | {:ecto, "~> 2.2"}, 59 | {:timex, "~> 3.0"}, 60 | {:timex_ecto, "~> 3.0"}, 61 | {:excoveralls, "~> 0.5", only: :test}, 62 | {:ex_doc, "~> 0.11", only: [:dev, :docs]}, 63 | {:earmark, "~> 1.0", override: true, only: [:dev, :docs]}, 64 | {:dialyxir, "~> 0.3", only: :dev, runtime: false}, 65 | ] 66 | end 67 | 68 | defp package() do 69 | [ maintainers: ["Jeff Weiss", "Manny Batule"], 70 | licenses: ["MIT"], 71 | links: %{"Github" => "https://github.com/jeffweiss/ecto_ldap"} ] 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"certifi": {:hex, :certifi, "2.0.0", "a0c0e475107135f76b8c1d5bc7efb33cd3815cb3cf3dea7aefdd174dabead064", [:rebar3], [], "hexpm"}, 2 | "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"}, 3 | "decimal": {:hex, :decimal, "1.4.1", "ad9e501edf7322f122f7fc151cce7c2a0c9ada96f2b0155b8a09a795c2029770", [:mix], [], "hexpm"}, 4 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, 5 | "earmark": {:hex, :earmark, "1.2.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [:mix], [], "hexpm"}, 6 | "ecto": {:hex, :ecto, "2.2.8", "a4463c0928b970f2cee722cd29aaac154e866a15882c5737e0038bbfcf03ec2c", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, 7 | "ex_doc": {:hex, :ex_doc, "0.18.1", "37c69d2ef62f24928c1f4fdc7c724ea04aecfdf500c4329185f8e3649c915baf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "excoveralls": {:hex, :excoveralls, "0.8.0", "99d2691d3edf8612f128be3f9869c4d44b91c67cec92186ce49470ae7a7404cf", [:mix], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "gettext": {:hex, :gettext, "0.13.1", "5e0daf4e7636d771c4c71ad5f3f53ba09a9ae5c250e1ab9c42ba9edccc476263", [:mix], [], "hexpm"}, 11 | "hackney": {:hex, :hackney, "1.10.1", "c38d0ca52ea80254936a32c45bb7eb414e7a96a521b4ce76d00a69753b157f21", [:rebar3], [{:certifi, "2.0.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "hoedown": {:git, "https://github.com/hoedown/hoedown.git", "980b9c549b4348d50b683ecee6abee470b98acda", []}, 13 | "idna": {:hex, :idna, "5.1.0", "d72b4effeb324ad5da3cab1767cb16b17939004e789d8c0ad5b70f3cea20c89a", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 14 | "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"}, 15 | "markdown": {:git, "https://github.com/devinus/markdown.git", "9b3beaf69a9b9c6450526c41d591a94d2eedf1e8", []}, 16 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], []}, 17 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], []}, 18 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []}, 19 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, 20 | "timex": {:hex, :timex, "3.1.24", "d198ae9783ac807721cca0c5535384ebdf99da4976be8cefb9665a9262a1e9e3", [:mix], [{:combine, "~> 0.7", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, 21 | "timex_ecto": {:hex, :timex_ecto, "3.2.1", "461140751026e1ca03298fab628f78ab189e78784175f5e301eefa034ee530aa", [:mix], [{:ecto, "~> 2.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm"}, 22 | "tzdata": {:hex, :tzdata, "0.5.12", "1c17b68692c6ba5b6ab15db3d64cc8baa0f182043d5ae9d4b6d35d70af76f67b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 23 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [], [], "hexpm"}} 24 | -------------------------------------------------------------------------------- /test/ecto_ldap_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoLdapTest do 2 | alias Ecto.Ldap.TestRepo 3 | alias Ecto.Ldap.TestUser 4 | require Ecto.Query 5 | use ExUnit.Case 6 | use Timex 7 | doctest Ecto.Ldap.Adapter 8 | 9 | test "retrieve model by id/dn" do 10 | dn = "uid=jeff.weiss,ou=users,dc=example,dc=com" 11 | user = TestRepo.get TestUser, dn 12 | assert user != nil 13 | assert user.dn == dn 14 | end 15 | 16 | test "retrieve by unknown dn returns nil" do 17 | dn = "uid=unknown,ou=users,dc=example,dc=com" 18 | assert(TestRepo.get(TestUser, dn) == nil) 19 | end 20 | 21 | test "model fields not in ldap attributes are nil" do 22 | user = TestRepo.get TestUser, "uid=jeff.weiss,ou=users,dc=example,dc=com" 23 | assert user.mobile == nil 24 | end 25 | 26 | test "model fields with multiple ldap values return the first one" do 27 | user = TestRepo.get(TestUser, "uid=jeff.weiss,ou=users,dc=example,dc=com") 28 | assert user.mail == "jeff.weiss@example.com" 29 | end 30 | 31 | test "model fields which are arrays return all ldap values" do 32 | user = TestRepo.get(TestUser, "uid=jeff.weiss,ou=users,dc=example,dc=com") 33 | assert Enum.count(user.objectClass) > 1 34 | end 35 | 36 | test "get_by with criteria" do 37 | user = TestRepo.get_by TestUser, uid: "jeff.weiss" 38 | assert user != nil 39 | end 40 | 41 | test "all returns both users from our sandbox" do 42 | users = TestRepo.all TestUser 43 | assert Enum.count(users) == 2 44 | end 45 | 46 | test "all with criteria" do 47 | users = TestRepo.all TestUser, uid: "jeff.weiss" 48 | assert Enum.count(users) == 1 49 | end 50 | 51 | test "all with negation criteria" do 52 | users = TestRepo.all(Ecto.Query.from(u in TestUser, where: u.uid != "jeff.weiss")) 53 | assert Enum.count(users) == 1 54 | end 55 | 56 | test "all with greater than or equal to" do 57 | users = TestRepo.all(Ecto.Query.from(u in TestUser, where: u.uidNumber >= 5002)) 58 | assert Enum.count(users) == 1 59 | end 60 | 61 | test "all with less than or equal to" do 62 | users = TestRepo.all(Ecto.Query.from(u in TestUser, where: u.uidNumber <= 5001)) 63 | assert Enum.count(users) == 1 64 | end 65 | 66 | @update_examples [ 67 | %{"loginShell" => nil}, 68 | %{"loginShell" => "/bin/zsh"}, 69 | %{"skills" => nil}, 70 | %{"skills" => ["nunchucks", "bow staff", "katanas", "sais"]}, 71 | ] 72 | 73 | for params <- @update_examples do 74 | quote do 75 | test "update #{unquote(inspect params)}" do 76 | attr = unquote(params |> Map.keys |> List.first) 77 | value = unquote(params |> Map.values |> List.first) 78 | TestUser 79 | |> TestRepo.get("uid=manny,ou=users,dc=example,dc=com") 80 | |> TestUser.changeset(unquote(params)) 81 | |> TestRepo.update 82 | 83 | updated_user = TestRepo.get(TestUser, "uid=manny,ou=users,dc=example,dc=com") 84 | 85 | assert Map.get(updated_user, attr |> String.to_atom) == value 86 | end 87 | end 88 | end 89 | 90 | test "update with empty list comes back nil" do 91 | TestUser 92 | |> TestRepo.get("uid=manny,ou=users,dc=example,dc=com") 93 | |> TestUser.changeset(%{}) 94 | |> Ecto.Changeset.put_change(:skills, []) 95 | |> TestRepo.update 96 | 97 | updated_user = TestRepo.get(TestUser, "uid=manny,ou=users,dc=example,dc=com") 98 | assert updated_user.skills == nil 99 | end 100 | 101 | test "update multiple attributes at once" do 102 | surname = "Batulo" 103 | mail = "manny@example.co.uk" 104 | 105 | TestUser 106 | |> TestRepo.get("uid=manny,ou=users,dc=example,dc=com") 107 | |> TestUser.changeset(%{}) 108 | |> Ecto.Changeset.put_change(:sn, surname) 109 | |> Ecto.Changeset.put_change(:mail, mail) 110 | |> TestRepo.update 111 | 112 | updated_user = TestRepo.get(TestUser, "uid=manny,ou=users,dc=example,dc=com") 113 | assert updated_user.sn == surname 114 | assert updated_user.mail == mail 115 | end 116 | 117 | test "updating a field not present on the model has no effect on the entry" do 118 | user = TestRepo.get(TestUser, "uid=manny,ou=users,dc=example,dc=com") 119 | 120 | user 121 | |> TestUser.changeset(%{"nickname" => "Rockman"}) 122 | |> TestRepo.update 123 | 124 | updated_user = TestRepo.get(TestUser, "uid=manny,ou=users,dc=example,dc=com") 125 | assert updated_user == user 126 | end 127 | 128 | test "fields returned are limited by select" do 129 | values = TestRepo.all(Ecto.Query.from(u in TestUser, select: u.uid)) 130 | assert values == ["jeff.weiss", "manny"] 131 | end 132 | 133 | test "multiple fields ordered correctly by select" do 134 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: u.uid == "jeff.weiss", select: [u.sn, u.mail])) 135 | assert values == [["Weiss", "jeff.weiss@example.com"]] 136 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: u.uid == "jeff.weiss", select: [u.mail, u.sn])) 137 | assert values == [["jeff.weiss@example.com", "Weiss"]] 138 | end 139 | 140 | test "like without explicit %" do 141 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: like(u.sn, "Weis"))) 142 | assert Enum.count(values) == 1 143 | assert hd(values).uid == "jeff.weiss" 144 | query = "Weis" 145 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: like(u.sn, ^query))) 146 | assert Enum.count(values) == 1 147 | assert hd(values).uid == "jeff.weiss" 148 | end 149 | 150 | test "like with trailing %" do 151 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: like(u.uid, "jeff%"))) 152 | assert Enum.count(values) == 1 153 | assert hd(values).uid == "jeff.weiss" 154 | query = "jeff%" 155 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: like(u.uid, ^query))) 156 | assert Enum.count(values) == 1 157 | assert hd(values).uid == "jeff.weiss" 158 | end 159 | 160 | test "like with leading %" do 161 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: like(u.sn, "%eiss"))) 162 | assert Enum.count(values) == 1 163 | assert hd(values).uid == "jeff.weiss" 164 | query = "%eiss" 165 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: like(u.sn, ^query))) 166 | assert Enum.count(values) == 1 167 | assert hd(values).uid == "jeff.weiss" 168 | end 169 | 170 | test "like with leading and trailing %" do 171 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: like(u.sn, "%Weis%"))) 172 | assert Enum.count(values) == 1 173 | assert hd(values).uid == "jeff.weiss" 174 | query = "%Weis%" 175 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: like(u.sn, ^query))) 176 | assert Enum.count(values) == 1 177 | assert hd(values).uid == "jeff.weiss" 178 | end 179 | 180 | test "in keyword with a list" do 181 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: u.uid in ["jeff.weiss", "jeff"])) 182 | assert Enum.count(values) == 1 183 | assert hd(values).uid == "jeff.weiss" 184 | list = ["jeff.weiss", "jeff"] 185 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: u.uid in ^list)) 186 | assert Enum.count(values) == 1 187 | assert hd(values).uid == "jeff.weiss" 188 | end 189 | 190 | test "multiple criteria with `and`" do 191 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: u.st == "OR" and "elixir" in u.skills)) 192 | assert Enum.count(values) == 1 193 | assert hd(values).uid == "jeff.weiss" 194 | end 195 | 196 | test "multiple criteria with `or`" do 197 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: u.st == "OR" or not is_nil(u.skills))) 198 | assert Enum.count(values) == 2 199 | end 200 | 201 | test "query for one record using a single select argument returns single attribute in a list" do 202 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: u.uid == "jeff.weiss", select: u.st)) 203 | assert values == ["OR"] 204 | end 205 | 206 | test "query for one record using a single select argument returns an array attribute as a list inside a list" do 207 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: u.uid == "jeff.weiss", select: u.skills)) 208 | assert values == [["dad jokes", "being awesome", "elixir"]] 209 | end 210 | 211 | test "query for one record using a single-entry list argument for select returns an array attribute as a list inside a list of lists" do 212 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: u.uid == "jeff.weiss", select: [u.skills])) 213 | assert values == [[["dad jokes", "being awesome", "elixir"]]] 214 | end 215 | 216 | test "query for one record using a single-entry list argument for select returns single attribute in a list of lists" do 217 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: u.uid == "jeff.weiss", select: [u.st])) 218 | assert values == [["OR"]] 219 | end 220 | 221 | test "query for a single attribute across multiple records returns selected attribute in a list" do 222 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: u.st == "OR", select: u.uid)) 223 | assert values == ["jeff.weiss", "manny"] 224 | end 225 | 226 | test "query for single attribute as a single-entry list argument for select across multiple records returns selected attribute in a list of lists" do 227 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: u.st == "OR", select: [u.uid])) 228 | assert values == [["jeff.weiss"], ["manny"]] 229 | end 230 | 231 | test "query for multiple attributes across multiple records returns selected attributes in a list of lists" do 232 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: u.st == "OR", select: [u.uid, u.uidNumber])) 233 | assert values == [["jeff.weiss", "5001"], ["manny", "5002"]] 234 | end 235 | 236 | test "query for bound variable in array attribute" do 237 | obj = "posixAccount" 238 | values = TestRepo.all(Ecto.Query.from(u in TestUser, where: ^obj in u.objectClass)) 239 | assert Enum.count(values) == 2 240 | end 241 | 242 | test "delete_all unsupported" do 243 | assert_raise RuntimeError, fn -> 244 | TestRepo.delete_all(TestUser, dn: "uid=manny,ou=users,dc=example,dc=com") 245 | end 246 | end 247 | 248 | test "update_all not supported" do 249 | assert_raise ArgumentError, fn -> 250 | TestRepo.update_all(TestUser, dn: "uid=manny,ou=users,dc=example,dc=com") 251 | end 252 | end 253 | 254 | test "autogenerate not supported" do 255 | assert_raise ArgumentError, fn -> 256 | Ecto.Ldap.Adapter.autogenerate(nil) 257 | end 258 | end 259 | 260 | test "delete not supported" do 261 | assert_raise ArgumentError, fn -> 262 | TestRepo.delete(%TestUser{dn: "foo"}) 263 | end 264 | end 265 | 266 | test "insert not supported" do 267 | assert_raise ArgumentError, fn -> 268 | TestRepo.insert(%TestUser{}) 269 | end 270 | end 271 | 272 | test "insert_all not supported" do 273 | assert_raise ArgumentError, fn -> 274 | TestRepo.insert_all(TestUser, [[dn: "foo"], [dn: "bar"]]) 275 | end 276 | end 277 | 278 | end 279 | -------------------------------------------------------------------------------- /test/support/test_repo.ex: -------------------------------------------------------------------------------- 1 | Code.require_file "../../deps/ecto/integration_test/support/repo.exs", __DIR__ 2 | defmodule Ecto.Ldap.TestRepo do 3 | use Ecto.Integration.Repo, otp_app: :ecto 4 | end 5 | -------------------------------------------------------------------------------- /test/support/test_user.ex: -------------------------------------------------------------------------------- 1 | defmodule Ecto.Ldap.TestUser do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @primary_key {:dn, :string, autogenerate: false} 6 | schema "users" do 7 | field :objectClass, {:array, :string} 8 | field :loginShell, :string 9 | field :mail, :string 10 | field :mobile, :string 11 | field :skills, {:array, :string} 12 | field :sn, :string 13 | field :st, :string 14 | field :startDate, :naive_datetime 15 | field :uid, :string 16 | field :jpegPhoto, :binary 17 | field :uidNumber, :integer 18 | end 19 | 20 | def changeset(model, params \\ :empty) do 21 | model 22 | |> cast(params, ~w(dn), ~w(objectClass loginShell mail mobile skills sn uid)) 23 | |> unique_constraint(:dn) 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Code.require_file "support/test_repo.ex", __DIR__ 2 | Code.require_file "support/test_user.ex", __DIR__ 3 | 4 | Ecto.Ldap.TestRepo.start_link 5 | 6 | ExUnit.start() 7 | --------------------------------------------------------------------------------