Audit Trail Schemas for Coordinate-Level Access Logs
An audit trail for spatial data has a problem no other audit trail has: recording what somebody accessed means recording where, and a log of positions is itself location data. This guide sits beneath security boundaries in spatial QA and covers designing a record that answers the questions an audit exists to answer — who, what, when, why — without becoming a second, less-protected copy of the dataset it is auditing.
The central design decision is what stands in for “what was accessed”. Logging the geometry reproduces the data; logging nothing useful makes the audit worthless. The answer is almost always an extent plus a count, which is enough to reconstruct the scope of an access without reconstructing its content.
Root cause: the log inherits the data’s classification
A conventional access log records an identifier and an action. A spatial access log that records a query envelope has recorded a position, and a position is the thing the access control existed to protect. The consequence is that the audit trail acquires the same classification as the data — the same retention limits, the same access restrictions, the same obligations under a deletion request.
That is manageable when it is a deliberate design decision and disastrous when it is discovered later, because logs are routinely shipped to systems chosen for volume rather than for confidentiality, replicated across regions, and retained far longer than the data they describe.
Schema reference
| Field | Example | Answers | Sensitive? |
|---|---|---|---|
actor |
svc:tile-renderer or a user id |
Who | No |
actor_type |
human / service / ci_job |
Which review applies | No |
action |
read / export / aggregate |
What kind of access | No |
dataset |
parcels_v3 |
Which data | No |
extent_ref |
tile:12/2045/1372 or lsoa:E01000123 |
Where, coarsely | No, if the unit is coarse |
feature_count |
412 |
How much | No |
purpose |
incident:INC-4812 |
Why | No |
at |
ISO timestamp | When | No |
query_geometry |
— | Where, precisely | Yes — omit |
The last row is the one that decides everything. A named extent — a tile identifier, an administrative code, a grid cell — carries the scope without carrying a position, and it is what makes the record ordinary rather than sensitive.
Step-by-step implementation
Step 1 — Resolve the query to a named extent
The conversion from a geometry to an identifier is what removes the position, and it must happen before the record is constructed rather than in a later sanitisation pass.
import mercantile
def extent_ref(bounds, zoom: int = 12) -> str:
"""Coarsest tile fully containing the query bounds."""
west, south, east, north = bounds
tile = mercantile.bounding_tile(west, south, east, north)
z = min(tile.z, zoom) # never finer than the declared zoom
parent = mercantile.parent(tile, zoom=z) if tile.z > z else tile
return f"tile:{parent.z}/{parent.x}/{parent.y}"
Capping the zoom is the important part. A bounding tile computed without a cap can be arbitrarily fine for a small query, which reintroduces the precision the identifier was supposed to remove.
Step 2 — Build the record with the geometry excluded by construction
A schema that cannot express a geometry is more reliable than a sanitiser that removes one.
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
@dataclass(frozen=True)
class AccessRecord:
actor: str
actor_type: str
action: str
dataset: str
extent_ref: str
feature_count: int
purpose: str
at: str
def record_access(actor, actor_type, action, dataset, bounds, count, purpose) -> dict:
return asdict(AccessRecord(
actor=actor, actor_type=actor_type, action=action, dataset=dataset,
extent_ref=extent_ref(bounds), feature_count=count, purpose=purpose,
at=datetime.now(timezone.utc).isoformat(),
))
Step 3 — Make purpose mandatory
An audit trail without a purpose field answers what happened and never why, which is the question a review actually asks. Making it required at the type level forces the caller to supply one.
def read_features(query, *, actor: str, purpose: str):
if not purpose:
raise ValueError("purpose is required for an audited read")
features = store.query(query)
emit(record_access(actor, "service", "read", "parcels_v3",
query.bounds, len(features), purpose))
return features
Step 4 — Assert the log carries no geometry
The guard that matters is a test, because the field will be added by somebody eventually.
import re
COORD_LIKE = re.compile(r"-?\d{1,3}\.\d{4,}") # a decimal degree with real precision
def test_audit_record_has_no_coordinates(sample_record):
text = json.dumps(sample_record)
assert not COORD_LIKE.search(text), f"audit record contains a coordinate: {text[:120]}"
assert "geometry" not in sample_record and "wkt" not in sample_record
Verify the fix
Run the schema guard alongside the ordinary suite and confirm it fails when a geometry is added:
pytest -q tests/audit/ -v
Adding a bbox field with real coordinates should fail the coordinate test immediately. If it passes, the pattern is too narrow — a bounding box serialised as integers in a projected CRS will not match a decimal-degree pattern, which is a good argument for asserting on the field names as well as on the values.
What the audit is actually for
Two uses justify the effort, and they want different things from the record.
Answering a specific question after an incident — who read this dataset, in this area, in this window. This is served by the extent reference and the timestamp, and it is the use most audit designs anticipate.
Detecting a pattern nobody asked about — a service reading far more than its function requires, an actor whose access widened gradually, a CI job touching regulated data it should never see. This is served by aggregation over the records, and it needs the feature_count and actor_type fields that a question-answering design often omits.
The second use is the more valuable and the more neglected. A weekly summary of feature counts by actor is a small query over records you are already keeping, and it surfaces the class of problem — quiet, gradual, nobody’s fault — that a targeted question never finds because nobody knows to ask it.
There is a third consideration that decides retention. An audit record with no positions has no inherited obligation, so it can be kept as long as it is useful, which for pattern detection means at least a year. A record carrying geometry cannot, and the retention conflict — audit wants long, privacy wants short — is precisely the conflict the extent-reference design avoids.
Choosing the extent unit
The extent reference is the design’s load-bearing element, and the unit chosen for it decides both how useful the audit is and how much position it leaks. Three families are available and they suit different datasets.
A tile identifier is convenient because it is hierarchical: a coarse tile is a valid answer for any query inside it, and the resolution is a single parameter. It suits any dataset already served as tiles, and its weakness is that tile boundaries are arbitrary with respect to the data, so a query spanning two tiles resolves to a much coarser parent than its actual size warrants.
An administrative code aligns with how people describe places and with how most reporting is aggregated, which makes the audit readable without a map. It suits datasets organised administratively, and its weakness is that areas vary enormously in population, so one code can be far more identifying than another of the same nominal level.
A fixed grid cell gives uniform resolution everywhere and no hierarchy, which makes aggregation simple and roll-up impossible without a second lookup.
Failure modes and edge cases
- Logging the query envelope. The commonest mistake and the one that creates the second copy. Resolve to a named extent before the record is built.
- An extent unit that is too fine. A tile at zoom 20 identifies a building. Cap the resolution, and choose the cap from what the audit needs rather than from what is available.
- A single-feature access. An extent covering one feature is that feature’s position however coarse the unit, because the count reveals it. Suppress or generalise records below a small count.
- Purpose left optional. It will be omitted, and the review question becomes unanswerable. Make it required at the type level.
- Audit logs in a general log stream. Even a clean schema benefits from a separate destination with its own retention, because a general stream’s settings are chosen for something else.
- No aggregation. A record store nobody queries is storage cost. The weekly summary is what turns the trail into a control rather than an archive.
Conclusion
A spatial audit trail is a design problem rather than a plumbing one, and the whole design turns on what stands in for “where”. A coarse named extent plus a feature count answers every question a review asks while leaving no position in the log, which keeps the record ordinary, keeps its retention long enough to detect patterns, and avoids creating exactly the second copy that security boundaries in spatial QA exists to prevent.
Related
- Security Boundaries in Spatial QA — the parent layer and the re-identification boundary this record must not cross.
- Redacting Spatial PII in Test Fixtures — the same reasoning applied to the data rather than to the log.
- Scoping Test Database Roles for PostGIS Suites — limiting what a suite can read, so there is less to audit.
- Spatial Test Observability and Metrics — why coordinates stay out of the structured log schema too.