Skip to content

Instantly share code, notes, and snippets.

@rkoster
Created March 20, 2026 11:14
Show Gist options
  • Select an option

  • Save rkoster/1f34f463aa9cfde657dc0a1dbb6fb59d to your computer and use it in GitHub Desktop.

Select an option

Save rkoster/1f34f463aa9cfde657dc0a1dbb6fb59d to your computer and use it in GitHub Desktop.
Experiment: GoRouter route tags with shared routes (CF mTLS RFC)

Experiment: GoRouter Route Tags with Shared Routes

Context

Cloud Foundry's RFC for Domain-Scoped mTLS on GoRouter proposes scope-based authorization that uses GoRouter's existing route-emitter tags (organization_id, space_id) to enforce "same org/space" boundary checks at the domain level. This experiment verifies that the tags carry the correct information when routes are shared across spaces.

Question

When a route is shared from Space A to Space B (and both spaces have apps mapped to it), do the GoRouter route table tags reflect:

  • (a) The route owner's org/space (Space A for all endpoints), or
  • (b) Each destination app's org/space (Space A's tags for App A, Space B's tags for App B)?

If (b), then scope: org and scope: space checks are viable — GoRouter can compare the caller's identity against endpoint tags without needing explicit GUID lists in BOSH manifests.

Setup

  • CF deployment with admin access and BOSH director
  • Existing app cf-env in my-org/my-space
  • Script creates test-space, pushes test-app, shares the cf-env route, maps test-app as a second destination
  • GoRouter's /routes status endpoint is queried at each step via bosh ssh

Steps

  1. Capture initial state (1 endpoint in route pool)
  2. Enable route_sharing feature flag
  3. Create second space, push a minimal app
  4. Share route with second space, map app as destination
  5. Query GoRouter — observe 2 endpoints with different space_id tags
  6. Unmap original app — observe only new app's endpoint with its own tags
  7. Clean up all resources

Findings

  1. Tags are per-endpoint, set by the route-emitter for each app instance
  2. Tags reflect the destination app's org/space, not the route owner's
  3. With shared routes, a single route pool contains endpoints from multiple spaces — each carries its own space_id and organization_id
  4. After "migrating" (unmapping old app), only the new app's endpoint remains with correct tags

Implications for scope-based authorization

Scope Behavior Why it works
scope: org Caller's org GUID must match any endpoint's organization_id Route sharing is within the same org by default
scope: space Caller's space GUID must match any endpoint's space_id Callers from any space that has a mapped app are allowed
scope: any Any authenticated caller passes No tag comparison needed

Files

  • shared-route-tags.sh — The experiment script (self-cleaning)
  • shared-route-tags-output.log — Captured output from a clean run on 2026-03-20
