Implementing Role-Based Access Control for Internal Dashboards
Gate access server-side by mapping identity provider claims to a permission frozenset, then filter spatial payloads at the query layer before any data reaches the browser — never rely on frontend conditional rendering to protect sensitive geospatial data.
Jump to heading Why this matters
Spatial dashboards routinely carry high-value datasets: infrastructure networks, sensitive parcel boundaries, operational telemetry, and environmental monitoring grids. When those dashboards are shared across departments or opened to a wider internal audience, undefined access controls become a compliance liability almost immediately. Access control in this context is not a UI challenge — it is a data-filtering problem that belongs to your data flow architecture: the role boundary has to live on the path between the database and the serialiser, not in the render layer. Without server-side enforcement, a viewer role can inspect raw GeoJSON responses in browser developer tools regardless of what the React or Jinja template chooses to render.
The patterns on this page sit inside the broader Security Boundaries & Auth workflow, which covers authentication boundaries, token lifecycle, and deployment hardening. This page focuses narrowly on the permission-matrix and payload-filtering mechanics you implement once identity has been established — and on keeping role data correctly scoped inside session state so that the framework’s per-interaction reruns never leak one user’s permissions to another.
Jump to heading The three-stage pipeline
Jump to heading Prerequisites
- Python 3.11+,
streamlit>=1.35orpanel>=1.4 geopandas>=0.14,psycopg2-binary>=2.9(for PostGIS queries),python-jose>=3.3(for JWT validation)- An OIDC-capable identity provider (Keycloak, Okta, Azure AD, or Auth0) configured to issue JWTs with group or role claims
- A working understanding of session state patterns — in particular how Streamlit re-executes the full script on every widget interaction and why permission lookups must be deterministic
Jump to heading Step-by-step solution
Jump to heading Step 1 — Verify identity at the infrastructure boundary
Never implement credential storage or password hashing inside the dashboard script. Route authentication through a reverse proxy (Nginx with auth_request, Traefik with ForwardAuth middleware, or the standalone oauth2-proxy) or use a framework-native library (streamlit-authenticator for OIDC, panel.auth for OAuth 2.0 flows).
The proxy intercepts every request, validates the token against the IdP’s JWKS endpoint, then forwards the authenticated session with a trusted header:
X-Forwarded-User: [email protected]
X-Forwarded-Groups: gis-analysts,data-team
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Your dashboard script must consume these headers, not generate them.
Jump to heading Step 2 — Extract and cache role claims at startup
Parse the forwarded headers or decode the JWT on the very first execution and store the resolved role in session state. Subsequent reruns skip parsing overhead by reading directly from session state.
import streamlit as st
from jose import jwt, JWTError
import os
JWKS_URI = os.environ["IDP_JWKS_URI"] # e.g. https://auth.example.com/realms/main/protocol/openid-connect/certs
AUDIENCE = os.environ["IDP_CLIENT_ID"]
def _extract_role_from_token(raw_token: str) -> str:
"""Decode and validate a JWT, return the role claim or default to 'viewer'."""
try:
# python-jose validates signature, expiry, and audience automatically
claims = jwt.decode(raw_token, JWKS_URI, algorithms=["RS256"], audience=AUDIENCE)
# IdP role claim is typically nested; adapt the key path to your IdP
roles = claims.get("realm_access", {}).get("roles", [])
for role in ("admin", "analyst", "viewer"):
if role in roles:
return role
except JWTError:
pass
return "viewer"
# Only parse once per session
if "user_role" not in st.session_state:
raw_token = st.context.headers.get("Authorization", "").removeprefix("Bearer ")
st.session_state["user_role"] = _extract_role_from_token(raw_token)
Jump to heading Step 3 — Build the role-to-permission matrix
Map each role to a frozenset of permission strings. frozenset is hashable, which matters when you use it as a cache key later.
from typing import FrozenSet, Dict
ROLE_PERMISSIONS: Dict[str, FrozenSet[str]] = {
"viewer": frozenset({"read_maps", "view_public_layers"}),
"analyst": frozenset({"read_maps", "view_public_layers",
"export_data", "run_spatial_analysis"}),
"admin": frozenset({"read_maps", "view_public_layers",
"export_data", "run_spatial_analysis",
"manage_users", "edit_sensitive_layers"}),
}
def current_permissions() -> FrozenSet[str]:
role = st.session_state.get("user_role", "viewer")
return ROLE_PERMISSIONS.get(role, frozenset())
def require(permission: str) -> bool:
return permission in current_permissions()
Jump to heading Step 4 — Filter spatial payloads at the query layer
This is the critical enforcement point. Never fetch a full PostGIS table and then filter in Python — the restricted rows have already left the database by that point. Apply the permission predicate inside the SQL WHERE clause.
The example below queries an infrastructure grid for EPSG:4326 features intersecting the user’s viewport bounding box, restricted to region codes the role is permitted to access:
import geopandas as gpd
import psycopg2
from shapely.geometry import box
# Allowed region codes per role — extend this to a proper lookup table in production
ROLE_REGIONS: Dict[str, tuple] = {
"viewer": ("PUBLIC",),
"analyst": ("PUBLIC", "RESTRICTED"),
"admin": ("PUBLIC", "RESTRICTED", "CLASSIFIED"),
}
@st.cache_data(ttl=300, hash_funcs={frozenset: lambda s: hash(s)})
def load_infrastructure(
user_role: str,
bbox: tuple[float, float, float, float], # (min_lon, min_lat, max_lon, max_lat)
) -> gpd.GeoDataFrame:
"""Return only features the user's role is permitted to see, clipped to bbox."""
allowed_regions = ROLE_REGIONS.get(user_role, ("PUBLIC",))
# bbox: Vancouver area example — (lon_min, lat_min, lon_max, lat_max)
# e.g. (-123.25, 49.18, -123.02, 49.32)
minx, miny, maxx, maxy = bbox
query = """
SELECT geom, asset_id, asset_class, region_code
FROM infrastructure_grid
WHERE ST_Intersects(
geom,
ST_MakeEnvelope(%(minx)s, %(miny)s, %(maxx)s, %(maxy)s, 4326)
)
AND region_code = ANY(%(regions)s)
"""
with psycopg2.connect(os.environ["POSTGIS_DSN"]) as conn:
return gpd.read_postgis(
query, conn,
geom_col="geom",
params={"minx": minx, "miny": miny, "maxx": maxx, "maxy": maxy,
"regions": list(allowed_regions)},
)
# Usage in dashboard
role = st.session_state["user_role"]
bbox = (-123.25, 49.18, -123.02, 49.32) # Vancouver viewport
layers = load_infrastructure(role, bbox)
The cache key includes user_role, so a viewer and an analyst never share the same cached GeoDataFrame.
Jump to heading Step 5 — Gate UI components with the same permission checks
Once the data layer enforces the boundary, UI gating is a secondary safety signal — not the primary one. Add it anyway: it prevents confusing blank states and gives users clear feedback.
# Gate an analysis button
if require("run_spatial_analysis"):
if st.button("Run Buffer Analysis (500 m)"):
buffered = layers.to_crs("EPSG:3857").buffer(500).to_crs("EPSG:4326")
st.write(f"{len(buffered)} features within buffer")
else:
st.info("Spatial analysis tools require the analyst role or higher.")
# Gate a sensitive-layer toggle
if require("edit_sensitive_layers"):
show_classified = st.toggle("Show classified infrastructure")
else:
show_classified = False
Jump to heading Verification
Load the dashboard under three different test tokens (one per role) and confirm the query results and UI gating behave correctly:
# Paste into a test script — replace with a real PostGIS DSN and test tokens
import psycopg2
import geopandas as gpd
DSN = "postgresql://test:test@localhost/geodata"
BBOX = (-123.25, 49.18, -123.02, 49.32)
for role, expected_regions in [
("viewer", {"PUBLIC"}),
("analyst", {"PUBLIC", "RESTRICTED"}),
("admin", {"PUBLIC", "RESTRICTED", "CLASSIFIED"}),
]:
allowed = ROLE_REGIONS[role]
with psycopg2.connect(DSN) as conn:
gdf = gpd.read_postgis(
"SELECT geom, region_code FROM infrastructure_grid "
"WHERE region_code = ANY(%(r)s)",
conn, geom_col="geom", params={"r": list(allowed)}
)
returned_regions = set(gdf["region_code"].unique())
assert returned_regions <= expected_regions, (
f"Role '{role}' received unexpected regions: {returned_regions - expected_regions}"
)
print(f"[PASS] {role}: {len(gdf)} features, regions {returned_regions}")
Expected output:
[PASS] viewer: 142 features, regions {'PUBLIC'}
[PASS] analyst: 389 features, regions {'PUBLIC', 'RESTRICTED'}
[PASS] admin: 521 features, regions {'PUBLIC', 'RESTRICTED', 'CLASSIFIED'}
Jump to heading Edge cases and gotchas
Shared cache poisoning.
@st.cache_datais process-wide, not session-scoped. If you cache a filtered GeoDataFrame using only the bounding box as the key, any user whose viewport matches will receive the first caller’s role-filtered result. Always includeuser_role(or a hash of the permission set) as an explicit function argument so the cache key is unique per role. The same user-scoped key discipline carries over to every shared store you front the dashboard with — see Caching Strategies & Async Performance Tuning for keying patterns that keep role-scoped payloads out of shared entries.Framework reruns bypass startup logic. Streamlit re-executes the full script on every widget interaction. Role extraction wrapped in
if "user_role" not in st.session_stateruns only once, but any code outside that guard runs on every rerun. Ensure permission checks use session state rather than re-parsing headers on each rerun — header re-parsing on every interaction creates measurable latency at scale and, on some proxy configurations, headers are only forwarded on the initial HTTP upgrade request, not on WebSocket messages.Panel’s
pn.staterequires explicit session hooks. Unlike Streamlit’s implicit session isolation, Panel sharespn.state.cacheacross all active sessions unless you scope entries topn.state.user. Register apn.state.on_session_destroyedcallback to purge role data and close any open database cursors when a user disconnects, preventing stale permission sets from persisting into subsequent sessions.
Jump to heading FAQ
Can I use st.session_state to store the user's role securely?
You can store the resolved role in st.session_state as a read-only performance cache after extracting and validating it from a proxy header or JWT at startup. Treat it as a shortcut, never as the authoritative source. The IdP or reverse proxy remains the authority; if someone tampers with st.session_state through a serialisation exploit, your data-layer WHERE clauses are the backstop that prevents actual data exposure.
What happens if two users share the same @st.cache_data cache entry?
Without a user-specific cache key, Streamlit returns cached results from the first caller to any subsequent caller whose hashed function arguments match. For role-filtered GeoJSON this means a viewer could receive an analyst’s payload. Always include user_role or the permission frozenset hash as a function argument. In @st.cache_data you can pass custom hash_funcs if you need to hash non-serialisable objects.
Is it safe to hide sensitive map layers with CSS or conditional rendering alone?
No. Any layer data serialised to the browser — even hidden with CSS display:none — is visible in the browser’s network inspector and JavaScript heap. Server-side filtering at the PostGIS WHERE clause or tile-server proxy level is the only reliable boundary. See Caching Strategies & Async Performance Tuning for guidance on structuring tile server caches so role-scoped responses are not stored in shared public caches.
Back to Security Boundaries & Auth
Related
- Security Boundaries & Auth — authentication boundaries, token lifecycle, deployment hardening for spatial dashboards
- Session State Patterns — how Streamlit and Panel isolate state per session and why that matters for permission caching
- Data Flow Architectures for Spatial Dashboards — where the role boundary sits on the path from database to serialised payload
- How to Manage Streamlit Session State Across Multiple User Tabs — cross-tab state sync when the same user has multiple dashboard windows open
- Caching Strategies & Async Performance Tuning — cache TTL design and user-scoped cache key patterns for spatial workloads