How to Configure GeoNode User Roles for Agency Teams

This guide walks through provisioning, synchronizing, and troubleshooting GeoNode role-based access control so that separate agency teams share one portal without leaking data across tenant boundaries.

It is a hands-on companion to the parent reference Implementing RBAC for Multi-Tenant GIS Portals and the broader Core Portal Architecture & Security Boundaries program — read those first for the request-path placement and tenant-isolation model that the steps below assume. Where that material treats access control as a declarative, version-controlled boundary, this page reduces it to the concrete commands an administrator runs to stand up AgencyAdmin, DataSteward, Analyst, and Viewer roles and to keep them in sync with GeoServer.

GeoNode inherits Django’s group-based permission model and extends it through django-guardian object-level permissions plus automated GeoServer ACL propagation over Celery. Treat role configuration as a synchronized subsystem rather than a one-off admin task: every group you create maps to Django permissions such as view_resourcebase, change_resourcebase, delete_resourcebase, and download_resourcebase, and each of those must propagate cleanly into GeoServer’s security layer before an agency user actually sees the access you granted.

Prerequisites

Confirm the following before you assign a single permission. Mismatched versions or backends are the root cause of most “the group exists but access is denied” tickets.

  • GeoNode 4.x with django-guardian installed (ships by default) and Django 3.2+.
  • GeoServer 2.23+ reachable from the GeoNode app container, with REST enabled.
  • A Celery worker and broker (Redis or RabbitMQ) running — permission sync is asynchronous.
  • settings.py access to confirm GEONODE_SECURITY_BACKEND, AUTHENTICATION_BACKENDS, RESOURCE_PUBLISHING, and GROUPS_MANAGERS_GROUP_NAME.
  • Environment variables GEOSERVER_ADMIN_USER and GEOSERVER_ADMIN_PASSWORD set and matching the GeoServer service account.
  • Superuser (or manage.py shell) access to run the provisioning steps idempotently across environment promotions.
  • Read access to the log paths /var/log/geonode/celery_worker.log and geoserver.log for verification.

Step-by-step implementation

1. Verify the security backends are ordered correctly

Object-level permissions only take effect if both Django’s ModelBackend and guardian’s ObjectPermissionBackend are registered, in that order. Confirm settings.py contains:

AUTHENTICATION_BACKENDS = (
    "django.contrib.auth.backends.ModelBackend",
    "guardian.backends.ObjectPermissionBackend",
)

GEONODE_SECURITY_BACKEND = "geonode.security.backends.GeoNodeBackend"

# RESOURCE_PUBLISHING scopes resources to their owning group, acting as the
# tenant container that keeps one agency's layers out of another's catalog.
RESOURCE_PUBLISHING = True
GROUPS_MANAGERS_GROUP_NAME = "managers"

A misconfigured GEONODE_SECURITY_BACKEND is what produces permission drift during high-concurrency API calls and bulk metadata imports — the kind of bulk work covered in the metadata ingestion pipelines — so fix this before provisioning.

2. Create the agency groups idempotently

Define groups through the ORM, not the admin UI, so the same routine runs cleanly in dev, staging, and production. Using get_or_create keeps the step repeatable across environment promotions:

from django.contrib.auth.models import Group

AGENCY_ROLES = ["AgencyAdmin", "DataSteward", "Analyst", "Viewer"]

for role in AGENCY_ROLES:
    group, created = Group.objects.get_or_create(name=role)
    if created:
        print(f"Created group: {role}")

3. Assign object-level permissions inside a transaction

Wrap bulk assign_perm calls in an explicit transaction.atomic() block. If a deployment is interrupted mid-assignment, the transaction rolls back instead of leaving orphaned permission rows that later read as silent denials:

from guardian.shortcuts import assign_perm
from django.contrib.auth.models import Group
from django.db import transaction

with transaction.atomic():
    admins = Group.objects.get(name="AgencyAdmin")
    analysts = Group.objects.get(name="Analyst")
    for resource in agency_resources:           # queryset scoped to the agency
        assign_perm("view_resourcebase", admins, resource)
        assign_perm("change_resourcebase", admins, resource)
        assign_perm("delete_resourcebase", admins, resource)
        assign_perm("view_resourcebase", analysts, resource)
        assign_perm("download_resourcebase", analysts, resource)

Keep Viewer limited to view_resourcebase and DataSteward to the change/download set without delete_resourcebase; the principle of least privilege here is the same boundary discipline described in Security Boundary Mapping for OGC Services.

4. Tune Celery so sync survives peak ingestion

Permission changes propagate to GeoServer asynchronously. Under heavy concurrent load, a worker that dies mid-task can leave a layer read-only. Set acknowledgement-late semantics and a prefetch of one so an interrupted task is redelivered rather than lost:

# celeryconf.py / settings.py
CELERY_TASK_ACKS_LATE = True
CELERY_WORKER_PREFETCH_MULTIPLIER = 1

5. Force a synchronization when access must be immediate

If agency users report failures right after a role assignment, do not wait for the queue to drain — trigger the GeoServer sync directly and reload the store:

python manage.py sync_geoserver_data --reload-store

The mechanism below traces how an agency role becomes an enforced permission — from the Django taxonomy, through guardian object permissions, into the asynchronous Celery sync that writes GeoServer ACLs.

From Agency Role to Enforced GeoServer Permission Four agency roles form a taxonomy: AgencyAdmin holds view, change and delete; DataSteward holds change and download; Analyst holds view and download; Viewer holds view only. All four map into Django and django-guardian object-level permissions on resourcebase objects. A Celery sync task propagates those permissions into GeoServer ACLs. When that task times out or is lost it is redelivered with acks-late semantics and recorded in celery_worker.log, so access granted in Django is eventually enforced in GeoServer. AGENCY ROLE TAXONOMY AgencyAdmin view / change / delete DataSteward change / download Analyst view / download Viewer view only Django + django-guardian object-level permissions assign_perm on resourcebase Celery sync task async, acks-late GeoServer ACLs timeout / retry celery_worker.log