=== 1. Capture initial state ===
API endpoint: https://api.10.246.0.21.sslip.io
API version: 3.214.0
user: admin
org: my-org
space: my-space
[INFO] App A: cf-env (guid: df40ee1a-8d33-4d39-b622-e4ae49aa7410)
[INFO] Org: my-org (guid: e41cb26f-52ad-4176-9ee0-34a1410a9050)
[INFO] Space A: my-space (guid: d78cd2bf-c8a8-4044-9f2e-0f8b352626b8)
[INFO] Route: cf-env.10.246.0.21.sslip.io
[INFO] Route GUID: 3beef65b-fdc9-4936-bff3-0fd3991085cf
=== 2. Query GoRouter route table BEFORE sharing ===
--- Before sharing ---
Route: cf-env.10.246.0.21.sslip.io
Endpoints: 1
[0] app_id=df40ee1a-8d33-4d39-b622-e4ae49aa7410 app_name=cf-env space_id=d78cd2bf-c8a8-4044-9f2e-0f8b352626b8 space_name=my-space organization_id=e41cb26f-52ad-4176-9ee0-34a1410a9050 organization_name=my-org
=== 3. Enable route sharing feature flag ===
Enabling feature flag route_sharing as admin...
OK
=== 4. Create second space and push app ===
Creating space test-space in org my-org as admin...
OK
Assigning role SpaceManager to user admin in org my-org / space test-space as admin...
OK
Assigning role SpaceDeveloper to user admin in org my-org / space test-space as admin...
OK
TIP: Use 'cf target -o "my-org" -s "test-space"' to target new space
API endpoint: https://api.10.246.0.21.sslip.io
API version: 3.214.0
user: admin
org: my-org
space: test-space
[INFO] Space B: test-space (guid: 53cddff4-8574-4e34-89b5-ce3b77916e13)
Pushing app test-app to org my-org / space test-space as admin...
Packaging files to upload...
Uploading files...
42.66 MiB / 42.66 MiB 100.00% 1s
42.66 MiB / 42.66 MiB 100.00% 1s
42.66 MiB / 42.66 MiB 100.00% 1s
42.66 MiB / 42.66 MiB 100.00% 1s
42.66 MiB / 42.66 MiB 100.00% 1s
42.66 MiB / 42.66 MiB 100.00% 1s
Waiting for API to complete processing files...
Staging app and tracing logs...
Downloading binary_buildpack...
Downloaded binary_buildpack
Cell 6cc2a5a4-92a3-41aa-87f7-5a705166ee67 creating container for instance 74094297-0b66-470f-86cd-232bd5c65bd2
Security group rules were updated
Cell 6cc2a5a4-92a3-41aa-87f7-5a705166ee67 successfully created container for instance 74094297-0b66-470f-86cd-232bd5c65bd2
Downloading app package...
Downloaded app package (345.7M)
-----> Binary Buildpack version 1.1.21
Exit status 0
Uploading droplet, build artifacts cache...
Uploading build artifacts cache...
Uploading droplet...
Uploaded build artifacts cache (218B)
Waiting for app test-app to start...
Instances starting...
Instances starting...
Instances starting...
Instances starting...
name: test-app
requested state: started
routes:
last uploaded: Fri 20 Mar 11:11:50 UTC 2026
stack: cflinuxfs4
buildpacks:
name version detect output buildpack name
binary_buildpack 1.1.21 binary binary
type: web
sidecars:
instances: 1/1
memory usage: 64M
start command: while true; do echo -e "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK" | nc -l -p 8080 -w1; done
state since cpu memory disk logging cpu entitlement details
#0 running 2026-03-20T11:12:04Z 0.0% 0B of 0B 0B of 0B 0B/s of 0B/s 0.0%
[INFO] App B: test-app (guid: 5a7fd73c-f182-437d-981b-edc3e9b9062a)
=== 5. Share route with Space B ===
{"data":[{"guid":"53cddff4-8574-4e34-89b5-ce3b77916e13"}],"links":{"self":{"href":"https://api.10.246.0.21.sslip.io/v3/routes/3beef65b-fdc9-4936-bff3-0fd3991085cf/relationships/shared_spaces"}}}
=== 6. Map shared route to App B ===
{"destinations":[{"guid":"e785d434-404d-4ec9-9360-2df652193962","app":{"guid":"df40ee1a-8d33-4d39-b622-e4ae49aa7410","process":{"type":"web"}},"weight":null,"port":8080,"protocol":"http1","created_at":"2026-03-20T11:06:08Z","updated_at":"2026-03-20T11:06:08Z"},{"guid":"ecedb45b-2d41-492c-a478-64f559a2af81","app":{"guid":"5a7fd73c-f182-437d-981b-edc3e9b9062a","process":{"type":"web"}},"weight":null,"port":8080,"protocol":"http1","created_at":"2026-03-20T11:12:04Z","updated_at":"2026-03-20T11:12:04Z"}],"links":{"self":{"href":"https://api.10.246.0.21.sslip.io/v3/routes/3beef65b-fdc9-4936-bff3-0fd3991085cf/destinations"},"route":{"href":"https://api.10.246.0.21.sslip.io/v3/routes/3beef65b-fdc9-4936-bff3-0fd3991085cf"}}}
[INFO] Waiting 5s for route-emitter to register...
=== 7. Query GoRouter route table AFTER sharing ===
--- After sharing (both apps mapped) ---
Route: cf-env.10.246.0.21.sslip.io
Endpoints: 2
[0] app_id=df40ee1a-8d33-4d39-b622-e4ae49aa7410 app_name=cf-env space_id=d78cd2bf-c8a8-4044-9f2e-0f8b352626b8 space_name=my-space organization_id=e41cb26f-52ad-4176-9ee0-34a1410a9050 organization_name=my-org
[1] app_id=5a7fd73c-f182-437d-981b-edc3e9b9062a app_name=test-app space_id=53cddff4-8574-4e34-89b5-ce3b77916e13 space_name=test-space organization_id=e41cb26f-52ad-4176-9ee0-34a1410a9050 organization_name=my-org
=== 8. Simulate migration: unmap App A from route ===
{}
[INFO] Unmapped App A from route
[INFO] Waiting 5s for route-emitter to deregister...
=== 9. Query GoRouter route table AFTER unmapping App A ===
--- After migration (only App B) ---
Route: cf-env.10.246.0.21.sslip.io
Endpoints: 1
[0] app_id=5a7fd73c-f182-437d-981b-edc3e9b9062a app_name=test-app space_id=53cddff4-8574-4e34-89b5-ce3b77916e13 space_name=test-space organization_id=e41cb26f-52ad-4176-9ee0-34a1410a9050 organization_name=my-org
=== 10. Cleanup ===
{"destinations":[{"guid":"4821309f-90cc-446a-a363-efdf345fc429","app":{"guid":"df40ee1a-8d33-4d39-b622-e4ae49aa7410","process":{"type":"web"}},"weight":null,"port":8080,"protocol":"http1","created_at":"2026-03-20T11:12:16Z","updated_at":"2026-03-20T11:12:16Z"},{"guid":"ecedb45b-2d41-492c-a478-64f559a2af81","app":{"guid":"5a7fd73c-f182-437d-981b-edc3e9b9062a","process":{"type":"web"}},"weight":null,"port":8080,"protocol":"http1","created_at":"2026-03-20T11:12:04Z","updated_at":"2026-03-20T11:12:04Z"}],"links":{"self":{"href":"https://api.10.246.0.21.sslip.io/v3/routes/3beef65b-fdc9-4936-bff3-0fd3991085cf/destinations"},"route":{"href":"https://api.10.246.0.21.sslip.io/v3/routes/3beef65b-fdc9-4936-bff3-0fd3991085cf"}}}
{}
{}
API endpoint: https://api.10.246.0.21.sslip.io
API version: 3.214.0
user: admin
org: my-org
space: test-space
Deleting app test-app in org my-org / space test-space as admin...
OK
API endpoint: https://api.10.246.0.21.sslip.io
API version: 3.214.0
user: admin
org: my-org
space: my-space
Deleting space test-space in org my-org as admin...
OK
[INFO] Cleanup complete
=== Summary ===
Findings:
1. Route tags are PER-ENDPOINT, set by the route-emitter
2. Tags reflect the DESTINATION APP's org/space, not the route owner's
3. When a route is shared and mapped to an app in another space:
- Both endpoints appear in the same GoRouter pool
- Each endpoint carries its own space_id/organization_id
4. After "migrating" (unmapping old app, keeping new app):
- Only the new app's endpoint remains
- Tags correctly reflect the new app's space
Implication for scope-based authorization:
- scope: org → Compare caller cert org GUID against endpoint tags organization_id
Works because all endpoints in a shared route are in the same org
(route sharing requires same org unless cross-org feature enabled)
- scope: space → Compare caller cert space GUID against ANY endpoint's space_id
Naturally handles shared routes: callers from any space that has
a mapped app are allowed
- scope: any → Any authenticated caller passes domain-level check
#!/bin/bash
# experiments/shared-route-tags.sh
#
# Experiment: How do GoRouter route tags behave with shared routes?
#
# Context: Beyhan proposed simplifying mTLS authorization by using the route
# destination's tags (organization_id, space_id) to enforce "same org/space"
# checks instead of explicit GUID lists in BOSH manifests. This experiment
# verifies that the tags carry the right information for shared routes.
#
# Prerequisites:
# - CF environment with admin access
# - BOSH director configured (source bosh.env)
# - GoRouter status endpoint credentials (from gorouter.yml)
#
# Run: ./experiments/shared-route-tags.sh
#
# Date: 2026-03-20
# Result: Tags reflect the destination APP's org/space, not the route owner's.
# Each endpoint carries its own org_id/space_id. Shared routes produce
# endpoints with different space_ids in the same pool. This makes
# "scope: org" and "scope: space" checks viable.
set -euo pipefail
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
NC='\033[0m'
step() { echo -e "\n${CYAN}=== $1 ===${NC}"; }
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
# --------------------------------------------------------------------------
# Configuration - adjust for your environment
# --------------------------------------------------------------------------
ORG="my-org"
SPACE_A="my-space"
SPACE_B="test-space"
APP_A="cf-env" # existing app in SPACE_A
APP_B="test-app" # will be created in SPACE_B
DOMAIN="10.246.0.21.sslip.io"
# GoRouter status endpoint (from /var/vcap/jobs/gorouter/config/gorouter.yml)
ROUTER_STATUS_USER="router-status"
ROUTER_STATUS_PASS="<ROUTER_STATUS_PASSWORD>"
ROUTER_STATUS_PORT="8082"
# --------------------------------------------------------------------------
# Setup
# --------------------------------------------------------------------------
step "1. Capture initial state"
cf target -o "$ORG" -s "$SPACE_A"
APP_A_GUID=$(cf app "$APP_A" --guid)
ORG_GUID=$(cf org "$ORG" --guid)
SPACE_A_GUID=$(cf space "$SPACE_A" --guid)
ROUTE_HOST="$APP_A"
ROUTE_URL="${ROUTE_HOST}.${DOMAIN}"
info "App A: $APP_A (guid: $APP_A_GUID)"
info "Org: $ORG (guid: $ORG_GUID)"
info "Space A: $SPACE_A (guid: $SPACE_A_GUID)"
info "Route: $ROUTE_URL"
# Find route GUID
ROUTE_GUID=$(cf curl "/v3/routes?hosts=${ROUTE_HOST}" | python3 -c "
import sys, json
data = json.loads(sys.stdin.read())
print(data['resources'][0]['guid'])
")
info "Route GUID: $ROUTE_GUID"
step "2. Query GoRouter route table BEFORE sharing"
query_router() {
local label="$1"
source "$(dirname "$0")/../bosh.env"
bosh -d cf ssh router/0 -c \
"curl -s http://${ROUTER_STATUS_USER}:${ROUTER_STATUS_PASS}@localhost:${ROUTER_STATUS_PORT}/routes" \
2>/dev/null | python3 -c "
import sys, json
raw = sys.stdin.read()
for line in raw.split('\n'):
line = line.strip()
if 'stdout |' in line:
line = line.split('stdout |', 1)[1].strip()
if line.startswith('{'):
data = json.loads(line)
for route_key, endpoints in data.items():
if '${ROUTE_URL}' == route_key:
print('--- ${label} ---')
print(f'Route: {route_key}')
print(f'Endpoints: {len(endpoints)}')
for i, ep in enumerate(endpoints):
tags = ep.get('tags', {}) or {}
print(f' [{i}] app_id={tags.get(\"app_id\",\"N/A\")} '
f'app_name={tags.get(\"app_name\",\"N/A\")} '
f'space_id={tags.get(\"space_id\",\"N/A\")} '
f'space_name={tags.get(\"space_name\",\"N/A\")} '
f'organization_id={tags.get(\"organization_id\",\"N/A\")} '
f'organization_name={tags.get(\"organization_name\",\"N/A\")}')
break
"
}
query_router "Before sharing"
# --------------------------------------------------------------------------
# Enable route sharing and create second space
# --------------------------------------------------------------------------
step "3. Enable route sharing feature flag"
cf enable-feature-flag route_sharing
step "4. Create second space and push app"
cf create-space "$SPACE_B" -o "$ORG" 2>/dev/null || true
cf target -s "$SPACE_B"
SPACE_B_GUID=$(cf space "$SPACE_B" --guid)
info "Space B: $SPACE_B (guid: $SPACE_B_GUID)"
# Push a minimal app
cf push "$APP_B" --no-route -b binary_buildpack -m 64M \
-c 'while true; do echo -e "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK" | nc -l -p 8080 -w1; done'
APP_B_GUID=$(cf app "$APP_B" --guid)
info "App B: $APP_B (guid: $APP_B_GUID)"
# --------------------------------------------------------------------------
# Share route and map destination
# --------------------------------------------------------------------------
step "5. Share route with Space B"
cf curl -X POST "/v3/routes/${ROUTE_GUID}/relationships/shared_spaces" \
-d "{\"data\": [{\"guid\": \"${SPACE_B_GUID}\"}]}"
step "6. Map shared route to App B"
cf curl -X POST "/v3/routes/${ROUTE_GUID}/destinations" \
-d "{\"destinations\": [{\"app\": {\"guid\": \"${APP_B_GUID}\"}}]}"
info "Waiting 5s for route-emitter to register..."
sleep 5
step "7. Query GoRouter route table AFTER sharing"
query_router "After sharing (both apps mapped)"
# --------------------------------------------------------------------------
# Simulate app migration: remove App A from route
# --------------------------------------------------------------------------
step "8. Simulate migration: unmap App A from route"
# Get App A's destination GUID
DEST_A_GUID=$(cf curl "/v3/routes/${ROUTE_GUID}/destinations" | python3 -c "
import sys, json
data = json.loads(sys.stdin.read())
for d in data['destinations']:
if d['app']['guid'] == '${APP_A_GUID}':
print(d['guid'])
break
")
if [ -n "$DEST_A_GUID" ]; then
cf curl -X DELETE "/v3/routes/${ROUTE_GUID}/destinations/${DEST_A_GUID}"
info "Unmapped App A from route"
info "Waiting 5s for route-emitter to deregister..."
sleep 5
fi
step "9. Query GoRouter route table AFTER unmapping App A"
query_router "After migration (only App B)"
# --------------------------------------------------------------------------
# Cleanup
# --------------------------------------------------------------------------
step "10. Cleanup"
# Re-map App A (restore original state)
cf curl -X POST "/v3/routes/${ROUTE_GUID}/destinations" \
-d "{\"destinations\": [{\"app\": {\"guid\": \"${APP_A_GUID}\"}}]}"
# Remove App B destination
DEST_B_GUID=$(cf curl "/v3/routes/${ROUTE_GUID}/destinations" | python3 -c "
import sys, json
data = json.loads(sys.stdin.read())
for d in data['destinations']:
if d['app']['guid'] == '${APP_B_GUID}':
print(d['guid'])
break
")
if [ -n "$DEST_B_GUID" ]; then
cf curl -X DELETE "/v3/routes/${ROUTE_GUID}/destinations/${DEST_B_GUID}"
fi
# Unshare route
cf curl -X DELETE "/v3/routes/${ROUTE_GUID}/relationships/shared_spaces/${SPACE_B_GUID}"
# Delete test app and space
cf target -s "$SPACE_B"
cf delete "$APP_B" -f -r
cf target -s "$SPACE_A"
cf delete-space "$SPACE_B" -f
info "Cleanup complete"
# --------------------------------------------------------------------------
# Summary
# --------------------------------------------------------------------------
step "Summary"
cat <<'EOF'
Findings:
1. Route tags are PER-ENDPOINT, set by the route-emitter
2. Tags reflect the DESTINATION APP's org/space, not the route owner's
3. When a route is shared and mapped to an app in another space:
- Both endpoints appear in the same GoRouter pool
- Each endpoint carries its own space_id/organization_id
4. After "migrating" (unmapping old app, keeping new app):
- Only the new app's endpoint remains
- Tags correctly reflect the new app's space
Implication for scope-based authorization:
- scope: org → Compare caller cert org GUID against endpoint tags organization_id
Works because all endpoints in a shared route are in the same org
(route sharing requires same org unless cross-org feature enabled)
- scope: space → Compare caller cert space GUID against ANY endpoint's space_id
Naturally handles shared routes: callers from any space that has
a mapped app are allowed
- scope: any → Any authenticated caller passes domain-level check
EOF
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment