A self-hosted, from-scratch zero-trust access control system running on 4 KVM/libvirt Debian 12 VMs. Built as a learning/portfolio project against the CISA Zero Trust Maturity Model v2.0 β complete with an attack-simulation phase and a maturity scorecard.
Architecture correction: Uses nginx + oauth2-proxy + authz-bridge + OPA (not Pomerium β Pomerium Community doesn't support custom Rego policies, that's Enterprise-only). This is a fully open-source, properly separated PEP/PDP stack.
This lab is structured as 9 progressive checkpoints (Phase 0 β 8). Each phase ships with a detailed checkpoint document containing pre-reqs, failure modes, rollback steps, and a definition-of-done checklist. Complete one phase before starting the next.
| Phase | Topic | You'll Build |
|---|---|---|
| 0 | Lab environment | Isolated trusted/untrusted KVM networks |
| 1 | Identity | Authentik OIDC IdP with WebAuthn MFA |
| 2 | Device posture | osquery-based posture checking + HMAC signing |
| 3 | Network segmentation | WireGuard tunnel + nftables egress rules |
| 4 | PEP + PDP | nginx auth_request + oauth2-proxy + OPA Rego policies |
| 5 | Protected app | Flask demo app (public vs. sensitive routes) |
| 6 | Visibility | Centralized logging + posture-based revocation |
| 7 | Attack simulation | 4 bypass attempts from a hostile VM |
| 8 | Maturity self-assessment | CISA ZTMM 2.0 scorecard |
By the end you will have implemented β not just read about β the three pillars of zero trust: identity, device posture, and continuous authorization.
sequenceDiagram
autonumber
actor U as User (with MFA)
participant N as nginx :443 (PEP transport)
participant AB as authz-bridge (PEP logic)
participant OP as oauth2-proxy (AuthN)
participant IDP as Authentik IdP (MFA)
participant OPA as OPA (PDP Β· Rego)
participant APP as Flask App
U->>N: HTTPS request to /sensitive
N->>AB: auth_request (subrequest)
AB->>OP: Validate session cookie
OP->>IDP: OIDC session / re-auth
IDP-->>OP: Authenticated + MFA verified
OP-->>AB: identity claims (auth_time)
AB->>AB: Fetch device posture
AB->>OPA: input {identity, posture, resource, auth_time}
OPA-->>AB: allow / deny (Rego decision)
alt allow
AB-->>N: 200 OK + X-ZT headers
N->>APP: Proxy to Flask /sensitive
APP-->>U: Protected content
else deny
AB-->>N: 403 Forbidden
N-->>U: Access denied
end
Every access decision combines three signals: who you are (OIDC + MFA), what your device says (signed posture), and how recently you authenticated (auth_time step-up policy) β evaluated by OPA on every request, not just at login.
untrusted-net (10.10.2.0/24)
β
βββββββββ
ββββββ΄βββββ
β attacker β
β 10.10.2.10β
ββββββ¬βββββ
β
gateway:443 (only ingress)
β
βββββββββββ΄βββββββββββββββββββ
β gateway VM β
β 10.10.1.1 β
β β
β nginx (PEP transport) β
β βββ auth_request βββββββΊβ
β authz-bridge (PEP logic) β
β ββββΊ oauth2-proxy (authn)β
β ββββΊ OPA (PDP, authz) β
β ββββΊ posture store β
β WireGuard + nftables β
βββββββββββ¬βββββββββββββββββββ
β wg0 tunnel
βββββββββββ΄βββββββββββ
β β
ββββββββ΄βββββββ ββββββββ΄βββββββ
β idp VM β β app VM β
β 10.10.1.10 β β 10.10.1.20 β
β Authentik β β Flask app β
β (OIDC IdP) β β /public β
βββββββββββββββ β /sensitive β
βββββββββββββββ
The ASCII diagram above shows the logical topology. For a detailed breakdown of each component, see the phase checkpoints and the gateway service definitions.
| VM | IP | Role | RAM | Disk |
|---|---|---|---|---|
| gateway | 10.10.1.1 | PEP/PDP, WireGuard, nftables, logs | 2 GB | 20 GB |
| idp | 10.10.1.10 | Authentik (OIDC identity provider) | 2 GB | 20 GB |
| app | 10.10.1.20 | Protected Flask demo app | 2 GB | 20 GB |
| attacker | 10.10.2.10 | Untrusted client for attack tests | 2 GB | 20 GB |
# 1. Define networks
sudo virsh net-define networks/trusted-net.xml && sudo virsh net-start trusted-net
sudo virsh net-define networks/untrusted-net.xml && sudo virsh net-start untrusted-net
# 2. Create VMs (edit scripts/create-vms.sh for your ISO path)
sudo bash scripts/create-vms.sh
# 3. Install Debian 12 minimal on each VM, configure networking
# 4. Follow phases in order:
# Phase 1: idp VM β Authentik docker-compose, OIDC, WebAuthn
# Phase 2: client VMs β osquery + posture_check.py
# Phase 3: gateway + app β WireGuard + nftables
# Phase 4: gateway β docker compose up (nginx + oauth2-proxy + authz-bridge + OPA)
# Phase 5: included in docker-compose (Flask demo app)
# Phase 6: logs + revocation script
# Phase 7: attacker VM β run 4 bypass tests
# Phase 8: maturity scorecardEach phase has a detailed checkpoint document with pre-reqs, failure modes, rollback, and definition-of-done checklist. Do not proceed to the next phase until the current one's checklist is complete.
| Phase | Topic | Key File(s) |
|---|---|---|
| 0 | Lab environment | networks/trusted-net.xml, gateway/nftables.conf |
| 1 | Identity (Authentik) | idp/docker-compose.yml |
| 2 | Device posture | scripts/posture_check.py, scripts/ztlab-posture.{service,timer} |
| 3 | Network segmentation (WireGuard) | gateway/wg0.conf, scripts/wg-add-peer.sh |
| 4 | PEP+PDP (nginx+oauth2-proxy+authz-bridge+OPA) | gateway/docker-compose.yml, gateway/opa/policy.rego, gateway/authz-bridge/app.py |
| 5 | Demo app (Flask) | app/app.py, app/Dockerfile |
| 6 | Visibility + automation | scripts/posture_revoker.py |
| 7 | Attack simulation | phase7-attack-simulation.md |
| 8 | Maturity self-assessment | phase8-maturity-scorecard.md |
- Why nginx + oauth2-proxy + authz-bridge instead of Pomerium? Pomerium Community doesn't support custom Rego policies β that's Enterprise-only. The nginx
auth_requestpattern with a separate authz-bridge service gives us a real, decoupled PDP/PEP separation using only free software. - Why a shared JSON file for posture data? Lab simplicity. The pattern (PEP reads posture at request time and includes it in OPA input) is architecture-identical to a production Redis-backed approach.
- Why Flask instead of FastAPI? Simpler for a two-route demo with inline templates. The app is intentionally dumb β it has no auth logic, trusting the upstream PEP.
- Posture signing uses a shared symmetric key β the HMAC secret is the same for all devices. A per-device key pair (asymmetric) would be production-grade. The current fix closes the "anyone can write posture.json" gap but a key compromise breaks all devices.
- Posture store is still a JSON file β not a database. No query isolation, no atomic writes, no access audit. The HMAC signing prevents forgery but doesn't fix structural weaknesses.
- No mTLS between services β internal service auth is done by network segmentation, not by mutual TLS. A process that reaches the compose network can talk to any service without authentication. See
scripts/setup-mtls.shfor a self-managed local CA approach. - Self-signed TLS certs β OK for lab, do NOT use in production. Replace with Let's Encrypt or an internal CA with automated renewal.
- No persistent log storage β Loki data lives in a Docker volume;
docker compose down -vloses all history. Production needs S3/GCS backend or Loki'sfilesystemconfig with a host bind mount. - OPA :8181 is not exposed by default β use
docker compose -f docker-compose.yml -f docker-compose.override.yml upfor direct OPA curl testing during development. - oauth2-proxy header names are version-dependent β must verify against installed version before upgrading.
- Mock re-auth β
auth_timecomes from the OIDC claim, not from an actual WebAuthn re-prompt. The step-up check enforces policy but the UX flow is simulated. - No TPM 2.0 hardware attestation β device posture is purely software-reported via osquery. A compromised client VM can report healthy posture even when compromised. Production would verify measured boot and disk encryption via TPM 2.0 PCR quotes (see phase8-maturity-scorecard.md).
- No SPIFFE/SPIRE workload identity β services authenticate each other by network presence, not cryptographic identity. Production would run a SPIRE server with per-service agent attestors and short-lived X.509 SVIDs.
- No rate limiting or input validation on Flask β the demo app trusts the upstream PEP, but a misconfigured nginx could allow direct requests. Production would add rate limiting at the nginx layer and input validation on every route.
pip install -r requirements-dev.txt
pytest -v# Install OPA (macOS)
curl -L -o /usr/local/bin/opa https://openpolicyagent.org/downloads/latest/opa_darwin_amd64
chmod +x /usr/local/bin/opa
# Run tests
opa test gateway/opa/ -vflake8 .make test lintCommon tasks are available via make:
| Command | Description |
|---|---|
make test |
Run Python + OPA tests |
make lint |
Run flake8 |
make build |
Build all Docker images |
make up |
Start gateway stack (detached) |
make down |
Stop gateway stack |
make logs |
Tail gateway stack logs |
make clean |
Remove caches |
Pre-built images are published to GitHub Container Registry on each release:
# Pull images
docker pull ghcr.io/dev9269/ztlab-demo-app:latest
docker pull ghcr.io/dev9269/ztlab-authz-bridge:latest
docker pull ghcr.io/dev9269/ztlab-mock-oidc:latest
# Or use the production override
make prod-upForking? The docker-publish.yml uses
${{ github.repository_owner }}so images publish under your namespace automatically. Update theghcr.io/dev9269/paths above and ingateway/docker-compose.prod.ymlto match your own GHCR namespace.
The deploy.yml workflow SSHs into your server on every push to main. Set these repository secrets:
| Secret | Description |
|---|---|
DEPLOY_HOST |
Server IP or hostname |
DEPLOY_USER |
SSH username |
DEPLOY_SSH_KEY |
Private SSH key (ed25519 recommended) |
The workflow clones/pulls the repo to /opt/ztlab and runs docker compose up -d.
Documentation is auto-deployed to GitHub Pages on every push to main. Enable it in repo Settings > Pages > Source: GitHub Actions.
zero-trust Β· security Β· iam Β· oauth2-proxy Β· opentext-policy Β· opa Β· identity-management Β· cybersecurity Β· wireguard Β· network-security