Mapping OIDC Group Claims to GeoNode Roles
This procedure makes an OIDC provider’s group membership authoritative inside GeoNode, so that adding a user to a directory group grants the matching portal role automatically on their next login.
It follows on from Configuring a Keycloak Realm for GeoNode SSO and belongs to the wider Core Portal Architecture & Security Boundaries practice; the realm guide gets a user logged in, and this one decides what that user is allowed to do once the token arrives. The pattern applies whether the issuer is Keycloak or Dex — both can emit a groups claim, and GeoNode treats it the same way.
Prerequisites
Have these in place before editing any mapper or Django setting. A missing claim or an unsynced group is the root of nearly every failure here.
- Working OIDC SSO into GeoNode 4.x (the realm and client from the companion guide, or an equivalent Dex setup).
- Admin rights on the provider to add a group-membership mapper or a client scope.
- GeoNode superuser access to inspect Django groups and layer permissions.
- A naming agreement between directory groups and portal roles, e.g.
gis-editors,gis-viewers,metadata-stewards— decide these before wiring them. - A JWT decoder (
jwt-cliorjq+base64 -d) to inspect claims during verification.
The flow below traces a single claim from the directory group where it originates, through the token, into the GeoNode login handler that reconciles it against Django groups and layer ACLs.
Step-by-step implementation
1. Emit a groups claim from the provider
The provider must place group names into a groups claim inside the token GeoNode receives. In Keycloak this is a group-membership mapper attached to the client (or a shared client scope); the key detail is emitting bare names so the downstream match is a simple string comparison.
{
"name": "geonode-groups",
"protocol": "openid-connect",
"protocolMapper": "oidc-group-membership-mapper",
"config": {
"claim.name": "groups",
"full.path": "false",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
For Dex the equivalent lives in the connector: an LDAP connector’s groupSearch populates groups from a directory attribute, and the OIDC scope must request groups for the claim to appear. Either way, confirm the claim is present before touching GeoNode — a mapper that writes only to the access token but not the ID token is a frequent cause of an empty result.
2. Read the claim in GeoNode and map to Django groups
Override the OIDC backend so that each login reconciles the token’s groups against Django group membership. This method runs on every authentication, which is what makes the mapping continuous rather than one-time.
# geonode custom OIDC backend — map the groups claim onto Django groups
from django.contrib.auth.models import Group
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
# directory group name -> GeoNode/Django group name
GROUP_ROLE_MAP = {
"gis-editors": "editors",
"gis-viewers": "viewers",
"metadata-stewards": "metadata_stewards",
}
class GroupMappingOIDCBackend(OIDCAuthenticationBackend):
def _sync_groups(self, user, claims):
claimed = claims.get("groups", []) or []
target_names = {
GROUP_ROLE_MAP[g] for g in claimed if g in GROUP_ROLE_MAP
}
# add memberships the claim asserts
for name in target_names:
group, _ = Group.objects.get_or_create(name=name)
user.groups.add(group)
return target_names
def create_user(self, claims):
user = super().create_user(claims)
self._sync_groups(user, claims)
return user
def update_user(self, user, claims):
user = super().update_user(user, claims)
self._sync_groups(user, claims)
return user
Point Django at the custom backend so it replaces the stock one.
# local_settings.py
AUTHENTICATION_BACKENDS = (
"geonode_portal.auth.GroupMappingOIDCBackend",
"django.contrib.auth.backends.ModelBackend",
)
3. Make membership authoritative by pruning
Adding groups is only half the contract. If a user is removed from a directory group, that removal must revoke the portal role too, otherwise access lingers after off-boarding. Extend the sync to prune any managed group not present in the current claim, while leaving groups the portal manages itself untouched.
# extend GroupMappingOIDCBackend: prune managed groups absent from the claim
MANAGED_GROUPS = set(GROUP_ROLE_MAP.values())
def _sync_groups(self, user, claims):
claimed = claims.get("groups", []) or []
target_names = {
GROUP_ROLE_MAP[g] for g in claimed if g in GROUP_ROLE_MAP
}
current = set(user.groups.values_list("name", flat=True))
# add asserted memberships
for name in target_names - current:
group, _ = Group.objects.get_or_create(name=name)
user.groups.add(group)
# revoke managed memberships the claim no longer asserts
for name in (current & MANAGED_GROUPS) - target_names:
user.groups.remove(Group.objects.get(name=name))
return target_names
Scoping the prune to MANAGED_GROUPS is deliberate: it keeps the provider authoritative over exactly the groups it owns without clobbering local-only groups an admin assigned by hand.
4. Enforce at the layer ACL
Django group membership is the input; the enforcement happens when GeoNode translates a group into concrete permissions on datasets. Grant the portal role its object-level permissions once, and every synced member inherits them.
# grant a Django group standard view/download permissions on a dataset
from geonode.layers.models import Dataset
from guardian.shortcuts import assign_perm
from django.contrib.auth.models import Group
viewers = Group.objects.get(name="viewers")
for ds in Dataset.objects.filter(group__isnull=False):
assign_perm("view_resourcebase", viewers, ds.resourcebase_ptr)
assign_perm("download_resourcebase", viewers, ds.resourcebase_ptr)
Because permissions attach to the group rather than the individual, the whole chain stays maintainable: the directory decides membership, the login handler reconciles it, and the ACL enforces it on every WMS, WFS, and CSW request. Fitting these roles into a tenant-scoped model is the subject of How to Configure GeoNode User Roles for Agency Teams.
Deciding what the directory is allowed to control
A group mapping is a delegation: it hands part of the portal’s authorisation model to whoever administers the directory. That is usually correct — the directory is where joiners and leavers are already processed — but it is only safe when the boundary is explicit. Without one, a directory administrator who creates a group for an unrelated purpose can grant portal permissions by accident, and a portal administrator can grant permissions that the next login silently removes.
The third band matters most and is the cheapest to enforce: keep an explicit deny list so that no directory group, however named, can ever map to portal administration. Otherwise the mapping becomes a privilege-escalation path that runs through a system with different administrators and a different change process — creating a group called superuser in the directory should be a harmless mistake, not a way to take over the portal.
Verification
Decode a real token, confirm the claim, then check the resulting role assignment inside GeoNode.
# 1. Obtain a token for a test user and decode its payload to confirm the groups claim
TOKEN=$(curl -fsS -X POST https://id.agency.gov/realms/geoportal/protocol/openid-connect/token \
-d grant_type=password -d client_id=geonode-portal \
-d client_secret="$OIDC_RP_CLIENT_SECRET" \
-d username=testeditor -d password="$TEST_PW" -d scope="openid groups" | jq -r .access_token)
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '.groups'
# [ "gis-editors" ]
# 2. After the user logs in once, confirm GeoNode created the mapped Django group membership
geonode manage shell -c "
from django.contrib.auth import get_user_model
u = get_user_model().objects.get(username='testeditor')
print(sorted(g.name for g in u.groups.all()))
"
# ['editors']
# 3. Confirm the mapped group carries the expected object permission on a dataset
geonode manage shell -c "
from guardian.shortcuts import get_perms
from django.contrib.auth.models import Group
from geonode.layers.models import Dataset
g = Group.objects.get(name='editors')
print(get_perms(g, Dataset.objects.first().resourcebase_ptr))
"
# ['view_resourcebase', 'change_resourcebase', 'download_resourcebase']
A groups array in step 1, the mapped ['editors'] membership in step 2, and the expected permission list in step 3 confirm the claim propagated all the way to the ACL. Remove a test user from the directory group and log in again to confirm the prune revokes the role.
Rollout without locking anyone out
Turning the mapping on is the risky moment, because the first login after deployment applies the pruning logic to real accounts. If the claim is missing, misspelled, or scoped so it is only present for some users, the mapping will faithfully conclude those users belong to no groups and remove the memberships they had. The portal will not error — it will quietly downgrade people, and the reports will arrive one at a time over the following days as each person next logs in.
Roll out in three passes with a switch between them. First run in observation mode: compute the mapping on every login, log the difference between current and intended membership, and change nothing. Leave it there long enough to cover the least-frequent logins — a weekly reporting user is exactly the account whose claim turns out to be missing. Second, enable additive mode: grant what the claim implies but never remove. Third, enable pruning, once the observation log has been empty of surprises for a full cycle.
Keep a break-glass account outside the mapping entirely — a local portal administrator with a strong credential, excluded from pruning and monitored — because the failure mode this rollout protects against is precisely the one that would otherwise leave nobody able to fix it.
Troubleshooting matrix
| Symptom | Likely cause | Fix |
|---|---|---|
groups claim absent from the decoded token |
Mapper not writing to the ID/access token, or scope not requested | Enable the claim on both tokens; add groups to OIDC_RP_SCOPES |
| Claim present but no Django group created | Directory name missing from GROUP_ROLE_MAP |
Add the mapping entry; names are matched case-sensitively |
| User keeps a role after removal from the group | Prune step not implemented or group outside MANAGED_GROUPS |
Add the revoke loop; include the group name in MANAGED_GROUPS |
Hierarchical names like /agency/gis-editors don’t match |
Provider emitting full group paths | Set full.path to false in the mapper, or strip the prefix before matching |
| Admin-assigned local groups get removed on login | Prune scope too broad | Restrict the prune to MANAGED_GROUPS; never prune all groups |
| Correct group but user can’t see layers | Permissions never assigned to the group | Run the assign_perm grant for the group on the target datasets |
| Stock backend still runs instead of the custom one | AUTHENTICATION_BACKENDS not updated |
Put the custom backend first and restart GeoNode |
Related
- Configuring a Keycloak Realm for GeoNode SSO — the realm and client that emit the
groupsclaim this guide consumes. - How to Configure GeoNode User Roles for Agency Teams — shaping the roles these mapped groups grant.
Up one level: Keycloak vs Dex for OIDC Federation in Geospatial Portals.