mvanduijker / scim-bundle
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
Requires
- php: >=8.3
- symfony/framework-bundle: ^7.4 || ^8.0
- symfony/http-client: ^7.4 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.5 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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, oruserNamewithout one, so mapobjectId→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
- Enterprise applications → New application → Create your own → "Integrate any other application (non-gallery)".
- 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
- Tenant URL:
- Mappings: disable "Provision Microsoft Entra ID Groups", because this bundle only does users.
- User mappings: map
objectId→externalIdand give it matching precedence 1. Entra then matches on the Entra object ID (theoidclaim) that an Entra login or Graph-based sync would store, so existing users are linked, not duplicated. - 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 → ExpressionAssertiveAppRoleAssignmentsComplex([appRoleAssignments])→ targetroles. Assign users to the app with the right role. (Use the Assertive variant: the plain one never sends role removals.) - 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.