Auditing Secret Access for Agency Compliance
This guide makes a portal’s secret access answerable: which identity read which credential, when, and whether any read was unexpected. It belongs to Secrets Management for Geospatial Platforms, within the Core Portal Architecture & Security Boundaries framework.
Prerequisites
- A secret store with an audit facility — the Vault deployment from storing GeoServer secrets in Vault with Kubernetes auth is assumed here.
- A log destination outside the cluster being audited, with its own retention and access control.
- The agency’s stated retention period for security event records, and the list of credentials in scope.
- A named owner per credential, so an unexpected read has somebody to ask.
What an auditor is actually asking
Audit requirements are usually written in general terms and reduce, in practice, to four questions. Designing the log to answer those four is considerably cheaper than retaining everything and reconstructing answers later.
The third row is the one that turns a pile of log lines into an answer. A record of every read is not evidence of anything on its own; it becomes evidence when compared against a declared expectation, so that the interesting event — a read by an identity nobody expected — is separable from the thousands of routine ones.
Step-by-step implementation
1. Enable an audit device that cannot be silently lost
# Write to a file the node ships elsewhere. Vault refuses to serve requests if
# every audit device fails, which is the correct behaviour for an audit log and
# a real availability consideration — so configure two.
vault audit enable file file_path=/vault/logs/audit.log
# A second device means one destination failing does not stop the store.
vault audit enable -path=syslog syslog tag="vault" facility="AUTH"
Two devices is a deliberate choice. A single audit device that fills its disk takes the secret store down, and a store that stops serving takes every restarting workload with it; two devices keep the guarantee while removing that single point of failure.
2. Ship the log off the node, unaltered
# vector.yaml — forward the audit log without transformation
sources:
vault_audit:
type: file
include: ["/vault/logs/audit.log"]
read_from: beginning
transforms:
parse:
type: remap
inputs: ["vault_audit"]
source: |
. = parse_json!(string!(.message))
# Keep the hashed values as they are — Vault HMACs sensitive fields, and
# un-hashing them here would put credentials into the log pipeline.
.portal_env = "production"
sinks:
archive:
type: aws_s3
inputs: ["parse"]
bucket: "agency-security-events"
key_prefix: "vault-audit/%Y/%m/%d/"
compression: gzip
# Object-lock retention set on the bucket, not here, so the shipper cannot
# shorten it.
Vault HMACs sensitive fields before writing them, so the log records that a value was read without recording the value. Preserve that property through the pipeline: any transformation that tries to make the log more readable by resolving those fields has turned an audit log into a secret store with no access control.
3. Declare what each credential’s expected readers are
The comparison in the third audit question needs a declared expectation, and the natural place for it is beside the policy that grants access.
# secrets-inventory.yaml — committed, reviewed, and used by the reconciliation job
credentials:
- path: kv/data/geoportal/geoserver/postgis
owner: platform-team
class: serious # per the credential classes in the topic page
expected_readers:
- auth/k8s-geoportal/geoserver # the rendering engine's workload identity
- auth/approle/backup-runner # nightly backup job
rotation_days: 90
- path: kv/data/geoportal/harvester/partner-token
owner: catalogue-team
class: contained
expected_readers:
- auth/k8s-geoportal/harvester
rotation_days: 180
4. Reconcile the log against the expectation
# audit_reconcile.py — nightly; emits only the reads nobody declared
import json, sys, yaml
from collections import defaultdict
inventory = {c["path"]: set(c["expected_readers"])
for c in yaml.safe_load(open("secrets-inventory.yaml"))["credentials"]}
seen = defaultdict(set)
for line in sys.stdin: # a day of audit events
event = json.loads(line)
if event.get("type") != "response":
continue
req = event.get("request", {})
if req.get("operation") != "read":
continue
path = req.get("path", "")
actor = (event.get("auth", {}) or {}).get("display_name", "unknown")
seen[path].add(actor)
unexpected = []
for path, actors in seen.items():
expected = inventory.get(path)
if expected is None:
unexpected.append((path, sorted(actors), "path not in inventory"))
continue
for actor in actors - expected:
unexpected.append((path, [actor], "reader not declared"))
for path, actors, why in sorted(unexpected):
print(f"UNEXPECTED {path} actor={','.join(actors)} reason={why}")
sys.exit(1 if unexpected else 0)
Reading the result without drowning in it
A reconciliation that reports thousands of lines is not read, and an audit control nobody reads is a control that does not exist. Three habits keep the output small enough to matter.
Keep the full event stream regardless of what the report shows. The report is for the daily read; the archive is for the question asked in eighteen months, which will be specific, unanticipated, and answerable only from the raw record.
Human access is a different question
Everything above concerns machine reads, which are frequent, routine, and answered by reconciliation. Human access to production credentials is the opposite in every respect — rare, exceptional, and the thing an auditor will actually ask about — and it deserves its own treatment rather than being buried in the same stream.
Route human reads to their own alert rather than into the daily reconciliation. In a healthy portal that alert fires a handful of times a year, each time with a person attached and a reason recorded — which is precisely the evidence an access review needs and the hardest to reconstruct after the fact.
Verification
# 1. Audit devices are enabled and both are healthy
vault audit list -detailed
# expect: two devices, neither marked as failed
# 2. A read by the engine's identity appears in the log with that identity
kubectl -n geoportal exec geoserver-0 -- sh -c 'cat /vault/secrets/postgis >/dev/null'
grep -c 'auth/k8s-geoportal/geoserver' /vault/logs/audit.log
# expect: a non-zero count
# 3. Secret values are not present in the log
grep -c 'hmac-sha256:' /vault/logs/audit.log
# expect: many — sensitive fields are hashed, not written in the clear
# 4. The reconciliation finds a deliberately undeclared reader
vault write auth/approle/role/tempcheck policies=geoserver-read
# ... read the path with that role, then:
./audit_reconcile.py < yesterday.jsonl
# expect: UNEXPECTED ... reason=reader not declared
# 5. Retention on the archive cannot be shortened by the shipper
aws s3api get-object-lock-configuration --bucket agency-security-events
# expect: a retention mode and period matching the agency requirement
Troubleshooting matrix
| Symptom | Likely cause | Fix |
|---|---|---|
| The secret store stops serving requests | The only audit device cannot write — full disk or unreachable syslog | Configure two devices; alert on device failure and on log volume growth |
| The log names a role, not a workload | An AppRole or shared token is used instead of workload identity | Move consumers to workload identity so the log names the component |
| Reconciliation reports hundreds of unexpected readers | The inventory has drifted from reality after a migration | Rebuild the expectation from a quiet week’s log, then review it as a whole |
| Auditors reject the evidence as alterable | Logs stored where the platform team can edit them | Ship to an append-only destination with object-lock retention |
| A credential read cannot be traced to a person | Read performed by automation on someone’s behalf | Correlate on the pipeline run id; record who triggered the run |
| The archive holds less history than the audit window | Retention configured on the shipper, not on the destination | Set retention at the destination, where the shipper cannot shorten it |
| Nobody reads the daily report | It restates steady state instead of reporting change | Report only new identity-and-path pairs since the previous run |
FAQ
Is an access log enough, or does every read need approval?
For machine consumers, logging is the proportionate control: a rendering engine reads its database credential at every start-up, and requiring approval for that would be theatre. Approval belongs on human access to production credentials, which should be rare, time-bound, and separately recorded — a different mechanism from the one described here.
How long should audit records be retained?
As long as the agency’s own security-event retention requires, which is usually longer than any default. The important detail is where the retention is enforced: at the destination, with an immutability setting, so that neither the platform team nor a compromised shipper can shorten it.
Does hashing the values make the log less useful?
No — the audit question is which credential was read, not what its value was. Hashing means the log can be shipped, searched and retained without becoming a secondary copy of the secrets, which is exactly the property that makes long retention acceptable in the first place.
What should happen when the reconciliation reports something unexpected?
Treat it as a question rather than an incident, because the common causes are benign: a new component that was deployed without updating the inventory, or a debugging session by an engineer with legitimate access. Ask the credential’s owner, then either update the declared expectation or revoke the access — and record which of the two happened.
Can this replace a formal access review?
It supports one and does not replace it. A periodic review asks whether the declared expectations are still appropriate — whether a component that was granted access two years ago still needs it — which is a judgement the log cannot make. The value of the reconciliation is that it makes the review start from an accurate picture rather than from documentation.
Related
- Storing GeoServer Secrets in Vault with Kubernetes Auth — the workload identities this log names.
- Sealing GeoNode Secrets for GitOps — the model that does not provide this capability.
- Implementing RBAC for Multi-Tenant GIS Portals — the same auditability applied to data access.
Up one level: Secrets Management for Geospatial Platforms.