Zero Trust Security Implementation
Security Architecture Overview
Current State
- ✅ Cloudflare Tunnel exposes Fedora VM at fcrepo.michaeljwright.com.au
- ⚠️ VM ports exposed (5432, 8080, 9090, 3000)
- ⚠️ No authentication on frontend applications
- ✅ Workers authenticate to Fedora with Basic Auth
Target State: Zero Trust Architecture
Internet Users
↓
[Cloudflare Access (Azure SSO)]
↓
Cloudflare Workers (submit, manage, data)
↓
[Cloudflare Tunnel - Encrypted]
↓
Azure VM (no public ports)
↓
Fedora Repository (localhost only)
Phase 1: Secure the Azure VM (Block Public Access)
1.1 Configure Firewall on Azure VM
SSH into your Azure VM and run:
# Allow only SSH from your IP (replace with your IP)
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from YOUR_IP_ADDRESS to any port 22
sudo ufw enable
# Verify rules
sudo ufw status verbose
1.2 Update docker-compose.yml - Bind to Localhost Only
Change all port mappings to bind to 127.0.0.1 (localhost only):
version: "3.8"
services:
db:
container_name: mjw-db
image: "postgres:12.3"
ports:
- "127.0.0.1:5432:5432" # Only accessible from localhost
environment:
POSTGRES_USER: fcrepo-user
POSTGRES_PASSWORD: fcrepo-pw
POSTGRES_DB: fcrepo
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
mjw-fedora:
container_name: mjw-fedora
image: "fcrepo/fcrepo:6-tomcat9"
ports:
- "127.0.0.1:8080:8080" # Only accessible from localhost
volumes:
- fcrepo_data:/data
- ./fcrepo.properties:/fcrepo.properties
- ./scripts/init-tomcat-users.sh:/docker-entrypoint.sh
entrypoint: ["/bin/bash", "/docker-entrypoint.sh"]
environment:
CATALINA_OPTS: "-Djava.awt.headless=true -server -Xms1G -Xmx2G -XX:MaxNewSize=1G -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/mem -Dfcrepo.config.file=/fcrepo.properties"
FEDORA_ADMIN_USERNAME: ${FEDORA_ADMIN_USERNAME:-fedoraAdmin}
FEDORA_ADMIN_PASSWORD: ${FEDORA_ADMIN_PASSWORD:-fedoraAdmin}
FEDORA_CURATOR_USERNAME: ${FEDORA_CURATOR_USERNAME}
FEDORA_CURATOR_PASSWORD: ${FEDORA_CURATOR_PASSWORD}
depends_on:
db:
condition: service_started
restart: unless-stopped
prometheus:
container_name: mjw-prometheus
image: prom/prometheus
ports:
- "127.0.0.1:9090:9090" # Only accessible from localhost
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
restart: unless-stopped
grafana:
container_name: mjw-grafana
image: grafana/grafana
ports:
- "127.0.0.1:3000:3000" # Only accessible from localhost
restart: unless-stopped
volumes:
postgres_data:
fcrepo_data:
1.3 Configure Azure Network Security Group (NSG)
In Azure Portal:
- Navigate to your VM → Networking → Network Security Group
- Remove all inbound rules except:
- SSH (port 22) from your IP only
- Keep default outbound rules (allow all outbound)
OR use Azure CLI:
# Get your NSG name
az network nsg list --query "[].{Name:name, RG:resourceGroup}" -o table
# Remove all inbound rules except SSH
az network nsg rule delete --resource-group YOUR_RG --nsg-name YOUR_NSG --name default-allow-http
az network nsg rule delete --resource-group YOUR_RG --nsg-name YOUR_NSG --name default-allow-https
# Add SSH rule (replace YOUR_IP)
az network nsg rule create \
--resource-group YOUR_RG \
--nsg-name YOUR_NSG \
--name AllowSSHFromMyIP \
--priority 100 \
--source-address-prefixes YOUR_IP/32 \
--destination-port-ranges 22 \
--access Allow \
--protocol Tcp
1.4 Verify Cloudflare Tunnel Configuration
Your tunnel should be routing traffic to localhost:8080 (not public IP):
# Check cloudflared config
cat ~/.cloudflared/config.yml
# Should look like:
# tunnel: YOUR_TUNNEL_ID
# credentials-file: /path/to/credentials.json
# ingress:
# - hostname: fcrepo.michaeljwright.com.au
# service: http://localhost:8080
# - service: http_status:404
Phase 2: Implement Cloudflare Access (Azure SSO)
2.1 Configure Azure AD Application
Azure Portal → Azure Active Directory → App registrations → New registration
- Name:
MJW Archive SSO - Redirect URI:
https://mjw-archive.cloudflareaccess.com/cdn-cgi/access/callback - Note the Application (client) ID
- Name:
Certificates & secrets → New client secret
- Description:
Cloudflare Access - Note the secret value (copy immediately!)
- Description:
API permissions → Add permission
- Microsoft Graph → Delegated permissions
- Select:
User.Read,email,openid,profile - Click Grant admin consent
Token configuration → Add optional claim
- ID token:
email,upn
- ID token:
2.2 Configure Cloudflare Access
In Cloudflare Dashboard:
- Zero Trust → Access → Applications → Add an application
Application 1: Frontend (data.michaeljwright.com.au)
Type: Self-hosted
Name: MJW Archive Frontend
Session Duration: 24 hours
Application domain: data.michaeljwright.com.au
Identity Provider:
Type: Azure AD
Application (client) ID: [from Azure]
Client secret: [from Azure]
Directory (tenant) ID: [your tenant ID]
Access Policy:
Policy name: Azure Users Only
Action: Allow
Include: Emails ending in @yourdomain.com
OR
Include: Everyone (if you want to allow guest access)
Application 2: Submit Portal (submit.michaeljwright.com.au)
Type: Self-hosted
Name: MJW Submit Portal
Session Duration: 24 hours
Application domain: submit.michaeljwright.com.au
Use same identity provider and policy.
Application 3: Manage Portal (manage.michaeljwright.com.au)
Type: Self-hosted
Name: MJW Manage Portal
Session Duration: 24 hours
Application domain: manage.michaeljwright.com.au
Use same identity provider and policy.
Optional: Add Role-Based Access
Create separate policies for curators vs. viewers:
Policy 1: Curators (Full Access)
Include: Emails in list: curator1@domain.com, curator2@domain.com
Policy 2: Viewers (Read-only)
Include: Everyone
Exclude: Emails in list: [curator emails]
2.3 Update Workers to Validate Access Token
Each worker should validate the Cloudflare Access JWT token:
Add to submit-ingest/src/index.js, manage/src/index.js:
// Add at the top of your fetch handler
async function validateCloudflareAccess(request) {
const token = request.headers.get('Cf-Access-Jwt-Assertion');
if (!token) {
return new Response('Unauthorized - No Access Token', { status: 401 });
}
// Validate JWT with Cloudflare's public key
const teamDomain = 'mjw-archive'; // Your Cloudflare team name
const certsUrl = `https://${teamDomain}.cloudflareaccess.com/cdn-cgi/access/certs`;
try {
const certsResponse = await fetch(certsUrl);
const { keys } = await certsResponse.json();
// Verify JWT signature (simplified - use jose library for production)
const [header, payload, signature] = token.split('.');
const decodedPayload = JSON.parse(atob(payload));
// Check expiration
if (decodedPayload.exp < Date.now() / 1000) {
return new Response('Token Expired', { status: 401 });
}
// Return user info
return {
email: decodedPayload.email,
sub: decodedPayload.sub,
};
} catch (error) {
return new Response('Invalid Access Token', { status: 401 });
}
}
// In your main fetch handler:
export default {
async fetch(request, env, ctx) {
// Validate access
const user = await validateCloudflareAccess(request);
if (user instanceof Response) {
return user; // Return 401 error
}
// Continue with normal request handling
// ... rest of your code
}
}
Phase 3: Implement Role-Based Access Control (RBAC)
3.1 Define User Roles
Store roles in Cloudflare KV or D1:
// In wrangler.toml
[[kv_namespaces]]
binding = "USER_ROLES"
id = "your-kv-namespace-id"
// In worker code:
async function getUserRole(email, env) {
const role = await env.USER_ROLES.get(email);
return role || 'viewer'; // Default to viewer
}
// Check permissions
function canEdit(role) {
return ['admin', 'curator'].includes(role);
}
function canDelete(role) {
return role === 'admin';
}
3.2 Update Manage Worker with RBAC
// In manage worker
const userRole = await getUserRole(user.email, env);
if (request.method === 'PUT' && !canEdit(userRole)) {
return new Response('Forbidden - Curators only', { status: 403 });
}
if (request.method === 'DELETE' && !canDelete(userRole)) {
return new Response('Forbidden - Admins only', { status: 403 });
}
Phase 4: Additional Security Hardening
4.1 Enable HTTPS Only
All workers should enforce HTTPS:
if (request.url.startsWith('http://')) {
return Response.redirect(request.url.replace('http://', 'https://'), 301);
}
4.2 Add Content Security Policy (CSP)
const securityHeaders = {
'Content-Security-Policy': "default-src 'self'; img-src 'self' https://fcrepo.michaeljwright.com.au; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';",
'X-Frame-Options': 'DENY',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'geolocation=(), microphone=(), camera=()',
};
4.3 Rate Limiting
Use Cloudflare Workers Rate Limiting API:
// In wrangler.toml
[[unsafe.bindings]]
name = "RATE_LIMITER"
type = "ratelimit"
namespace_id = "your-namespace"
// In worker:
const rateLimitKey = user.email;
const { success } = await env.RATE_LIMITER.limit({ key: rateLimitKey });
if (!success) {
return new Response('Too Many Requests', { status: 429 });
}
4.4 Audit Logging
Log all access and modifications:
await env.AUDIT_LOG.put(
`${Date.now()}-${crypto.randomUUID()}`,
JSON.stringify({
timestamp: new Date().toISOString(),
user: user.email,
action: request.method,
resource: request.url,
ip: request.headers.get('CF-Connecting-IP'),
})
);
Implementation Checklist
Immediate (Critical Security)
- Update docker-compose.yml to bind ports to 127.0.0.1
- Configure Azure NSG to block all inbound except SSH from your IP
- Enable UFW firewall on VM
- Restart Docker services:
sudo docker-compose down && sudo docker-compose up -d - Test Cloudflare Tunnel still works
- Verify ports are not publicly accessible
Phase 2 (Authentication)
- Create Azure AD App Registration
- Configure Cloudflare Access with Azure SSO
- Test login flow for all three domains
- Update workers to validate Cf-Access-Jwt-Assertion header
Phase 3 (Authorization)
- Create KV namespace for user roles
- Populate initial user roles
- Implement RBAC in manage worker
- Test edit/delete permissions
Phase 4 (Hardening)
- Add security headers to all workers
- Implement rate limiting
- Set up audit logging
- Configure WAF rules in Cloudflare
Testing Security
Test 1: Verify VM Ports are Blocked
# From external network (not your IP):
telnet YOUR_VM_PUBLIC_IP 8080 # Should timeout
telnet YOUR_VM_PUBLIC_IP 5432 # Should timeout
telnet YOUR_VM_PUBLIC_IP 9090 # Should timeout
Test 2: Verify Tunnel Still Works
curl https://fcrepo.michaeljwright.com.au/fcrepo/rest
# Should return 401 (authentication required)
Test 3: Verify SSO Login
- Visit https://data.michaeljwright.com.au
- Should redirect to Azure login
- After login, should access application
Test 4: Test Unauthorized Access
- Try accessing workers without Cloudflare Access token
- Should return 401 Unauthorized
Monitoring & Alerts
Set up alerts in Cloudflare Zero Trust:
- Failed authentication attempts
- Unusual access patterns
- Rate limit violations
Set up Azure Monitor:
- NSG flow logs
- VM network traffic
- Failed SSH attempts
Recovery & Backup
Emergency Access
If locked out:
- Use Azure Serial Console to access VM
- Disable UFW:
sudo ufw disable - Fix configuration
- Re-enable:
sudo ufw enable
Backup Access Credentials
- Store Azure AD client secret in password manager
- Document all Cloudflare Access policies
- Keep backup of .env file with Fedora credentials
Next Steps
- Start with Phase 1 (VM hardening) - can be done immediately
- Test thoroughly - ensure tunnel still works after each change
- Implement Phase 2 (SSO) - requires Azure AD setup
- Add RBAC - once authentication is working
- Continuous monitoring - set up dashboards and alerts
Would you like me to help implement any specific phase?