Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

56 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

CI OPA tests Python tests CISA ZTMM PRs Co-authored Last Commit License Sponsor

ZTLab β€” Zero Trust Lab

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.


πŸŽ“ What You'll Learn

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.

πŸ” How a Request Gets Authorized

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
Loading

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.

πŸ—οΈ Architecture

                          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 Specs

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

πŸš€ Quick Start

# 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 scorecard

Each 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 Map

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

🧠 Key Architecture Decisions

  • Why nginx + oauth2-proxy + authz-bridge instead of Pomerium? Pomerium Community doesn't support custom Rego policies β€” that's Enterprise-only. The nginx auth_request pattern 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.

⚠️ Lab Shortcuts (Not Production-Ready)

  • 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.sh for 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 -v loses all history. Production needs S3/GCS backend or Loki's filesystem config with a host bind mount.
  • OPA :8181 is not exposed by default β€” use docker compose -f docker-compose.yml -f docker-compose.override.yml up for direct OPA curl testing during development.
  • oauth2-proxy header names are version-dependent β€” must verify against installed version before upgrading.
  • Mock re-auth β€” auth_time comes 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.

πŸ§ͺ Testing

Python unit tests

pip install -r requirements-dev.txt
pytest -v

OPA policy tests

# 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/ -v

Lint

flake8 .

All at once

make test lint

πŸ› οΈ Makefile

Common 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

🚒 Deployment

Docker images (GHCR)

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-up

Forking? The docker-publish.yml uses ${{ github.repository_owner }} so images publish under your namespace automatically. Update the ghcr.io/dev9269/ paths above and in gateway/docker-compose.prod.yml to match your own GHCR namespace.

Auto-deploy to server

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.

GitHub Pages

Documentation is auto-deployed to GitHub Pages on every push to main. Enable it in repo Settings > Pages > Source: GitHub Actions.

🏷️ Topics & Tags

zero-trust Β· security Β· iam Β· oauth2-proxy Β· opentext-policy Β· opa Β· identity-management Β· cybersecurity Β· wireguard Β· network-security

About

CISA Zero Trust Maturity Model lab - self-hosted gateway stack with nginx + oauth2-proxy + OPA + authz-bridge

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages