Repository: linkerd/linkerd2
Pull request #11940 (2024-01-15 16:27:33)

Index ExternalWorkload resources in the policy controller
ExternalWorkload resources represent as a resource configuration associated with a process (or a group of processes) that are foreign to a Kubernetes cluster. It allows Linkerd to read / write and store configuration for mesh expansion. Since VMs will be able to receive inbound traffic from a variety of resources, the proxy should be able to dynamically discover inbound authorisation policies.

This change introduces a set of callbacks in the indexer that will apply (or delete) ExternalWorkload resources. In addition, we ensure that ExternalWorkloads can be processed in a similar fashion to pods (where applicable, of course) wrt to server matching and defaulting. To serve discovery requests for a VM, the policy controller will now also start a watcher for external workloads and allow requests to reference an `external_workload` target

A quick list of changes:

* ExternalWorkloads can now be indexed in the inbound (policy) index.
* Renamed the pod module in the inbound index to be more generic ("workload"); the module has some re-usable building blocks that we can use for external workloads.
* Moved common functions (e.g. building a default inbound server) around to share what's already been done without abstracting more or introducing generics.
* Changed gRPC target types to a tuple of `(Workload, port)` from a tuple of `(String, String, port)`
* Added RBAC to watch external workloads.
---

### Note to reviewers

I intended only to do the index but the gRPC part wasn't that complicated. I chose to do both since the gRPC integration is important for functional testing.

To do some manual QA, I'd recommend these steps:

1. Install linkerd (most recent edge would be best); ensure there's support for the new crd.
2. Create some authentication resources
3. Run the policy controller and send discovery requests with a client such as `grpcurl`

```
HOSTNAME="foo" cargo run --features rustls-tls -- --admission-controller-disabled --server-tls-key "" --server-tls-certs "" --default-opaque-ports ""
```

<details>

<summary> Here's what I did: </summary>


* Manifests

```yaml
 apiVersion: workload.linkerd.io/v1alpha1
 kind: ExternalWorkload
 metadata:
  name: test-policy
  labels:
    app: nginx
 spec:
  meshTls:
    serverName: "foo"
    identity: "foo"
  workloadIPs:
  - ip: "192.0.2.0"
  ports:
  - port: 80
---
apiVersion: policy.linkerd.io/v1beta2
kind: Server
metadata:
  namespace: default
  name: external-nginx-server
spec:
  externalWorkloadSelector:
    matchLabels:
      app: nginx
  port: 80
  proxyProtocol: HTTP/1
---
apiVersion: policy.linkerd.io/v1alpha1
kind: AuthorizationPolicy
metadata:
  name: workload-authn
  namespace: default
spec:
  targetRef:
    group: policy.linkerd.io
    kind: Server
    name: external-nginx-server
  requiredAuthenticationRefs:
    - name: external-workload-meshtls
      kind: MeshTLSAuthentication
      group: policy.linkerd.io
---
apiVersion: policy.linkerd.io/v1alpha1
kind: MeshTLSAuthentication
metadata:
  name: external-workload-meshtls
  namespace: default
spec:
  identities:
    - "*.non-default.serviceaccount.identity.linkerd.cluster.local"
```

* grpcurl to do discovery

1. Test authn policies are discovered and bound to our declared server:

```
# I'm in the proxy-api /proto directory here
grpcurl --plaintext --proto inbound.proto -d '{"workload": "{ \"external_workload\": \"test-policy\", \"ns\": \"default\"}", "port": 80}'  localhost:8090 io.linkerd.proxy.inbound.InboundServerPolicies.GetPort
```

```
{
  "protocol": {
    "http1": {
      "routes": [
        {
          "metadata": {
            "default": "default"
          },
          "rules": [
            {
              "matches": [
                {
                  "path": {
                    "prefix": "/"
                  }
                }
              ]
            }
          ]
        }
      ]
    }
  },
  "authorizations": [
    {
      "networks": [
        {
          "net": {
            "ip": {
              "ipv4": 0
            }
          }
        },
        {
          "net": {
            "ip": {
              "ipv6": {

              }
            }
          }
        }
      ],
      "authentication": {
        "meshTLS": {
          "identities": {
            "suffixes": [
              {
                "parts": [
                  "non-default",
                  "serviceaccount",
                  "identity",
                  "linkerd",
                  "cluster",
                  "local"
                ]
              }
            ]
          }
        }
      },
      "labels": {
        "group": "policy.linkerd.io",
        "kind": "authorizationpolicy",
        "name": "workload-authn"
      },
      "metadata": {
        "resource": {
          "group": "policy.linkerd.io",
          "kind": "authorizationpolicy",
          "name": "workload-authn"
        }
      }
    }
  ],
  "labels": {
    "group": "policy.linkerd.io",
    "kind": "server",
    "name": "external-nginx-server"
  }
}
```

2. Test that a non-documented port gets a default policy

```
:; grpcurl --plaintext --proto inbound.proto -d '{"workload": "{ \"external_workload\": \"test-policy\", \"ns\": \"default\"}", "port": 8080}'  localhost:8090 io.linkerd.proxy.inbound.InboundServerPolicies.GetPort
```

```
{
  "protocol": {
    "detect": {
      "timeout": "10s",
      "httpRoutes": [
        {
          "metadata": {
            "default": "default"
          },
          "rules": [
            {
              "matches": [
                {
                  "path": {
                    "prefix": "/"
                  }
                }
              ]
            }
          ]
        }
      ]
    }
  },
  "authorizations": [
    {
      "networks": [
        {
          "net": {
            "ip": {
              "ipv4": 0
            }
          }
        },
        {
          "net": {
            "ip": {
              "ipv6": {

              }
            }
          }
        }
      ],
      "authentication": {
        "unauthenticated": {

        }
      },
      "labels": {
        "group": "",
        "kind": "default",
        "name": "all-unauthenticated"
      },
      "metadata": {
        "default": "all-unauthenticated"
      }
    }
  ],
  "labels": {
    "group": "",
    "kind": "default",
    "name": "all-unauthenticated"
  }
}
```