Choosing the boundary an agency actually needs

Before creating a single group, decide which of three separations the agency is asking for, because they cost very different amounts and are routinely confused in requirements documents. “Team A must not see Team B’s drafts” is a visibility rule inside one tenant and is satisfied entirely by group-scoped publishing. “Team A must not be able to change Team B’s published layers” is an authority rule and needs distinct roles with different verbs. “Team A’s data must never be readable by Team B even if the portal is misconfigured” is an isolation rule, and it cannot be met by roles at all — it needs the data-layer enforcement described in the parent implementing RBAC for multi-tenant GIS portals guide.

Visibility, authority and isolation — three different asks, three different mechanisms Three stacked bands from the lightest requirement to the strongest, each naming the request in plain words, the mechanism that satisfies it, and what still fails if only that level is in place. Visibility “Team B should not see our drafts” group-scoped publishing in the portal still fails if: a direct OGC request bypasses the portal UI Authority “Team B must not edit our layers” distinct roles with different verbs, synced to the OGC engine still fails if: the ACL sync task silently stops running Isolation “Team B must never read our rows” row-level security in the database, below every application path survives: a misconfigured role, a bypassed UI, a stale ACL

Most agency requests stop at the middle band, and building for the third when nobody asked for it produces a portal where every incident begins with a database session variable nobody remembers setting. Ask which band applies per dataset rather than per portal, write the answer down next to the group definition, and revisit it only when a new data-sharing agreement changes the requirement.

Verification

Confirm each layer of the stack agrees on the permissions you assigned before handing the portal back to the agency.

# 1. Confirm the groups exist
python manage.py shell -c "from django.contrib.auth.models import Group; \
print(list(Group.objects.values_list('name', flat=True)))"

# 2. Inspect object-level permissions for a known resource
python manage.py shell -c "from guardian.shortcuts import get_groups_with_perms; \
from geonode.base.models import ResourceBase; \
print(get_groups_with_perms(ResourceBase.objects.get(pk=42)))"

# 3. Watch the Celery sync land without timeouts
tail -f /var/log/geonode/celery_worker.log

# 4. Confirm GeoServer accepted the ACL write (expect 200, not 401/403)
grep "RuleAdminService" geoserver.log | tail -n 20

You can also cross-check the live matrix in GeoNode’s admin at /en/admin/guardian/, where the guardian_groupobjectpermission rows should match what step 3 assigned.

Keeping role changes auditable

Roles drift. A steward leaves, an analyst is granted a temporary elevation for a migration and keeps it for two years, a group is created for a project that ended. None of this shows up as an error, and by the time an audit asks who could publish to a restricted workspace last March, the only honest answer available from a live system is who can publish today. The fix is to treat every membership change as an event worth recording at the moment it happens.

GeoNode’s Django layer gives you the hooks for this directly: the group-membership and permission models emit signals, and a small receiver that writes an append-only record — actor, subject, group, verb, timestamp, and the request id — costs almost nothing and answers the audit question exactly. Record the intended expiry alongside temporary grants, because an elevation with no end date is the one that becomes permanent.

From a membership change to an answerable audit question A left-to-right flow: change event, signal receiver, append-only record store, and a nightly reconciliation that compares the record store against both live portal groups and OGC access control lists, emitting drift. Change made admin UI or management command Signal receiver m2m_changed on group membership Append-only record actor · subject · group verb · timestamp · request id expiry, for temporary grants Nightly reconcile records vs live groups records vs engine ACLs drift report a grant with no record, or a record with no grant Two questions this answers 1. Who could publish to this workspace on a given date? 2. Which live permissions were never requested by anyone?

The reconciliation step is what makes the trail worth keeping. Comparing the recorded intent against both the portal’s live groups and the ACLs pushed into the OGC engine catches the two silent failures this design is exposed to: a permission granted directly in the engine, out of band from the portal, and a sync task that failed so the engine never received a revocation. Either one is invisible in the portal UI, and both are obvious in a nightly diff.

Troubleshooting matrix

Symptom Likely cause Fix
Silent permission denials despite a correct group Global Django group permissions overriding object-level rules, or backends out of order Confirm ModelBackend then ObjectPermissionBackend in AUTHENTICATION_BACKENDS; cross-reference guardian_groupobjectpermission against django_content_type
Intermittent 403s after a bulk role update Stale cached permission evaluations Flush with python manage.py invalidate all (django-cacheops) or restart the app server to force cache invalidation
One agency sees another’s layers Tenant scoping bypassed by legacy SQL or non-group-aware endpoints Audit people_profile and group membership; ensure RESOURCE_PUBLISHING = True is paired with strict group ownership; never modify permissions via direct SQL (it skips Django signals)
GeoServer REST sync fails Service-account mismatch or role-name casing/whitespace differences Check geoserver.log for 401/403 during ACL propagation; verify GEOSERVER_ADMIN_USER / GEOSERVER_ADMIN_PASSWORD and exact role-name parity
Access granted in Django but not enforced in GeoServer Celery task timed out or was lost Re-run sync_geoserver_data --reload-store; set CELERY_TASK_ACKS_LATE and CELERY_WORKER_PREFETCH_MULTIPLIER = 1; inspect celery_worker.log for REST timeouts

Treat these role definitions as infrastructure-as-code: version-control the migration and assign_perm routines alongside your deployment pipeline — the same discipline applied to Syncing GeoNode Environments with Terraform — and add the verification commands above as a CI gate so permission drift is caught before it reaches an agency user.