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.

Four-Layer Security Boundary Model Concentric rectangles showing the four security layers: outermost is Network/Storage, then Repository, then Dataset/Pointer, and innermost is Geometry/Attribute. Each layer is labelled with its primary controls. Layer 1 β€” Network / Storage: TLS, bucket policies, VPC endpoints, presigned URL expiry Layer 2 β€” Repository: branch protection, signed commits, webhook HMAC, merge queue Layer 3 β€” Dataset / Pointer: OPA policy gating, remote access scoping, encryption-at-rest Layer 4 β€” Geometry / Attribute (innermost) CRS normalisation to canonical EPSG code Topology validation + make_valid() repair Field-level redaction or HMAC hashing of PII columns Coordinate jittering (public-release derivatives only) Delta-patch reconstruction validation (reconstructed state, not patch)

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:

  1. 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.
  2. Webhook signature verification β€” Sign all repository webhooks with HMAC-SHA256. Reject unsigned or timestamp-expired (> 300 s old) payloads to prevent replay attacks. Rotate secrets every 90 days.
  3. Nightly pointer manifest drift detection β€” Compare all .dvc pointer 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 using geopandas chunksize parameter.
  • Memory-mapped reads β€” Open raster payloads with rasterio using mode="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) with numpy vectorised 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.

Back to Geospatial Data Versioning Fundamentals & Architecture