Security Boundary Mapping for OGC Services
When an OGC endpoint is published without explicit trust boundaries, the failure mode is rarely a dramatic breach — it is a slow erosion of isolation: a GetCapabilities response that leaks an unpublished layer, a FILTER predicate that reaches a table the requestor was never entitled to, or a single tenant’s BBOX query that saturates the shared connection pool and takes every other tenant down with it. Mapping where each boundary physically lives is what turns these from latent incidents into enforced controls. This guide sits inside the Core Portal Architecture & Security Boundaries reference and treats the trust perimeter the same way that program treats topology and identity — as a declarative, version-controlled surface that platform engineers, GIS administrators, and government technology teams can audit rather than infer. The audience is whoever owns a production WMS, WFS, WCS, or WMTS service and has to prove, on demand, that an unauthenticated request cannot cross from the edge into tenant-scoped PostGIS data.
Security boundary mapping establishes explicit demarcations between trusted internal components and untrusted external requestors. For OGC-compliant services, those boundaries must be codified across the network, application, data, and identity layers so that horizontal scaling and multi-tenant expansion never silently widen the attack surface. The sections below walk the request from the edge inward — where each boundary lives in the stack, the concrete isolation mechanism that enforces it, how to express that mechanism as policy-as-code, how the authentication boundary forwards verified identity, how CI/CD keeps the whole map from drifting, and how to diagnose it when a control fails open.
Each OGC request traverses a series of trust boundaries before it reaches tenant-scoped data; the diagram below maps that layered path.
Architectural Placement: Where Each Boundary Lives in the Stack
The single most useful discipline in boundary mapping is to refuse to think about “security” as one thing applied in one place. An OGC request is validated, classified, authenticated, scoped, and finally executed at four physically distinct tiers, and a control that belongs at one tier is worthless when it is applied at another. Parameter validation at the database is too late; tenant scoping at the edge is too early to be trustworthy. The map only holds if each boundary owns exactly the decisions it has the context to make.
The network and edge boundary lives at the reverse proxy and web application firewall (WAF), in front of every renderer. It terminates TLS, normalizes the request, and rejects anything malformed before a backend ever parses it. This is the only tier that sees raw, untrusted bytes, so it owns syntactic defense: header validation, request-size caps, and OGC parameter shape-checking against the REQUEST, LAYERS, BBOX, CRS, and FILTER keys. It deliberately makes no authorization decision, because at the edge there is no trustworthy identity yet. The detailed rule chains for this tier — CRS validation, XML payload inspection, and CI-promoted policy — live in Securing MapProxy with Nginx and ModSecurity.
The application and routing boundary sits between the edge and the OGC engines, and its job is isolation and classification: which service handles this request, what cache partition it may touch, and what throughput class it belongs to. Whether this boundary is a property of an integrated portal or a dedicated proxy fleet is the architectural fork analyzed in GeoNode vs MapProxy Architecture Comparison; a full-stack GeoNode collapses routing and governance into one tier, while a MapProxy data plane keeps them separable. Either way, routing configuration is an immutable artifact, validated in staging with synthetic OGC request generators before promotion.
The identity and access boundary is the first tier that may safely make an authorization decision, because it is the first to hold a verified principal. It validates the token, resolves the tenant, and decides which layers a GetCapabilities response may even mention. The full role model behind this tier is covered in Implementing RBAC for Multi-Tenant GIS Portals.
The data and connection boundary is the last line, where PostGIS executes the spatial work. It enforces resource limits — pool caps, statement timeouts, index protection — so that an authorized-but-abusive query cannot become a denial-of-service vector. Tuning these limits without starving tile rendering is the subject of Optimizing PostgreSQL/PostGIS Connection Limits.
Network and Edge Boundary Enforcement
The first perimeter is the reverse proxy and WAF layer. Enterprise and government deployments terminate TLS at the edge, enforce strict HTTP header validation, and route on service endpoint. Because this tier is the only one that sees raw OGC parameters, it carries the syntactic contract: a BBOX must be four (or six) comma-separated numbers, a CRS/SRS must match an allow-list of supported authorities, a WIDTH/HEIGHT must fall inside a sane render budget, and an XML POST body for a WFS transaction must validate against an expected schema before it reaches the engine.
Operationalizing this means declarative Nginx configuration paired with ModSecurity rule sets that specifically target OGC payloads and malformed spatial queries, rather than relying on generic web rules that have no concept of a coordinate reference system. The example below shows the shape of that contract — an allow-list on the verbs and CRS authorities a service accepts, plus a hard ceiling on render dimensions that closes off the cheapest amplification vector against a tile engine:
# Edge boundary: OGC parameter shape-checking before the renderer is touched.
map $arg_request $ogc_request_allowed {
default 0;
"~*^GetMap$" 1;
"~*^GetCapabilities$" 1;
"~*^GetFeatureInfo$" 1;
"~*^GetTile$" 1; # WMTS
}
# Only EPSG authorities this portal actually publishes are accepted.
map $arg_crs $ogc_crs_allowed {
default 0;
"~*^EPSG:3857$" 1;
"~*^EPSG:4326$" 1;
"~*^EPSG:25832$" 1;
}
server {
listen 443 ssl http2;
server_name maps.example.gov;
# Reject oversized render requests: a 20000x20000 GetMap is an amplification attack.
if ($arg_width ~* "^[0-9]{5,}$") { return 400 "render dimension out of bounds"; }
if ($arg_height ~* "^[0-9]{5,}$") { return 400 "render dimension out of bounds"; }
location /ows {
if ($ogc_request_allowed = 0) { return 405; } # unknown OGC verb
if ($ogc_crs_allowed = 0) { return 400; } # unpublished CRS
client_max_body_size 256k; # cap WFS transaction payloads
modsecurity on;
modsecurity_rules_file /etc/nginx/modsec/ogc-rules.conf;
proxy_pass http://mapproxy_upstream;
}
}
The geospatial rules layer on top of, not instead of, a baseline: integrate the OWASP ModSecurity Core Rule Set for generic web coverage before adding OGC-specific filters, and parse parameters against the canonical OGC Web Service Standards so the shape-check matches the specification rather than a guess. Edge configuration that fronts WMS/WFS at scale should be designed alongside Reverse Proxy Configuration for WMS/WFS, which covers the upstream and load-balancing side of the same boundary.
Data Isolation and the Tenant Security Model
Syntactic validation at the edge cannot answer the only question that matters once a request is authenticated: is this requestor entitled to these rows? That decision belongs at the data boundary, and the most defensible mechanism is to push tenant isolation into PostgreSQL itself with row-level security (RLS) rather than trusting every application code path to add a WHERE tenant_id = ? clause it might forget. RLS makes the isolation a property of the table, not of the query, so an OGC FILTER that walks past the application layer still cannot escape its tenant.
-- Data boundary: tenant isolation enforced by the database, not the application.
ALTER TABLE features ENABLE ROW LEVEL SECURITY;
ALTER TABLE features FORCE ROW LEVEL SECURITY; -- applies even to the table owner
-- The policy reads the tenant from a per-transaction GUC the connection sets at checkout.
CREATE POLICY tenant_isolation ON features
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- A spatial query is only allowed to touch the requestor's own extent.
CREATE POLICY tenant_bbox ON features
USING (
tenant_id = current_setting('app.tenant_id', true)::uuid
AND ST_Intersects(geom, current_setting('app.allowed_extent', true)::geometry)
);
The GUC pattern is the linchpin and the most common place this boundary fails open: the value must be set with SET LOCAL inside the same transaction that runs the query, so it cannot leak across a pooled connection to the next tenant. That coupling — RLS policy plus per-transaction context plus a pooled connection that resets the GUC on release — is exactly why the connection-limit work in Optimizing PostgreSQL/PostGIS Connection Limits is a security control and not merely a performance tuning. PgBouncer in transaction-pooling mode must cap concurrent sessions per tenant and reset session state between transactions; a pool that hands out a connection with a stale app.tenant_id is a cross-tenant leak with no log line to show for it.
Boundary Policy as Code
Every boundary on the map is only as trustworthy as the artifact that defines it, which means none of them may live as hand-edited runtime state. The authorization decision at the identity boundary is the clearest candidate for policy-as-code: express it as Rego evaluated by Open Policy Agent (OPA) at the proxy or service-mesh layer, so that what a GetCapabilities may expose and which GetFeatureInfo requests are permitted becomes a unit-tested, reviewable rule set rather than tribal knowledge baked into a renderer config.
# policy/ogc_authz.rego — identity boundary decision, version-controlled and tested.
package ogc.authz
import future.keywords.in
default allow := false
# Read verbs an unprivileged tenant may issue.
read_actions := {"GetMap", "GetTile", "GetFeatureInfo", "GetCapabilities"}
# Allow only when the verified tenant owns the requested layer.
allow if {
input.request.action in read_actions
some layer in input.request.layers
layer in data.tenants[input.identity.tenant_id].layers
}
# GetCapabilities must be filtered, never broadcast: emit the authorized layer set
# so the proxy can rewrite the document down to what this tenant may see.
visible_layers[layer] {
some layer in data.tenants[input.identity.tenant_id].layers
}
The companion declarative manifest pins the resource limits and rate classes for the data and resilience boundaries so they ship through the same review pipeline. Keeping the limits in one annotated file — rather than scattered across Nginx, PgBouncer, and engine configs — is what makes the boundary map auditable:
# boundary-policy.yaml — single source of truth for non-authz boundary limits.
data_boundary:
pgbouncer:
pool_mode: transaction # required so SET LOCAL tenant GUC cannot leak
default_pool_size: 20 # per (database, user) — sized per tenant role
server_reset_query: DISCARD ALL # wipe session state on connection release
postgres:
statement_timeout: "15s" # kill runaway spatial joins
idle_in_transaction_session_timeout: "30s"
resilience_boundary:
rate_limits:
- zone: per_ip
key: "$binary_remote_addr"
rate: "30r/s" # GetMap/GetTile burst ceiling per client
- zone: getcapabilities
key: "$binary_remote_addr"
rate: "1r/s" # throttle metadata-harvesting crawlers
circuit_breaker:
backend: haproxy
fall: 3 # mark backend down after 3 failed health checks
rise: 2 # require 2 successes before re-adding
option_redispatch: true # re-route in-flight requests off a dead backend
Authentication and API Boundary Enforcement
The identity boundary only holds if verified identity is the only identity that crosses it, and the classic OGC failure is a client-supplied X-Tenant-ID header that the gateway forwards untouched. The boundary contract is therefore strip-then-inject: the edge discards any inbound tenant or principal headers, validates the bearer token, and re-injects a tenant claim that the backend is configured to trust precisely because nothing downstream can forge it.
The token flow runs front to back as: client presents a short-lived JWT (issued by the portal’s identity provider) → the gateway verifies signature, audience, and expiry → it extracts the tenant_id claim → it strips any inbound X-Tenant-ID and injects the verified value → the backend maps that header onto the app.tenant_id GUC for RLS. The snippet below shows the non-negotiable strip-and-inject step at the boundary:
location /ows {
# 1. Verify the bearer token at the boundary (auth_request to a JWT validator).
auth_request /_token_introspect;
# 2. Never trust a client-supplied tenant header — strip it unconditionally.
proxy_set_header X-Tenant-ID "";
# 3. Re-inject only the value the validator extracted from the verified claim.
auth_request_set $verified_tenant $upstream_http_x_tenant_id;
proxy_set_header X-Tenant-ID $verified_tenant;
proxy_pass http://ogc_engine;
}
Two corollaries keep this boundary honest. First, the cache key must include the tenant scope: a tile cached for tenant A and served to tenant B is a leak that bypasses every upstream control, which is why fallback and cache routing has to be tenant-aware as covered in Fallback Routing Strategies for Tile Servers. Second, service-to-service calls — a Celery worker rendering an asynchronous export, for instance — need scoped, rotated credentials rather than a long-lived shared key, so a token that expires mid-job is refreshed rather than widened in scope.
CI/CD Integration and Drift Detection
A boundary map that is correct at deploy time and unverified thereafter is a map of where the controls used to be. The whole point of expressing boundaries as code is that the pipeline can gate them: every change to an Nginx vhost, a ModSecurity override, a Rego policy, a PgBouncer config, or the boundary-policy.yaml manifest passes static validation, policy unit tests, and a synthetic OGC compliance run before it can merge.
# .github/workflows/boundary-gate.yml
name: boundary-gate
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint Nginx + ModSecurity config
run: nginx -t -c nginx/maps.conf # fails on any syntax error
- name: Format and test OPA policy
run: |
opa fmt --diff policy/ # fails if any policy is unformatted
opa test policy/ -v # tenant-isolation assertions live here
- name: Synthetic OGC boundary probe
run: |
# An unauthenticated cross-tenant GetMap MUST be rejected at the edge.
./scripts/ogc_probe.sh --expect-status 403 \
--request GetMap --tenant foreign --layer restricted
Synchronize the validated configuration across environments with a GitOps controller such as Argo CD or Flux, and enable drift detection so an out-of-band change to a WAF rule or a pool limit is flagged and reverted — runtime state must match the reviewed Git state or an alert fires. The same parity discipline that keeps these boundaries identical between staging and production is detailed in Environment Parity in Geospatial CI Pipelines, and because the data boundary often runs on a stateful database tier, its limits should be reconciled alongside Kubernetes StatefulSets for PostGIS Databases.
Operational Troubleshooting
Boundary failures are usually quiet: a leak produces extra rows, not an error, and a saturated pool produces latency, not a stack trace. The diagnostic discipline is to walk the request path outside-in and find the boundary that dropped its context. The matrix below keys common symptoms to the boundary at fault, the log path to check, and the configuration flag that most often explains it.
| Symptom | Likely boundary / cause | Where to look | Fix |
|---|---|---|---|
GetCapabilities lists an unpublished layer |
Identity boundary not filtering the document | OPA decision log; proxy rewrite output | Rewrite capabilities through visible_layers; never proxy the raw engine document |
| Tenant A intermittently sees Tenant B features | Stale app.tenant_id GUC leaking across a pooled connection |
PostgreSQL log_statement=all; PgBouncer server_reset_query |
Use SET LOCAL per transaction; set DISCARD ALL on release |
GetMap with huge WIDTH/HEIGHT exhausts the renderer |
Edge boundary missing dimension cap | Nginx access log; oversized arg_width values |
Enforce the render-dimension ceiling at the edge |
| Pool exhausted, all tenants slow | Data boundary: no per-tenant default_pool_size cap |
PgBouncer SHOW POOLS; statement_timeout misses |
Cap pool per tenant; enforce statement_timeout |
X-Tenant-ID spoofable from client |
Auth boundary forwarding inbound header | Ingress access log; inspect forwarded headers | Strip inbound header, inject only the verified claim |
| Cached tile served to the 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 |
Malformed BBOX/CRS reaches the engine |
Edge parameter shape-check disabled or bypassed | ModSecurity audit log (/var/log/modsec_audit.log) |
Re-enable the OGC rule file; add a nginx -t gate |
| WAF rule reverts after deploy | GitOps drift; out-of-band change not reconciled | Argo CD / Flux sync status | Enforce auto-sync with self-heal; treat manual edits as drift |
Treating the trust perimeter as a mapped, version-controlled surface is what turns an OGC portal from a service that is “probably secure” into one whose isolation can be demonstrated on demand. When every boundary owns exactly the decision it has context to make — syntax at the edge, classification in routing, authorization at identity, and resource limits at the data tier — horizontal scaling and multi-tenant growth stop eroding isolation and start inheriting it.
Related
- Core Portal Architecture & Security Boundaries — the parent architecture reference this boundary map fits inside.
- Securing MapProxy with Nginx and ModSecurity — the edge rule chains and CRS validation behind the network boundary.
- Optimizing PostgreSQL/PostGIS Connection Limits — pool caps and timeouts that make the data boundary a security control.
- Implementing RBAC for Multi-Tenant GIS Portals — the role model the identity boundary enforces.
- GeoNode vs MapProxy Architecture Comparison — how the component split decides where each boundary physically lands.