Get started with AshScim

Copy Markdown View Source

This guide walks you through adding a SCIM 2.0 server to an existing Ash application — wiring up AshScim on your user resource, exposing a /scim/v2 endpoint, and authenticating identity providers (Okta, Azure AD, etc.) with bearer tokens.

If you don't yet have an Ash application, start with the Ash Getting Started guide. If you also want logged-in users (separate concern from SCIM), follow the AshAuthentication Getting Started guide first — AshScim integrates cleanly with it.

What you'll build

By the end of this guide:

  • Your User resource is exposed as a SCIM User resource at /scim/v2/Users.
  • Optionally, a Group resource is exposed at /scim/v2/Groups, with group memberships managed via SCIM PATCH ops.
  • IdPs authenticate using bearer JWTs minted by AshAuthentication, stored in your existing token resource so they can be revoked at any time.
mix igniter.install ash_scim

This:

  • adds :ash_scim to your deps and .formatter.exs,
  • adds the AshScim.User extension to your user resource (default MyApp.Accounts.User — pass --user to override),
  • adds the :scim_external_id and :active attributes if they're not already present,
  • adds a default scim do block with sensible mappings,
  • adds the AshScim.Checks.AshScimInteraction policy bypass,
  • generates a MyAppWeb.ScimRouter module wired to your accounts domain.

When AshAuthentication is also a dep of the project, the router defaults to {AshScim.Auth.AshAuthenticationToken, otp_app: :my_app}. Otherwise it defaults to {AshScim.Auth.StaticBearer, tokens: {:env, "SCIM_BEARER_TOKEN"}}.

The installer is idempotent — re-running it on a partially-installed app picks up where it left off. The printed notices cover the two manual steps that remain: mounting the router in your endpoint, and minting a token.

If you'd rather wire things by hand, the rest of this guide covers the equivalent manual setup.

Add the dependency (manual)

# mix.exs
defp deps do
  [
    # ...
    {:ash_scim, "~> 0.1"}
  ]
end

Add :ash_scim to your .formatter.exs:

# .formatter.exs
[
  import_deps: [..., :ash_scim]
]

Add the extension to your User resource

Whatever your existing User resource looks like, add AshScim.User to its extensions: list and declare the SCIM mapping in a scim do ... end block:

defmodule MyApp.Accounts.User do
  use Ash.Resource,
    domain: MyApp.Accounts,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer],
    # Add AshScim.User to your existing extensions
    extensions: [AshAuthentication, AshScim.User]

  scim do
    map :userName,   attribute: :email
    map :active,     attribute: :active
    map :externalId, attribute: :scim_external_id

    complex :name do
      map :givenName,  attribute: :first_name
      map :familyName, attribute: :last_name
    end

    multivalued :emails do
      map :value,   attribute: :email
      map :primary, value: true
      map :type,    value: "work"
    end
  end

  attributes do
    uuid_primary_key :id
    attribute :email,            :ci_string, allow_nil?: false, public?: true
    attribute :first_name,       :string,    public?: true
    attribute :last_name,        :string,    public?: true
    attribute :active,           :boolean,   default: true, public?: true
    attribute :scim_external_id, :string,    public?: true
  end

  identities do
    identity :unique_email, [:email]
  end

  # ...your authentication block, actions, etc...
end

The scim do block describes how SCIM JSON projects to and from Ash attributes. See Multi-valued attributes for the full breakdown of map/complex/multivalued.

Add the policy bypass

If your resource uses Ash.Policy.Authorizer, add a bypass for AshScim's internal interactions so the router's reads, creates, updates, and destroys aren't blocked by your user-facing policies:

policies do
  bypass AshScim.Checks.AshScimInteraction do
    authorize_if always()
  end

  # ... your existing policies ...
end

See Policies & the bypass check for the rationale.

Optional: add Group + Membership for SCIM groups

Most IdPs sync groups in addition to users. The simplest model is a Group resource plus a Membership join resource:

defmodule MyApp.Accounts.Membership do
  use Ash.Resource,
    domain: MyApp.Accounts,
    data_layer: AshPostgres.DataLayer

  attributes do
    uuid_primary_key :id
    attribute :user_id,  :string, allow_nil?: false, public?: true
    attribute :group_id, :uuid,   allow_nil?: false, public?: true
  end

  actions do
    defaults [:read, :destroy, create: :*, update: :*]
  end

  identities do
    identity :user_in_group, [:user_id, :group_id]
  end
