Skip to content
Stribog

Compliance

All writing

GDPR Compliance Audits: The Regulator's, Not the Customer's

Article 58 gives a supervisory authority its own audit powers, separate from a customer's DPA audit right. What self-hosted infrastructure can produce.

Stribog23 min read

Search the phrase and you get checklists, almost all describing the due-diligence questionnaire a customer's procurement team sends before signing. Real, contractual, well covered — and not the one that can stop your processing. This article is about the other: what the Regulation lets a supervisory authority do, and what your infrastructure must emit.

Two Different Letters, Both Called an Audit

You can tell them apart from the first page. A customer's audit cites a clause — the audit right in your data processing agreement under Article 28 — and is negotiable: scope, notice, frequency, who pays. The worst outcome is commercial.

A supervisory authority's letter cites the Regulation. It names the legal basis for its request and is negotiable only in part: argue scope and proportionality, not whether the power exists. Article 31 is plain: the controller and the processor "shall cooperate, on request, with the supervisory authority in the performance of its tasks."

The difference is who sets the terms: in a DPA audit you are a party; in an Article 58 investigation, a subject.

What Article 58 Actually Authorises

Article 58(1) lists six investigative powers every supervisory authority has. Four determine what your infrastructure has to be able to do.

  • 58(1)(a) — an information order. The authority may order the controller and the processor "to provide any information it requires for the performance of its tasks." The authority scopes it, not you.
  • 58(1)(b) — the audit itself. The power "to carry out investigations in the form of data protection audits" — independent of every contract you signed.
  • 58(1)(e) — access to the data. The power to obtain "access to all personal data and to all information necessary for the performance of its tasks." A separate power from (a): one asks about your processing, the other reaches the data.
  • 58(1)(f) — access to premises. The power to obtain "access to any premises of the controller and the processor, including to any data processing equipment and means, in accordance with Union or Member State procedural law."

Then Article 58(2), the corrective powers. The one that concentrates minds is 58(2)(f), the power "to impose a temporary or definitive limitation including a ban on processing." A fine is a number on a balance sheet; a processing ban is an outage no architecture fixes.

The exposure most often missed sits in Article 83(5)(e). The higher fine tier — "up to 20 000 000 EUR, or in the case of an undertaking, up to 4 % of the total worldwide annual turnover of the preceding financial year, whichever is higher" — covers "non-compliance with an order or a temporary or definitive limitation on processing or the suspension of data flows by the supervisory authority pursuant to Article 58(2) or failure to provide access in violation of Article 58(1)." Read the limb as written: the offence is "failure to provide access," not any exercise of an Article 58(1) power — (c), a certification review, and (d), a notification the authority issues, are not access. Article 83(4)(a) puts the controller and processor obligations of "Articles 8, 11, 25 to 39 and 42 and 43" — Article 30 and Article 31 among them — in the lower 10 000 000 EUR / 2 % tier. Both tiers run through Article 83(2), whose chapeau makes every listed factor bear on whether a fine is imposed and on its amount. 83(2)(f) is "the degree of cooperation with the supervisory authority, in order to remedy the infringement and mitigate the possible adverse effects of the infringement."

How an Investigation Starts, and the Clock It Starts

Three routes account for most first contact, though not a closed list — an authority may also act on its own initiative or on a referral.

  1. A data subject complaint. One person, one grievance, lodged with their authority — the smallest trigger there is.
  2. Your own breach notification. Article 33(1) requires notification "without undue delay and, where feasible, not later than 72 hours" — unless the breach is "unlikely to result in a risk to the rights and freedoms of natural persons" — with reasons for any delay past 72 hours. That exemption is a risk assessment you must defend, not a box to tick. Filing hands the authority a dated account of an incident it may examine.
  3. A coordinated sweep. Under the EDPB's Coordinated Enforcement Framework, authorities pick a theme and work it together. The 2026 action, launched on 19 March 2026 with 25 participating authorities, covers transparency rather than auditing — the mechanism is the point: participating authorities "contact controllers from different sectors across Europe, either through enforcement actions or fact-finding exercises," and after a fact-find "might also decide to undertake additional follow-up actions." A questionnaire is a way in.

Now the question that decides pace: does the case concern cross-border processing? Regulation (EU) 2025/2518 — the GDPR procedural regulation — confines itself to "complaint-based and ex officio cases ... where those cases concern cross-border processing." GDPR Article 4(23), which it adopts, makes processing cross-border either when it happens in the context of establishments in more than one Member State, or when a single establishment's processing "substantially affects or is likely to substantially affect data subjects in more than one Member State." One EU establishment is not by itself outside the regulation, because limb (b) can catch it — but limb (b) is a substantial-effect test judged case by case, and EDPB Guidelines 8/2022 are explicit that processing the data of "even a large number" of individuals across several Member States does not necessarily have a substantial effect. A case meeting neither limb runs on national procedural law, with no EU stage clock — and it is not the only one that does. Article 6(5) withholds Chapter III, and with it the Article 12 clock, from a case handled under GDPR Article 56(2): one whose subject matter relates only to an establishment in a single Member State, or substantially affects data subjects only there — which can be true of a controller established in more than one.

