Implementing RBAC for Multi-Tenant GIS Portals: An Operational Guide

When a single geospatial deployment serves several agencies, departments, or external partners, the failure mode that bites hardest is silent cross-tenant data exposure: a misconfigured group, a cached tile that ignores row visibility, or an OGC query that walks straight past the application layer. Role-Based Access Control (RBAC) is what stops that, and in a shared deployment it stops being an application feature and becomes a data-sovereignty and compliance boundary that platform engineers, GIS administrators, and government technology teams have to own. This guide sits inside the broader Core Portal Architecture & Security Boundaries reference and treats access control the way the rest of that program treats topology and trust zones — as a declarative, version-controlled component that scales alongside compute and storage rather than drifting behind a UI. The patterns below cover where RBAC lives in the request path, how to enforce tenant isolation in PostGIS, how to express roles as policy-as-code, how to keep stateless API boundaries honest, and how to wire all of it into CI/CD with an operational troubleshooting matrix for when it breaks.

The request path below shows where each RBAC control is applied — from edge token verification and policy evaluation through application-level checks down to row-level security in the database.

Where RBAC Controls Sit in the Request Path A left-to-right flow. An agency user or API client reaches the ingress and API gateway trust zone, which holds two stages: JWT verification with X-Tenant-ID injection, and an OPA or Kyverno policy check. The policy check branches: an allow path continues right through GeoNode application RBAC, PostGIS row-level security, and finally tenant-scoped data; a deny path drops down to a 403 denied response with an audit-log entry. Agency user / API client INGRESS / API GATEWAY Verify JWT inject X-Tenant-ID Policy check OPA / Kyverno GeoNode application RBAC PostGIS row-level security Tenant-scoped data 403 denied + audit log allow deny

Architectural Placement & Request Routing

Multi-tenant GIS portals route traffic through a reverse proxy, API gateway, or service mesh before it reaches the backend geospatial engines. The first design decision — enforce policy at the edge or inside the application — dictates latency, auditability, and horizontal scalability. The trade-offs here mirror the data-plane/control-plane split discussed in the GeoNode vs MapProxy Architecture Comparison: coarse-grained tenant routing and request filtering belong at the ingress controller, while granular resource-level permissions stay in the application domain where they can see object ownership and metadata.

The practical rule is to resolve tenant context as early as possible and carry it as an immutable claim. Standardize on a tenant identifier injected at the edge — an X-Tenant-ID header derived from a verified JWT claim, never from a client-supplied header that an attacker could spoof. The gateway must strip any inbound X-Tenant-ID before re-injecting its own, so downstream services can trust it without re-validating. Carrying tenant context as a header rather than a session lookup keeps the application tier stateless, which is what lets Gunicorn and Celery pods scale horizontally across a Kubernetes cluster without sticky sessions or shared session storage. A common anti-pattern is binding tenant identity to a server-side session: it works on a single node and collapses the moment you add a second replica behind a round-robin load balancer.

Placement also determines your audit surface. Decisions made at the edge are cheap to log uniformly but blind to object-level context; decisions made in the application see ownership and classification but are scattered across view code. The resilient pattern is layered enforcement — a deny at any layer is a deny — with each layer emitting a structured audit record keyed to the same tenant and request identifier so the trail reconciles end to end.

Data Isolation & Infrastructure Provisioning

RBAC is only as strong as the data isolation underneath it. In a shared PostgreSQL/PostGIS cluster, the durable mechanism is row-level security (RLS), optionally combined with schema-per-tenant or database-per-tenant strategies for stricter blast-radius control. RLS pushes the tenant predicate into the database engine itself, so an application bug that forgets a WHERE tenant_id = ... filter still cannot return another tenant’s geometries. Provisioning of tenant namespaces, database roles, and object-storage buckets should be codified in Terraform or Crossplane modules rather than created by hand, and connection-pool sizing for the multiplied role count should follow the patterns in Optimizing PostgreSQL/PostGIS Connection Limits so per-tenant roles do not exhaust max_connections.

The RLS policy itself reads the tenant from a session variable that the application sets per request, immediately after acquiring a pooled connection:

-- Enable RLS on the tenant-scoped spatial table and force it for table owners too.
ALTER TABLE layers_featuredata ENABLE ROW LEVEL SECURITY;
ALTER TABLE layers_featuredata FORCE ROW LEVEL SECURITY;

-- Policy: a row is visible only when its tenant_id matches the request-scoped GUC.
-- current_setting('app.tenant_id', true) returns NULL instead of erroring when unset,
-- so an unscoped connection sees zero rows rather than the whole table.
CREATE POLICY tenant_isolation ON layers_featuredata
    USING (tenant_id = current_setting('app.tenant_id', true)::uuid);

