Security Boundaries in Spatial Repositories
Enforcing security boundaries in a geospatial repository means controlling access across four simultaneous surfaces β storage, repository metadata, binary pointer objects, and the geometry payload itself β a problem that does not arise in ordinary source-code version control. Part of Geospatial Data Versioning Fundamentals & Architecture.
Prerequisites & Environment Setup
Before implementing the workflow below, verify your environment meets these baseline requirements:
The four-layer boundary model that this page implements is visualised below. Understanding how the layers nest is critical: the large file handling in DVC for GIS guide explains why large raster datasets bypass standard Git diff engines and why their pointer files introduce a separate access surface.
Core Algorithmic Patterns
Pattern 1 β Tier-Keyed Policy Routing
Tier-keyed policy routing assigns each dataset a classification string (public, internal, restricted, ephemeral) at commit time and routes every subsequent operation β pointer resolution, remote fetch, merge queue decision β through an OPA policy that reads that tag. The spatial complexity is O(1) per access decision because the policy engine evaluates a hash lookup, not a geometry intersection. The critical invariant is that untagged datasets are denied by default, not permitted.
| Tier | Access scope | Metadata required | Retention |
|---|---|---|---|
public |
Global read | ISO 19115 basic | Indefinite |
internal |
Authenticated org members | ISO 19115 extended + lineage | Indefinite |
restricted |
Explicit IAM/RBAC allowlist | Full audit trail + DPA tags | Indefinite (access-logged) |
ephemeral |
CI/CD service accounts only | Auto-expiry tag (ISO 8601) | 7β30 days |
Pattern 2 β Topology-Aware Pre-Commit Gate
Standard byte-level diff tools cannot determine whether a geometry change is a benign coordinate correction or a covert attribute extraction. A topology-aware pre-commit gate runs shapely.is_valid() and CRS reconciliation on every changed vector file before the commit is accepted. This gate also surfaces the interaction between the delta tracking algorithms for vector data and attribute redaction: delta engines reconstruct geometry from incremental patches, so an unredacted attribute on any patch in the chain can survive into the final state. The gate must validate the reconstructed geometry, not only the patch.
Pattern 3 β Pointer Manifest Drift Detection
Pointer synchronization for raster datasets introduces a consistency requirement: the checksum recorded in the pointer file must match the object actually stored in the remote cache. Drift β where the pointer references a deleted, modified, or re-uploaded object β can be exploited to substitute an attacker-controlled payload. A nightly drift-detection job compares all pointer manifests against storage-backend ETags or MD5 checksums and raises an alert on any mismatch.
Production Workflow Implementation
Step 1 β Classify and Tag Every Spatial Asset
Map every dataset to its security tier before the first commit. Enforce classification via a repository-level CODEOWNERS file and a pre-commit hook that rejects untagged spatial assets. Write the tier as a custom metadata field in the datasetβs ISO 19115 record and, for DVC-tracked objects, embed it in the DVC .dvc file as a custom field.
# scripts/classify_spatial_asset.py
import json
import subprocess
import sys
from pathlib import Path
VALID_TIERS = {"public", "internal", "restricted", "ephemeral"}
def classify_asset(dvc_file: Path, tier: str) -> None:
if tier not in VALID_TIERS:
raise ValueError(f"Unknown tier '{tier}'. Must be one of {VALID_TIERS}.")
meta = json.loads(dvc_file.read_text())
meta.setdefault("meta", {})["security_tier"] = tier
dvc_file.write_text(json.dumps(meta, indent=2))
subprocess.run(["git", "add", str(dvc_file)], check=True)
print(f"[classify] {dvc_file.name} β tier={tier}")
if __name__ == "__main__":
classify_asset(Path(sys.argv[1]), sys.argv[2])
Step 2 β Gate Pointer Resolution with OPA
Version control systems track pointers, not raw binaries. Securing these pointers prevents unauthorized fetches and pointer drift. Deploy the following OPA Rego policy to gate DVC or Git-LFS pointer resolution based on user identity and dataset classification:
package spatial_repo.access
default allow = false
# Authenticated users may always read public datasets
allow {
input.user.authenticated == true
input.dataset.security_tier == "public"
}
# Internal datasets require authenticated org membership
allow {
input.user.authenticated == true
input.dataset.security_tier == "internal"
input.user.org == "my-org"
}
# Restricted datasets require explicit team allowlist
allow {
input.user.authenticated == true
input.dataset.security_tier == "restricted"
input.user.teams[_] == "gis-security-team"
}
# CI service accounts may fetch ephemeral layers during their own job
allow {
input.user.service_account == true
input.dataset.security_tier == "ephemeral"
input.user.job_id == input.dataset.issuing_job_id
}
# Deny anything untagged β no implicit permit
deny[msg] {
not input.dataset.security_tier
msg := "Dataset lacks security_tier tag. Pointer resolution denied."
}
Integrate this policy into your CI/CD pipeline and storage gateway. For the format-specific vulnerabilities that arise when teams use shapefiles β including .prj injection and .dbf field truncation β review setting up secure access controls for versioned shapefiles.
Step 3 β Enforce Geometry and Attribute Policies
Repository-level controls are insufficient when geometries themselves leak sensitive information. The following Python implementation validates topology, normalises CRS to EPSG:4326, and applies field-level redaction to restricted layers:
import geopandas as gpd
import pyproj
import hashlib
import hmac
import os
import numpy as np
from shapely.validation import make_valid
CANONICAL_CRS = pyproj.CRS("EPSG:4326")
HMAC_SECRET = os.environ["SPATIAL_HMAC_SECRET"].encode()
# Columns requiring redaction in restricted layers
SENSITIVE_COLS = ["owner_name", "parcel_id", "exact_address", "phone", "email"]
def _hmac_hash(value: str) -> str:
"""Deterministic HMAC-SHA256 preserves referential integrity across joins."""
return hmac.new(HMAC_SECRET, value.encode(), hashlib.sha256).hexdigest()[:16]
def enforce_spatial_boundary(
gdf_path: str,
tier: str,
reject_unknown_epsg: bool = True,
) -> gpd.GeoDataFrame:
gdf = gpd.read_file(gdf_path)
# CRS normalisation β reject commits with non-allowlisted EPSG codes
if gdf.crs is None:
raise ValueError(f"File {gdf_path} has no CRS. Assign one before committing.")
if reject_unknown_epsg and gdf.crs.to_epsg() is None:
raise ValueError(f"Non-EPSG CRS in {gdf_path} rejected by policy.")
if gdf.crs != CANONICAL_CRS:
gdf = gdf.to_crs(CANONICAL_CRS)
# Topology validation β repair invalid geometries, log repaired count
invalid_mask = ~gdf.geometry.is_valid
repaired = int(invalid_mask.sum())
if repaired:
gdf.loc[invalid_mask, "geometry"] = (
gdf.loc[invalid_mask, "geometry"].apply(make_valid)
)
print(f"[boundary] Repaired {repaired} invalid geometries in {gdf_path}")
# Attribute policy β HMAC hash instead of null to preserve join keys
if tier == "restricted":
for col in SENSITIVE_COLS:
if col in gdf.columns:
gdf[col] = gdf[col].astype(str).apply(_hmac_hash)
return gdf
Step 4 β Wire the Gate into the Pre-Commit Lifecycle
Embed boundary checks into your Git lifecycle using pre-commit hooks and post-merge webhooks.
.pre-commit-config.yaml:
repos:
- repo: local
hooks:
- id: spatial-boundary-check
name: Validate Spatial Security Boundaries
entry: python scripts/validate_spatial_boundary.py
language: system
types: [file]
files: \.(gpkg|shp|geojson|parquet|tif|tiff)$
pass_filenames: true
- id: dvc-tier-check
name: Require security_tier tag on DVC files
entry: python scripts/classify_spatial_asset.py --check-only
language: system
files: \.dvc$
pass_filenames: true
Step 5 β Automate Audit Trails and Drift Detection
Manual reviews fail at scale. Three mechanisms are required:
- Immutable access logs β Write all OPA access decisions, pointer fetches, and merge-queue results to an append-only log (AWS CloudTrail, Datadog, or an ELK stack). Log the user identity, tier, dataset path, and decision outcome.
- Webhook signature verification β Sign all repository webhooks with HMAC-SHA256. Reject unsigned or timestamp-expired (
> 300 sold) payloads to prevent replay attacks. Rotate secrets every 90 days. - Nightly pointer manifest drift detection β Compare all
.dvcpointer checksums against the remote storage ETags. Alert immediately on any mismatch.
# scripts/drift_detect.py
import boto3
import json
from pathlib import Path
def detect_pointer_drift(dvc_root: str, s3_bucket: str) -> list[dict]:
s3 = boto3.client("s3")
mismatches = []
for dvc_file in Path(dvc_root).rglob("*.dvc"):
meta = json.loads(dvc_file.read_text())
for entry in meta.get("outs", []):
md5 = entry.get("md5", "")
key = f"files/md5/{md5[:2]}/{md5[2:]}"
try:
head = s3.head_object(Bucket=s3_bucket, Key=key)
remote_etag = head["ETag"].strip('"')
if remote_etag != md5:
mismatches.append({"file": str(dvc_file), "expected": md5, "found": remote_etag})
except s3.exceptions.NoSuchKey:
mismatches.append({"file": str(dvc_file), "expected": md5, "found": "MISSING"})
return mismatches
Code Reliability Patterns
CRS tolerance snapping β Always snap coordinate precision to the canonical tolerance (1e-8 degrees for EPSG:4326) before topology validation. Floating-point drift from reprojection can produce near-duplicate vertices that pass is_valid() but generate spurious self-intersections under repeated round-trips.
Rollback on partial failure β Wrap multi-step boundary enforcement in a transaction pattern. If CRS normalisation succeeds but attribute redaction raises a KeyError, roll back the CRS change and re-raise so the commit is rejected cleanly rather than leaving a partially processed file.
Poison-pill sentinel β Prepend a deliberate canary record with a known HMAC value to every restricted export. If the canary appears in a downstream dataset without the expected hash, it signals that redaction was bypassed.
Empty-geometry guard β make_valid() can return GEOMETRYCOLLECTION EMPTY for degenerate inputs. After repair, filter out empty geometries and log them; empty features that slip through can corrupt spatial indexes on merge.
Performance and Scale Considerations
Boundary enforcement adds latency to every commit. Keep the pre-commit gate under 5 seconds for typical dataset sizes by following these guidelines:
- Spatial index acceleration β Build an STRtree (
shapely.STRtree) over the incoming geometries before running validity checks. For large polygon layers (> 500 k features), process in 50 k-row chunks usinggeopandaschunksizeparameter. - Memory-mapped reads β Open raster payloads with
rasteriousingmode="r"(memory-mapped); avoid loading the full array into RAM when only the metadata header needs inspection for CRS and tier tag validation. - Parallel CRS reprojection β Use
pyproj.Transformer.from_crs(..., always_xy=True)withnumpyvectorised coordinate arrays rather than row-wise reprojection; this is typically 10β30Γ faster on large point datasets. - Benchmark reference β On a 2023 M2 MacBook Pro, the full gate (CRS normalisation + topology validation + HMAC redaction) processes a 1 M-row polygon GeoPackage in approximately 18 seconds. For repositories where this exceeds the acceptable hook latency, run the full gate only in CI and use a lightweight metadata-only check in the local hook.
Troubleshooting and Failure Modes
| Symptom | Root cause | Fix |
|---|---|---|
OPA: deny β Dataset lacks security_tier tag |
.dvc file written before classification hook was active |
Run python scripts/classify_spatial_asset.py data/layer.dvc restricted, re-stage, and recommit |
| Pointer fetch succeeds but downloaded file has wrong checksum | Pointer drift β object was re-uploaded or deleted after pointer was written | Run drift_detect.py, identify the mismatched object, re-run dvc push from the authoritative machine, and rotate storage credentials |
ValueError: Non-EPSG CRS in ... rejected by policy |
Dataset uses a local or unnamed projection (e.g. a custom Transverse Mercator) | Obtain the EPSG code for the local CRS, add it to the ALLOWED_EPSG allowlist, re-reproject the file, and update the .prj or GeoPackage spatial reference |
make_valid() returns GEOMETRYCOLLECTION EMPTY |
Degenerate input geometry (zero-area polygon or spike) that cannot be repaired | Filter out empty results, log the feature IDs, and flag them for manual digitising review before next commit |
| Attribute redaction passes CI but PII appears in downstream export | Delta engine reconstructed geometry from an unredacted patch in the version chain | Validate the reconstructed GeoDataFrame state (run enforce_spatial_boundary on the output of dvc repro), not only the diff patch |
| Webhook signature check fails intermittently | Server clock drift causes timestamp comparison to reject valid payloads | Sync server time via NTP, widen the acceptance window to 600 s, and confirm the signing secret is consistent across all webhook sources |
FAQ
Why is access control harder for spatial data than for regular source code?
Spatial repositories store multi-gigabyte binary payloads as pointer files outside Gitβs object store. A user who can clone the repository but lacks storage credentials can still reconstruct remote object paths from the pointer file and attempt direct object-store requests. Layered policies across Git, the storage backend, and the metadata schema are all required simultaneously β there is no single choke point equivalent to a code review gate.
Does coordinate jittering adequately protect PII in geometry layers?
Jittering alone is insufficient for high-precision PII data such as residential parcel centroids. Random offsets can be partially reversed through aggregation attacks if enough records are published. Deterministic HMAC hashing of sensitive identifiers combined with field-level redaction and a separate access-controlled internal copy provides stronger protection. Jittering is appropriate only as a secondary obfuscation step when publishing pre-approved public derivatives.
How should CRS normalisation be treated as a security control?
Ambiguous or non-standard CRS metadata is a spoofing vector: an attacker can encode coordinates in an obscure local projection so they pass topology validation while representing real-world sensitive locations. Mandate a canonical CRS (EPSG:4326 for global data or a project-specific code) in pre-commit hooks, reject commits carrying unrecognised EPSG codes, and record both the original and the normalised CRS in the audit log.
What is the safest retention policy for ephemeral spatial layers?
Ephemeral scratch layers should carry an explicit ISO 8601 expiry tag in their metadata. A scheduled job reads all pointer manifests, identifies expired objects, revokes presigned URLs, and runs dvc gc to purge orphaned objects from the remote cache. Thirty days is a common maximum; reduce to 7 days for anything containing PII or restricted infrastructure geometry.
Can field-level redaction preserve referential integrity across table joins?
Yes. Replace sensitive string identifiers with an HMAC-SHA256 hash keyed to a secret held in a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.). This preserves join-key consistency while making values unreadable without the key. Numeric fields such as parcel areas or population counts should use calibrated noise addition rather than nulling, which destroys analytical utility without a corresponding security benefit.
Related
- Setting Up Secure Access Controls for Versioned Shapefiles β format-specific vulnerability mitigation for
.prj,.dbf, and.shxcomponents - Delta Tracking Algorithms for Vector Data β how incremental patch chains interact with attribute redaction requirements
- Pointer Synchronization for Raster Datasets β pointer manifest consistency and drift detection for binary payloads
- Large File Handling in DVC for GIS β storage architecture decisions that determine where security boundaries can be enforced
Back to Geospatial Data Versioning Fundamentals & Architecture