Where it does apply, the lead supervisory authority "shall submit a draft decision ... within 15 months" of confirming its competence — or of a binding Article 65(1)(b) Board decision — extendable only "[o]n an exceptional basis ... for a period of no longer than 12 months, due to the complexity of the case," with the other authorities concerned informed at least four weeks before expiry and due account taken of an objection lodged within two weeks (Article 12(3) and (4)). Article 12(2) suspends that limit while a lead-authority request under Article 11(6) sits with the Board. Under simple cooperation the limit is 12 months, extendable once by "no longer than two months" — but only where national law requires prior or subsequent domestic proceedings that necessitate submitting the Article 60(3) draft decision after that 12-month limit has expired. Which procedure applies changes what you are shown. On the ordinary path, Article 19(2) requires preliminary findings to "set out clearly all the facts, the list of the evidence relied upon and the entire legal assessment raised against the parties." Under simple cooperation there are none: Article 6 provides that "Articles 10, 11, 16, 19, 20, Article 23(1), points (a) and (b), and Article 23(2) ... shall not apply." A hearing survives — Article 6(4) requires the parties to be heard before the draft decision "where applicable," under the national procedural law of the lead supervisory authority or, where applicable, of the authority with which the complaint was lodged — but the structured document you would answer does not. Article 24(1) is not in that exclusion list, so the administrative file — "including all inculpatory and exculpatory evidence," excluding the authority's internal communications — is defined either way.

The 2027 date and the cross-border caption sit on the lane boundary on purpose: that clock reaches neither a case outside Article 4(23), nor one handled under GDPR Article 56(2) — Article 6(5) withholds Chapter III from those — nor, under Article 36, an ex officio investigation opened before 2 April 2027 or a complaint lodged before it. Inside the lane, the procedure fork decides whether preliminary findings exist at all, and the simple-cooperation clock's two-month extension is unlocked only by domestic proceedings that force the draft decision past the 12-month limit — proceedings that finish inside it buy nothing.

Accountability Is a Running System, Not a Binder

Article 5(2) is one sentence and the whole argument: "The controller shall be responsible for, and be able to demonstrate compliance with, paragraph 1 (accountability)." Being compliant is necessary; demonstrating it is a separate obligation — the one infrastructure either satisfies or does not.

Article 24(1) sharpens that: measures "to ensure and to be able to demonstrate that processing is performed in accordance with this Regulation," which "shall be reviewed and updated where necessary." That last clause is the part people skip: a measure demonstrated once, in a 2024 document, is not under review.

Two further provisions name the audience. Article 30(4): the controller or processor "shall make the record available to the supervisory authority on request." Article 33(5): breach documentation must comprise "the facts ... its effects and the remedial action taken," and "shall enable the supervisory authority to verify compliance with this Article." The standard is not that you documented the breach; it is that the documentation lets someone else check you.

Together they describe a system property: evidence must be continuously produced output, timestamped before the request, because anything assembled afterwards is a reconstruction.

The Evidence Plane, and What Self-Hosting Changes

An Article 58 investigation touches two planes. The data plane is the personal data itself — the stores holding it and the export path producing a scoped extract, which answers 58(1)(e). The demonstrability plane evidences how you handle it: the record of processing, the audit trail, breach documentation, the state of your Article 32 technical measures. That answers a 58(1)(a) order, an Article 30(4) request, and Article 5(2).

The useful question about each node is not whether it exists but who operates it — which is what decides whether the answer is produced or requested. Funding one row and not the other costs twice: the top row alone produces a subject's records but not who reached them, and the bottom row alone turns a scoped extract into a bespoke project.

Self-hosting earns its argument here. Operate the control plane and the API server audit log is a file on a disk you own, written by a policy you wrote, retained for a period you set. The record of processing is a file in a repository you control, its commit history showing when each entry changed and by whom.

Where someone else operates a component the evidence still exists — you just have to ask, in their format and on their timeline rather than the authority's. Often the right trade; make it deliberate, with a documented list of what you produce and what you request.

Concrete: An Audit Policy, a Bundle, a Schedule

Start with the API server audit policy, where a plausible-looking configuration worsens your GDPR position. The mechanics — stages, backends, retention flags, SIEM shipping — are in the Kubernetes audit log pipeline article. This policy turns on one decision: Metadata versus RequestResponse, under a personal-data test rather than a forensics one.

