audit-core/deploy/networkpolicies.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

234 lines
7.5 KiB
YAML

# Default-deny plus the narrowest set of exceptions (AUDIT-WP-0005-T03).
#
# The receiver holds the audit trail, so reachability is part of its threat
# model: only the declared sender may write, and only the declared operator
# path may read.
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: audit-core-default-deny
namespace: audit-core
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
# No rules: everything not permitted below is denied.
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: audit-core-sender-ingress
namespace: audit-core
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: audit-core
policyTypes: [Ingress]
ingress:
# user-engine is the only sender. A second sender is a deliberate change
# here and a matching entry in AUDIT_CORE_SENDERS — the network rule and
# the credential binding must move together.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: user-engine
ports:
- {protocol: TCP, port: 8080}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: audit-core-tenant-engine-ingress
namespace: audit-core
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: audit-core
policyTypes: [Ingress]
ingress:
# AUDIT-WP-0010-T03 / AUDIT-IN-0002. Attributive mutation evidence.
# Both selectors belong to one peer and are therefore ANDed. Attributive
# rather than load-bearing changes what may be claimed of the stream, not
# how narrow its reachability should be — a weaker evidence class is not a
# reason for a wider network rule.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: tenant-engine
podSelector:
matchLabels:
app.kubernetes.io/name: tenant-engine
ports:
- {protocol: TCP, port: 8080}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: audit-core-whitehat-ingress
namespace: audit-core
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: audit-core
policyTypes: [Ingress]
ingress:
# Governed E2 evidence plane. Both selectors belong to one peer and are
# therefore ANDed: only the registered audit-core probe in the dedicated
# whitehat namespace reaches this port. Application sender authentication
# and tenant scope remain the inner boundary.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: whitehat
podSelector:
matchLabels:
whitehat.security/plane: "true"
whitehat.security/target: audit-core
ports:
- {protocol: TCP, port: 8080}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: audit-core-approval-engine-ingress
namespace: audit-core
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: audit-core
policyTypes: [Ingress]
ingress:
# AUDIT-WP-0009-T09 / AUDIT-IN-0001. Load-bearing approval evidence
# (§9.4). Both selectors belong to one peer and are therefore ANDed:
# only the approval-engine workload in its own namespace reaches this
# port. Splitting them into two list items would turn AND into OR and
# admit every pod in either set.
#
# Narrower than user-engine's namespace-only rule on purpose: this is a
# new sender, and a new rule should not inherit an older rule's breadth.
# user-engine's policy is deliberately left unchanged.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: approval-engine
podSelector:
matchLabels:
app.kubernetes.io/name: approval-engine
ports:
- {protocol: TCP, port: 8080}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: audit-core-informed-decision-ingress
namespace: audit-core
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: audit-core
policyTypes: [Ingress]
ingress:
# AUDIT-WP-0009-T11 / AUDIT-IN-0003. Load-bearing presentation evidence
# under GH-DEC-2026-012 limit 3 and GH-DEC-2026-014. Both selectors belong
# to one peer and are therefore ANDed, following the approval-engine rule
# rather than user-engine's older namespace-only breadth.
#
# Note what this rule does NOT create: no egress from audit-core to
# informed-decision. The commitment-only record's custody declaration is
# carried, never dereferenced from here — audit-core makes no retrieval
# call, and the egress policy below is the proof.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: informed-decision
podSelector:
matchLabels:
app.kubernetes.io/name: informed-decision
ports:
- {protocol: TCP, port: 8080}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: audit-core-operator-ingress
namespace: audit-core
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: audit-core
policyTypes: [Ingress]
ingress:
# Operator read path: lookup, dead letters, secret findings, stats.
# Namespace-scoped rather than open, and still gated on a credential
# carrying may_read — the network rule is the outer of two checks, not the
# only one.
- from:
- namespaceSelector:
matchLabels:
railiance.io/audit-core-reader: "true"
ports:
- {protocol: TCP, port: 8080}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: audit-core-attest-egress
namespace: audit-core
spec:
# Scoped to the attestation job by component label, so the receiver itself
# gains nothing from this rule. The receiver must not be able to reach the
# API server: a compromised receiver that could rewrite the chain-head
# ConfigMap could forge its own attestation, which is the one thing the
# separation of these two workloads exists to prevent.
podSelector:
matchLabels:
app.kubernetes.io/name: audit-core
app.kubernetes.io/component: attest
policyTypes: [Egress]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: databases
ports:
- {protocol: TCP, port: 5432}
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- {protocol: UDP, port: 53}
- {protocol: TCP, port: 53}
# kube-apiserver. On this single-node k3s cluster the API server is the
# host itself, so this is a host-network destination rather than a pod
# selector; narrow it to the API port.
- ports:
- {protocol: TCP, port: 6443}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: audit-core-egress
namespace: audit-core
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: audit-core
app.kubernetes.io/component: receiver
policyTypes: [Egress]
egress:
# PostgreSQL custody store. This is the only destination the receiver needs;
# it calls no other service.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: databases
ports:
- {protocol: TCP, port: 5432}
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- {protocol: UDP, port: 53}
- {protocol: TCP, port: 53}