Search by

mvanduijker / scim-bundle

mvanduijker

Minimal SCIM 2.0 /Users endpoint for Microsoft Entra provisioning

Package info

github.com/mvanduijker/scim-bundle

Type:symfony-bundle

pkg:composer/mvanduijker/scim-bundle

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-10-13 19:46 UTC

This package is auto-updated.

Last update: 2026-10-13 19:50:06 UTC


README

A minimal SCIM 2.0 /Users endpoint for Microsoft Entra provisioning.

composer require mvanduijker/scim-bundle

Install

// config/bundles.php
Duijker\ScimBundle\ScimBundle::class => ['all' => true],
# config/packages/scim.yaml
scim:
    token: '%env(SCIM_TOKEN)%'      # per deployment: bin/console secrets:set SCIM_TOKEN --random=32

# config/routes/scim.yaml
scim:
    resource: Duijker\ScimBundle\ScimController
    type: attribute
    prefix: /scim/v2

# config/packages/security.yaml — put this BEFORE your Entra login firewall; the bundle checks the bearer token itself
security:
    firewalls:
        scim:
            pattern: ^/scim/
            security: false

Token from somewhere else

Leave out scim.token and implement ScimTokenProvider, e.g. to let an admin set the token in your app:

final class DbScimTokenProvider implements ScimTokenProvider
{
    public function __construct(private SettingsRepository $settings) {}

    public function getToken(): string
    {
        return $this->settings->get('scim_token') ?? ''; // empty never matches, so SCIM stays off until it's set
    }
}

Customers with both apps

Install the bundle in both apps. Entra talks only to the main app, which forwards every change to the second one:

# main app deployment, only for customers who also have app 2
scim:
    token: '%env(SCIM_TOKEN)%'
    delegates:
        - { url: 'https://app2.customer.example.com/scim/v2', token: '%env(SCIM_APP2_TOKEN)%' }

SCIM_APP2_TOKEN is app 2's own SCIM_TOKEN. To manage this in your app instead, leave out delegates and implement ScimDelegateProvider; return an empty list for customers without app 2:

final class DbScimDelegateProvider implements ScimDelegateProvider
{
    public function __construct(private HttpClientInterface $http, private SettingsRepository $settings) {}

    public function getDelegates(): iterable
    {
        foreach ($this->settings->scimDelegates() as ['url' => $url, 'token' => $token]) {
            yield new ScimDelegate($this->http, $url, $token);
        }
    }
}

App 2 needs no delegate config, and must never delegate back to the main app, because that loops.

  • Each app keeps its own ids. App 2 matches users by externalId, or userName without one, so map objectId → externalId (see below).
  • The change is forwarded before it's saved locally. If app 2 is down, Entra gets a 502 and retries later, so the apps stay in sync.
  • PATCHes are forwarded as Entra's operations, so app 2 also gets attributes the main app doesn't store.
  • All users and roles from the one Entra app go to both apps. Use distinct role values (Admin, App2Admin) and let each app's allowlist pick its own.

Map it onto your user table

Implement ScimUserStore. If it's the only implementation in src/, autowiring picks it up. Example for a Doctrine User entity (rename the fields to match yours):

namespace App\Scim;

use App\Entity\User;
use Duijker\ScimBundle\{ScimUser, ScimUserStore};
use Doctrine\ORM\EntityManagerInterface;

final class UserScimStore implements ScimUserStore
{
    // Entra app role value => app role. Customers choose the role values in their own tenant,
    // so only map known values; never copy them into User::$roles as-is.
    private const ROLES = ['Admin' => 'ROLE_ADMIN', 'User' => 'ROLE_USER'];

    public function __construct(private EntityManagerInterface $em) {}

    public function find(string $id): ?ScimUser
    {
        $user = $this->em->find(User::class, $id);

        return $user ? $this->toScim($user) : null;
    }

    public function search(?string $attribute, ?string $value, int $offset, int $limit): array
    {
        $qb = $this->em->getRepository(User::class)->createQueryBuilder('u');
        match ($attribute) {
            'userName' => $qb->andWhere('LOWER(u.email) = LOWER(:v)')->setParameter('v', $value),
            'externalId' => $qb->andWhere('u.entraObjectId = :v')->setParameter('v', $value),
            null => null,
        };
        $total = (int) (clone $qb)->select('COUNT(u.id)')->getQuery()->getSingleScalarResult();
        $users = $qb->setFirstResult($offset)->setMaxResults($limit)->getQuery()->getResult();

        return [array_map($this->toScim(...), $users), $total];
    }

    public function save(ScimUser $scim): ScimUser
    {
        $user = $scim->id
            ? $this->em->find(User::class, $scim->id)
            : new User();

        $user->setEmail($scim->userName);
        $user->setEntraObjectId($scim->externalId);
        $user->setFirstName($scim->givenName);
        $user->setLastName($scim->familyName);
        $user->setActive($scim->active);
        $user->setRoles(array_values(array_intersect_key(self::ROLES, array_flip($scim->roles))));
        // Unmapped fields live in $scim->attributes, e.g.:
        // $user->setDepartment($scim->attributes[ScimUser::ENTERPRISE]['department'] ?? null);

        $this->em->persist($user);
        $this->em->flush();

        return $this->toScim($user);
    }

    public function delete(string $id): bool
    {
        $user = $this->em->find(User::class, $id);
        if (!$user) {
            return false;
        }
        $this->em->remove($user); // or anonymise / soft-delete if other rows reference the user
        $this->em->flush();

        return true;
    }

    private function toScim(User $u): ScimUser
    {
        return new ScimUser(
            userName: $u->getEmail(),
            id: (string) $u->getId(),
            externalId: $u->getEntraObjectId(),
            active: $u->isActive(),
            givenName: $u->getFirstName(),
            familyName: $u->getLastName(),
            email: $u->getEmail(),
            roles: array_keys(array_intersect(self::ROLES, $u->getRoles())),
        );
    }
}

Users with active = false must not be able to log in. Check that in your Entra login authenticator or a user checker.

Customer setup in Entra

  1. Enterprise applications → New application → Create your own → "Integrate any other application (non-gallery)".
  2. Provisioning → Mode Automatic
    • Tenant URL: https://<your-host>/scim/v2?aadOptscim062020 (the flag turns on RFC-compliant PATCH)
    • Secret token: that deployment's SCIM_TOKEN
    • Test connection
  3. Mappings: disable "Provision Microsoft Entra ID Groups", because this bundle only does users.
  4. User mappings: map objectId → externalId and give it matching precedence 1. Entra then matches on the Entra object ID (the oid claim) that an Entra login or Graph-based sync would store, so existing users are linked, not duplicated.
  5. Roles: in the app registration (App roles), create roles whose Value matches the keys of your role map (Admin, User). In the user mappings, Add new mapping → Expression AssertiveAppRoleAssignmentsComplex([appRoleAssignments]) → target roles. Assign users to the app with the right role. (Use the Assertive variant: the plain one never sends role removals.)
  6. Scope: "Sync only assigned users and groups", then assign users and start provisioning.

Not supported

/Groups, /Schemas, /ServiceProviderConfig, PUT, and filters other than userName|externalId eq "…". Entra's user provisioning doesn't need any of these.