The trap: RequestResponse writes request and response bodies into the audit log. Apply it to Secrets, ConfigMaps or any custom resource carrying subject data and the log becomes a second copy — different retention, usually broader read access. You have created a GDPR problem while solving one. RBAC objects are the case people miss: a RoleBinding subject list can name people directly. A User subject is usually personal data under Article 4(1); a ServiceAccount, or a group such as system:authenticated, is not. Metadata for every binding is still right, since any may carry User subjects.

yaml
# /etc/kubernetes/audit/gdpr-audit-policy.yaml
apiVersion: audit.k8s.io/v1
kind: Policy

# One event per request is enough to answer "who reached what, and when".
# ResponseStarted is only generated for long-running requests such as watch,
# so it appears sparsely by nature rather than on every call.
omitStages:
  - "RequestReceived"

rules:
  # 1. Control-plane read noise, dropped on NAMED resources only. Rules are
  #    evaluated in order and the first match wins, so the same rule without a
  #    resources filter would also swallow kube-controller-manager reading a
  #    Secret and listing objects in the subject-data namespaces below - the
  #    exact events the Metadata rules exist to keep. Upstream's own examples
  #    drop this noise by resource (endpoints, nodes), never globally. Extend
  #    the list to match your own noise floor, one resource at a time.
  - level: None
    users: ["system:kube-scheduler", "system:kube-controller-manager"]
    verbs: ["get", "list", "watch"]
    resources:
      - group: ""
        resources: ["nodes", "endpoints", "pods", "replicationcontrollers"]
      - group: "apps"
        resources: ["deployments", "replicasets", "statefulsets", "daemonsets"]
      - group: "coordination.k8s.io"
        resources: ["leases"]
      - group: "discovery.k8s.io"
        resources: ["endpointslices"]

  - level: None
    nonResourceURLs: ["/healthz*", "/readyz*", "/livez*", "/version", "/metrics"]

  - level: None
    resources:
      - group: ""
        resources: ["events"]

  # 2. Anything whose BODY can carry or name a natural person: Metadata only.
  #    RoleBinding and ClusterRoleBinding subject lists are usernames and
  #    email addresses - personal data under Article 4(1). Admission and
  #    authentication objects can reference named users in the same way.
  - level: Metadata
    resources:
      - group: ""
        resources: ["secrets", "configmaps", "serviceaccounts"]
      - group: "rbac.authorization.k8s.io"
        resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]
      - group: "authentication.k8s.io"
        resources: ["tokenreviews"]
      - group: "admissionregistration.k8s.io"
        resources:
          - "validatingwebhookconfigurations"
          - "mutatingwebhookconfigurations"
          - "validatingadmissionpolicies"
          - "validatingadmissionpolicybindings"

  # 3. Namespaces and custom resources that hold subject data. This list is a
  #    STARTING SET - extend it to match your own estate before use.
  - level: Metadata
    namespaces: ["identity", "billing", "subject-data"]

  - level: Metadata
    resources:
      - group: "privacy.internal.example"
        resources: ["datasubjectrequests", "consentrecords", "retentionpolicies"]

  # 4. RequestResponse ONLY where the body names no natural person at all.
  #    These objects are pure cluster policy, so full bodies add evidential
  #    value without adding a second copy of anyone's personal data.
  - level: RequestResponse
    resources:
      - group: "networking.k8s.io"
        resources: ["networkpolicies"]
      - group: ""
        resources: ["resourcequotas", "limitranges"]
      - group: "storage.k8s.io"
        resources: ["storageclasses"]
      - group: "scheduling.k8s.io"
        resources: ["priorityclasses"]
      - group: "policy"
        resources: ["poddisruptionbudgets"]

  # 5. CATCH-ALL. Without this, every resource not named above - including
  #    any CRD installed after this file was written - matches no rule and is
  #    therefore absent from the log entirely. Metadata, not None: "we did
  #    not know that resource existed" is not an answer to an Article 58(1)(a)
  #    information order.
  - level: Metadata
Level chosen on a personal-data test. First match wins, which cuts both ways: an unscoped None rule near the top silently outranks every personal-data rule below it, and without the catch-all at the bottom an unmatched resource is recorded nowhere at all.

Next, the bundle — the point being that the artefact exists, dated, before anyone asks. Step 04 reads wgpolicyk8s.io PolicyReports, a Kyverno or Kubewarden CRD rather than core Kubernetes; without one, run POLICY_REPORTS=skip and the gap is recorded, not guessed at.

bash
#!/usr/bin/env bash
# gdpr-evidence-bundle.sh - build one checksummed evidence archive for a
# stated window, then put it beyond your own reach.
#
# Requires GNU coreutils (date -d), jq, kubectl and mc. A BusyBox image will
# not do: its date(1) does not parse the relative expressions used below.
set -euo pipefail