-- The application role must NOT own the table and must NOT have BYPASSRLS.
GRANT SELECT, INSERT, UPDATE, DELETE ON layers_featuredata TO geonode_app;

The application sets app.tenant_id from the verified X-Tenant-ID header at the start of every transaction — SET LOCAL app.tenant_id = $1 so the value is scoped to the transaction and never leaks to the next checkout of a pooled connection. When scaling across availability zones, reinforce these database boundaries at the pod level with Kubernetes NetworkPolicy objects and dedicated service accounts, so a compromised tenant workload cannot move laterally to another tenant’s database or cache during a traffic spike. For the authoritative semantics of USING versus WITH CHECK clauses and policy evaluation order, consult the PostgreSQL documentation on row-level security.

Policy-as-Code & Role Taxonomy

Roles for government and agency teams need a structured taxonomy aligned with organizational hierarchy and data classification, not an ad-hoc list of checkboxes accumulated through the admin UI. Define the taxonomy once — for example AgencyAdmin, DataSteward, Analyst, Viewer — and express the permission matrix as policy-as-code with Open Policy Agent (OPA) so it can be diffed, reviewed, and tested. The concrete GeoNode group-and-permission mapping for these roles is detailed in How to Configure GeoNode User Roles for Agency Teams; the value of lifting it into Rego is that the gateway can evaluate the same rules against live OGC request payloads before they ever reach the application.

A minimal Rego policy that enforces tenant scoping and role-to-action mapping for an incoming request looks like this:

package portal.rbac

import future.keywords.in

# Default deny — every request must be explicitly allowed.
default allow := false

# Roles permitted to mutate resources; everyone else is read-only.
write_roles := {"AgencyAdmin", "DataSteward"}

# Allow when the token's tenant matches the routed tenant AND the role
# is authorized for the requested OGC action.
allow if {
    input.token.tenant_id == input.request.tenant_id
    action := input.request.ogc_action          # e.g. "GetMap", "Transaction"
    role_permits_action(input.token.role, action)
}

role_permits_action(role, action) if {
    action in {"GetMap", "GetFeature", "GetCapabilities"}   # read actions
}

role_permits_action(role, action) if {
    action == "Transaction"                                 # WFS-T write
    role in write_roles
}

Store the policy, the role-to-permission matrix, and any spatial query restrictions in version control, and wire OPA evaluation into deployment gates so a configuration that violates least-privilege is rejected before it reaches production. The Open Policy Agent documentation covers the Rego patterns for unit-testing these rules with opa test, which is what turns the policy from a static document into something CI can prove.

Authentication & Stateless API Security

Stateless authentication is what holds RBAC together across distributed services. Every request must carry cryptographically verifiable tenant and role claims, validated at the API gateway before any backend pod sees it. Configure the ingress controller to verify JWT signatures against a centralized identity provider — Keycloak or Dex — using the published JWKS endpoint, reject expired or unsigned tokens, and strip sensitive upstream headers before forwarding. This is also where the gateway extracts the tenant_id and role claims that feed both the X-Tenant-ID injection and the OPA evaluation described above.

Stateless Token Flow Across the RBAC Boundary A top-down sequence diagram with five vertical lifelines: client, identity provider, API gateway, GeoNode application, and PostGIS. Step 1, the client requests a token from the identity provider. Step 2, the provider returns a signed JWT with tenant_id and role claims. Step 3, the client sends its request plus the bearer JWT to the gateway. Step 4, the gateway verifies the signature against JWKS and strips upstream headers. Step 5a, on deny the gateway returns a 403 and audit log to the client. Step 5b, on allow the gateway injects X-Tenant-ID and forwards to the application. Step 6, the application sets app.tenant_id and queries the database. Step 7, PostGIS returns only tenant-scoped rows under row-level security. Client / API job Identity Keycloak / Dex API gateway + OPA GeoNode application PostGIS RLS 1. request token (credentials) 2. signed JWT: tenant_id, role 3. request + Bearer JWT 4. verify sig vs JWKS, strip upstream headers 5a. deny → 403 + audit log 5b. allow: inject X-Tenant-ID 6. SET LOCAL app.tenant_id 7. tenant-scoped rows only

For machine-to-machine API access — bulk metadata jobs, scheduled exports, partner integrations — issue scoped tokens with short expiry windows rather than long-lived credentials, and enforce refresh-token rotation so a leaked token has a small blast radius. Token lifecycles should be managed by an automated secret-rotation controller so credentials never drift across long-running GIS processing jobs; a Celery worker that picked up a 90-day static token is a credential that will outlive its own threat model. The same token-forwarding discipline matters when a stateless cache tier sits in front of the portal: as the GeoNode vs MapProxy Architecture Comparison notes, a proxy that caches an authorized response can serve it to an unauthorized tenant unless cache keys incorporate tenant scope.

