Skip to content

Shared Kubernetes security lab

This local proof of concept connects mcp-svid-auth, mcp-spiffe-bridge and hipaa-fhir-mcp. ecs-contract is excluded. It does not deploy a cloud cluster. The Headlamp interface provides a local read-only view.

Requires components that are not yet published

mcp-spiffe-bridge is not yet published, so this lab cannot be reproduced from public sources. The quickstart single-repository path (tests, local end-to-end demo, packaging and interoperability) needs no lab. Results on this page are recorded evidence from the maintainer's environment.

Components and requests

flowchart LR
  subgraph Lima[Dedicated Lima VM: 2 CPUs, 4 GiB]
    subgraph Kubernetes[k3s Kubernetes with containerd]
      SS[SPIRE server] --> SA[SPIRE agent]
      SA -->|JWT-SVID and X.509-SVID| A[Research workload]
      SA --> AS[mcp-svid-auth authorization server]
      SA --> G[mcp-spiffe-bridge inside FHIR service]
      A -->|JWT-SVID assertion| AS
      AS -->|300 second resource-bound access token| A
      A -->|mTLS and access token| G
      G -->|Verified caller context| F[hipaa-fhir-mcp tools]
      A --> N[Notes MCP services]
    end
  end

The bridge is a library loaded by the FHIR gateway, not an extra independently running daemon. The gateway uses the existing FHIR tools with an explicitly synthetic, offline FHIR adapter. It does not call the public sandbox or real data. Its domain schemas, fixture, gateway and audit checker stay in the FHIR repository. The shared lab receives bounded pass/fail outcomes from that checker.

The FHIR endpoint is https://fhir.security-lab.svc.cluster.local:8443/mcp. Its X.509-SVID includes this DNS name, so clients keep normal CA and hostname checks. The access token must have exactly this audience, fhir:read, the fixed issuer, and a subject equal to the authenticated mTLS peer. Public verification keys are fetched from the fixed local issuer without forwarding incoming credentials. There is no write scope for the FHIR service.

SPIRE node attestation uses a projected ServiceAccount token with audience spire-server. Workload registration requires both Kubernetes namespace and ServiceAccount selectors. Copying a research label onto an intruder workload does not change its identity. The kubelet's certificate is verified. Neither the containerd socket nor a Podman socket is mounted into SPIRE or application pods.

Start, inspect and stop

Check out the three security repositories as siblings; mcp-spiffe-bridge is not yet published, so this step requires maintainer access. Install Lima 2.2.1+ and the root project's locked Python dependencies. The VM mounts only those three repositories, read-only, and does not share home directories or SSH credentials.

uv sync --locked
make lab-render   # review generated manifests without starting a VM
make up           # start the VM, build local images and launch the services
limactl shell security-lab sudo k3s kubectl -n security-lab get pods
make down         # remove this lab's namespaces, RBAC and workload socket
make runtime-stop # only when no sibling project needs the shared VM

For a fresh full validation, use make demo instead of make up. It starts the same stack and asserts all 18 scenarios, including the original OAuth cases, real SVID renewal, copied-label denial, three FHIR tools, wrong-audience denial, absent/disallowed client certificates, input rejection and the FHIR audit check. Existing namespaces cause a refusal; run make down before a fresh run.

The review preview contains explicit runtime placeholders for image digests and the trust bundle. Startup supplies real values; do not apply the preview manually.

The VM uses k3s v1.37.1+k3s1, which embeds Kubernetes 1.37.1 and containerd 2.3.4-k3s1. Release binaries and Debian base images are pinned by checksums. SPIRE and application base images are pinned by digest. Locally built images are imported into containerd and referred to by their resulting digest; imagePullPolicy: Never prevents a registry fallback. Buildah and Skopeo belong to CNCF Podman Container Tools and are used only for local image construction and inspection. No container engine API is used for builds, and no image is pushed.

The profile limits guest memory to 4 GiB and disables Traefik, ServiceLB, metrics-server and local-storage. Every application has CPU/memory limits, a read-only root filesystem, no Linux capabilities and no mounted Kubernetes API token. The separate Headlamp viewer uses a short-lived projected API token and read-only namespace Roles. Runtime keys live in ephemeral volumes or process memory. Automatic host port forwarding is disabled; the admin kubeconfig stays inside the VM with mode 0600. The driver always targets the VM's loopback API endpoint.

Stopping releases compute resources. It retains VM disk and image caches. Building images requires additional disk space and temporarily more CPU than an idle lab. The 12 GiB sparse guest disk does not limit total host cache usage.

Validation on 2026-10-06

