Two of three group mappings worked — which is what made the third so hard to find
I wired BookStack up to an OIDC provider with group-to-role sync: identity provider groups map onto BookStack roles, so people get the right permissions automatically as they join and leave groups. Three mappings:
| IdP group | BookStack role |
|---|---|
wiki Admins |
Admin |
editors |
Editor |
readers |
Viewer |
Logged in. Got Editor and Viewer. No Admin.
That partial result is the whole story, and it sent me the wrong way for a while — because two thirds of the feature working is evidence, and every obvious reading of that evidence is wrong.
Why partial success is worse than total failure
If group sync had granted nothing, the hypothesis list is short and correct:
- the
groupsclaim isn’t in the token - the claim is under a different name
- sync is switched off
- the OIDC login isn’t reaching the sync code at all
Every one of those is quick to check, and any of them would explain a total failure.
But two roles arrived. So the claim exists. It’s named correctly. Sync is on and running. The code path executes and successfully assigns roles. All four hypotheses are dead — and they’re dead because of the successes, not the failure.
What’s left is much less comfortable: something about that specific mapping differs from the other two. And when the config looks identical, the natural next step is to doubt the thing that isn’t broken — the claim contents, the provider, the role permissions. I spent time there. It’s the wrong direction.
The actual cause: two normalisations that don’t agree
BookStack matches groups to roles in app/Access/GroupSyncService.php. Two functions matter, and they are only a few lines apart.
Incoming group names from the IdP:
protected function matchGroupsToSystemsRoles(array $groupNames): Collection
{
foreach ($groupNames as $i => $groupName) {
$groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
}
The role’s configured external ID:
protected function parseRoleExternalAuthId(string $externalId): array
{
$inputIds = preg_split('/(?<!\\\),/', strtolower($externalId));
$cleanIds = [];
foreach ($inputIds as $inputId) {
$cleanIds[] = str_replace('\,', ',', trim($inputId));
}
return $cleanIds;
}
Both lowercase. Both trim. Only one replaces spaces with hyphens. They are then compared with in_array() — an exact string match.
So for a group called wiki Admins:
| side | after normalisation |
|---|---|
| incoming group name | wiki-admins |
| role external auth ID | wiki admins |
Those never match. Meanwhile editors and readers contain no spaces, so both sides normalise them identically and they map perfectly.
The one property that distinguished the broken mapping from the working ones was a space in the group name. Nothing about the token, the provider or the permissions ever mattered.
The fix
Write the role’s external auth ID in the already-normalised form — spaces replaced with hyphens:
wiki Admins -> wiki-admins
Site Editors -> site-editors
readers -> readers (unchanged)
Underscores pass through untouched, so a group like read_only needs no change. Only spaces are affected.
This bites hardest on identity providers that ship default groups with spaces in them. Authentik, for instance, creates a group called authentik Admins out of the box — note the space — so the single most likely group anyone maps to an admin role is exactly the one that silently fails.
Two related notes:
- The same
GroupSyncServicehandles LDAP and SAML, not just OIDC. Identical trap. - If a role has no external auth ID set, BookStack falls back to matching the role’s display name, and that path does apply the hyphen rule (
str_replace(' ', '-', ...)on the display name). So the fallback is self-consistent. It’s only the explicit external-ID path that is asymmetric.
The takeaway
When a comparison fails, check that both sides were normalised the same way. Asymmetric normalisation is a whole bug class: one side gets lowercased, or trimmed, or unicode-folded, or has its separators rewritten, and the other doesn’t. The values look identical in the config screen and in the logs, because the transformation happens after you stop looking.
The sharper lesson is about reading partial failure. Two of three mappings working felt like a small problem — most of it works, so the remaining bit must be a small misconfiguration. It’s the opposite. Partial success is a controlled experiment you didn’t have to design: the working cases and the broken case differ in exactly one respect, and that respect is the answer.
The right question was never “why didn’t Admin get assigned?” It was “what is different about that group compared to the two that worked?” — and the only answer was a space.
Hostnames, paths, identifiers and product-specific strings in this post are illustrative. The failure modes, commands and fixes are real.