audit-core/deploy/attest-cronjob.yaml
tegwick de9e3abe5f AUDIT-WP-0009 T02/T10 — schedule attestation, and make the §5 check total
T02. deploy/attest-cronjob.yaml: daily at 03:17 UTC against the 168h window,
its own ServiceAccount, and a Role reaching exactly one named ConfigMap —
get/update/patch, no create, no list. audit_core/attest_publish.py does the
publish in stdlib; the image carries no kubectl, and adding one to an audit
receiver's image to write a single file is the worse trade.

Three refusals, all deliberate:

  The producer is not the receiver. A receiver that could rewrite its own
  attestation could forge it. audit-core-egress is now scoped to
  component: receiver and a separate audit-core-attest-egress carries the 6443
  rule, so the receiver never gains API-server reach. Asserted by test.

  It refuses to publish over a broken chain. A fresh head written over a break
  replaces an honest chain_break with a fresh-looking attestation. Stale
  degrades the claim visibly; false does not.

  Mounted as a directory, not subPath. Found while writing the manifest: a
  subPath ConfigMap mount is resolved once at pod start and never updates, so
  the daily attestation would land in the ConfigMap and never reach the running
  receiver — tamper_evidence would age out to false while the job reported
  success every night, silent in both directions.

The offsite copy stays an operator step. audit-core holds no Nextcloud
credential and should not acquire one to publish a hash, so docs/integrity.md
states the bound plainly: until that copy exists the delivered control defends
against a database owner, not a cluster owner, and no stronger claim may be
made from it.

T10. layer.yaml lists four infrastructure contacts — platform-pg, state-hub,
kube-apiserver, the container registry — each with its role and whether another
layer reads it. tooling_contacts stays [], which is true under §5 as written;
the companion's totality request is met by the uncatalogued list rather than by
inventing a Tooling row. tests/test_layer_conformance.py derives the egress
destinations from the manifests and the registry from the pinned digests, so a
new contact appearing in deploy/ without a row fails the test rather than
waiting for a reviewer to notice.

Applying the manifests remains an operator action; nothing here was applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nb7Q6ZmXppNDkTWytfYqfv

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2069992@bnt-lap001
Assistant-Session: 167dd7f8-2a25-4be1-aa46-3b6f1a5f94c6
2026-09-10 16:39:20 +02:00

163 lines
6 KiB
YAML

# Chain-head attestation on a schedule (AUDIT-WP-0009-T02).
#
# T01 made `tamper_evidence` conditional on a fresh attestation. Until this
# runs, the honest answer in production is `false` — the precondition is simply
# not met. This is the job that meets it.
#
# WHERE THE ATTESTATION GOES, AND WHY IT MATTERS MORE THAN THE SCHEDULE.
# An attestation is worth exactly as much as its independence from the thing it
# attests. Two copies, with different properties, and neither is optional:
#
# 1. ConfigMap `audit-core-chain-head`, in-cluster, written here and mounted
# read-only by the Deployment. Outside platform-pg, so a database owner
# who rewrites a suffix cannot also rewrite the attestation without
# separate cluster access. This is the copy the receiver reads and the one
# that makes /readyz truthful.
#
# 2. The logical-offsite copy (rapp-postgres / Nextcloud + age, the path
# RESOURCE-WP-0002-T06 already uses). Survives loss of the cluster. This
# job does NOT write it — audit-core holds no Nextcloud credential and
# should not — so it remains an operator step, recorded in
# docs/integrity.md.
#
# NOT the Barman prefix. A copy restored alongside the table proves nothing:
# whoever rewrote the table restores the attestation that matches it.
#
# So copy 1 alone is a real but bounded control: it defends against a database
# owner, not against a cluster owner. docs/integrity.md states that bound; do
# not let this file be read as delivering more.
#
# Image digest must match deploy/audit-core.yaml.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: audit-core-attest
namespace: audit-core
labels:
app.kubernetes.io/name: audit-core
app.kubernetes.io/component: attest
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: audit-core-attest
namespace: audit-core
rules:
# One named ConfigMap, in this namespace, and no `create` on the collection.
# The ConfigMap is created once by the operator; this job may only replace
# its contents. Deliberately not `list` — a job that can enumerate the
# namespace's ConfigMaps has more reach than writing one head needs.
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["audit-core-chain-head"]
verbs: ["get", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: audit-core-attest
namespace: audit-core
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: audit-core-attest
subjects:
- kind: ServiceAccount
name: audit-core-attest
namespace: audit-core
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: audit-core-attest-chain
namespace: audit-core
labels:
app.kubernetes.io/name: audit-core
app.kubernetes.io/component: attest
spec:
# Daily, against the 168h freshness window declared in docs/integrity.md.
# Seven cadences of headroom on purpose: a missed run degrades the claim
# gradually rather than flapping tamper_evidence false on one bad night.
schedule: "17 3 * * *"
timeZone: Etc/UTC
concurrencyPolicy: Forbid
startingDeadlineSeconds: 3600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 7
jobTemplate:
spec:
backoffLimit: 2
ttlSecondsAfterFinished: 172800
template:
metadata:
labels:
app.kubernetes.io/name: audit-core
app.kubernetes.io/component: attest
spec:
restartPolicy: OnFailure
serviceAccountName: audit-core-attest
securityContext:
runAsNonRoot: true
runAsUser: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: attest
image: forgejo.coulomb.social/coulomb/audit-core@sha256:c2fe39a0185b99be3fc0cb14d2de69772b8e66e20490097c9d11d90cc39719a6
imagePullPolicy: IfNotPresent
command:
- python
- -m
- audit_core.attest_publish
# attest_publish runs `attest-chain`, refuses to publish over a
# broken chain, and PATCHes the ConfigMap through the API with
# the projected ServiceAccount token. Written in stdlib rather
# than shelling out because this image carries no kubectl, and
# adding one to an audit receiver's image to write one file is a
# worse trade than twenty lines of urllib.
env:
- name: AUDIT_CORE_CREDENTIAL_DIR
value: /etc/audit-core/db
- name: AUDIT_CORE_DATABASE_SCHEMA
value: audit_core
# Read-only work: never migrate from the attestation job.
- name: AUDIT_CORE_AUTO_MIGRATE
value: "0"
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {cpu: 500m, memory: 256Mi}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
- name: database-credential
mountPath: /etc/audit-core/db
readOnly: true
volumes:
- name: tmp
emptyDir: {}
- name: database-credential
secret:
secretName: audit-core-database
defaultMode: 0440
---
# Created empty by the operator so the CronJob's Role needs no `create`, and
# so the Deployment can mount it before the first run. An absent or undated
# attestation degrades the claim rather than breaking the receiver — that is
# T01's `no_attestation` path, and it is the correct behaviour on day one.
apiVersion: v1
kind: ConfigMap
metadata:
name: audit-core-chain-head
namespace: audit-core
labels:
app.kubernetes.io/name: audit-core
app.kubernetes.io/component: attest
data:
chain-head.json: "{}"