A fresh rebuild with private umask 077 passed all eighteen scenarios at 14:55 UTC. A repeat with the native ZITADEL profile enabled passed eighteen at 15:02 UTC, using fresh notes-a credential and FHIR audit state. The no-certificate probe requires an explicit peer TLS alert rather than a generic transport reset. Public image staging files are readable by the non-root container user without changing private source files or runtime credential permissions.

The main profile passed eleven synthetic WebAuthn/OIDC checks and four closed Google provider setup checks with fake inputs. Five protected desktop views, actual browser logout, detached/idempotent start, shutdown on identity backend loss and recovery passed. The hosted login form reached passkey authentication without a password form. These checks do not prove a physical authenticator or real Google login. See identity evidence.

An earlier final cycle failed after macOS disk exhaustion and guest I/O errors. After the operator-approved VM restart, the complete recovery cycle from 15:25 to 15:31 UTC passed: a fresh eighteen-scenario rebuild, native startup, viewer stop/restart/idempotence, backend-loss shutdown and recovery, eleven identity checks, four closed Google setup checks and five desktop views plus mobile. The final make down removed the three owned namespaces, private identity records and all five viewer listeners while preserving CISO's namespace UID and the running shared VM. Sixty-four five-second samples including native startup observed at most 1858.1 MiB guest use, with no measurement errors. Do not stop the shared VM while a sibling project depends on it without coordination.

Earlier validation on 2026-10-05

The final fresh image build and make demo run passed all 18 scenarios. Its report started at 13:58:44 UTC. make down removed the owned namespaces, RBAC and workload socket, then make runtime-stop stopped security-lab.

Check Result
Root make check 242 tests; lint, formatting, strict types and generated configuration passed
Gateway token verifier 21 Node crypto and configuration assertions, exercised by the root test suite
Bridge 56 tests including real TLS handshakes, expired credentials, stream loss and failed startup; types and build passed
Bridge lint Passed with nine existing warnings
FHIR 10 tests including independent MCP client calls, concurrent caller isolation and stderr audit; types, formatting and build passed
FHIR log scan Zero sensitive-data findings
Integration All 18 scenarios passed with real SPIRE and Kubernetes ServiceAccount attestation
Privacy and secret scans Root tree and full-history privacy checks passed; all three Git histories and non-ignored source snapshots had zero Gitleaks findings

The security review covered all seven root invariants:

Invariant Evidence
Deny by default Gateway startup refuses undeclared or duplicate tool scopes; crypto/configuration tests and live unknown-tool rejection passed
Audience binding Signed tokens require one exact resource URI; crypto tests and cross-resource live replay denial passed
No token passthrough Existing byte-capture regression checks and the new public-key fetch capture test passed
Strict token checks ES256, at+jwt, issuer, audience, caller and lifetime checks passed; live expired assertion and strict replay denials passed
No secrets in output Fixed error strings, bounded outcomes, FHIR audit assertions, privacy checks and secret scans passed
Fail closed Existing stdio expiry/cleanup tests and bridge expiry/stream-loss tests passed; the gateway exits after identity loss
Runtime keys SPIRE state uses ephemeral memory volumes, gateway audit keys are generated in memory and TLS test keys are generated at runtime; source and history scans passed

At 13:56:17 UTC after the preceding successful run, guest used memory was 1311.3 MiB, calculated as MemTotal - MemAvailable. Container working sets totalled 618.0 MiB, including CoreDNS; the FHIR gateway accounted for 109.5 MiB. This single idle snapshot excludes macOS/Lima overhead and image build peaks. It is not a Docker comparison or a performance guarantee. The configured VM cap remains 4 GiB. Safe local results remain under ignored deploy/.data/; no image, commit, package or runtime was published externally.

Trust boundary and remaining work

This is a single-node lab, without production availability, a managed secrets store, durable audit retention or a production FHIR backend. The OAuth issuer uses explicitly local HTTP; the FHIR transport uses real mTLS. SMART-on-FHIR and KMS implementations remain out of scope.

Anyone who controls the cluster or may create workloads with an authorized ServiceAccount can assume that identity. The SPIRE agent runs as root with host PID visibility, and application pods mount the node's Workload API socket. Namespace/ServiceAccount attestation strengthens the previous label-only demo, but it does not protect against the VM or cluster operator.

The gateway rejects calls if its credential stream is lost or expired. It exits after stream loss so Kubernetes replaces the process and obtains fresh identity. Bridge instances cannot restart after stop or failed startup; create a new one.

Next steps are a SPIFFE CSI socket mount, explicit admission/RBAC restrictions on who may use each ServiceAccount, persistent independently retained audit checkpoints, and HTTPS for the issuer with verified workload credentials.

Primary references: k3s CNCF project, k3s release and embedded versions, SPIRE PSAT attestation, SPIRE Kubernetes workload attestation.