# Window defaults to the previous calendar month in UTC, so the scheduled run
# needs no arguments and a manual run can still name any window explicitly.
WINDOW_START="${1:-$(date -u -d "$(date -u +%Y-%m-01) -1 month" +%Y-%m-%dT%H:%M:%SZ)}"
WINDOW_END="${2:-$(date -u -d "$(date -u +%Y-%m-01) -1 second" +%Y-%m-%dT%H:%M:%SZ)}"

: "${ROPA_SOURCE:=/srv/governance/record-of-processing.yaml}"
: "${AUDIT_LOG_DIR:=/var/log/kubernetes/audit}"
: "${AUDIT_POLICY:=/etc/kubernetes/audit/gdpr-audit-policy.yaml}"
: "${CLUSTER_NAME:?set CLUSTER_NAME - the manifest must say which cluster this came from}"
: "${POLICY_REPORTS:=require}"   # require | skip
: "${WORM_ALIAS:=evidence}"
: "${WORM_BUCKET:=gdpr-evidence}"
# An example lock duration in mc's documented Ny form - not a GDPR period.
# Article 5(1)(e) sets no number; derive yours from the limitation period you
# expect to have to defend in, and say where it came from.
: "${RETENTION:=6y}"

# kubectl target. In-cluster (the CronJob below) there is no kubeconfig
# context to name and the ServiceAccount is the identity, so KUBE_CONTEXT
# stays empty. From a workstation, set it explicitly - never inherit whatever
# context happens to be current.
: "${KUBE_CONTEXT:=}"
KUBECTL=(kubectl)
if [ -n "${KUBE_CONTEXT}" ]; then
  KUBECTL+=(--context "${KUBE_CONTEXT}")
fi

STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
BUNDLE="gdpr-evidence-${STAMP}"
WORKDIR="$(mktemp -d)"
OUT="${WORKDIR}/${BUNDLE}"
trap 'rm -rf "${WORKDIR}"' EXIT
mkdir -p "${OUT}"

fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }

# 01 - Record of processing activities. Article 30(4): made available to the
#      supervisory authority on request.
[ -r "${ROPA_SOURCE}" ] || fail "no readable record of processing at ${ROPA_SOURCE}"
cp "${ROPA_SOURCE}" "${OUT}/01-record-of-processing.yaml"

# 02 - API server audit log, sliced to the window. Timestamps are UTC, which
#      only means anything if the nodes agree on the time.
#
#      The log backend rotates (--audit-log-maxage, --audit-log-maxbackup,
#      --audit-log-maxsize), so a month-long window is almost never inside the
#      live file alone: read every rotation in the directory, compressed or
#      not. Where the audit backend ships to a SIEM, point AUDIT_LOG_DIR at an
#      export from it instead - that is the copy that actually retains a year.
shopt -s nullglob
# One glob, so rotations sort ahead of the live file and compressed rotations
# sort with their uncompressed siblings by timestamp.
AUDIT_FILES=("${AUDIT_LOG_DIR}"/audit*.log*)
shopt -u nullglob
[ "${#AUDIT_FILES[@]}" -gt 0 ] || fail "no audit log files under ${AUDIT_LOG_DIR}"

read_audit() {
  local f
  for f in "${AUDIT_FILES[@]}"; do
    case "${f}" in
      *.gz) gzip -dc -- "${f}" ;;
      *)    cat -- "${f}" ;;
    esac
  done
}

# requestReceivedTimestamp is a meta/v1.MicroTime and carries fractional
# seconds that the window bounds do not. Trim to whole seconds before
# comparing: "...T00:00:00.4Z" sorts BEFORE "...T00:00:00Z" lexically, because
# '.' precedes 'Z' in ASCII, which would silently drop every event in the
# first second of the window.
read_audit \
  | jq -c --arg from "${WINDOW_START}" --arg to "${WINDOW_END}" \
      '(.requestReceivedTimestamp | sub("\\.[0-9]+Z$"; "Z")) as $t
       | select($t >= $from and $t <= $to)' \
  > "${OUT}/02-apiserver-audit.jsonl"
[ -s "${OUT}/02-apiserver-audit.jsonl" ] \
  || fail "audit slice ${WINDOW_START}..${WINDOW_END} is empty - rotated away, or wrong window"

# A non-empty slice is not a complete one. Coverage of the window is only
# demonstrated if the history on disk reaches back PAST its start: if the
# oldest event available begins at or after WINDOW_START, rotation may already
# have taken the front of the month and this bundle must not be labelled as
# covering it. Trimmed to whole seconds, both sides are fixed-width RFC 3339
# UTC - the case where ASCII lexical order really is chronological order.
EARLIEST_AVAILABLE="$({ read_audit || true; } | head -n 1 \
  | jq -r '.requestReceivedTimestamp | sub("\\.[0-9]+Z$"; "Z")')"