OGC Service Boundary Enforcement

Geospatial portals expose standardized OGC endpoints — WMS, WFS, WCS, WMTS — and these routinely slip past traditional web application firewalls because the security-relevant parameters live in query strings and XML bodies, not in headers a WAF inspects by default. Each service needs explicit boundary mapping so a crafted FILTER expression or a Transaction payload cannot read or mutate another tenant’s layers. The parameter-level mapping is covered in depth in Security Boundary Mapping for OGC Services; the RBAC concern is ensuring those mapped boundaries are enforced consistently at every layer — the OPA policy validates the ogc_action, the application validates layer ownership, and RLS validates the row. Map service-level permissions onto Kubernetes RBAC Role and ClusterRole bindings so the same audit trail spans infrastructure and application, and deploy sidecar proxies or eBPF-based filters to inspect OGC requests at the transport layer without degrading render performance. This is also where tile-serving resilience and security intersect — the Fallback Routing Strategies for Tile Servers patterns must preserve tenant scope on failover, never serving an unscoped cache as a degraded-mode fallback.

CI/CD Integration & Operational Workflows

RBAC lifecycle management belongs in the deployment pipeline, not in a runbook someone remembers to follow. Lint Rego with opa fmt --diff, run the policy unit tests, apply the policy against a disposable staging tenant namespace, and generate a compliance report as a pipeline artifact. A GitHub Actions stage that gates a merge on policy validity looks like this:

# .github/workflows/rbac-policy.yml
name: validate-rbac-policy
on:
  pull_request:
    paths: ["policy/**.rego", "policy/**_test.rego"]
jobs:
  opa:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install OPA
        run: |
          curl -sSLo /usr/local/bin/opa \
            https://openpolicyagent.org/downloads/v0.65.0/opa_linux_amd64_static
          chmod +x /usr/local/bin/opa
      - name: Check formatting
        run: opa fmt --diff policy/        # fails if any policy is unformatted
      - name: Run policy unit tests
        run: opa test policy/ -v           # least-privilege assertions live here

Synchronize the validated role configuration across clusters with a GitOps controller such as Argo CD or Flux, and enable drift detection so an out-of-band UI change to permissions is flagged and reverted automatically — the live cluster state must match the reviewed Git state or an alert fires. Monitor RBAC effectiveness through structured audit logs, tracking denied requests, role escalations, and cross-tenant access attempts, and feed those counters into the observability stack so a spike in cross-tenant denials triggers a remediation playbook instead of waiting for a quarterly review. Keep one policy registry as the single source of truth so infrastructure, application, and network layers evaluate identical rule sets at runtime.

Operational Troubleshooting

RBAC failures in multi-tenant portals are usually quiet — a user sees fewer rows than expected, or none — so the diagnostic discipline is to walk the request path top to bottom and find the layer that dropped the tenant context. The matrix below keys common symptoms to the layer at fault, the log path to check, and the configuration flag that most often explains it.

Symptom Likely cause Where to look Fix
User authenticates but sees zero rows app.tenant_id GUC never set on the pooled connection, so RLS matches nothing PostgreSQL log_statement=all; check for missing SET LOCAL app.tenant_id Set the GUC per transaction immediately after connection checkout
Tenant A intermittently sees Tenant B data Tenant context leaking across a pooled connection; GUC set without LOCAL geonode/django.log request/tenant correlation IDs Use SET LOCAL; reset GUC on connection release
403 on valid write request Role not in OPA write_roles, or ogc_action parsed as read OPA decision log (/v1/data audit) Correct the role-to-action mapping; add an opa test case
X-Tenant-ID spoofable from client Gateway forwarding inbound header instead of stripping and re-injecting Ingress access log; inspect forwarded headers Strip inbound X-Tenant-ID at the edge before injecting the verified claim
Cached tile served to wrong tenant Cache key omits tenant scope Proxy cache logs; compare key to tenant Add tenant_id to the cache key; never serve unscoped fallback
Permissions revert after deploy GitOps drift; UI change not reconciled to Git Argo CD / Flux sync status Enforce auto-sync with self-heal; treat UI edits as drift
JWT rejected for service jobs Expired short-lived token on a long-running Celery task celery_worker.log auth errors Move to refresh-token rotation via the secret-rotation controller

Treating RBAC as a first-class infrastructure component is what turns a multi-tenant GIS portal from a fragile, hand-configured deployment into a resilient, auditable platform. Anchoring access control in early tenant resolution, codified data isolation, policy-as-code, and pipeline-enforced validation lets platform teams scale geospatial services across agencies and jurisdictions while keeping every boundary provable rather than assumed.