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.
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.
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.