if ! [[ "${EARLIEST_AVAILABLE}" < "${WINDOW_START}" ]]; then
  fail "audit history begins ${EARLIEST_AVAILABLE}, at or after window start ${WINDOW_START} - nothing on disk predates the window, so full coverage cannot be shown; widen --audit-log-maxage/--audit-log-maxbackup or bundle from the SIEM export"
fi

# 03 - The policy that produced file 02. Without it, the slice cannot be read
#      as evidence of anything: absence of an event only means something if
#      you can show the event would have been recorded.
[ -r "${AUDIT_POLICY}" ] || fail "no readable audit policy at ${AUDIT_POLICY}"
cp "${AUDIT_POLICY}" "${OUT}/03-audit-policy.yaml"

# 04 - Measure state. Fail rather than ship a bundle that claims coverage it
#      does not have. Where no wgpolicyk8s.io engine is installed, run with
#      POLICY_REPORTS=skip: the omission is then recorded in the bundle and
#      checksummed with everything else, rather than left to be inferred.
if [ "${POLICY_REPORTS}" = "skip" ]; then
  printf 'policyReports: omitted\nreason: no wgpolicyk8s.io engine in %s\n' \
    "${CLUSTER_NAME}" > "${OUT}/04-policy-reports.yaml"
else
  "${KUBECTL[@]}" api-resources --api-group=wgpolicyk8s.io -o name \
    | grep -q '^policyreports' \
    || fail "no PolicyReport CRD in ${CLUSTER_NAME}; re-run with POLICY_REPORTS=skip to record the gap instead"
  "${KUBECTL[@]}" get policyreports,clusterpolicyreports \
    --all-namespaces -o yaml > "${OUT}/04-policy-reports.yaml"
fi

# 05 - Manifest. The checksums are what let an authority - or you, two years
#      later - tell that nothing in the bundle has moved since it was built.
{
  printf 'bundle: %s\n' "${BUNDLE}"
  printf 'generated_at: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  printf 'window_start: %s\n' "${WINDOW_START}"
  printf 'window_end: %s\n' "${WINDOW_END}"
  printf 'cluster: %s\n' "${CLUSTER_NAME}"
  printf 'files:\n'
} > "${OUT}/manifest.yaml"

( cd "${OUT}" && sha256sum -- 0*.yaml 0*.jsonl ) > "${WORKDIR}/sums.txt"
while read -r sum name; do
  printf '  - name: %s\n    sha256: %s\n' "${name}" "${sum}" >> "${OUT}/manifest.yaml"
done < "${WORKDIR}/sums.txt"

tar -C "${WORKDIR}" -czf "${WORKDIR}/${BUNDLE}.tar.gz" "${BUNDLE}"
( cd "${WORKDIR}" && sha256sum "${BUNDLE}.tar.gz" > "${BUNDLE}.tar.gz.sha256" )

# The bucket needs versioning and object locking. Neither has to be decided at
# creation: AWS S3 documents enabling Object Lock on an existing bucket, and
# MinIO AIStor allows setting object locking on any existing bucket from
# RELEASE.2025-05-20T20-30-00Z via 'mc retention set --default' - only earlier
# MinIO releases required the choice at creation. Versioning is a prerequisite
# either way. COMPLIANCE mode then protects the object from write operations
# by every user, including the root user, and the lock cannot be changed or
# removed before the retention duration passes.
mc cp "${WORKDIR}/${BUNDLE}.tar.gz"        "${WORM_ALIAS}/${WORM_BUCKET}/"
mc cp "${WORKDIR}/${BUNDLE}.tar.gz.sha256" "${WORM_ALIAS}/${WORM_BUCKET}/"
mc retention set COMPLIANCE "${RETENTION}" \
  "${WORM_ALIAS}/${WORM_BUCKET}/${BUNDLE}.tar.gz"

# A COMPLIANCE lock protects the VERSION just written, not the object name: on
# the S3 semantics MinIO calls identical, a newer version or a delete marker can
# still be put in front of it, and a later default GET would then return that
# instead. The locked bytes stay retrievable by version id - so record the id
# now, while it is still in hand, to a second object under the SAME COMPLIANCE
# retention. Printing it is not enough: successfulJobsHistoryLimit below keeps
# about a year of run logs, and the retention window here is six.
BUNDLE_VERSION="$(mc stat --json "${WORM_ALIAS}/${WORM_BUCKET}/${BUNDLE}.tar.gz" \
  | jq -r 'select(.versionID != null) | .versionID' | head -n 1)"
[ -n "${BUNDLE_VERSION}" ] \
  || fail "no version id for ${BUNDLE}.tar.gz - enable versioning on ${WORM_BUCKET} before locking anything"