4. Check that an annotation set on the workload changes default policy (I've set mine to deny through `config.linkerd.io/default-inbound-policy="deny"`).

```
:; grpcurl --plaintext --proto inbound.proto -d '{"workload": "{ \"external_workload\": \"test-policy\", \"ns\": \"default\"}", "port": 8080}'  localhost:8090 io.linkerd.proxy.inbound.InboundServerPolicies.GetPort
```

```
{
  "protocol": {
    "detect": {
      "timeout": "10s",
      "httpRoutes": [
        {
          "metadata": {
            "default": "default"
          },
          "rules": [
            {
              "matches": [
                {
                  "path": {
                    "prefix": "/"
                  }
                }
              ]
            }
          ]
        }
      ]
    }
  },
  "labels": {
    "group": "",
    "kind": "default",
    "name": "deny"
  }
}
```

5. Test a route:

```yaml
apiVersion: policy.linkerd.io/v1beta3
kind: HTTPRoute
metadata:
  name: server-route
spec:
  parentRefs:
    - name: external-nginx-server
      kind: Server
      group: policy.linkerd.io
  rules:
  - matches:
    - path:
        value: "/test-this"
      method: GET
```

```
:; grpcurl --plaintext --proto inbound.proto -d '{"workload": "{ \"external_workload\": \"test-policy\", \"ns\": \"default\"}", "port": 80}'  localhost:8090 io.linkerd.proxy.inbound.InboundServerPolicies.GetPort
```

```
{
  "protocol": {
    "http1": {
      "routes": [
        {
          "metadata": {
            "resource": {
              "group": "policy.linkerd.io",
              "kind": "HTTPRoute",
              "name": "server-route"
            }
          },
          "rules": [
            {
              "matches": [
                {
                  "path": {
                    "prefix": "/test-this"
                  },
                  "method": {
                    "registered": "GET"
                  }
                }
              ]
            }
          ]
        }
      ]
    }
  },
```

6. And finally, delete the server  and observe default route for documented port (80):

```
:; grpcurl --plaintext --proto inbound.proto -d '{"workload": "{ \"external_workload\": \"test-policy\", \"ns\": \"default\"}", "port": 80}'  localhost:8090 io.linkerd.proxy.inbound.InboundServerPolicies.GetPort
```


```
{
  "protocol": {
    "detect": {
      "timeout": "10s",
      "httpRoutes": [
        {
          "metadata": {
            "default": "default"
          },
          "rules": [
            {
              "matches": [
                {
                  "path": {
                    "prefix": "/"
                  }
                }
              ]
            }
          ]
        }
      ]
    }
  },
  "labels": {
    "group": "",
    "kind": "default",
    "name": "deny"
  }
}

```

</details>



Patch:
diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index 41bf3aa42..2b4bc30c4 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -148,7 +148,7 @@ jobs:
     timeout-minutes: 5
     steps:
       - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
-      - uses: tj-actions/changed-files@716b1e13042866565e00e85fd4ec490e186c4a2f
+      - uses: tj-actions/changed-files@62f4729b5df35e6e0e01265fa70a82ccaf196b4b
         id: changed
         with:
           files: |
@@ -287,7 +287,7 @@ jobs:
     timeout-minutes: 5
     steps:
       - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
-      - uses: tj-actions/changed-files@716b1e13042866565e00e85fd4ec490e186c4a2f
+      - uses: tj-actions/changed-files@62f4729b5df35e6e0e01265fa70a82ccaf196b4b
         id: changed
         with:
           files: |
@@ -330,7 +330,7 @@ jobs:
     timeout-minutes: 5
     steps:
       - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
-      - uses: tj-actions/changed-files@716b1e13042866565e00e85fd4ec490e186c4a2f
+      - uses: tj-actions/changed-files@62f4729b5df35e6e0e01265fa70a82ccaf196b4b
         id: changed
         with:
           files: |
diff --git a/Cargo.lock b/Cargo.lock
index ec31c5562..bc4f23a43 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1297,6 +1297,7 @@ dependencies = [
  "k8s-openapi",
  "kube",
  "linkerd-policy-controller-core",
+ "linkerd-policy-controller-grpc",
  "linkerd-policy-controller-k8s-api",
  "linkerd2-proxy-api",
  "maplit",
diff --git a/charts/linkerd-control-plane/templates/destination-rbac.yaml b/charts/linkerd-control-plane/templates/destination-rbac.yaml
index 7fa47eb30..17f88589c 100644
--- a/charts/linkerd-control-plane/templates/destination-rbac.yaml
+++ b/charts/linkerd-control-plane/templates/destination-rbac.yaml
@@ -243,6 +243,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
diff --git a/go.mod b/go.mod
index 022eda022..323ced862 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
 	github.com/briandowns/spinner v0.0.0-20190212173954-5cf08d0ac778
 	github.com/clarketm/json v1.15.7
 	github.com/emicklei/proto v1.13.0
-	github.com/evanphx/json-patch v5.7.0+incompatible
+	github.com/evanphx/json-patch v5.8.0+incompatible
 	github.com/fatih/color v1.16.0
 	github.com/fsnotify/fsnotify v1.7.0
 	github.com/go-openapi/spec v0.20.14
@@ -18,14 +18,14 @@ require (
 	github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0
 	github.com/imdario/mergo v0.3.16
 	github.com/julienschmidt/httprouter v1.3.0
-	github.com/linkerd/linkerd2-proxy-api v0.12.0
+	github.com/linkerd/linkerd2-proxy-api v0.13.0
 	github.com/mattn/go-isatty v0.0.20
 	github.com/mattn/go-runewidth v0.0.15
 	github.com/nsf/termbox-go v1.1.1
 	github.com/patrickmn/go-cache v2.1.0+incompatible
 	github.com/pkg/browser v0.0.0-20170505125900-c90ca0c84f15
 	github.com/prometheus/client_golang v1.18.0
-	github.com/prometheus/common v0.45.0
+	github.com/prometheus/common v0.46.0
 	github.com/sergi/go-diff v1.3.1
 	github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect
 	github.com/shurcooL/vfsgen v0.0.0-20181202132449-6a9ea43bcacd
@@ -108,7 +108,6 @@ require (
 	github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
 	github.com/mailru/easyjson v0.7.7 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
-	github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
 	github.com/mitchellh/copystructure v1.2.0 // indirect
 	github.com/mitchellh/reflectwalk v1.0.2 // indirect
 	github.com/moby/locker v1.0.1 // indirect
@@ -142,7 +141,7 @@ require (
 	go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect
 	golang.org/x/crypto v0.18.0 // indirect
 	golang.org/x/mod v0.14.0 // indirect
-	golang.org/x/oauth2 v0.13.0 // indirect
+	golang.org/x/oauth2 v0.16.0 // indirect
 	golang.org/x/sync v0.5.0 // indirect
 	golang.org/x/sys v0.16.0 // indirect
 	golang.org/x/term v0.16.0 // indirect
diff --git a/go.sum b/go.sum
index 61fab1e87..93fed8627 100644
--- a/go.sum
+++ b/go.sum
@@ -123,8 +123,8 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI=
-github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
+github.com/evanphx/json-patch v5.8.0+incompatible h1:1Av9pn2FyxPdvrWNQszj1g6D6YthSmvCfcN6SYclTJg=
+github.com/evanphx/json-patch v5.8.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
 github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
 github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
 github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
@@ -296,8 +296,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
 github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
-github.com/linkerd/linkerd2-proxy-api v0.12.0 h1:ZB9a2OF7JAVk3tjtoukPz7zfBRZ3U+jhkdFCpBzJAR4=
-github.com/linkerd/linkerd2-proxy-api v0.12.0/go.mod h1:n9g59OaIKK7y0VF1lIwRkIhuvFtE1KHRtA8iz6Wenh8=
+github.com/linkerd/linkerd2-proxy-api v0.13.0 h1:CYHnqeH7LZ8rB7kKSjK7KwHy6mSPG2LS2Ctn4ihFSsc=
+github.com/linkerd/linkerd2-proxy-api v0.13.0/go.mod h1:CVUFCu0OXK11ecBtCU+FiGqUX0KyOR3zvxPF0vwSbPs=
 github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -309,8 +309,6 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m
 github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
 github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
-github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
-github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
 github.com/miekg/dns v1.1.25 h1:dFwPR6SfLtrSwgDcIq2bcU/gVutB4sNApq2HBdqcakg=
 github.com/miekg/dns v1.1.25/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
 github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
@@ -381,8 +379,8 @@ github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cY
 github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
 github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
 github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc=
-github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
-github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
+github.com/prometheus/common v0.46.0 h1:doXzt5ybi1HBKpsZOL0sSkaNHJJqkyfEWZGGqqScV0Y=
+github.com/prometheus/common v0.46.0/go.mod h1:Tp0qkxpb9Jsg54QMe+EAmqXkSV7Evdy1BTn+g2pa/hQ=
 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
 github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
@@ -544,8 +542,8 @@ golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4Iltr
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
-golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0=
+golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ=
+golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
diff --git a/policy-controller/grpc/src/inbound.rs b/policy-controller/grpc/src/inbound.rs
index 2a7bcd7e8..966237ab0 100644
--- a/policy-controller/grpc/src/inbound.rs
+++ b/policy-controller/grpc/src/inbound.rs
@@ -1,4 +1,4 @@
-use crate::{http_route, workload::Kind, workload::Workload};
+use crate::{http_route, workload::Workload};
 use futures::prelude::*;
 use linkerd2_proxy_api::{
     self as api,
@@ -31,7 +31,7 @@ pub struct InboundPolicyServer<T> {
 
 impl<T> InboundPolicyServer<T>
 where
-    T: DiscoverInboundServer<(String, String, NonZeroU16)> + Send + Sync + 'static,
+    T: DiscoverInboundServer<(Workload, NonZeroU16)> + Send + Sync + 'static,
 {
     pub fn new(discover: T, cluster_networks: Vec<IpNet>, drain: drain::Watch) -> Self {
         Self {
@@ -48,33 +48,21 @@ where
     fn check_target(
         &self,
         proto::PortSpec { workload, port }: proto::PortSpec,
-    ) -> Result<(String, String, NonZeroU16), tonic::Status> {
-        let (ns, name) = match Workload::from_str(&workload)? {
-            Workload {
-                namespace,
-                kind: Kind::Pod(pod),
-            } => (namespace, pod),
-            _ => {
-                // TODO: handle external workloads
-                return Err(tonic::Status::invalid_argument(
-                    "only pod workload supported at the moment",
-                ));
-            }
-        };
-
+    ) -> Result<(Workload, NonZeroU16), tonic::Status> {
+        let workload = Workload::from_str(&workload)?;
         // Ensure that the port is in the valid range.
         let port = u16::try_from(port)
             .and_then(NonZeroU16::try_from)
             .map_err(|_| tonic::Status::invalid_argument(format!("Invalid port: {port}")))?;
 
-        Ok((ns.to_string(), name.to_string(), port))
+        Ok((workload, port))
     }
 }
 
 #[async_trait::async_trait]
 impl<T> InboundServerPolicies for InboundPolicyServer<T>
 where
-    T: DiscoverInboundServer<(String, String, NonZeroU16)> + Send + Sync + 'static,
+    T: DiscoverInboundServer<(Workload, NonZeroU16)> + Send + Sync + 'static,
 {
     async fn get_port(
         &self,
diff --git a/policy-controller/grpc/src/lib.rs b/policy-controller/grpc/src/lib.rs
index 404d2c9f2..834a81472 100644
--- a/policy-controller/grpc/src/lib.rs
+++ b/policy-controller/grpc/src/lib.rs
@@ -2,7 +2,7 @@
 #![forbid(unsafe_code)]
 
 mod http_route;
-mod workload;
 
 pub mod inbound;
 pub mod outbound;
+pub mod workload;
diff --git a/policy-controller/k8s/index/src/inbound.rs b/policy-controller/k8s/index/src/inbound.rs
index c4d5901f4..d2e2c33f6 100644
--- a/policy-controller/k8s/index/src/inbound.rs
+++ b/policy-controller/k8s/index/src/inbound.rs
@@ -3,9 +3,9 @@ mod http_route;
 pub mod index;
 mod meshtls_authentication;
 mod network_authentication;
-mod pod;
 mod server;
 pub mod server_authorization;
+mod workload;
 
 pub use index::{Index, SharedIndex};
 
diff --git a/policy-controller/k8s/index/src/inbound/index.rs b/policy-controller/k8s/index/src/inbound/index.rs
index 8b94c1c49..d2861d0d2 100644
--- a/policy-controller/k8s/index/src/inbound/index.rs
+++ b/policy-controller/k8s/index/src/inbound/index.rs
@@ -8,7 +8,7 @@
 
 use super::{
     authorization_policy, http_route::RouteBinding, meshtls_authentication, network_authentication,
-    pod, server, server_authorization,
+    server, server_authorization, workload,
 };
 use crate::{
     http_route::{gkn_for_gateway_http_route, gkn_for_linkerd_http_route, gkn_for_resource},
@@ -65,10 +65,11 @@ struct AuthenticationNsIndex {
     by_ns: HashMap<String, AuthenticationIndex>,
 }
 
-/// Holds `Pod`, `Server`, and `ServerAuthorization` indices for a single namespace.
+/// Holds `Pod`, `ExternalWorkload`, `Server`, and `ServerAuthorization` indices for a single namespace.
 #[derive(Debug)]
 struct Namespace {
     pods: PodIndex,
+    external_workloads: ExternalWorkloadIndex,
     policy: PolicyIndex,
 }
 
@@ -85,7 +86,7 @@ struct PodIndex {
 /// or as `Server` resources select a port.
 #[derive(Debug)]
 struct Pod {
-    meta: pod::Meta,
+    meta: workload::Meta,
 
     /// The pod's named container ports. Used by `Server` port selectors.
     ///
@@ -97,7 +98,7 @@ struct Pod {
     /// `Namespace::reindex`--when a port is selected by a `Server`--or by
     /// `Namespace::get_pod_server` when a client discovers a port that has no
     /// configured server (and i.e. uses the default policy).
-    port_servers: PortMap<PodPortServer>,
+    port_servers: PortMap<WorkloadPortServer>,
 
     /// The pod's probe ports and their respective paths.
     ///
@@ -107,18 +108,45 @@ struct Pod {
     probes: PortMap<BTreeSet<String>>,
 }
 
-/// Holds the state of a single port on a pod.
+/// Holds the state of a single port on a workload (e.g. a pod or an external
+/// workload).
 #[derive(Debug)]
-struct PodPortServer {
+struct WorkloadPortServer {
     /// The name of the server resource that matches this port. Unset when no
     /// server resources match this pod/port (and, i.e., the default policy is
     /// used).
     name: Option<String>,
 
-    /// A sender used to broadcast pod port server updates.
+    /// A sender used to broadcast workload port server updates.
     watch: watch::Sender<InboundServer>,
 }
 
+/// Holds all external workload data for a single namespace
+#[derive(Debug)]
+struct ExternalWorkloadIndex {
+    namespace: String,
+    by_name: HashMap<String, ExternalWorkload>,
+}
+
+/// Holds data for a single external workload, with server watches for all known
+/// ports.
+///
+/// The set of ports / servers is updated as clients discover server
+/// configuration or as `Server` resources select a port.
+#[derive(Debug)]
+struct ExternalWorkload {
+    meta: workload::Meta,
+
+    // The workload's named container ports. Used by `Server` port selectors.
+    //
+    // A workload will not have multiple ports with the same name, e.g. two
+    // `admin-http` ports pointing to different numerical values.
+    port_names: HashMap<String, NonZeroU16>,
+
+    /// All known TCP server ports.
+    port_servers: PortMap<WorkloadPortServer>,
+}
+
 /// Holds the state of policy resources for a single namespace.
 #[derive(Debug)]
 struct PolicyIndex {
@@ -184,6 +212,34 @@ impl Index {
             .subscribe())
     }
 
+    /// Obtains an external_workload:port's server receiver.
+    ///
+    /// An error is returned if the external workload is not found. If the port
+    /// is not found, a default server is created.
+    pub fn external_workload_server_rx(
+        &mut self,
+        namespace: &str,
+        workload: &str,
+        port: NonZeroU16,
+    ) -> Result<watch::Receiver<InboundServer>> {
+        let ns = self
+            .namespaces
+            .by_ns
+            .get_mut(namespace)
+            .ok_or_else(|| anyhow::anyhow!("namespace not found: {}", namespace))?;
+        let external_workload =
+            ns.external_workloads
+                .by_name
+                .get_mut(workload)
+                .ok_or_else(|| {
+                    anyhow::anyhow!("external workload {}.{} not found", workload, namespace)
+                })?;
+        Ok(external_workload
+            .port_server_or_default(port, &self.cluster_info)
+            .watch
+            .subscribe())
+    }
+
     fn ns_with_reindex(&mut self, namespace: String, f: impl FnOnce(&mut Namespace) -> bool) {
         self.namespaces
             .get_with_reindex(namespace, &self.authentications, f)
@@ -311,15 +367,15 @@ impl kubert::index::IndexNamespacedResource<k8s::Pod> for Index {
         let port_names = pod
             .spec
             .as_ref()
-            .map(pod::tcp_ports_by_name)
+            .map(workload::pod_tcp_ports_by_name)
             .unwrap_or_default();
         let probes = pod
             .spec
             .as_ref()
-            .map(pod::pod_http_probes)
+            .map(workload::pod_http_probes)
             .unwrap_or_default();
 
-        let meta = pod::Meta::from_metadata(pod.metadata);
+        let meta = workload::Meta::from_metadata(pod.metadata);
 
         // Add or update the pod. If the pod was not already present in the
         // index with the same metadata, index it against the policy resources,
@@ -350,6 +406,57 @@ impl kubert::index::IndexNamespacedResource<k8s::Pod> for Index {
     // handle resets specially.
 }
 
+impl kubert::index::IndexNamespacedResource<k8s::external_workload::ExternalWorkload> for Index {
+    fn apply(&mut self, ext_workload: k8s::external_workload::ExternalWorkload) {
+        let ns = ext_workload.namespace().unwrap();
+        let name = ext_workload.name_unchecked();
+        let _span = info_span!("apply", %ns, %name).entered();
+
+        // Extract ports and settings.
+        // Note: external workloads do not have any probe paths to synthesise
+        // default policies for.
+        let port_names = workload::external_tcp_ports_by_name(&ext_workload.spec);
+        let meta = workload::Meta::from_metadata(ext_workload.metadata);
+
+        // Add or update the workload.
+        //
+        // If the resource is present in the index, but its metadata has
+        // changed, then it means the watches need to get an update.
+        let ns = self.namespaces.get_or_default(ns);
+        match ns.external_workloads.update(name, meta, port_names) {
+            // No update
+            Ok(None) => {}
+            // Update, so re-index
+            Ok(Some(workload)) => workload.reindex_servers(&ns.policy, &self.authentications),
+            Err(error) => {
+                tracing::error!(%error, "Illegal external workload update");
+            }
+        }
+    }
+
+    fn delete(&mut self, ns: String, name: String) {
+        tracing::debug!(%ns, %name, "delete");
+        if let Entry::Occupied(mut ns) = self.namespaces.by_ns.entry(ns) {
+            // Once the external workload is removed, there's nothing else to
+            // update. Any open watches will complete. No other parts of the
+            // index need to be updated.
+            if ns
+                .get_mut()
+                .external_workloads
+                .by_name
+                .remove(&name)
+                .is_some()
+                && ns.get().is_empty()
+            {
+                ns.remove();
+            }
+        }
+    }
+
+    // Since apply only reindexes a single external workload at a time, there's no need to
+    // handle resets specially.
+}
+
 impl kubert::index::IndexNamespacedResource<k8s::policy::Server> for Index {
     fn apply(&mut self, srv: k8s::policy::Server) {
         let ns = srv.namespace().expect("server must be namespaced");
@@ -828,6 +935,10 @@ impl Namespace {
                 namespace: namespace.clone(),
                 by_name: HashMap::default(),
             },
+            external_workloads: ExternalWorkloadIndex {
+                namespace: namespace.clone(),
+                by_name: HashMap::default(),
+            },
             policy: PolicyIndex {
                 namespace,
                 cluster_info,
@@ -842,12 +953,13 @@ impl Namespace {
     /// Returns true if the index does not include any resources.
     #[inline]
     fn is_empty(&self) -> bool {
-        self.pods.is_empty() && self.policy.is_empty()
+        self.pods.is_empty() && self.policy.is_empty() && self.external_workloads.is_empty()
     }
 
     #[inline]
     fn reindex(&mut self, authns: &AuthenticationNsIndex) {
         self.pods.reindex(&self.policy, authns);
+        self.external_workloads.reindex(&self.policy, authns);
     }
 }
 
@@ -862,7 +974,7 @@ impl PodIndex {
     fn update(
         &mut self,
         name: String,
-        meta: pod::Meta,
+        meta: workload::Meta,
         port_names: HashMap<String, PortSet>,
         probes: PortMap<BTreeSet<String>>,
     ) -> Result<Option<&mut Pod>> {
@@ -975,7 +1087,7 @@ impl Pod {
             Entry::Vacant(entry) => {
                 tracing::trace!(port = %port, server = %name, "Creating server");
                 let (watch, _) = watch::channel(server);
-                entry.insert(PodPortServer {
+                entry.insert(WorkloadPortServer {
                     name: Some(name.to_string()),
                     watch,
                 });
@@ -1013,7 +1125,7 @@ impl Pod {
 
     /// Updates a pod-port to use the given named server.
     fn set_default_server(&mut self, port: NonZeroU16, config: &ClusterInfo) {
-        let server = Self::default_inbound_server(
+        let server = PolicyIndex::default_inbound_server(
             port,
             &self.meta.settings,
             self.probes
@@ -1027,7 +1139,7 @@ impl Pod {
             Entry::Vacant(entry) => {
                 tracing::debug!(%port, server = %config.default_policy, "Creating default server");
                 let (watch, _) = watch::channel(server);
-                entry.insert(PodPortServer { name: None, watch });
+                entry.insert(WorkloadPortServer { name: None, watch });
             }
 
             Entry::Occupied(mut entry) => {
@@ -1068,11 +1180,11 @@ impl Pod {
         &mut self,
         port: NonZeroU16,
         config: &ClusterInfo,
-    ) -> &mut PodPortServer {
+    ) -> &mut WorkloadPortServer {
         match self.port_servers.entry(port) {
             Entry::Occupied(entry) => entry.into_mut(),
             Entry::Vacant(entry) => {
-                let (watch, _) = watch::channel(Self::default_inbound_server(
+                let (watch, _) = watch::channel(PolicyIndex::default_inbound_server(
                     port,
                     &self.meta.settings,
                     self.probes
@@ -1082,45 +1194,257 @@ impl Pod {
                         .map(|p| p.as_str()),
                     config,
                 ));
-                entry.insert(PodPortServer { name: None, watch })
+                entry.insert(WorkloadPortServer { name: None, watch })
             }
         }
     }
+}
 
-    fn default_inbound_server<'p>(
-        port: NonZeroU16,
-        settings: &pod::Settings,
-        probe_paths: impl Iterator<Item = &'p str>,
-        config: &ClusterInfo,
-    ) -> InboundServer {
-        let protocol = if settings.opaque_ports.contains(&port) {
-            ProxyProtocol::Opaque
-        } else {
-            ProxyProtocol::Detect {
-                timeout: config.default_detect_timeout,
+// === impl ExternalWorkloadIndex ===
+
+impl ExternalWorkloadIndex {
+    #[inline]
+    fn is_empty(&self) -> bool {
+        self.by_name.is_empty()
+    }
+
+    /// Indexes an external workload resource and computes any changes in the
+    /// workload's state.
+    ///
+    /// If the workload is indexed for the first time, or some of its settings
+    /// have changed (e.g. metadata) then indexing will trigger a namespace-wide
+    /// server re-index to push new state to the clients.
+    ///
+    /// Otherwise, if nothing has changed, do not trigger re-indexing.
+    fn update(
+        &mut self,
+        name: String,
+        meta: workload::Meta,
+        port_names: HashMap<String, NonZeroU16>,
+    ) -> Result<Option<&mut ExternalWorkload>> {
+        let workload = match self.by_name.entry(name.clone()) {
+            Entry::Vacant(entry) => entry.insert(ExternalWorkload {
+                meta,
+                port_names,
+                port_servers: PortMap::default(),
+            }),
+            Entry::Occupied(entry) => {
+                let workload = entry.into_mut();
+
+                if workload.meta == meta && workload.port_names == port_names {
+                    tracing::debug!(external_workload = %name, "No changes");
+                    return Ok(None);
+                }
+
+                if workload.meta != meta {
+                    tracing::trace!(external_workload = %name, "Updating workload's metadata");
+                    workload.meta = meta;
+                }
+
+                if workload.port_names != port_names {
+                    tracing::trace!(external_workload = %name, "Updating workload's ports");
+                    workload.port_names = port_names;
+                }
+
+                tracing::debug!(external_workload = %name, "Updating");
+                workload
             }
         };
+        Ok(Some(workload))
+    }
 
-        let mut policy = settings.default_policy.unwrap_or(config.default_policy);
-        if settings.require_id_ports.contains(&port) {
-            if let DefaultPolicy::Allow {
-                ref mut authenticated_only,
-                ..
-            } = policy
-            {
-                *authenticated_only = true;
+    /// For each external workload in a namespace, re-compute the server and
+    /// authorization policy states to determine if new changes need to be
+    /// pushed to clients.
+    fn reindex(&mut self, policy: &PolicyIndex, authns: &AuthenticationNsIndex) {
+        let _span = info_span!("reindex", ns = %self.namespace).entered();
+        for (name, ext_workload) in self.by_name.iter_mut() {
+            let _span = info_span!("external_workload", external_workload = %name).entered();
+            ext_workload.reindex_servers(policy, authns);
+        }
+    }
+}
+
+//
+impl ExternalWorkload {
+    /// Determines the policies for ports on this workload
+    fn reindex_servers(&mut self, policy: &PolicyIndex, authentications: &AuthenticationNsIndex) {
+        // Keep track of ports that are already known so that they may receive
+        // default policies if they are still not selected by a server.
+        //
+        // TODO (matei): we could eagerly fill this out if we require users to
+        // always document a workload's ports. i.e. right now a non-named port
+        // will be lazily discovered only when traffic is sent, we could eagerly
+        // set policies for it (but I feel like for now, this should be similar in
+        // behaviour to a pod)
+        //
+        let mut unmatched_ports = self.port_servers.keys().copied().collect::<PortSet>();
+
+        // Keep track of which ports have been matched with servers so that we
+        // can detect when more than one server matches a single port.
+        let mut matched_ports = PortMap::with_capacity_and_hasher(
+            unmatched_ports.len(),
+            std::hash::BuildHasherDefault::<PortHasher>::default(),
+        );
+
+        for (srvname, server) in policy.servers.iter() {
+            if let Selector::ExternalWorkload(selector) = &server.selector {
+                if selector.matches(&self.meta.labels) {
+                    // Each server selects exactly one port on an
+                    // external workload
+                    //
+                    // Note: an external workload has only one set of ports. A
+                    // pod has a union, each container declares its own set.
+                    let port = if let Some(srv_port) = self.selects_port(&server.port_ref) {
+                        srv_port
+                    } else {
+                        // If server references a named port, and our workload
+                        // contains no such port, then skip this server.
+                        continue;
+                    };
+
+                    if let Some(prior) = matched_ports.get(&port) {
+                        // If a different server has already matched this
+                        tracing::warn!(
+                        %port,
+                        server = %prior,
+                        conflict = %srvname,
+                        "Port already matched by another server; skipping"
+                        );
+                        continue;
+                    }
+
+                    let s = policy.inbound_server(
+                        srvname.clone(),
+                        server,
+                        authentications,
+                        Vec::new().into_iter(),
+                    );
+
+                    self.update_server(port, srvname, s);
+                    matched_ports.insert(port, srvname.clone());
+                    unmatched_ports.remove(&port);
+                }
             }
         }
 
-        let authorizations = policy.default_authzs(config);
+        // Reset all other ports that were previously selected to defaults
+        for port in unmatched_ports.into_iter() {
+            self.set_default_server(port, &policy.cluster_info);
+        }
+    }
 
-        let http_routes = config.default_inbound_http_routes(probe_paths);
+    /// Updates an external workload-port to use a given named server.
+    ///
+    /// We use name explicitly (and not derived from the 'server') to ensure we
+    /// are not handling a default server.
+    fn update_server(&mut self, port: NonZeroU16, name: &str, server: InboundServer) {
+        match self.port_servers.entry(port) {
+            Entry::Vacant(entry) => {
+                tracing::trace!(port = %port, server = %name, "Creating server");
+                let (watch, _) = watch::channel(server);
+                entry.insert(WorkloadPortServer {
+                    name: Some(name.to_string()),
+                    watch,
+                });
+            }
 
-        InboundServer {
-            reference: ServerRef::Default(policy.as_str()),
-            protocol,
-            authorizations,
-            http_routes,
+            Entry::Occupied(mut entry) => {
+                let ps = entry.get_mut();
+
+                ps.watch.send_if_modified(|current| {
+                    if ps.name.as_deref() == Some(name) && *current == server {
+                        tracing::trace!(port = %port, server = %name, "Skipped redundant server update");
+                        tracing::trace!(?server);
+                        return false;
+                    }
+
+                    // If the port's server previously matched a different
+                    // server, this can either mean that multiple servers
+                    // currently match the external_workload:port, or that we're
+                    // in the middle of an update. We make the opportunistic
+                    // choice to assume the cluster is configured coherently so
+                    // we take the update. The admission controller should
+                    // prevent conflicts.
+                    tracing::trace!(port = %port, server = %name, "Updating server");
+                    if ps.name.as_deref() != Some(name) {
+                        ps.name = Some(name.to_string());
+                    }
+
+                    *current = server;
+                    true
+                });
+            }
+        }
+
+        tracing::debug!(port = %port, server = %name, "Updated server");
+    }
+
+    /// Updates a workload-port to use a given named server.
+    fn set_default_server(&mut self, port: NonZeroU16, config: &ClusterInfo) {
+        // Create a default server policy, without authorising any probe paths
+        let server = PolicyIndex::default_inbound_server(
+            port,
+            &self.meta.settings,
+            Vec::new().into_iter(),
+            config,
+        );
+        match self.port_servers.entry(port) {
+            Entry::Vacant(entry) => {
+                tracing::debug!(%port, server = %config.default_policy, "Creating default server");
+                let (watch, _) = watch::channel(server);
+                entry.insert(WorkloadPortServer { name: None, watch });
+            }
+
+            Entry::Occupied(mut entry) => {
+                // A server must have selected this before; override with
+                // default policy
+                let ps = entry.get_mut();
+                ps.watch.send_if_modified(|current| {
+                    // Avoid sending redundant updates
+                    if *current == server {
+                        tracing::trace!(%port, server = %config.default_policy, "Default server already set");
+                        return false;
+                    }
+
+                    tracing::debug!(%port, server = %config.default_policy, "Setting default server");
+                    ps.name = None;
+                    *current = server;
+                    true
+                });
+            }
+        }
+    }
+
+    /// Returns an optional port when a server references a known external
+    /// workload port.
+    ///
+    /// Unlike a pod, an external workload has only one set of ports. Names
+    /// within the set are unique, and as a result, only one port will ever
+    /// match a given name.
+    fn selects_port(&mut self, port_ref: &Port) -> Option<NonZeroU16> {
+        match port_ref {
+            Port::Number(p) => Some(*p),
+            Port::Name(name) => self.port_names.get(name).cloned(),
+        }
+    }
+
+    fn port_server_or_default(
+        &mut self,
+        port: NonZeroU16,
+        config: &ClusterInfo,
+    ) -> &mut WorkloadPortServer {
+        match self.port_servers.entry(port) {
+            Entry::Occupied(entry) => entry.into_mut(),
+            Entry::Vacant(entry) => {
+                let (watch, _) = watch::channel(PolicyIndex::default_inbound_server(
+                    port,
+                    &self.meta.settings,
+                    Vec::new().into_iter(),
+                    config,
+                ));
+                entry.insert(WorkloadPortServer { name: None, watch })
+            }
         }
     }
 }
@@ -1187,6 +1511,43 @@ impl PolicyIndex {
         true
     }
 
+    fn default_inbound_server<'p>(
+        port: NonZeroU16,
+        settings: &workload::Settings,
+        probe_paths: impl Iterator<Item = &'p str>,
+        config: &ClusterInfo,
+    ) -> InboundServer {
+        let protocol = if settings.opaque_ports.contains(&port) {
+            ProxyProtocol::Opaque
+        } else {
+            ProxyProtocol::Detect {
+                timeout: config.default_detect_timeout,
+            }
+        };
+
+        let mut policy = settings.default_policy.unwrap_or(config.default_policy);
+        if settings.require_id_ports.contains(&port) {
+            if let DefaultPolicy::Allow {
+                ref mut authenticated_only,
+                ..
+            } = policy
+            {
+                *authenticated_only = true;
+            }
+        }
+
+        let authorizations = policy.default_authzs(config);
+
+        let http_routes = config.default_inbound_http_routes(probe_paths);
+
+        InboundServer {
+            reference: ServerRef::Default(policy.as_str()),
+            protocol,
+            authorizations,
+            http_routes,
+        }
+    }
+
     fn inbound_server<'p>(
         &self,
         name: String,
diff --git a/policy-controller/k8s/index/src/inbound/pod.rs b/policy-controller/k8s/index/src/inbound/workload.rs
similarity index 90%
rename from policy-controller/k8s/index/src/inbound/pod.rs
rename to policy-controller/k8s/index/src/inbound/workload.rs
index 0aa5f37ee..233ec0a3f 100644
--- a/policy-controller/k8s/index/src/inbound/pod.rs
+++ b/policy-controller/k8s/index/src/inbound/workload.rs
@@ -5,17 +5,17 @@ use anyhow::Result;
 use linkerd_policy_controller_k8s_api as k8s;
 use std::{collections::BTreeSet, num::NonZeroU16};
 
-/// Holds pod metadata/config that can change.
+/// Holds workload metadata/config that can change.
 #[derive(Debug, PartialEq)]
 pub(crate) struct Meta {
-    /// The pod's labels. Used by `Server` pod selectors.
+    /// The workload's labels. Used by `Server` selectors.
     pub labels: k8s::Labels,
 
-    // Pod-specific settings (i.e., derived from annotations).
+    // Workload-specific settings (i.e., derived from annotations).
     pub settings: Settings,
 }
 
-/// Per-pod settings, as configured by the pod's annotations.
+/// Per-workload settings, as configured by the workload's annotations.
 #[derive(Debug, Default, PartialEq)]
 pub(crate) struct Settings {
     pub require_id_ports: PortSet,
@@ -24,7 +24,7 @@ pub(crate) struct Settings {
 }
 
 /// Gets the set of named ports with `protocol: TCP` from a pod spec.
-pub(crate) fn tcp_ports_by_name(spec: &k8s::PodSpec) -> HashMap<String, PortSet> {
+pub(crate) fn pod_tcp_ports_by_name(spec: &k8s::PodSpec) -> HashMap<String, PortSet> {
     let mut ports = HashMap::<String, PortSet>::default();
     for (port, name) in spec
         .containers
@@ -37,6 +37,26 @@ pub(crate) fn tcp_ports_by_name(spec: &k8s::PodSpec) -> HashMap<String, PortSet>
     ports
 }
 
+/// Gets the set of named ports withn `protocol: TCP` from an external workload
+/// spec.
+///
+/// Since an external workload has only one set of ports, each name is
+/// guaranteed to be unique.
+pub(crate) fn external_tcp_ports_by_name(
+    spec: &k8s::external_workload::ExternalWorkloadSpec,
+) -> HashMap<String, NonZeroU16> {
+    let mut ports = HashMap::<String, NonZeroU16>::default();
+    for (port, name) in spec
+        .ports
+        .iter()
+        .flatten()
+        .filter_map(named_external_tcp_port)
+    {
+        ports.insert(name.into(), port);
+    }
+    ports
+}
+
 /// Gets the container probe ports for a Pod.
 ///
 /// The result is a mapping for each probe port exposed by a container in the
@@ -103,6 +123,16 @@ fn named_tcp_port(port: &k8s::ContainerPort) -> Option<(NonZeroU16, &str)> {
     Some((p, n))
 }
 
+fn named_external_tcp_port(spec: &k8s::external_workload::PortSpec) -> Option<(NonZeroU16, &str)> {
+    if let Some(ref proto) = spec.protocol {
+        if !proto.eq_ignore_ascii_case("TCP") {
+            return None;
+        }
+    }
+    let n = spec.name.as_deref()?;
+    Some((spec.port, n))
+}
+
 // === impl Meta ===
 
 impl Meta {
diff --git a/policy-controller/src/lib.rs b/policy-controller/src/lib.rs
index 8efbb1cb9..d524a6578 100644
--- a/policy-controller/src/lib.rs
+++ b/policy-controller/src/lib.rs
@@ -36,26 +36,49 @@ impl OutboundDiscover {
 }
 
 #[async_trait::async_trait]
-impl DiscoverInboundServer<(String, String, NonZeroU16)> for InboundDiscover {
+impl DiscoverInboundServer<(grpc::workload::Workload, NonZeroU16)> for InboundDiscover {
     async fn get_inbound_server(
         &self,
-        (namespace, pod, port): (String, String, NonZeroU16),
+        (workload, port): (grpc::workload::Workload, NonZeroU16),
     ) -> Result<Option<InboundServer>> {
-        let rx = match self.0.write().pod_server_rx(&namespace, &pod, port) {
-            Ok(rx) => rx,
-            Err(_) => return Ok(None),
+        let grpc::workload::Workload { namespace, kind } = workload;
+        let rx = match kind {
+            grpc::workload::Kind::External(name) => self
+                .0
+                .write()
+                .external_workload_server_rx(&namespace, &name, port),
+            grpc::workload::Kind::Pod(name) => {
+                self.0.write().pod_server_rx(&namespace, &name, port)
+            }
         };
-        let server = (*rx.borrow()).clone();
-        Ok(Some(server))
+
+        if let Ok(rx) = rx {
+            let server = (*rx.borrow()).clone();
+            Ok(Some(server))
+        } else {
+            Ok(None)
+        }
     }
 
     async fn watch_inbound_server(
         &self,
-        (namespace, pod, port): (String, String, NonZeroU16),
+        (workload, port): (grpc::workload::Workload, NonZeroU16),
     ) -> Result<Option<InboundServerStream>> {
-        match self.0.write().pod_server_rx(&namespace, &pod, port) {
-            Ok(rx) => Ok(Some(Box::pin(tokio_stream::wrappers::WatchStream::new(rx)))),
-            Err(_) => Ok(None),
+        let grpc::workload::Workload { namespace, kind } = workload;
+        let rx = match kind {
+            grpc::workload::Kind::External(name) => self
+                .0
+                .write()
+                .external_workload_server_rx(&namespace, &name, port),
+            grpc::workload::Kind::Pod(name) => {
+                self.0.write().pod_server_rx(&namespace, &name, port)
+            }
+        };
+
+        if let Ok(rx) = rx {
+            Ok(Some(Box::pin(tokio_stream::wrappers::WatchStream::new(rx))))
+        } else {
+            Ok(None)
         }
     }
 }
diff --git a/policy-controller/src/main.rs b/policy-controller/src/main.rs
index 9f40c6f0d..2e923bbfa 100644
--- a/policy-controller/src/main.rs
+++ b/policy-controller/src/main.rs
@@ -170,6 +170,13 @@ async fn main() -> Result<()> {
         kubert::index::namespaced(inbound_index.clone(), pods).instrument(info_span!("pods")),
     );
 
+    let external_workloads =
+        runtime.watch_all::<k8s::external_workload::ExternalWorkload>(watcher::Config::default());
+    tokio::spawn(
+        kubert::index::namespaced(inbound_index.clone(), external_workloads)
+            .instrument(info_span!("external_workloads")),
+    );
+
     let servers = runtime.watch_all::<k8s::policy::Server>(watcher::Config::default());
     let servers_indexes = IndexList::new(inbound_index.clone())
         .push(status_index.clone())
diff --git a/web/app/package.json b/web/app/package.json
index b5b1d81d6..1297bf38b 100644
--- a/web/app/package.json
+++ b/web/app/package.json
@@ -46,7 +46,7 @@
     "@babel/plugin-proposal-class-properties": "^7.17.12",
     "@babel/preset-env": "^7.23.7",
     "@babel/preset-react": "^7.23.3",
-    "@babel/runtime": "^7.23.6",
+    "@babel/runtime": "^7.23.8",
     "@lingui/cli": "3.17.2",
     "babel-core": "^7.0.0-bridge.0",
     "babel-jest": "^29.7.0",
@@ -56,7 +56,7 @@
     "babel-plugin-transform-react-remove-prop-types": "0.4.24",
     "chai": "4.3.10",
     "clean-webpack-plugin": "4.0.0",
-    "css-loader": "^6.8.1",
+    "css-loader": "^6.9.0",
     "enzyme": "3.11.0",
     "enzyme-adapter-react-16": "^1.15.7",
     "eslint": "^8.56.0",
@@ -76,7 +76,7 @@
     "react-test-renderer": "16.14.0",
     "sinon": "17.0.1",
     "sinon-stub-promise": "4.0.0",
-    "style-loader": "^3.3.3",
+    "style-loader": "^3.3.4",
     "url-loader": "^4.1.1",
     "webpack": "^5.89.0",
     "webpack-bundle-analyzer": "4.10.1",
diff --git a/web/app/yarn.lock b/web/app/yarn.lock
index 7f6c6f72d..603f44fb5 100644
--- a/web/app/yarn.lock
+++ b/web/app/yarn.lock
@@ -1106,10 +1106,10 @@
   resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310"
   integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
 
-"@babel/runtime@^7.1.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.23.6", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
-  version "7.23.6"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.6.tgz#c05e610dc228855dc92ef1b53d07389ed8ab521d"
-  integrity sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==
+"@babel/runtime@^7.1.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.23.8", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
+  version "7.23.8"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.8.tgz#8ee6fe1ac47add7122902f257b8ddf55c898f650"
+  integrity sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==
   dependencies:
     regenerator-runtime "^0.14.0"
 
@@ -3309,19 +3309,19 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3:
     shebang-command "^2.0.0"
     which "^2.0.1"
 
-css-loader@^6.8.1:
-  version "6.8.1"
-  resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.8.1.tgz#0f8f52699f60f5e679eab4ec0fcd68b8e8a50a88"
-  integrity sha512-xDAXtEVGlD0gJ07iclwWVkLoZOpEvAWaSyf6W18S2pOC//K8+qUDIx8IIT3D+HjnmkJPQeesOPv5aiUaJsCM2g==
+css-loader@^6.9.0:
+  version "6.9.0"
+  resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.9.0.tgz#0cc2f14df94ed97c526c5ae42b6b13916d1d8d0e"
+  integrity sha512-3I5Nu4ytWlHvOP6zItjiHlefBNtrH+oehq8tnQa2kO305qpVyx9XNIT1CXIj5bgCJs7qICBCkgCYxQLKPANoLA==
   dependencies:
     icss-utils "^5.1.0"
-    postcss "^8.4.21"
+    postcss "^8.4.31"
     postcss-modules-extract-imports "^3.0.0"
     postcss-modules-local-by-default "^4.0.3"
-    postcss-modules-scope "^3.0.0"
+    postcss-modules-scope "^3.1.0"
     postcss-modules-values "^4.0.0"
     postcss-value-parser "^4.2.0"
-    semver "^7.3.8"
+    semver "^7.5.4"
 
 css-mediaquery@^0.1.2:
   version "0.1.2"
@@ -6685,10 +6685,10 @@ mute-stream@0.0.8:
   resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
   integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
 
-nanoid@^3.3.6:
-  version "3.3.6"
-  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
-  integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
+nanoid@^3.3.7:
+  version "3.3.7"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
+  integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
 
 natural-compare@^1.4.0:
   version "1.4.0"
@@ -7268,10 +7268,10 @@ postcss-modules-local-by-default@^4.0.3:
     postcss-selector-parser "^6.0.2"
     postcss-value-parser "^4.1.0"
 
-postcss-modules-scope@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz#9ef3151456d3bbfa120ca44898dfca6f2fa01f06"
-  integrity sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==
+postcss-modules-scope@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.1.0.tgz#fbfddfda93a31f310f1d152c2bb4d3f3c5592ee0"
+  integrity sha512-SaIbK8XW+MZbd0xHPf7kdfA/3eOt7vxJ72IRecn3EzuZVLr1r0orzf0MX/pN8m+NMDoo6X/SQd8oeKqGZd8PXg==
   dependencies:
     postcss-selector-parser "^6.0.4"
 
@@ -7295,12 +7295,12 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
   resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
   integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
 
-postcss@^8.4.21:
-  version "8.4.31"
-  resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
-  integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
+postcss@^8.4.31:
+  version "8.4.33"
+  resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.33.tgz#1378e859c9f69bf6f638b990a0212f43e2aaa742"
+  integrity sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==
   dependencies:
-    nanoid "^3.3.6"
+    nanoid "^3.3.7"
     picocolors "^1.0.0"
     source-map-js "^1.0.2"
 
@@ -8008,7 +8008,7 @@ semver@^6.0.0, semver@^6.3.0, semver@^6.3.1:
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
   integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
 
-semver@^7.3.8, semver@^7.5.3, semver@^7.5.4:
+semver@^7.5.3, semver@^7.5.4:
   version "7.5.4"
   resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
   integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
@@ -8452,10 +8452,10 @@ strip-json-comments@^3.1.1:
   resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
   integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
 
-style-loader@^3.3.3:
-  version "3.3.3"
-  resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.3.tgz#bba8daac19930169c0c9c96706749a597ae3acff"
-  integrity sha512-53BiGLXAcll9maCYtZi2RCQZKa8NQQai5C4horqKyRmHj9H7QmcUyucrH+4KW/gBQbXM2AsB0axoEcFZPlfPcw==
+style-loader@^3.3.4:
+  version "3.3.4"
+  resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.4.tgz#f30f786c36db03a45cbd55b6a70d930c479090e7"
+  integrity sha512-0WqXzrsMTyb8yjZJHDqwmnwRJvhALK9LfRtRc6B4UTWe8AijYLZYZ9thuJTZc2VfQWINADW/j+LiJnfy2RoC1w==
 
 supports-color@^5.3.0:
   version "5.5.0"


Test patch:
diff --git a/cli/cmd/testdata/install_controlplane_tracing_output.golden b/cli/cmd/testdata/install_controlplane_tracing_output.golden
index 5c58c6a22..7ff2debb7 100644
--- a/cli/cmd/testdata/install_controlplane_tracing_output.golden
+++ b/cli/cmd/testdata/install_controlplane_tracing_output.golden
@@ -238,6 +238,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -861,7 +869,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - args:
         - identity
@@ -1232,7 +1240,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: 6c6be36634a1edea1ebcad1c3981959c48eb6b78684e7093058a50eca6ba2f97
+        checksum/config: d4bf92fed9aef0313ad18729f0b373da57c319c902152dc80c605834dffe636f
         linkerd.io/created-by: linkerd/cli dev-undefined
         linkerd.io/proxy-version: install-proxy-version
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
@@ -1246,7 +1254,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
@@ -1680,7 +1688,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_custom_domain.golden b/cli/cmd/testdata/install_custom_domain.golden
index 01096f0c3..731d1a213 100644
--- a/cli/cmd/testdata/install_custom_domain.golden
+++ b/cli/cmd/testdata/install_custom_domain.golden
@@ -238,6 +238,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -861,7 +869,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - args:
         - identity
@@ -1231,7 +1239,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: 6c6be36634a1edea1ebcad1c3981959c48eb6b78684e7093058a50eca6ba2f97
+        checksum/config: d4bf92fed9aef0313ad18729f0b373da57c319c902152dc80c605834dffe636f
         linkerd.io/created-by: linkerd/cli dev-undefined
         linkerd.io/proxy-version: install-proxy-version
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
@@ -1245,7 +1253,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
@@ -1678,7 +1686,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_custom_registry.golden b/cli/cmd/testdata/install_custom_registry.golden
index be2a34800..9ab409fc9 100644
--- a/cli/cmd/testdata/install_custom_registry.golden
+++ b/cli/cmd/testdata/install_custom_registry.golden
@@ -238,6 +238,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -861,7 +869,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - args:
         - identity
@@ -1231,7 +1239,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: 6c6be36634a1edea1ebcad1c3981959c48eb6b78684e7093058a50eca6ba2f97
+        checksum/config: d4bf92fed9aef0313ad18729f0b373da57c319c902152dc80c605834dffe636f
         linkerd.io/created-by: linkerd/cli dev-undefined
         linkerd.io/proxy-version: install-proxy-version
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
@@ -1245,7 +1253,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
@@ -1678,7 +1686,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_default.golden b/cli/cmd/testdata/install_default.golden
index 01096f0c3..731d1a213 100644
--- a/cli/cmd/testdata/install_default.golden
+++ b/cli/cmd/testdata/install_default.golden
@@ -238,6 +238,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -861,7 +869,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - args:
         - identity
@@ -1231,7 +1239,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: 6c6be36634a1edea1ebcad1c3981959c48eb6b78684e7093058a50eca6ba2f97
+        checksum/config: d4bf92fed9aef0313ad18729f0b373da57c319c902152dc80c605834dffe636f
         linkerd.io/created-by: linkerd/cli dev-undefined
         linkerd.io/proxy-version: install-proxy-version
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
@@ -1245,7 +1253,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
@@ -1678,7 +1686,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_default_override_dst_get_nets.golden b/cli/cmd/testdata/install_default_override_dst_get_nets.golden
index 64a99407b..503d6047e 100644
--- a/cli/cmd/testdata/install_default_override_dst_get_nets.golden
+++ b/cli/cmd/testdata/install_default_override_dst_get_nets.golden
@@ -238,6 +238,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -861,7 +869,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - args:
         - identity
@@ -1231,7 +1239,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: 6c6be36634a1edea1ebcad1c3981959c48eb6b78684e7093058a50eca6ba2f97
+        checksum/config: d4bf92fed9aef0313ad18729f0b373da57c319c902152dc80c605834dffe636f
         linkerd.io/created-by: linkerd/cli dev-undefined
         linkerd.io/proxy-version: install-proxy-version
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
@@ -1245,7 +1253,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
@@ -1678,7 +1686,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_default_token.golden b/cli/cmd/testdata/install_default_token.golden
index dd73e0c20..f93164598 100644
--- a/cli/cmd/testdata/install_default_token.golden
+++ b/cli/cmd/testdata/install_default_token.golden
@@ -238,6 +238,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -861,7 +869,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - args:
         - identity
@@ -1222,7 +1230,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: 6c6be36634a1edea1ebcad1c3981959c48eb6b78684e7093058a50eca6ba2f97
+        checksum/config: d4bf92fed9aef0313ad18729f0b373da57c319c902152dc80c605834dffe636f
         linkerd.io/created-by: linkerd/cli dev-undefined
         linkerd.io/proxy-version: install-proxy-version
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
@@ -1236,7 +1244,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
@@ -1660,7 +1668,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_ha_output.golden b/cli/cmd/testdata/install_ha_output.golden
index 6226c4124..6d6caef0d 100644
--- a/cli/cmd/testdata/install_ha_output.golden
+++ b/cli/cmd/testdata/install_ha_output.golden
@@ -238,6 +238,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -1329,7 +1337,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: d678d93a3ae6be52fc98976b988d287fb3f8bc41d1eb9cc5f06d63fc3a3b9ee7
+        checksum/config: 23c9059f42334b0dcfa4b8bc3542d48652964fb3b1c9b414cb3a9020f73e556f
         linkerd.io/created-by: linkerd/cli dev-undefined
         linkerd.io/proxy-version: install-proxy-version
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
diff --git a/cli/cmd/testdata/install_ha_with_overrides_output.golden b/cli/cmd/testdata/install_ha_with_overrides_output.golden
index a7c633867..108137823 100644
--- a/cli/cmd/testdata/install_ha_with_overrides_output.golden
+++ b/cli/cmd/testdata/install_ha_with_overrides_output.golden
@@ -238,6 +238,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -1329,7 +1337,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: d678d93a3ae6be52fc98976b988d287fb3f8bc41d1eb9cc5f06d63fc3a3b9ee7
+        checksum/config: 23c9059f42334b0dcfa4b8bc3542d48652964fb3b1c9b414cb3a9020f73e556f
         linkerd.io/created-by: linkerd/cli dev-undefined
         linkerd.io/proxy-version: install-proxy-version
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
diff --git a/cli/cmd/testdata/install_heartbeat_disabled_output.golden b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
index e17f5634f..adbd0edae 100644
--- a/cli/cmd/testdata/install_heartbeat_disabled_output.golden
+++ b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
@@ -238,6 +238,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -792,7 +800,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - args:
         - identity
@@ -1162,7 +1170,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: 6c6be36634a1edea1ebcad1c3981959c48eb6b78684e7093058a50eca6ba2f97
+        checksum/config: d4bf92fed9aef0313ad18729f0b373da57c319c902152dc80c605834dffe636f
         linkerd.io/created-by: linkerd/cli dev-undefined
         linkerd.io/proxy-version: install-proxy-version
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
@@ -1176,7 +1184,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
@@ -1549,7 +1557,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_helm_control_plane_output.golden b/cli/cmd/testdata/install_helm_control_plane_output.golden
index fb2546893..e627fd4d2 100644
--- a/cli/cmd/testdata/install_helm_control_plane_output.golden
+++ b/cli/cmd/testdata/install_helm_control_plane_output.golden
@@ -229,6 +229,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -834,7 +842,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - args:
         - identity
@@ -1206,7 +1214,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: 387ff1ed65e66e400e556c47490d20187adcb885526d44d9f0aa75dc97f23223
+        checksum/config: d2b9e62513426ee1bac42dfc80477e8730316fd3bda7f422f1bdd27e3e59316f
         linkerd.io/created-by: linkerd/helm linkerd-version
         linkerd.io/proxy-version: test-proxy-version
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
@@ -1220,7 +1228,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
@@ -1657,7 +1665,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_helm_control_plane_output_ha.golden b/cli/cmd/testdata/install_helm_control_plane_output_ha.golden
index ab4f21c77..a5acfcf42 100644
--- a/cli/cmd/testdata/install_helm_control_plane_output_ha.golden
+++ b/cli/cmd/testdata/install_helm_control_plane_output_ha.golden
@@ -229,6 +229,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -1304,7 +1312,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: ff01f9fed5a80929d1215ebec514a25888ac7fed762a5a753bd896928e26b951
+        checksum/config: 87abaa41d2dbabb91103c13138f417ed7b9d048fd0d559a3666a139becd41366
         linkerd.io/created-by: linkerd/helm linkerd-version
         linkerd.io/proxy-version: test-proxy-version
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
diff --git a/cli/cmd/testdata/install_helm_output_ha_labels.golden b/cli/cmd/testdata/install_helm_output_ha_labels.golden
index 7f3885fe9..cb7fbfb48 100644
--- a/cli/cmd/testdata/install_helm_output_ha_labels.golden
+++ b/cli/cmd/testdata/install_helm_output_ha_labels.golden
@@ -229,6 +229,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -1312,7 +1320,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: ff01f9fed5a80929d1215ebec514a25888ac7fed762a5a753bd896928e26b951
+        checksum/config: 87abaa41d2dbabb91103c13138f417ed7b9d048fd0d559a3666a139becd41366
         linkerd.io/created-by: linkerd/helm linkerd-version
         linkerd.io/proxy-version: test-proxy-version
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
diff --git a/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden b/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
index 8ac004e00..e66b1af20 100644
--- a/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
+++ b/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
@@ -229,6 +229,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -1294,7 +1302,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: cf05a64f720a18752cb8e0e759cb4cd6e22602eb0129a2144a2093ac6ea87659
+        checksum/config: 0ad2f6f283bdfccbf428bff27240f60235400318a505eaca82af2dfcbb4865f5
         linkerd.io/created-by: linkerd/helm linkerd-version
         linkerd.io/proxy-version: test-proxy-version
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
diff --git a/cli/cmd/testdata/install_no_init_container.golden b/cli/cmd/testdata/install_no_init_container.golden
index f4d992fb5..874ede197 100644
--- a/cli/cmd/testdata/install_no_init_container.golden
+++ b/cli/cmd/testdata/install_no_init_container.golden
@@ -238,6 +238,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -861,7 +869,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - args:
         - identity
@@ -1225,7 +1233,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: 6c6be36634a1edea1ebcad1c3981959c48eb6b78684e7093058a50eca6ba2f97
+        checksum/config: d4bf92fed9aef0313ad18729f0b373da57c319c902152dc80c605834dffe636f
         linkerd.io/created-by: linkerd/cli dev-undefined
         linkerd.io/proxy-version: install-proxy-version
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
@@ -1239,7 +1247,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
@@ -1666,7 +1674,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_output.golden b/cli/cmd/testdata/install_output.golden
index 2ae231777..b0748edb8 100644
--- a/cli/cmd/testdata/install_output.golden
+++ b/cli/cmd/testdata/install_output.golden
@@ -235,6 +235,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -830,7 +838,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - args:
         - identity
@@ -1197,7 +1205,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: d3eac3abc6cf5f1943e0f005fd15ad0107229283fa6f8db72f9ccb4766bb2228
+        checksum/config: 1b37f796a2363c75c0ecfb56a47b7b0676591bb435fe038c4f3918183c1ba7f0
         linkerd.io/created-by: CliVersion
         linkerd.io/proxy-version: ProxyVersion
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
@@ -1211,7 +1219,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
@@ -1648,7 +1656,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_proxy_ignores.golden b/cli/cmd/testdata/install_proxy_ignores.golden
index fdb1a225c..b5c45f35c 100644
--- a/cli/cmd/testdata/install_proxy_ignores.golden
+++ b/cli/cmd/testdata/install_proxy_ignores.golden
@@ -238,6 +238,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -861,7 +869,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - args:
         - identity
@@ -1231,7 +1239,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: 6c6be36634a1edea1ebcad1c3981959c48eb6b78684e7093058a50eca6ba2f97
+        checksum/config: d4bf92fed9aef0313ad18729f0b373da57c319c902152dc80c605834dffe636f
         linkerd.io/created-by: linkerd/cli dev-undefined
         linkerd.io/proxy-version: install-proxy-version
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
@@ -1245,7 +1253,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
@@ -1678,7 +1686,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/cli/cmd/testdata/install_values_file.golden b/cli/cmd/testdata/install_values_file.golden
index 5859872de..c0fe996a8 100644
--- a/cli/cmd/testdata/install_values_file.golden
+++ b/cli/cmd/testdata/install_values_file.golden
@@ -238,6 +238,14 @@ rules:
       - httproutes/status
     verbs:
       - patch
+  - apiGroups:
+      - workload.linkerd.io
+    resources:
+      - externalworkloads
+    verbs:
+      - get
+      - list
+      - watch
   - apiGroups:
       - coordination.k8s.io
     resources:
@@ -861,7 +869,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - args:
         - identity
@@ -1231,7 +1239,7 @@ spec:
   template:
     metadata:
       annotations:
-        checksum/config: 6c6be36634a1edea1ebcad1c3981959c48eb6b78684e7093058a50eca6ba2f97
+        checksum/config: d4bf92fed9aef0313ad18729f0b373da57c319c902152dc80c605834dffe636f
         linkerd.io/created-by: linkerd/cli dev-undefined
         linkerd.io/proxy-version: install-proxy-version
         cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
@@ -1245,7 +1253,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
@@ -1678,7 +1686,7 @@ spec:
     spec:
       nodeSelector:
         kubernetes.io/os: linux
-
+      
       containers:
       - env:
         - name: _pod_name
diff --git a/policy-test/Cargo.toml b/policy-test/Cargo.toml
index b8505a5a3..9ddc8f144 100644
--- a/policy-test/Cargo.toml
+++ b/policy-test/Cargo.toml
@@ -14,6 +14,7 @@ k8s-gateway-api = "0.15"
 k8s-openapi = { version = "0.20", features = ["v1_22"] }
 linkerd-policy-controller-core = { path = "../policy-controller/core" }
 linkerd-policy-controller-k8s-api = { path = "../policy-controller/k8s/api" }
+linkerd-policy-controller-grpc = { path = "../policy-controller/grpc"}
 maplit = "1"
 rand = "0.8"
 serde = "1"
diff --git a/policy-test/src/grpc.rs b/policy-test/src/grpc.rs
index 58d7903ec..348c3aaa6 100644
--- a/policy-test/src/grpc.rs
+++ b/policy-test/src/grpc.rs
@@ -9,6 +9,7 @@ use linkerd2_proxy_api::{
     inbound::inbound_server_policies_client::InboundServerPoliciesClient,
     outbound::outbound_policies_client::OutboundPoliciesClient,
 };
+use linkerd_policy_controller_grpc::workload;
 use linkerd_policy_controller_k8s_api::{self as k8s, ResourceExt};
 use tokio::io;
 
@@ -58,6 +59,25 @@ macro_rules! assert_protocol_detect {
     }};
 }
 
+#[macro_export]
+macro_rules! assert_protocol_detect_external {
+    ($config:expr) => {{
+        use linkerd2_proxy_api::inbound;
+
+        assert_eq!(
+            $config.protocol,
+            Some(inbound::ProxyProtocol {
+                kind: Some(inbound::proxy_protocol::Kind::Detect(
+                    inbound::proxy_protocol::Detect {
+                        timeout: Some(std::time::Duration::from_secs(10).try_into().unwrap()),
+                        http_routes: vec![$crate::grpc::defaults::http_route()],
+                    }
+                ))
+            })
+        )
+    }};
+}
+
 #[macro_export]
 macro_rules! assert_default_accrual_backoff {
     ($backoff:expr) => {{
@@ -157,6 +177,7 @@ impl InboundPolicyClient {
         Ok(rsp.into_inner())
     }
 
+    //TODO (matei): we should move our tests over to the new token format
     pub async fn watch_port(
         &mut self,
         ns: &str,
@@ -172,6 +193,52 @@ impl InboundPolicyClient {
             .await?;
         Ok(rsp.into_inner())
     }
+
+    //TODO (matei): see if we can collapse this into `get_port` once it supports
+    //new token format
+    pub async fn get_port_for_external_workload(
+        &mut self,
+        ns: &str,
+        name: &str,
+        port: u16,
+    ) -> Result<inbound::Server, tonic::Status> {
+        let token = serde_json::to_string(&workload::Workload {
+            kind: workload::Kind::External(name.into()),
+            namespace: ns.into(),
+        })
+        .unwrap();
+        let rsp = self
+            .client
+            .get_port(tonic::Request::new(inbound::PortSpec {
+                workload: token,
+                port: port as u32,
+            }))
+            .await?;
+
+        Ok(rsp.into_inner())
+    }
+
+    pub async fn watch_port_for_external_workload(
+        &mut self,
+        ns: &str,
+        name: &str,
+        port: u16,
+    ) -> Result<tonic::Streaming<inbound::Server>, tonic::Status> {
+        let token = serde_json::to_string(&workload::Workload {
+            kind: workload::Kind::External(name.into()),
+            namespace: ns.into(),
+        })
+        .unwrap();
+        let rsp = self
+            .client
+            .watch_port(tonic::Request::new(inbound::PortSpec {
+                workload: token,
+                port: port as u32,
+            }))
+            .await?;
+
+        Ok(rsp.into_inner())
+    }
 }
 
 // === impl OutboundPolicyClient ===
@@ -309,6 +376,15 @@ pub mod defaults {
         }
     }
 
+    pub fn proxy_protocol_external() -> inbound::ProxyProtocol {
+        use inbound::proxy_protocol::{Http1, Kind};
+        inbound::ProxyProtocol {
+            kind: Some(Kind::Http1(Http1 {
+                routes: vec![http_route()],
+            })),
+        }
+    }
+
     pub fn http_route() -> inbound::HttpRoute {
         use http_route::{path_match, HttpRouteMatch, PathMatch};
         use inbound::{http_route::Rule, HttpRoute};
diff --git a/policy-test/tests/inbound_api_external_workload.rs b/policy-test/tests/inbound_api_external_workload.rs
new file mode 100644
index 000000000..d4bb9edaf
--- /dev/null
+++ b/policy-test/tests/inbound_api_external_workload.rs
@@ -0,0 +1,464 @@
+use std::{num::NonZeroU16, time::Duration};
+
+use futures::prelude::*;
+use k8s::policy::LocalTargetRef;
+use kube::ResourceExt;
+use linkerd_policy_controller_core::{Ipv4Net, Ipv6Net};
+use linkerd_policy_controller_k8s_api as k8s;
+use linkerd_policy_test::{
+    assert_default_all_unauthenticated_labels, assert_is_default_all_unauthenticated,
+    assert_protocol_detect_external, create, grpc, with_temp_ns,
+};
+use maplit::{btreemap, convert_args, hashmap};
+use tokio::time;
+
+#[tokio::test(flavor = "current_thread")]
+async fn external_workload_srv_with_authorization_policy() {
+    with_temp_ns(|client, ns| async move {
+        // Create an external workload object.
+        let ext_workload = create(&client, mk_external_workload(&ns, "wkld-1")).await;
+
+        tracing::trace!(
+            external_workload = %ext_workload.name_any(),
+            ip = ?ext_workload.spec.workload_ips.as_ref().unwrap()[0]
+        );
+
+        let mut rx = retry_watch_server(&client, &ns, &ext_workload.name_any()).await;
+        let config = rx
+            .next()
+            .await
+            .expect("watch must not fail")
+            .expect("watch must return an initial config");
+        tracing::trace!(?config);
+        assert_is_default_all_unauthenticated!(config);
+        assert_protocol_detect_external!(config);
+
+        // Create a server that selects the http port on the workload and ensure
+        // the update now uses this server (which has no authorizations)
+        let server = create(&client, mk_http_server(&ns, "external-http")).await;
+        let config = rx
+            .next()
+            .await
+            .expect("watch must not fail")
+            .expect("watch must return an initial config");
+        assert_eq!(
+            config.protocol,
+            Some(grpc::defaults::proxy_protocol_external())
+        );
+        assert_eq!(config.authorizations, vec![]);
+        assert_eq!(
+            config.labels,
+            convert_args!(hashmap!(
+                "group" => "policy.linkerd.io",
+                "kind" => "server",
+                "name" => "external-http"
+            )),
+        );
+
+        // Create a server authorization that refers to the `linkerd-admin`
+        // server (by name) and ensure that the update now reflects this
+        // authorization.
+        create(
+            &client,
+            k8s::policy::AuthorizationPolicy {
+                metadata: kube::api::ObjectMeta {
+                    namespace: Some(ns.clone()),
+                    name: Some("all-http".to_string()),
+                    ..Default::default()
+                },
+                spec: k8s::policy::AuthorizationPolicySpec {
+                    target_ref: LocalTargetRef {
+                        group: Some("policy.linkerd.io".to_string()),
+                        kind: "server".to_string(),
+                        name: server.name_any(),
+                    },
+                    required_authentication_refs: vec![],
+                },
+            },
+        )
+        .await;
+        let config = rx
+            .next()
+            .await
+            .expect("watch must not fail")
+            .expect("watch must return an updated config");
+        tracing::trace!(?config);
+        assert_eq!(
+            config.protocol,
+            Some(grpc::defaults::proxy_protocol_external())
+        );
+        assert_eq!(
+            config.authorizations.first().unwrap().labels,
+            convert_args!(hashmap!(
+                "group" => "policy.linkerd.io",
+                "kind" => "authorizationpolicy",
+                "name" => "all-http",
+            )),
+        );
+        assert_eq!(
+            *config
+                .authorizations
+                .first()
+                .unwrap()
+                .authentication
+                .as_ref()
+                .unwrap(),
+            grpc::inbound::Authn {
+                permit: Some(grpc::inbound::authn::Permit::Unauthenticated(
+                    grpc::inbound::authn::PermitUnauthenticated {}
+                )),
+            }
+        );
+        assert_eq!(
+            config.labels,
+            convert_args!(hashmap!(
+                "group" => "policy.linkerd.io",
+                "kind" => "server",
+                "name" => server.name_unchecked()
+            ))
+        );
+
+        // Delete the `Server` and ensure that the update reverts to the
+        // default.
+        kube::Api::<k8s::policy::Server>::namespaced(client.clone(), &ns)
+            .delete(
+                &server.name_unchecked(),
+                &kube::api::DeleteParams::default(),
+            )
+            .await
+            .expect("Server must be deleted");
+        let config = rx
+            .next()
+            .await
+            .expect("watch must not fail")
+            .expect("watch must return an updated config");
+
+        assert_is_default_all_unauthenticated!(config);
+        assert_protocol_detect_external!(config);
+    })
+    .await
+}
+
+#[tokio::test(flavor = "current_thread")]
+async fn external_workload_srv_with_http_route() {
+    with_temp_ns(|client, ns| async move {
+        // Create an external workload object.
+        let ext_workload = create(&client, mk_external_workload(&ns, "wkld-1")).await;
+
+        tracing::trace!(
+            external_workload = %ext_workload.name_any(),
+            ip = ?ext_workload.spec.workload_ips.as_ref().unwrap()[0]
+        );
+
+        let mut rx = retry_watch_server(&client, &ns, &ext_workload.name_any()).await;
+        let config = rx
+            .next()
+            .await
+            .expect("watch must not fail")
+            .expect("watch must return an initial config");
+        tracing::trace!(?config);
+        assert_is_default_all_unauthenticated!(config);
+        assert_protocol_detect_external!(config);
+
+        // Create a server that selects the http port on the workload and ensure
+        // the update now uses this server (which has no authorizations)
+        let server = create(&client, mk_http_server(&ns, "external-http")).await;
+        let config = rx
+            .next()
+            .await
+            .expect("watch must not fail")
+            .expect("watch must return an initial config");
+        assert_eq!(
+            config.protocol,
+            Some(grpc::defaults::proxy_protocol_external())
+        );
+        assert_eq!(config.authorizations, vec![]);
+        assert_eq!(
+            config.labels,
+            convert_args!(hashmap!(
+                "group" => "policy.linkerd.io",
+                "kind" => "server",
+                "name" => "external-http"
+            )),
+        );
+
+        let created_route = {
+            use k8s::policy::httproute as api;
+            let http_route = api::HttpRoute {
+                metadata: kube::api::ObjectMeta {
+                    namespace: Some(ns.to_string()),
+                    name: Some("http-route".to_string()),
+                    ..Default::default()
+                },
+                spec: api::HttpRouteSpec {
+                    inner: api::CommonRouteSpec {
+                        parent_refs: Some(vec![api::ParentReference {
+                            group: Some("policy.linkerd.io".to_string()),
+                            kind: Some("Server".to_string()),
+                            name: server.name_any(),
+                            namespace: None,
+                            section_name: None,
+                            port: None,
+                        }]),
+                    },
+                    hostnames: None,
+                    rules: Some(vec![api::HttpRouteRule {
+                        matches: Some(vec![api::HttpRouteMatch {
+                            path: Some(api::HttpPathMatch::Exact {
+                                value: "/endpoint".to_string(),
+                            }),
+                            headers: None,
+                            query_params: None,
+                            method: Some("GET".to_string()),
+                        }]),
+                        filters: None,
+                        backend_refs: None,
+                        timeouts: None,
+                    }]),
+                },
+                status: None,
+            };
+
+            create(&client, http_route).await
+        };
+
+        let config = rx
+            .next()
+            .await
+            .expect("watch must not fail")
+            .expect("watch must return an initial config");
+        let kind = config
+            .protocol
+            .as_ref()
+            .expect("must have proxy protocol")
+            .kind
+            .as_ref()
+            .expect("must have kind");
+        let routes = if let grpc::inbound::proxy_protocol::Kind::Http1(ref http1) = kind {
+            &http1.routes[..]
+        } else {
+            panic!("proxy protocol must be 'Http1'; actually got:\n{kind:#?}");
+        };
+
+        assert_eq!(routes.len(), 1);
+        let route = routes.first().expect("must have route");
+        // Route should have no authz policy by default
+        assert_eq!(route.authorizations, vec![]);
+        assert_eq!(
+            route.metadata.to_owned().expect("route must have metadata"),
+            grpc::meta::Metadata {
+                kind: Some(grpc::meta::metadata::Kind::Resource(grpc::meta::Resource {
+                    group: "policy.linkerd.io".to_string(),
+                    kind: "HTTPRoute".to_string(),
+                    name: "http-route".to_string(),
+                    ..Default::default()
+                }))
+            }
+        );
+
+        // Route has path match
+        let rule_match = route
+            .rules
+            .first()
+            .expect("must have rule")
+            .matches
+            .first()
+            .expect("must have match");
+        assert_eq!(
+            rule_match
+                .path
+                .to_owned()
+                .expect("must have path match")
+                .kind
+                .expect("must have kind"),
+            grpc::http_route::path_match::Kind::Exact("/endpoint".to_string())
+        );
+
+        // Create a network authn and a policy that refers to the route
+        let all_networks = create(
+            &client,
+            k8s::policy::NetworkAuthentication {
+                metadata: kube::api::ObjectMeta {
+                    namespace: Some(ns.clone()),
+                    name: Some("all-net".to_string()),
+                    ..Default::default()
+                },
+                spec: k8s::policy::NetworkAuthenticationSpec {
+                    networks: vec![
+                        k8s::policy::network_authentication::Network {
+                            cidr: Ipv4Net::default().into(),
+                            except: None,
+                        },
+                        k8s::policy::network_authentication::Network {
+                            cidr: Ipv6Net::default().into(),
+                            except: None,
+                        },
+                    ],
+                },
+            },
+        )
+        .await;
+        create(
+            &client,
+            k8s::policy::AuthorizationPolicy {
+                metadata: kube::api::ObjectMeta {
+                    namespace: Some(ns.clone()),
+                    name: Some("all-net".to_string()),
+                    ..Default::default()
+                },
+                spec: k8s::policy::AuthorizationPolicySpec {
+                    target_ref: k8s::policy::LocalTargetRef::from_resource(&created_route),
+                    required_authentication_refs: vec![
+                        k8s::policy::NamespacedTargetRef::from_resource(&all_networks),
+                    ],
+                },
+            },
+        )
+        .await;
+
+        let config = rx
+            .next()
+            .await
+            .expect("watch must not fail")
+            .expect("watch must return an initial config");
+        let http1 = if let grpc::inbound::proxy_protocol::Kind::Http1(http1) = config
+            .protocol
+            .expect("must have proxy protocol")
+            .kind
+            .expect("must have kind")
+        {
+            http1
+        } else {
+            panic!("proxy protocol must be HTTP1");
+        };
+        let h1_route = http1.routes.first().expect("must have route");
+        assert_eq!(h1_route.authorizations.len(), 1, "must have authorizations");
+
+        // Delete the `HttpRoute` and ensure that the update reverts to the
+        // default.
+        kube::Api::<k8s::policy::HttpRoute>::namespaced(client.clone(), &ns)
+            .delete("http-route", &kube::api::DeleteParams::default())
+            .await
+            .expect("HttpRoute must be deleted");
+        let config = rx
+            .next()
+            .await
+            .expect("watch must not fail")
+            .expect("watch must return an initial config");
+        assert_eq!(
+            config.protocol,
+            Some(grpc::defaults::proxy_protocol_external())
+        );
+    })
+    .await;
+}
+#[tokio::test(flavor = "current_thread")]
+async fn external_workload_default_http_route() {
+    with_temp_ns(|client, ns| async move {
+        // Create an external workload object.
+        let ext_workload = create(&client, mk_external_workload(&ns, "wkld-1")).await;
+
+        tracing::trace!(
+            external_workload = %ext_workload.name_any(),
+            ip = ?ext_workload.spec.workload_ips.as_ref().unwrap()[0]
+        );
+
+        let mut rx = retry_watch_server(&client, &ns, &ext_workload.name_any()).await;
+        let config = rx
+            .next()
+            .await
+            .expect("watch must not fail")
+            .expect("watch must return an initial config");
+        tracing::trace!(?config);
+        assert_is_default_all_unauthenticated!(config);
+        assert_protocol_detect_external!(config);
+
+        let kind = config
+            .protocol
+            .as_ref()
+            .expect("must have proxy protocol")
+            .kind
+            .as_ref()
+            .expect("must have kind");
+        let routes = if let grpc::inbound::proxy_protocol::Kind::Detect(ref detect) = kind {
+            &detect.http_routes[..]
+        } else {
+            panic!("proxy protocol must be 'Detect'; actually got:\n{kind:#?}");
+        };
+
+        assert_eq!(routes.len(), 1);
+        let route_authzs = &routes[0].authorizations;
+        assert_eq!(route_authzs.len(), 0);
+    })
+    .await
+}
+
+fn mk_external_workload(ns: &str, name: &str) -> k8s::external_workload::ExternalWorkload {
+    k8s::external_workload::ExternalWorkload {
+        metadata: k8s::ObjectMeta {
+            namespace: Some(ns.into()),
+            name: Some(name.into()),
+            labels: Some(convert_args!(btreemap!(
+                        "app" => "ext",
+            ))),
+            ..Default::default()
+        },
+        spec: k8s::external_workload::ExternalWorkloadSpec {
+            mesh_tls: k8s::external_workload::MeshTls {
+                identity: "some-identity".to_string(),
+                server_name: "some-sni".to_string(),
+            },
+            ports: Some(vec![k8s::external_workload::PortSpec {
+                name: Some("http".into()),
+                port: NonZeroU16::new(80).unwrap(),
+                protocol: Default::default(),
+            }]),
+            workload_ips: Some(vec![k8s::external_workload::WorkloadIP {
+                ip: "192.0.2.0".to_string(),
+            }]),
+        },
+        status: None,
+    }
+}
+
+async fn retry_watch_server(
+    client: &kube::Client,
+    ns: &str,
+    workload_name: &str,
+) -> tonic::Streaming<grpc::inbound::Server> {
+    let mut policy_api = grpc::InboundPolicyClient::port_forwarded(client).await;
+    loop {
+        match policy_api
+            .watch_port_for_external_workload(ns, workload_name, 80)
+            .await
+        {
+            Ok(rx) => return rx,
+            Err(error) => {
+                tracing::error!(
+                    ?error,
+                    ns,
+                    workload_name,
+                    "failed to watch policy for port 80"
+                );
+                time::sleep(Duration::from_secs(1)).await;
+            }
+        }
+    }
+}
+
+fn mk_http_server(ns: &str, name: &str) -> k8s::policy::Server {
+    k8s::policy::Server {
+        metadata: k8s::ObjectMeta {
+            namespace: Some(ns.to_string()),
+            name: Some(name.to_string()),
+            ..Default::default()
+        },
+        spec: k8s::policy::ServerSpec {
+            selector: k8s::policy::server::Selector::ExternalWorkload(
+                k8s::labels::Selector::default(),
+            ),
+            port: k8s::policy::server::Port::Name("http".to_string()),
+            proxy_protocol: Some(k8s::policy::server::ProxyProtocol::Http1),
+        },
+    }
+}