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.

What the log inherits depends on what it records A protected dataset is shown behind access control, carrying a retention limit and an obligation to honour deletion requests. Two audit designs are compared. The first records the query geometry, so the log contains positions drawn from the protected dataset and therefore inherits its classification, while physically living in a log system chosen for volume rather than confidentiality, replicated across regions and retained longer than the source. The second records only an extent identifier and a feature count, capturing the scope of the access without reproducing any content, so it can be handled as an ordinary operational log. protected dataset access control · retention limit deletion obligation record: the query geometry positions reproduced in the log the log inherits the classification shipped for volume replicated widely retained longer record: extent id + count scope captured, content not ordinary log handling applies no inherited obligation no second copy exists

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
Four questions, four fields, one deliberate omission Four audit questions with the field answering each. Who is answered by the actor identifier together with the actor type, which decides whether a human or a service review applies. What is answered by the dataset name together with a coarse extent identifier and a feature count, giving the scope of the access without its content. When is answered by the timestamp. Why is answered by the purpose field, which is the one most often absent and the one a review actually turns on. A fifth row records that the question of where precisely is deliberately left unanswerable, because answering it would reproduce the protected data inside the log. who actor + actor_type — decides which review applies what dataset + extent_ref + feature_count — scope, not content when at — an ISO timestamp, in UTC why purpose — most often missing, and the field a review turns on a ticket reference, a job name, a named process Where precisely is deliberately unanswerable — answering it would put the protected data inside the log.

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.

Three units for an extent reference Three candidate units for the extent reference field. A tile identifier is hierarchical so a coarse tile is a valid answer for anything inside it and resolution is one parameter; it suits data already served as tiles, and it coarsens abruptly when a query happens to straddle a tile boundary. An administrative code reads naturally without a map and aligns with how reporting is aggregated; its weakness is that areas at the same nominal level vary enormously in population, so one code can be far more identifying than another. A fixed grid cell gives uniform resolution everywhere and has no hierarchy, which makes aggregation straightforward and makes rolling up to a coarser level require a separate lookup. UNIT STRENGTH WEAKNESS tile identifier tile:12/2045/1372 hierarchical; resolution is one parameter you can cap coarsens abruptly at a tile boundary administrative code lsoa:E01000123 readable without a map; matches how reporting rolls up areas vary hugely in population at the same nominal level fixed grid cell grid1km:4821_2013 uniform resolution everywhere; aggregation is trivial no hierarchy — rolling up needs a second lookup Whichever is chosen, cap the resolution explicitly and apply a minimum count — a unit containing one feature identifies it however coarse the unit’s name looks.

Failure modes and edge cases

  1. 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.
  2. 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.
  3. 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.
  4. Purpose left optional. It will be omitted, and the review question becomes unanswerable. Make it required at the type level.
  5. 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.
  6. 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.