printf '%s\n' "${BUNDLE_VERSION}" > "${WORKDIR}/${BUNDLE}.tar.gz.versionid"
mc cp "${WORKDIR}/${BUNDLE}.tar.gz.versionid" "${WORM_ALIAS}/${WORM_BUCKET}/"
mc retention set COMPLIANCE "${RETENTION}" \
  "${WORM_ALIAS}/${WORM_BUCKET}/${BUNDLE}.tar.gz.versionid"

printf 'OK: %s covering %s..%s version=%s\n' \
  "${BUNDLE}" "${WINDOW_START}" "${WINDOW_END}" "${BUNDLE_VERSION}"
Every input is checked and the run fails loudly rather than producing a bundle that quietly omits something.

Two properties of that last step matter. MinIO's current documentation states that an object under a COMPLIANCE lock "is protected from write operations by all users, including the MinIO AIStor root user," and that you "cannot change or remove the lock before the configured retention duration has passed." That is what evidence needs: an archive the operator cannot quietly amend. A legal hold survives expiry until someone with permission lifts it — how you freeze a window the moment an investigation touches it. One limit: the lock protects a version, not the object name. A newer version or a delete marker can land in front of it, so a later default GET can miss an archive nobody could amend — hence reading the version id back and storing it under the same lock. The same object-lock reasoning that protects backups from ransomware does a second job here.

Then schedule it, and let the schedule fail visibly.

yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: gdpr-evidence-bundle
  namespace: governance
spec:
  # 02:15 UTC on the first of each month, covering the month just closed.
  schedule: "15 2 1 * *"
  timeZone: "Etc/UTC"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 3600
  # Twelve months of run records in-cluster. The CronJob's own history is the
  # first place a missing month shows up.
  successfulJobsHistoryLimit: 12
  failedJobsHistoryLimit: 12
  jobTemplate:
    spec:
      backoffLimit: 0
      template:
        spec:
          restartPolicy: Never
          # Its bound Role must allow list on wgpolicyk8s.io policyreports and
          # clusterpolicyreports, or step 04 fails closed.
          serviceAccountName: gdpr-evidence
          # Each kube-apiserver writes its own --audit-log-path file on its own
          # node, so the hostPath below captures one apiserver. That is the
          # whole cluster only on a single-control-plane kubeadm layout. On HA
          # the Job lands on one node of several and bundles only that one; on
          # EKS, GKE or AKS the control plane is not schedulable and the Job
          # never starts. Both cases want the webhook backend, not this mount -
          # and that means dropping the selector and toleration as well.
          nodeSelector:
            node-role.kubernetes.io/control-plane: ""
          tolerations:
            - key: node-role.kubernetes.io/control-plane
              operator: Exists
              effect: NoSchedule
          securityContext:
            runAsNonRoot: true
            runAsUser: 10001
            # This UID has to be able to READ the node's audit files, which
            # kube-apiserver creates root-owned 0600 inside a 0700 directory.
            # A shared group does not help at those modes: pre-create the
            # directory 0750 and the file 0640 owned by that group, and
            # lumberjack keeps the file mode across rotation. Otherwise step
            # 02 fails closed on the first read. Failing closed is correct -
            # it is still an afternoon nobody budgeted, and the webhook
            # backend avoids the question by never touching the node
            # filesystem.
            supplementalGroups: [10001]
            seccompProfile:
              type: RuntimeDefault
          containers:
            - name: bundle
              image: registry.internal.example/governance/gdpr-evidence:1.4.0
              command: ["/usr/local/bin/gdpr-evidence-bundle.sh"]
              securityContext:
                allowPrivilegeEscalation: false
                readOnlyRootFilesystem: true
                capabilities:
                  drop: ["ALL"]
              env:
                # In-cluster the ServiceAccount is the identity, so KUBE_CONTEXT
                # stays unset and kubectl uses the in-cluster config. The name
                # is carried separately, for the manifest only.
                - name: CLUSTER_NAME
                  value: "prod-eu-central"
                # Example lock duration, not a statutory GDPR period.
                - name: RETENTION
                  value: "6y"
                # mc reads an alias straight from the environment, so no
                # credentials are baked into the image. The value is the WORM
                # endpoint URL carrying the access and secret pair, in mc's
                # documented MC_HOST_<alias> form.
                - name: MC_HOST_evidence
                  valueFrom:
                    secretKeyRef:
                      name: gdpr-evidence-worm
                      key: alias-url
                - name: MC_CONFIG_DIR
                  value: /tmp/.mc
                # readOnlyRootFilesystem leaves kubectl unable to write its
                # discovery cache under $HOME, so it re-discovers the whole API
                # surface every run. Point both at the scratch mount.
                - name: HOME
                  value: /tmp
                - name: KUBECACHEDIR
                  value: /tmp/.kube/cache
                # Stated rather than inherited: the mount path and the
                # directory the script reads are one decision, and a future
                # edit to either must move both.
                - name: AUDIT_LOG_DIR
                  value: /var/log/kubernetes/audit
              volumeMounts:
                - name: audit-log
                  mountPath: /var/log/kubernetes/audit
                  readOnly: true
                - name: audit-policy
                  mountPath: /etc/kubernetes/audit
                  readOnly: true
                - name: governance
                  mountPath: /srv/governance
                  readOnly: true
                - name: scratch
                  mountPath: /tmp
          volumes:
            - name: audit-log
              hostPath:
                # The directory --audit-log-path writes into, NOT its parent.
                # The bundle globs audit*.log* one level deep, so mounting
                # /var/log/kubernetes here matches nothing and step 02 fails
                # closed. Kubernetes' own example and the audit-logs post
                # linked above both use /var/log/kubernetes/audit/audit.log.
                path: /var/log/kubernetes/audit
                type: Directory
            # Must hold the same policy file the API server is running with -
            # otherwise file 03 describes a policy that did not produce file 02.
            - name: audit-policy
              configMap:
                name: gdpr-audit-policy
                items:
                  - key: gdpr-audit-policy.yaml
                    path: gdpr-audit-policy.yaml
            - name: governance
              configMap:
                name: record-of-processing
                items:
                  - key: record-of-processing.yaml
                    path: record-of-processing.yaml
            - name: scratch
              emptyDir: {}
