Michael J Wright Archive Documentation

Zero Trust Security Implementation

Security Architecture Overview

Current State

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:

  1. Navigate to your VM → Networking → Network Security Group
  2. Remove all inbound rules except:
    • SSH (port 22) from your IP only
  3. 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

  1. Azure PortalAzure Active DirectoryApp registrationsNew registration

    • Name: MJW Archive SSO
    • Redirect URI: https://mjw-archive.cloudflareaccess.com/cdn-cgi/access/callback
    • Note the Application (client) ID
  2. Certificates & secretsNew client secret

    • Description: Cloudflare Access
    • Note the secret value (copy immediately!)
  3. API permissionsAdd permission

    • Microsoft Graph → Delegated permissions
    • Select: User.Read, email, openid, profile
    • Click Grant admin consent
  4. Token configurationAdd optional claim

    • ID token: email, upn

2.2 Configure Cloudflare Access

In Cloudflare Dashboard:

  1. Zero TrustAccessApplicationsAdd 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)

Phase 2 (Authentication)

Phase 3 (Authorization)

Phase 4 (Hardening)


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

Test 4: Test Unauthorized Access


Monitoring & Alerts

Set up alerts in Cloudflare Zero Trust:

Set up Azure Monitor:


Recovery & Backup

Emergency Access

If locked out:

  1. Use Azure Serial Console to access VM
  2. Disable UFW: sudo ufw disable
  3. Fix configuration
  4. Re-enable: sudo ufw enable

Backup Access Credentials


Next Steps

  1. Start with Phase 1 (VM hardening) - can be done immediately
  2. Test thoroughly - ensure tunnel still works after each change
  3. Implement Phase 2 (SSO) - requires Azure AD setup
  4. Add RBAC - once authentication is working
  5. Continuous monitoring - set up dashboards and alerts

Would you like me to help implement any specific phase?