end
defmodule MyApp.Accounts.Group do
  use Ash.Resource,
    domain: MyApp.Accounts,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer],
    extensions: [AshScim.Group]

  scim do
    map :displayName, attribute: :name
    map :externalId,  attribute: :scim_external_id

    multivalued :members do
      relationship :memberships
      map :value, attribute: :user_id
    end
  end

  attributes do
    uuid_primary_key :id
    attribute :name,             :string, allow_nil?: false, public?: true
    attribute :scim_external_id, :string, public?: true
  end

  actions do
    defaults [:read, :destroy, create: :*]

    update :update do
      primary? true
      accept :*
      require_atomic? false
    end
  end

  policies do
    bypass AshScim.Checks.AshScimInteraction do
      authorize_if always()
    end
  end

  relationships do
    has_many :memberships, MyApp.Accounts.Membership do
      destination_attribute :group_id
      public? true
    end
  end
end

Add both resources to your domain:

defmodule MyApp.Accounts do
  use Ash.Domain

  resources do
    resource MyApp.Accounts.User
    resource MyApp.Accounts.Group
    resource MyApp.Accounts.Membership
    # ... existing resources ...
  end
end

Run a codegen + migration so the new tables exist:

mix ash.codegen add_scim_groups
mix ecto.migrate

Define a SCIM router module

defmodule MyAppWeb.ScimRouter do
  use AshScim.Router,
    domains: [MyApp.Accounts],
    auth: {AshScim.Auth.AshAuthenticationToken, otp_app: :my_app},
    base_url: "https://my-app.example.com/scim/v2"
end

This module is a Plug. The auth: option chooses how IdP requests are authenticated — see Authentication for the built-in options.

Mount the router in your Phoenix endpoint

SCIM URLs include : characters in schema IDs (e.g. /Schemas/urn:ietf:params:scim:schemas:core:2.0:User), which Phoenix's default Plug.Static middleware rejects. The cleanest fix is to dispatch SCIM requests at the endpoint level, before Plug.Static:

defmodule MyAppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :my_app

  socket "/live", Phoenix.LiveView.Socket, # ...

  # Dispatch SCIM requests before Plug.Static.
  plug :scim_dispatch

  defp scim_dispatch(%Plug.Conn{path_info: ["scim", "v2" | rest]} = conn, _opts) do
    conn = %{conn | path_info: rest, script_name: conn.script_name ++ ["scim", "v2"]}

    conn
    |> MyAppWeb.ScimRouter.call(MyAppWeb.ScimRouter.init([]))
    |> Plug.Conn.halt()
  end

  defp scim_dispatch(conn, _opts), do: conn

  plug Plug.Static,
    at: "/",
    from: :my_app,
    only: MyAppWeb.static_paths()

  # ... rest of the standard endpoint plugs ...
end

Mint a SCIM bearer token

SCIM requests authenticate as a service-account user with a JWT whose purpose claim is "scim". You create one by inserting into your existing token resource via a normal Ash action — typically from an admin UI or an iex console attached to your production node.

The simplest approach for development:

# A one-off script — priv/repo/scim_token.exs
require Ash.Query

email = "scim-service@my-app.local"

user =
  case MyApp.Accounts.User
       |> Ash.Query.filter(email == ^email)
       |> Ash.read_one(authorize?: false) do
    {:ok, %MyApp.Accounts.User{} = u} -> u
    {:ok, nil} ->
      MyApp.Accounts.User
      |> Ash.Changeset.for_create(:create, %{email: email, active: true})
      |> Ash.create!(authorize?: false)
  end

{:ok, jwt, _claims} = AshAuthentication.Jwt.token_for_user(user, %{"purpose" => "scim"})

IO.puts("SCIM bearer token:\n#{jwt}")

Run with mix run priv/repo/scim_token.exs. Paste the JWT into your IdP's SCIM provisioning configuration (or use it for curl testing).

For production, expose this as a "Generate SCIM token" action on your existing token resource and call it from your admin UI. Tokens stored this way can be revoked at any time using AshAuthentication.TokenResource.revoke/2.

Verify the setup

Smoke-test from the command line:

TOKEN=...   # paste the JWT printed above

curl -sS https://my-app.example.com/scim/v2/ServiceProviderConfig \
  -H "Authorization: Bearer $TOKEN"

curl -sS https://my-app.example.com/scim/v2/Users \
  -H "Authorization: Bearer $TOKEN"

You should get a 200 with a SCIM ServiceProviderConfig document, and a 200 with a ListResponse (initially containing just your service account user).

Run a real compliance suite

For a thorough end-to-end check, run the scim2-tester compliance suite against your endpoint:

pipx install scim2-cli

scim -u https://my-app.example.com/scim/v2 \
     -h "Authorization:Bearer $TOKEN" \
     test

This exercises every endpoint AshScim emits, validates the responses against RFC 7643/7644, and reports any deviations.

What next?