backoffLimit: 0 is deliberate — a silent retry is how a month with no evidence goes unnoticed for a year.

The hostPath mount deserves the most thought, and the manifest comment says why: on anything but a single-control-plane kubeadm layout it produces silent partial coverage, or never runs at all. The fix keeps the principle intact — a dated, checksummed, retention-locked bundle on a schedule nobody has to remember.

Failure Modes That Turn a Fact-Find Into an Investigation

The EDPB's 2025 coordinated action shows what authorities find when many organisations answer the same questions. That year, 32 supervisory authorities investigated the right to erasure using an agreed common questionnaire, and 764 controllers responded. The report assesses overall compliance as "average" and sets out seven recurring issues. Three read as infrastructure problems, not policy ones.

  • Anonymisation used as a substitute for deletion. The report describes findings on "the reliance by some controllers on inefficient anonymisation techniques to handle erasure requests as an alternative to deletion." An erasure path that nulls a name but leaves a join key, a timestamp and a device fingerprint raises a re-identification question, not a deletion.
  • Retention periods. Authorities noted "inconsistent practices and the difficulties faced by controllers regarding the determination of retention periods." A retention policy living in a document rather than a scheduler shows up here: the document says 24 months, the table says whatever nobody deleted.
  • Backups. The same finding names "the deletion of personal data in back-ups." That is where deletion guarantees break, and where immutability added for ransomware resistance works against an erasure obligation. Resolve the tension deliberately, not during an investigation.

Those are that action's findings on erasure, not a characterisation of Article 58 audits generally. What generalises is the shape: authorities ask questions trivial to answer from a system output and painful from a policy. Three self-inflicted failure modes belong beside them: timestamps that disagree across nodes, so the trail cannot establish sequence — which is why time synchronisation is an evidential control; a retention flag shorter than the window under examination; and an audit log that has itself become an ungoverned personal-data store.

Exit Ramps: Evidence You Can Take With You

Evidence outlives its tooling. Retention windows for audit material run to years; the SIEM, the object store and the cluster will not all survive one, and an archive readable only by the platform that wrote it is a liability dressed as a control.

Three properties keep a bundle portable, all cheap now, costly to retrofit. Formats that outlive their tools — JSONlines, YAML, plain text, tar, anything a future engineer opens with standard utilities. Checksums over named files, verifiable without the system that produced the bundle. Self-description: the manifest carries the window, the cluster, the generation time and the collection policy.

The counter-pressure is familiar: compliance platforms sell an evidence repository with a good interface. One question decides it — if the contract ended tomorrow, what comes out, in what format, and can it still be verified? A platform exporting a signed archive of standard files is a tool; one exporting a PDF while keeping the artefacts in its own schema has become where your evidence lives.

The Long Game: A Standing Programme, Not a Scramble

Everything above converges on one change: run the investigation against yourself, on a schedule, before anyone else does. Pick a window, produce the bundle, and have someone who did not build it answer a realistic Article 58(1)(a) request from the archive alone — then fix the pipeline, not the answer.

Do that quarterly and the artefacts accumulate with dates on them — what Article 5(2) asks for, and what Article 24(1)'s "reviewed and updated where necessary" describes. It also changes the internal conversation: compliance stops asking engineering for things under deadline and becomes a consumer of what engineering already produces.

The sovereignty argument here is narrow and stronger for it. Self-hosting does not make you compliant; nothing in Article 58 cares who runs your servers. What changes is the distance between a question and an answer. When the evidence plane is yours, that distance is a scheduled job and a checksum. Otherwise it is a support ticket and a wait, and over a decade that compounds.

