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-cli or jq + 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.

From directory group to enforced GeoNode layer permission via the groups claim Left to right. A user belongs to directory groups in the identity provider. A group-membership mapper writes those names into the groups claim of a signed ID token. The token travels to GeoNode, whose OIDC login handler reads the groups claim. The handler maps each claim value to a Django group, creates the user if new, and prunes any Django group membership no longer present in the claim so the provider stays authoritative. The resulting Django group membership drives GeoNode role and permission assignment, which is finally enforced at the per-layer access-control list on every OGC request. GEONODE LOGIN HANDLER IdP directory group gis-editors · gis-viewers Keycloak or Dex Signed token "groups": ["gis-editors"] group-membership mapper map claim → Django group create user if new prune stale membership provider stays authoritative GeoNode role group → permissions Layer ACL enforced per OGC request mapper writes claim token in enforce

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.

Which groups the claim mapping owns, and which it must never touch Three bands: directory-managed, portal-managed, and reserved, each stating who may add and remove members and what the next login does to that membership. Directory-managed gis-editors, gis-viewers, agency-* — named by an agreed prefix added and removed only by the claim; the next login is authoritative pruned on login Portal-managed project teams, temporary review groups, per-dataset stewardship granted in the portal; the mapping must not see or remove them untouched on login Reserved superuser, staff, anything that can change the mapping itself never claimable

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.

Three-stage rollout, with an exit condition for each stage Three stages left to right — observe, add only, enforce — each with what it does to memberships and the measurable condition for moving to the next. 1 · Observe compute and log the diff change nothing exit: every active user has logged in once 2 · Add only grant what the claim implies never remove exit: diff log empty for a full cycle 3 · Enforce grant and prune managed groups portal-managed groups untouched keep the diff log — it is now the audit trail The one-way door Stage 3 removes memberships that nobody recorded elsewhere. Export current membership before enabling it, so a bad claim mapping can be undone from data rather than from memory.

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

Up one level: Keycloak vs Dex for OIDC Federation in Geospatial Portals.