§FAQ/Common questions

Frequently asked

What is the difference between a GDPR compliance audit by a customer and one by a supervisory authority?

A customer's audit is contractual — it comes from an audit right in the data processing agreement under Article 28 and its scope, notice and frequency are negotiable. A supervisory authority's audit is statutory: Article 58(1)(b) gives every authority the power to carry out investigations in the form of data protection audits, alongside powers to order any information it requires, to obtain access to all personal data, and to access premises in accordance with Union or Member State procedural law. In the first you are a party; in the second you are a subject.

What can a supervisory authority order if a GDPR investigation goes badly?

Article 58(2) sets out the corrective powers, and 58(2)(f) is the one with operational consequences: the authority may impose a temporary or definitive limitation including a ban on processing. Fines sit in Article 83. Note that Article 83(5)(e) puts non-compliance with an order, a temporary or definitive limitation on processing, or the suspension of data flows under Article 58(2), or failure to provide access in violation of Article 58(1), in the higher tier — up to 20 000 000 EUR or, for an undertaking, up to 4% of total worldwide annual turnover of the preceding financial year, whichever is higher. It does not make every Article 58(1) power that access offence. Article 83(4)(a) is the lower tier — 10 000 000 EUR or 2% — and covers the obligations of the controller and the processor pursuant to Articles 8, 11, 25 to 39 and 42 and 43, which is where Article 30 and Article 31 infringements sit.

Does the 15-month deadline in Regulation (EU) 2025/2518 apply to every GDPR investigation?

No, on two counts. By its Article 1 the regulation covers only complaint-based and ex officio cases that concern cross-border processing as GDPR Article 4(23) defines it — establishments in more than one Member State, or a single establishment whose processing substantially affects data subjects in more than one — so a case that is neither limb is outside its clocks. Article 6(5) separately withholds Chapter III, and with it the Article 12 clock, from a case handled under GDPR Article 56(2) — one whose subject matter relates only to an establishment in a single Member State, or substantially affects data subjects only there — which can be true of a controller established in more than one. One EU establishment is not by itself enough to be outside them, because limb (b) can still catch it — though limb (b) is a substantial-effect test judged case by case, and EDPB Guidelines 8/2022 say that processing the data of even a large number of individuals across several Member States does not necessarily have a substantial effect. And by Article 37(2) it applies only from 2 April 2027, with Article 36 reaching ex officio investigations opened after that date and complaint-based investigations only where the complaint was lodged after it — so a complaint lodged now stays off the Article 12 clock however late the investigation opens. Chapters V and VI, with their own deadlines, do reach a case referred to Article 65 dispute resolution or to the urgency procedure under Article 66(2) and (3) after that date. Where it does apply, the lead authority has 15 months from confirming competence — or from a binding Board decision under Article 65(1)(b) — to submit a draft decision, extendable once on an exceptional basis by no longer than 12 months for the complexity of the case, with the other supervisory authorities concerned informed at least four weeks before expiry and due account taken of an objection lodged within two weeks, and suspended under Article 12(2) while an Article 11(6) request is before the Board — or 12 months under the simple cooperation procedure, extendable once by no longer than two months, but only where national law requires prior or subsequent domestic proceedings that necessitate submitting the Article 60(3) draft decision after that 12-month limit has expired. That simple cooperation procedure also comes with less process: Article 6 disapplies Article 19, so no preliminary findings are issued in it.

Which audit log level should a GDPR-scoped Kubernetes audit policy use?

Metadata for anything whose body can carry or name a natural person, and RequestResponse only for objects that name nobody. RequestResponse writes request and response bodies into the audit log, so applying it to Secrets, ConfigMaps or RBAC bindings — whose subject lists can name users directly, and a `User` subject is usually personal data under Article 4(1) — turns the audit log into a second, less-governed copy of personal data. End the policy with a Metadata catch-all: Kubernetes applies the first matching rule and records nothing when no rule matches, so any resource you did not name, including CRDs added later, would otherwise be missing entirely.

What does the accountability principle require beyond being compliant?

Article 5(2) makes the controller responsible for, and able to demonstrate compliance with, the principles in Article 5(1). Article 24(1) adds that the measures implemented must both ensure and demonstrate compliant processing, and must be reviewed and updated where necessary. In practice that means the evidence has to be a continuously produced output with a timestamp predating the request — a record of processing available on request under Article 30(4), breach documentation sufficient under Article 33(5) for the authority to verify compliance, and an audit trail retained across the period in question.

gdpr compliance auditsgdpr article 58 investigative powerssupervisory authority data protection auditgdpr accountability principle article 5(2)gdpr compliance checklistregulation eu 2025/2518 procedural rules

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.