Repository: linkerd/linkerd2
Pull request #8075 (2022-03-15 21:33:25)

split creating and updating mirror endpoints
#8022 introduced `createOrUpdateEndpoints` which creates or updates mirror endpoints in multicluster installations. The reason that creating and updating endpoints was put behind a single function is so that we could consolidate the `gatewayAlive` check down to a single location. This is good because we know that we aren't missing any places in the service mirror code where we should be checking `gatewayAlive`.

This isn't ideal though for updating endpoints because it always involves an API `Endpoints(...).Get(...)` to satisfy the `Create` case. If `Get` fails, we know we have to create; if it does not fail we _ignore_ the returned endpoints and instead `Update` with the given endpoints. While this isn't a huge bottleneck, it does scale with the number of services that are being mirrored and is easy to skip.

We always know whether we want to update or create; we never are in a case of falling back to create endpoints. Therefore, we can split this function into separate `Create` and `Update` API calls and just make sure to have the same `gatewayAlive` check at the start of each.

Signed-off-by: Kevin Leimkuhler <kleimkuhler@icloud.com>



Patch:
diff --git a/Cargo.lock b/Cargo.lock
index b41b4d9ba..933a2d2aa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -256,9 +256,9 @@ dependencies = [
 
 [[package]]
 name = "dyn-clone"
-version = "1.0.4"
+version = "1.0.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee2626afccd7561a06cf1367e2950c4718ea04565e20fb5029b6c7d8ad09abcf"
+checksum = "21e50f3adc76d6a43f5ed73b698a87d0760ca74617f60f7c3b879003536fdd28"
 
 [[package]]
 name = "either"
@@ -415,7 +415,7 @@ checksum = "d39cd93900197114fa1fcb7ae84ca742095eed9442088988ae74fa744e930e77"
 dependencies = [
  "cfg-if",
  "libc",
- "wasi",
+ "wasi 0.10.0+wasi-snapshot-preview1",
 ]
 
 [[package]]
@@ -612,6 +612,10 @@ name = "ipnet"
 version = "2.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "35e70ee094dc02fd9c13fdad4940090f22dbd6ac7c9e7094a46cf0232a50bc7c"
+dependencies = [
+ "schemars",
+ "serde",
+]
 
 [[package]]
 name = "itertools"
@@ -852,10 +856,12 @@ name = "linkerd-policy-controller"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "async-trait",
  "clap",
  "drain",
  "futures",
  "hyper",
+ "ipnet",
  "jemallocator",
  "k8s-openapi",
  "kube",
@@ -865,6 +871,7 @@ dependencies = [
  "linkerd-policy-controller-k8s-api",
  "linkerd-policy-controller-k8s-index",
  "parking_lot 0.12.0",
+ "serde",
  "serde_json",
  "thiserror",
  "tokio",
@@ -901,6 +908,7 @@ dependencies = [
 name = "linkerd-policy-controller-k8s-api"
 version = "0.1.0"
 dependencies = [
+ "ipnet",
  "k8s-openapi",
  "kube",
  "schemars",
@@ -939,7 +947,9 @@ dependencies = [
  "kube",
  "linkerd-policy-controller-k8s-api",
  "rand",
+ "schemars",
  "serde",
+ "serde_json",
  "tokio",
  "tokio-test",
  "tracing",
@@ -1001,14 +1011,15 @@ checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a"
 
 [[package]]
 name = "mio"
-version = "0.8.0"
+version = "0.8.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba272f85fa0b41fc91872be579b3bbe0f56b792aa361a380eb669469f68dafb2"
+checksum = "7ba42135c6a5917b9db9cd7b293e5409e1c6b041e6f9825e92e55a894c63b6f8"
 dependencies = [
  "libc",
  "log",
  "miow",
  "ntapi",
+ "wasi 0.11.0+wasi-snapshot-preview1",
  "winapi",
 ]
 
@@ -2164,6 +2175,12 @@ version = "0.10.0+wasi-snapshot-preview1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
 
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
 [[package]]
 name = "wasm-bindgen"
 version = "0.2.79"
diff --git a/charts/linkerd-control-plane/templates/destination-rbac.yaml b/charts/linkerd-control-plane/templates/destination-rbac.yaml
index 95cb6e5d2..eba642aaf 100644
--- a/charts/linkerd-control-plane/templates/destination-rbac.yaml
+++ b/charts/linkerd-control-plane/templates/destination-rbac.yaml
@@ -165,7 +165,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/go.mod b/go.mod
index 323fce6c8..621abcb96 100644
--- a/go.mod
+++ b/go.mod
@@ -36,7 +36,7 @@ require (
 	github.com/spf13/pflag v1.0.5
 	go.opencensus.io v0.23.0
 	golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d
-	golang.org/x/tools v0.1.9
+	golang.org/x/tools v0.1.10
 	google.golang.org/grpc v1.45.0
 	google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.2.0
 	google.golang.org/protobuf v1.27.1
@@ -46,7 +46,7 @@ require (
 	k8s.io/apimachinery v0.23.4
 	k8s.io/client-go v0.23.4
 	k8s.io/code-generator v0.23.4
-	k8s.io/klog/v2 v2.40.1
+	k8s.io/klog/v2 v2.50.0
 	k8s.io/kube-aggregator v0.23.4
 	sigs.k8s.io/yaml v1.3.0
 )
@@ -136,7 +136,7 @@ require (
 	github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca // indirect
 	go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 // indirect
 	golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 // indirect
-	golang.org/x/mod v0.5.1 // indirect
+	golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 // indirect
 	golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
 	golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect
@@ -153,7 +153,7 @@ require (
 	k8s.io/cli-runtime v0.23.4 // indirect
 	k8s.io/gengo v0.0.0-20210813121822-485abfe95c7c // indirect
 	k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65 // indirect
-	k8s.io/utils v0.0.0-20211116205334-6203023598ed // indirect
+	k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 // indirect
 	oras.land/oras-go v1.1.0 // indirect
 	sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6 // indirect
 	sigs.k8s.io/kustomize/api v0.10.1 // indirect
diff --git a/go.sum b/go.sum
index 9dfbf19c5..23f169050 100644
--- a/go.sum
+++ b/go.sum
@@ -1007,6 +1007,7 @@ golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPh
 golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 h1:/pEO3GD/ABYAjuakUS6xSEmmlyVS4kxBNkeA9tLJiTI=
 golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -1045,8 +1046,8 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
-golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38=
-golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
+golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 h1:kQgndtyPBW/JIYERgdxfwMYh3AVStj88WQTlNDi2a+o=
+golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1330,8 +1331,8 @@ golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.6-0.20210820212750-d4cc65f0b2ff/go.mod h1:YD9qOF0M9xpSpdWTBbzEl5e/RnCefISl8E5Noe10jFM=
-golang.org/x/tools v0.1.9 h1:j9KsMiaP1c3B0OTQGth0/k+miLGTgLsAFUCrF2vLcF8=
-golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
+golang.org/x/tools v0.1.10 h1:QjFRCZxdOhBJ/UNgnBZLbNV13DlbnK0quyivTnXJM20=
+golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1583,8 +1584,8 @@ k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE=
 k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y=
 k8s.io/klog/v2 v2.4.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y=
 k8s.io/klog/v2 v2.30.0/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
-k8s.io/klog/v2 v2.40.1 h1:P4RRucWk/lFOlDdkAr3mc7iWFkgKrZY9qZMAgek06S4=
-k8s.io/klog/v2 v2.40.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
+k8s.io/klog/v2 v2.50.0 h1:2QWjlAHYTUqUM5XMSobX4mc//mXrf194+hiaZ4jy57Q=
+k8s.io/klog/v2 v2.50.0/go.mod h1:N3kgBtsFxMb4nQ0eBDgbHEt/dtxBuTkSFQ+7K5OUoz4=
 k8s.io/kube-aggregator v0.23.4 h1:gLk78rGLVfUXCdD14NrKg/JFBmNNCZ8FEs3tYt+W6Zk=
 k8s.io/kube-aggregator v0.23.4/go.mod h1:hpmPi4oaLBe014CkBCqzBYWok64H2C7Ka6FBLJvHgkg=
 k8s.io/kube-openapi v0.0.0-20200805222855-6aeccd4b50c6/go.mod h1:UuqjUnNftUyPE5H64/qeyjQoUZhGpeFDVdxjTeEVN2o=
@@ -1596,8 +1597,9 @@ k8s.io/kubectl v0.23.4/go.mod h1:Dgb0Rvx/8JKS/C2EuvsNiQc6RZnX0SbHJVG3XUzH6ok=
 k8s.io/metrics v0.23.4/go.mod h1:cl6sY9BdVT3DubbpqnkPIKi6mn/F2ltkU4yH1tEJ3Bo=
 k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
 k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
-k8s.io/utils v0.0.0-20211116205334-6203023598ed h1:ck1fRPWPJWsMd8ZRFsWc6mh/zHp5fZ/shhbrgPUxDAE=
 k8s.io/utils v0.0.0-20211116205334-6203023598ed/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
+k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 h1:HNSDgDCrr/6Ly3WEGKZftiE7IY19Vz2GdbOCyI4qqhc=
+k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
 oras.land/oras-go v1.1.0 h1:tfWM1RT7PzUwWphqHU6ptPU3ZhwVnSw/9nEGf519rYg=
 oras.land/oras-go v1.1.0/go.mod h1:1A7vR/0KknT2UkJVWh+xMi95I/AhK8ZrxrnUSmXN0bQ=
 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
diff --git a/multicluster/service-mirror/cluster_watcher.go b/multicluster/service-mirror/cluster_watcher.go
index bc13e3f54..0cf30f3a0 100644
--- a/multicluster/service-mirror/cluster_watcher.go
+++ b/multicluster/service-mirror/cluster_watcher.go
@@ -438,7 +438,7 @@ func (rcsw *RemoteClusterServiceWatcher) handleRemoteServiceUpdated(ctx context.
 	}
 	copiedEndpoints.Annotations[consts.RemoteGatewayIdentity] = rcsw.link.GatewayIdentity
 
-	err = rcsw.createOrUpdateEndpoints(ctx, copiedEndpoints)
+	err = rcsw.updateMirrorEndpoints(ctx, copiedEndpoints)
 	if err != nil {
 		return RetryableError{[]error{err}}
 	}
@@ -616,7 +616,7 @@ func (rcsw *RemoteClusterServiceWatcher) createGatewayEndpoints(ctx context.Cont
 	}
 
 	rcsw.log.Infof("Creating a new endpoints for %s", serviceInfo)
-	err = rcsw.createOrUpdateEndpoints(ctx, endpointsToCreate)
+	err = rcsw.createMirrorEndpoints(ctx, endpointsToCreate)
 	if err != nil {
 		if svcErr := rcsw.localAPIClient.Client.CoreV1().Services(exportedService.Namespace).Delete(ctx, localServiceName, metav1.DeleteOptions{}); svcErr != nil {
 			rcsw.log.Errorf("Failed to delete service %s after endpoints creation failed: %s", localServiceName, svcErr)
@@ -999,7 +999,7 @@ func (rcsw *RemoteClusterServiceWatcher) repairEndpoints(ctx context.Context) er
 			rcsw.log.Error(err)
 			continue
 		}
-		err = rcsw.createOrUpdateEndpoints(ctx, updatedEndpoints)
+		err = rcsw.updateMirrorEndpoints(ctx, updatedEndpoints)
 		if err != nil {
 			rcsw.log.Error(err)
 		}
@@ -1110,14 +1110,35 @@ func (rcsw *RemoteClusterServiceWatcher) handleCreateOrUpdateEndpoints(
 			},
 		}
 	}
-	return rcsw.createOrUpdateEndpoints(ctx, ep)
+	return rcsw.updateMirrorEndpoints(ctx, ep)
 }
 
-// createOrUpdateEndpoints will create or update endpoints based off gateway
-// liveness. If the gateway is not alive, then the addresses in each subset
-// will be set to not ready; these will moved back to ready once the gateway
-// is alive and an update is received.
-func (rcsw *RemoteClusterServiceWatcher) createOrUpdateEndpoints(ctx context.Context, endpoints *corev1.Endpoints) error {
+// createMirrorEndpoints will create endpoints based off gateway liveness. If
+// the gateway is not alive, then the addresses in each subset will be set to
+// not ready.
+func (rcsw *RemoteClusterServiceWatcher) createMirrorEndpoints(ctx context.Context, endpoints *corev1.Endpoints) error {
+	rcsw.updateReadiness(endpoints)
+	_, err := rcsw.localAPIClient.Client.CoreV1().Endpoints(endpoints.Namespace).Create(ctx, endpoints, metav1.CreateOptions{})
+	if err != nil {
+		return fmt.Errorf("Failed to create mirror endpoints for %s/%s: %w", endpoints.Namespace, endpoints.Name, err)
+	}
+	return nil
+}
+
+// updateMirrorEndpoints will update endpoints based off gateway liveness. If
+// the gateway is not alive, then the addresses in each subset will be set to
+// not ready. Future calls to updateMirrorEndpoints can set the addresses back
+// to ready if the gateway is alive.
+func (rcsw *RemoteClusterServiceWatcher) updateMirrorEndpoints(ctx context.Context, endpoints *corev1.Endpoints) error {
+	rcsw.updateReadiness(endpoints)
+	_, err := rcsw.localAPIClient.Client.CoreV1().Endpoints(endpoints.Namespace).Update(ctx, endpoints, metav1.UpdateOptions{})
+	if err != nil {
+		return fmt.Errorf("Failed to update mirror endpoints for %s/%s: %w", endpoints.Namespace, endpoints.Name, err)
+	}
+	return err
+}
+
+func (rcsw *RemoteClusterServiceWatcher) updateReadiness(endpoints *corev1.Endpoints) {
 	if !rcsw.gatewayAlive {
 		rcsw.log.Warnf("gateway for %s/%s does not have ready addresses; setting addresses to not ready", endpoints.Namespace, endpoints.Name)
 		for i := range endpoints.Subsets {
@@ -1125,19 +1146,6 @@ func (rcsw *RemoteClusterServiceWatcher) createOrUpdateEndpoints(ctx context.Con
 			endpoints.Subsets[i].Addresses = nil
 		}
 	}
-	_, err := rcsw.localAPIClient.Client.CoreV1().Endpoints(endpoints.Namespace).Get(ctx, endpoints.Name, metav1.GetOptions{})
-	if err != nil {
-		if !kerrors.IsNotFound(err) {
-			return fmt.Errorf("Failed to get endpoints for %s/%s: %w", endpoints.Namespace, endpoints.Name, err)
-		}
-		_, err := rcsw.localAPIClient.Client.CoreV1().Endpoints(endpoints.Namespace).Create(ctx, endpoints, metav1.CreateOptions{})
-		if err != nil {
-			return fmt.Errorf("Failed to create endpoints for %s/%s: %w", endpoints.Namespace, endpoints.Name, err)
-		}
-		return nil
-	}
-	_, err = rcsw.localAPIClient.Client.CoreV1().Endpoints(endpoints.Namespace).Update(ctx, endpoints, metav1.UpdateOptions{})
-	return err
 }
 
 func isExportedEndpoints(obj interface{}, log *logging.Entry) bool {
diff --git a/multicluster/service-mirror/cluster_watcher_headless.go b/multicluster/service-mirror/cluster_watcher_headless.go
index 5b44e1045..d6eac9b56 100644
--- a/multicluster/service-mirror/cluster_watcher_headless.go
+++ b/multicluster/service-mirror/cluster_watcher_headless.go
@@ -169,7 +169,7 @@ func (rcsw *RemoteClusterServiceWatcher) createOrUpdateHeadlessEndpoints(ctx con
 
 	// Update endpoints
 	mirrorEndpoints.Subsets = newSubsets
-	err = rcsw.createOrUpdateEndpoints(ctx, mirrorEndpoints)
+	err = rcsw.updateMirrorEndpoints(ctx, mirrorEndpoints)
 	if err != nil {
 		return RetryableError{[]error{err}}
 	}
diff --git a/policy-controller/Cargo.toml b/policy-controller/Cargo.toml
index a5d380ded..643f8160f 100644
--- a/policy-controller/Cargo.toml
+++ b/policy-controller/Cargo.toml
@@ -12,16 +12,19 @@ rustls-tls = ["kube/rustls-tls"]
 
 [dependencies]
 anyhow = "1"
+async-trait = "0.1"
 clap = { version = "3", default-features = false, features = ["derive", "env", "std"] }
 drain = "0.1"
 futures = { version = "0.3", default-features = false }
 k8s-openapi = { version = "0.14", features = ["v1_20"] }
 hyper = { version = "0.14", features = ["http1", "http2", "runtime", "server"] }
+ipnet = { version = "2", default-features = false }
 linkerd-policy-controller-core = { path = "./core" }
 linkerd-policy-controller-grpc = { path = "./grpc" }
 linkerd-policy-controller-k8s-index = { path = "./k8s/index" }
 linkerd-policy-controller-k8s-api = { path = "./k8s/api" }
 parking_lot = "0.12"
+serde = "1"
 serde_json = "1"
 thiserror = "1"
 tracing = "0.1"
diff --git a/policy-controller/k8s/api/Cargo.toml b/policy-controller/k8s/api/Cargo.toml
index 7f0f25d01..8e0bf2458 100644
--- a/policy-controller/k8s/api/Cargo.toml
+++ b/policy-controller/k8s/api/Cargo.toml
@@ -8,6 +8,7 @@ publish = false
 [dependencies]
 k8s-openapi = { version = "0.14", default-features = false, features = ["v1_20"] }
 kube = { version = "0.69", default-features = false, features = ["client", "derive", "runtime"] }
+ipnet = { version = "2.4", features = ["json"] }
 schemars = "0.8"
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
diff --git a/policy-controller/k8s/api/src/policy/server_authorization.rs b/policy-controller/k8s/api/src/policy/server_authorization.rs
index 2291294f0..f74c43cc5 100644
--- a/policy-controller/k8s/api/src/policy/server_authorization.rs
+++ b/policy-controller/k8s/api/src/policy/server_authorization.rs
@@ -1,4 +1,5 @@
 use super::super::labels;
+use ipnet::IpNet;
 use kube::CustomResource;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
@@ -56,8 +57,8 @@ pub struct MeshTls {
 
 #[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
 pub struct Network {
-    pub cidr: String,
-    pub except: Option<Vec<String>>,
+    pub cidr: IpNet,
+    pub except: Option<Vec<IpNet>>,
 }
 
 /// References a Kubernetes `ServiceAccount` instance.
diff --git a/policy-controller/k8s/index/src/server_authorization.rs b/policy-controller/k8s/index/src/server_authorization.rs
index 947ae2806..4e13f3529 100644
--- a/policy-controller/k8s/index/src/server_authorization.rs
+++ b/policy-controller/k8s/index/src/server_authorization.rs
@@ -3,7 +3,7 @@ use ahash::{AHashMap as HashMap, AHashSet as HashSet};
 use anyhow::{anyhow, bail, Result};
 use futures::prelude::*;
 use linkerd_policy_controller_core::{
-    ClientAuthentication, ClientAuthorization, IdentityMatch, IpNet, NetworkMatch,
+    ClientAuthentication, ClientAuthorization, IdentityMatch, NetworkMatch,
 };
 use linkerd_policy_controller_k8s_api::{
     self as k8s,
@@ -189,15 +189,12 @@ fn mk_server_authz(
 
     let networks = if let Some(nets) = spec.client.networks {
         nets.into_iter()
-            .map(|policy::server_authorization::Network { cidr, except }| {
-                let net = cidr.parse::<IpNet>()?;
-                debug!(%net, "Unauthenticated");
-                let except = except
-                    .into_iter()
-                    .flatten()
-                    .map(|cidr| cidr.parse().map_err(Into::into))
-                    .collect::<Result<Vec<IpNet>>>()?;
-                Ok(NetworkMatch { net, except })
+            .map(|net| {
+                debug!(net = %net.cidr, "Unauthenticated");
+                Ok(NetworkMatch {
+                    net: net.cidr,
+                    except: net.except.unwrap_or_default(),
+                })
             })
             .collect::<Result<Vec<NetworkMatch>>>()?
     } else {
diff --git a/policy-controller/src/admission.rs b/policy-controller/src/admission.rs
index 2111b2a9b..7f1d5e2a7 100644
--- a/policy-controller/src/admission.rs
+++ b/policy-controller/src/admission.rs
@@ -1,9 +1,12 @@
-use crate::api;
+use crate::api::{
+    labels,
+    policy::{Server, ServerAuthorization, ServerAuthorizationSpec, ServerSpec},
+};
 use anyhow::{anyhow, bail, Result};
-use api::policy::ServerSpec;
 use futures::future;
 use hyper::{body::Buf, http, Body, Request, Response};
-use kube::{core::DynamicObject, ResourceExt};
+use kube::{core::DynamicObject, Resource, ResourceExt};
+use serde::de::DeserializeOwned;
 use std::task;
 use thiserror::Error;
 use tracing::{debug, info, warn};
@@ -22,6 +25,18 @@ pub enum Error {
     Json(#[from] serde_json::Error),
 }
 
+type Review = kube::core::admission::AdmissionReview<DynamicObject>;
+type AdmissionRequest = kube::core::admission::AdmissionRequest<DynamicObject>;
+type AdmissionResponse = kube::core::admission::AdmissionResponse;
+type AdmissionReview = kube::core::admission::AdmissionReview<DynamicObject>;
+
+#[async_trait::async_trait]
+trait Validate<T> {
+    async fn validate(self, ns: &str, name: &str, spec: T) -> Result<()>;
+}
+
+// === impl AdmissionService ===
+
 impl hyper::service::Service<Request<Body>> for Admission {
     type Response = Response<Body>;
     type Error = Error;
@@ -32,6 +47,7 @@ impl hyper::service::Service<Request<Body>> for Admission {
     }
 
     fn call(&mut self, req: Request<Body>) -> Self::Future {
+        tracing::trace!(?req);
         if req.method() != http::Method::POST || req.uri().path() != "/" {
             return Box::pin(future::ok(
                 Response::builder()
@@ -47,38 +63,20 @@ impl hyper::service::Service<Request<Body>> for Admission {
             let review: Review = match serde_json::from_reader(bytes.reader()) {
                 Ok(review) => review,
                 Err(error) => {
-                    warn!(%error, "Failed to parse request body");
-                    return json_response(AdmissionResponse::invalid(error).into_review());
-                }
-            };
-
-            let req: AdmissionRequest = match review.try_into() {
-                Ok(req) => req,
-                Err(error) => {
-                    warn!(%error, "Invalid admission request");
+                    warn!(%error, "failed to parse request body");
                     return json_response(AdmissionResponse::invalid(error).into_review());
                 }
             };
-            debug!(?req);
+            tracing::trace!(?review);
 
-            let rsp = AdmissionResponse::from(&req);
-
-            // Parse the server instance under review before doing anything with the API--i.e., if
-            // this fails we don't have to waste the API calls.
-            let (ns, name, review_spec) = match parse_server(req) {
-                Ok(s) => s,
-                Err(error) => {
-                    warn!(%error, "Failed to deserialize server from admission request");
-                    return json_response(AdmissionResponse::invalid(error).into_review());
+            let rsp = match review.try_into() {
+                Ok(req) => {
+                    debug!(?req);
+                    admission.admit(req).await
                 }
-            };
-
-            // If validation fails, deny admission.
-            let rsp = match admission.validate(&ns, &name, review_spec).await {
-                Ok(()) => rsp,
                 Err(error) => {
-                    info!(%error, %ns, %name, "Denying server");
-                    rsp.deny(error)
+                    warn!(%error, "invalid admission request");
+                    AdmissionResponse::invalid(error)
                 }
             };
             debug!(?rsp);
@@ -92,36 +90,61 @@ impl Admission {
         Self { client }
     }
 
-    /// Checks that `spec` doesn't select the same pod/ports as other existing Servers
-    //
-    // TODO(ver) this isn't rigorous about detecting servers that select the same port if one port
-    // specifies a numeric port and the other specifies the port's name.
-    async fn validate(self, ns: &str, name: &str, spec: api::policy::ServerSpec) -> Result<()> {
-        // Since we can't ensure that the local index is up-to-date with the API server (i.e.
-        // updates may be delayed), we issue an API request to get the latest state of servers in
-        // the namespace.
-        let servers = kube::Api::<api::policy::Server>::namespaced(self.client, ns)
-            .list(&kube::api::ListParams::default())
-            .await?;
-        for server in servers.items.into_iter() {
-            if server.name() != name
-                && server.spec.port == spec.port
-                && overlaps(&server.spec.pod_selector, &spec.pod_selector)
-            {
-                bail!("identical server spec already exists");
+    async fn admit(self, req: AdmissionRequest) -> AdmissionResponse {
+        if is_kind::<Server>(&req) {
+            return self.admit_spec::<ServerSpec>(req).await;
+        };
+
+        if is_kind::<ServerAuthorization>(&req) {
+            return self.admit_spec::<ServerAuthorizationSpec>(req).await;
+        };
+
+        AdmissionResponse::invalid(format_args!(
+            "unsupported resource type: {}.{}.{}",
+            req.kind.group, req.kind.version, req.kind.kind
+        ))
+    }
+
+    async fn admit_spec<T>(self, req: AdmissionRequest) -> AdmissionResponse
+    where
+        T: DeserializeOwned,
+        Self: Validate<T>,
+    {
+        let kind = req.kind.kind.clone();
+        let rsp = AdmissionResponse::from(&req);
+        let (ns, name, spec) = match parse_spec::<T>(req) {
+            Ok(spec) => spec,
+            Err(error) => {
+                info!(%error, "failed to parse {} spec", kind);
+                return rsp.deny(error);
             }
+        };
+
+        if let Err(error) = self.validate(&ns, &name, spec).await {
+            info!(%error, %ns, %name, %kind, "denied");
+            return rsp.deny(error);
         }
 
-        Ok(())
+        rsp
     }
 }
 
+fn is_kind<T>(req: &AdmissionRequest) -> bool
+where
+    T: Resource,
+    T::DynamicType: Default,
+{
+    let dt = Default::default();
+    *req.kind.group == *T::group(&dt) && *req.kind.kind == *T::kind(&dt)
+}
+
 /// Detects whether two pod selectors can select the same pod
 //
-// TODO(ver) We can probably detect overlapping selectors more effectively. For example, if `left`
-// selects pods with 'foo=bar' and `right` selects pods with 'foo', we should indicate the selectors
-// overlap. It's a bit tricky to work through all of the cases though, so we'll just punt for now.
-fn overlaps(left: &api::labels::Selector, right: &api::labels::Selector) -> bool {
+// TODO(ver) We can probably detect overlapping selectors more effectively. For
+// example, if `left` selects pods with 'foo=bar' and `right` selects pods with
+// 'foo', we should indicate the selectors overlap. It's a bit tricky to work
+// through all of the cases though, so we'll just punt for now.
+fn overlaps(left: &labels::Selector, right: &labels::Selector) -> bool {
     if left.selects_all() || right.selects_all() {
         return true;
     }
@@ -138,23 +161,76 @@ fn json_response(rsp: AdmissionReview) -> Result<Response<Body>, Error> {
         .expect("admission review response must be valid"))
 }
 
-type Review = kube::core::admission::AdmissionReview<DynamicObject>;
-type AdmissionRequest = kube::core::admission::AdmissionRequest<DynamicObject>;
-type AdmissionResponse = kube::core::admission::AdmissionResponse;
-type AdmissionReview = kube::core::admission::AdmissionReview<DynamicObject>;
+fn parse_spec<T: DeserializeOwned>(req: AdmissionRequest) -> Result<(String, String, T)> {
+    let obj = req
+        .object
+        .ok_or_else(|| anyhow!("admission request missing 'object"))?;
 
-/// Parses a `Server` instance and its namespace from the admission request.
-fn parse_server(req: AdmissionRequest) -> Result<(String, String, api::policy::ServerSpec)> {
-    let obj = req.object.ok_or_else(|| anyhow!("missing server"))?;
     let ns = obj
         .namespace()
-        .ok_or_else(|| anyhow!("no 'namespace' field set on server"))?;
+        .ok_or_else(|| anyhow!("admission request missing 'namespace'"))?;
     let name = obj.name();
-    let data = obj
-        .data
-        .get("spec")
-        .cloned()
-        .ok_or_else(|| anyhow!("no 'spec' field set on server"))?;
-    let spec = serde_json::from_value::<ServerSpec>(data)?;
+
+    let spec = {
+        let data = obj
+            .data
+            .get("spec")
+            .cloned()
+            .ok_or_else(|| anyhow!("admission request missing 'spec'"))?;
+        serde_json::from_value(data)?
+    };
+
     Ok((ns, name, spec))
 }
+
+#[async_trait::async_trait]
+impl Validate<ServerSpec> for Admission {
+    /// Checks that `spec` doesn't select the same pod/ports as other existing Servers
+    //
+    // TODO(ver) this isn't rigorous about detecting servers that select the same port if one port
+    // specifies a numeric port and the other specifies the port's name.
+    async fn validate(self, ns: &str, name: &str, spec: ServerSpec) -> Result<()> {
+        // Since we can't ensure that the local index is up-to-date with the API server (i.e.
+        // updates may be delayed), we issue an API request to get the latest state of servers in
+        // the namespace.
+        let servers = kube::Api::<Server>::namespaced(self.client, ns)
+            .list(&kube::api::ListParams::default())
+            .await?;
+        for server in servers.items.into_iter() {
+            if server.name() != name
+                && server.spec.port == spec.port
+                && overlaps(&server.spec.pod_selector, &spec.pod_selector)
+            {
+                bail!("identical server spec already exists");
+            }
+        }
+
+        Ok(())
+    }
+}
+
+#[async_trait::async_trait]
+impl Validate<ServerAuthorizationSpec> for Admission {
+    async fn validate(self, _ns: &str, _name: &str, spec: ServerAuthorizationSpec) -> Result<()> {
+        for net in spec.client.networks.into_iter().flatten() {
+            for except in net.except.into_iter().flatten() {
+                if except.contains(&net.cidr) {
+                    bail!(
+                        "cidr '{}' is completely negated by exception '{}'",
+                        net.cidr,
+                        except
+                    );
+                }
+                if !net.cidr.contains(&except) {
+                    bail!(
+                        "cidr '{}' does not include exception '{}'",
+                        net.cidr,
+                        except
+                    );
+                }
+            }
+        }
+
+        Ok(())
+    }
+}
diff --git a/web/srv/reverse_proxy.go b/web/srv/reverse_proxy.go
index f8312dc87..2f14f1a12 100644
--- a/web/srv/reverse_proxy.go
+++ b/web/srv/reverse_proxy.go
@@ -15,7 +15,6 @@ type reverseProxy struct {
 
 func newReverseProxy(addr string, prefix string) *reverseProxy {
 	director := func(req *http.Request) {
-		req.Host = addr
 		req.URL.Host = addr
 		req.URL.Scheme = "http"
 		req.URL.Path = strings.TrimPrefix(req.URL.Path, prefix)


Test patch:
diff --git a/cli/cmd/testdata/install_controlplane_tracing_output.golden b/cli/cmd/testdata/install_controlplane_tracing_output.golden
index 01b6ad4fb..2dfb8c797 100644
--- a/cli/cmd/testdata/install_controlplane_tracing_output.golden
+++ b/cli/cmd/testdata/install_controlplane_tracing_output.golden
@@ -878,7 +878,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/cli/cmd/testdata/install_custom_domain.golden b/cli/cmd/testdata/install_custom_domain.golden
index 44c9d2a3e..35620973f 100644
--- a/cli/cmd/testdata/install_custom_domain.golden
+++ b/cli/cmd/testdata/install_custom_domain.golden
@@ -878,7 +878,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/cli/cmd/testdata/install_custom_registry.golden b/cli/cmd/testdata/install_custom_registry.golden
index 8c26851ff..b69cd1965 100644
--- a/cli/cmd/testdata/install_custom_registry.golden
+++ b/cli/cmd/testdata/install_custom_registry.golden
@@ -878,7 +878,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/cli/cmd/testdata/install_default.golden b/cli/cmd/testdata/install_default.golden
index 44c9d2a3e..35620973f 100644
--- a/cli/cmd/testdata/install_default.golden
+++ b/cli/cmd/testdata/install_default.golden
@@ -878,7 +878,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
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 84a42658c..ce22b91c2 100644
--- a/cli/cmd/testdata/install_default_override_dst_get_nets.golden
+++ b/cli/cmd/testdata/install_default_override_dst_get_nets.golden
@@ -878,7 +878,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/cli/cmd/testdata/install_default_token.golden b/cli/cmd/testdata/install_default_token.golden
index 7515377bc..5e53c7f5f 100644
--- a/cli/cmd/testdata/install_default_token.golden
+++ b/cli/cmd/testdata/install_default_token.golden
@@ -878,7 +878,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/cli/cmd/testdata/install_ha_output.golden b/cli/cmd/testdata/install_ha_output.golden
index 8e139d2cf..8b07f17f3 100644
--- a/cli/cmd/testdata/install_ha_output.golden
+++ b/cli/cmd/testdata/install_ha_output.golden
@@ -878,7 +878,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/cli/cmd/testdata/install_ha_with_overrides_output.golden b/cli/cmd/testdata/install_ha_with_overrides_output.golden
index ac14ecf45..a6a2c6a66 100644
--- a/cli/cmd/testdata/install_ha_with_overrides_output.golden
+++ b/cli/cmd/testdata/install_ha_with_overrides_output.golden
@@ -878,7 +878,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/cli/cmd/testdata/install_heartbeat_disabled_output.golden b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
index 871c68711..2ff20a119 100644
--- a/cli/cmd/testdata/install_heartbeat_disabled_output.golden
+++ b/cli/cmd/testdata/install_heartbeat_disabled_output.golden
@@ -878,7 +878,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/cli/cmd/testdata/install_helm_control_plane_output.golden b/cli/cmd/testdata/install_helm_control_plane_output.golden
index 5db5749e0..8e57b5c6d 100644
--- a/cli/cmd/testdata/install_helm_control_plane_output.golden
+++ b/cli/cmd/testdata/install_helm_control_plane_output.golden
@@ -160,7 +160,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
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 277e1455b..4eb93b5f0 100644
--- a/cli/cmd/testdata/install_helm_control_plane_output_ha.golden
+++ b/cli/cmd/testdata/install_helm_control_plane_output_ha.golden
@@ -160,7 +160,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/cli/cmd/testdata/install_helm_output_ha_labels.golden b/cli/cmd/testdata/install_helm_output_ha_labels.golden
index 709a810ed..5a6473a35 100644
--- a/cli/cmd/testdata/install_helm_output_ha_labels.golden
+++ b/cli/cmd/testdata/install_helm_output_ha_labels.golden
@@ -160,7 +160,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
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 6081df55c..bd5f32db7 100644
--- a/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
+++ b/cli/cmd/testdata/install_helm_output_ha_namespace_selector.golden
@@ -160,7 +160,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/cli/cmd/testdata/install_no_init_container.golden b/cli/cmd/testdata/install_no_init_container.golden
index 19600fa82..0be862bf5 100644
--- a/cli/cmd/testdata/install_no_init_container.golden
+++ b/cli/cmd/testdata/install_no_init_container.golden
@@ -878,7 +878,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/cli/cmd/testdata/install_output.golden b/cli/cmd/testdata/install_output.golden
index ec17aaec3..d6168f019 100644
--- a/cli/cmd/testdata/install_output.golden
+++ b/cli/cmd/testdata/install_output.golden
@@ -875,7 +875,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/cli/cmd/testdata/install_proxy_ignores.golden b/cli/cmd/testdata/install_proxy_ignores.golden
index a478572cd..852678074 100644
--- a/cli/cmd/testdata/install_proxy_ignores.golden
+++ b/cli/cmd/testdata/install_proxy_ignores.golden
@@ -878,7 +878,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/cli/cmd/testdata/install_values_file.golden b/cli/cmd/testdata/install_values_file.golden
index d1c561abb..0eef9ae94 100644
--- a/cli/cmd/testdata/install_values_file.golden
+++ b/cli/cmd/testdata/install_values_file.golden
@@ -878,7 +878,9 @@ webhooks:
   - operations: ["CREATE", "UPDATE"]
     apiGroups: ["policy.linkerd.io"]
     apiVersions: ["v1alpha1", "v1beta1"]
-    resources: ["servers"]
+    resources:
+    - serverauthorizations
+    - servers
   sideEffects: None
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/policy-test/Cargo.toml b/policy-test/Cargo.toml
index 128170157..39fd0f24d 100644
--- a/policy-test/Cargo.toml
+++ b/policy-test/Cargo.toml
@@ -12,6 +12,8 @@ k8s-openapi = { version = "0.14", features = ["v1_20"] }
 linkerd-policy-controller-k8s-api = { path = "../policy-controller/k8s/api" }
 rand = "0.8"
 serde = "1"
+serde_json = "1"
+schemars = "0.8"
 tokio = { version = "1", features = ["macros", "rt"] }
 tracing = "0.1"
 tracing-subscriber = "0.3"
diff --git a/policy-test/tests/admit_server.rs b/policy-test/tests/admit_server.rs
index b23812c64..cbe82ba03 100644
--- a/policy-test/tests/admit_server.rs
+++ b/policy-test/tests/admit_server.rs
@@ -1,7 +1,6 @@
-use api::policy::server::ProxyProtocol;
 use linkerd_policy_controller_k8s_api::{
     self as api,
-    policy::server::{Port, Server, ServerSpec},
+    policy::server::{Port, ProxyProtocol, Server, ServerSpec},
 };
 use linkerd_policy_test::{admission, with_temp_ns};
 
diff --git a/policy-test/tests/admit_server_authorization.rs b/policy-test/tests/admit_server_authorization.rs
new file mode 100644
index 000000000..fe5203c23
--- /dev/null
+++ b/policy-test/tests/admit_server_authorization.rs
@@ -0,0 +1,158 @@
+use linkerd_policy_controller_k8s_api::{
+    self as api,
+    policy::server_authorization::{
+        Client, MeshTls, Network, Server, ServerAuthorization, ServerAuthorizationSpec,
+    },
+};
+use linkerd_policy_test::admission;
+
+#[tokio::test(flavor = "current_thread")]
+async fn accepts_valid() {
+    admission::accepts(|ns| ServerAuthorization {
+        metadata: api::ObjectMeta {
+            namespace: Some(ns),
+            name: Some("test".to_string()),
+            ..Default::default()
+        },
+        spec: ServerAuthorizationSpec {
+            server: Server {
+                name: Some("test".to_string()),
+                selector: None,
+            },
+            client: Client {
+                networks: Some(vec![
+                    Network {
+                        cidr: "10.1.0.0/24".parse().unwrap(),
+                        except: None,
+                    },
+                    Network {
+                        cidr: "10.1.1.0/24".parse().unwrap(),
+                        except: Some(vec!["10.1.1.0/28".parse().unwrap()]),
+                    },
+                ]),
+                unauthenticated: true,
+                mesh_tls: None,
+            },
+        },
+    })
+    .await;
+}
+
+#[tokio::test(flavor = "current_thread")]
+async fn rejects_except_whole_cidr() {
+    admission::rejects(|ns| ServerAuthorization {
+        metadata: api::ObjectMeta {
+            namespace: Some(ns),
+            name: Some("test".to_string()),
+            ..Default::default()
+        },
+        spec: ServerAuthorizationSpec {
+            server: Server {
+                name: Some("test".to_string()),
+                selector: None,
+            },
+            client: Client {
+                networks: Some(vec![Network {
+                    cidr: "10.1.1.0/24".parse().unwrap(),
+                    except: Some(vec!["10.1.0.0/16".parse().unwrap()]),
+                }]),
+                unauthenticated: true,
+                mesh_tls: None,
+            },
+        },
+    })
+    .await;
+}
+
+#[tokio::test(flavor = "current_thread")]
+async fn rejects_except_not_in_cidr() {
+    admission::rejects(|ns| ServerAuthorization {
+        metadata: api::ObjectMeta {
+            namespace: Some(ns),
+            name: Some("test".to_string()),
+            ..Default::default()
+        },
+        spec: ServerAuthorizationSpec {
+            server: Server {
+                name: Some("test".to_string()),
+                selector: None,
+            },
+            client: Client {
+                networks: Some(vec![Network {
+                    cidr: "10.1.1.0/24".parse().unwrap(),
+                    except: Some(vec!["10.1.2.0/24".parse().unwrap()]),
+                }]),
+                unauthenticated: true,
+                mesh_tls: None,
+            },
+        },
+    })
+    .await;
+}
+
+#[tokio::test(flavor = "current_thread")]
+async fn rejects_invalid_cidr() {
+    // Duplicate the CRD with relaxed validation so we can send an invalid CIDR value.
+    #[derive(
+        Clone,
+        Debug,
+        Default,
+        kube::CustomResource,
+        serde::Deserialize,
+        serde::Serialize,
+        schemars::JsonSchema,
+    )]
+    #[kube(
+        group = "policy.linkerd.io",
+        version = "v1alpha1",
+        kind = "ServerAuthorization",
+        namespaced
+    )]
+    #[serde(rename_all = "camelCase")]
+    pub struct ServerAuthorizationSpec {
+        pub server: Server,
+        pub client: Client,
+    }
+
+    #[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
+    #[serde(rename_all = "camelCase")]
+    pub struct Client {
+        pub networks: Option<Vec<Network>>,
+
+        #[serde(default)]
+        pub unauthenticated: bool,
+
+        #[serde(rename = "meshTLS")]
+        pub mesh_tls: Option<MeshTls>,
+    }
+
+    #[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
+    #[serde(rename_all = "camelCase")]
+    pub struct Network {
+        pub cidr: String,
+        pub except: Option<Vec<String>>,
+    }
+
+    admission::rejects(|ns| ServerAuthorization {
+        metadata: api::ObjectMeta {
+            namespace: Some(ns),
+            name: Some("test".to_string()),
+            ..Default::default()
+        },
+        spec: ServerAuthorizationSpec {
+            server: Server {
+                name: Some("test".to_string()),
+                selector: None,
+            },
+            client: Client {
+                networks: Some(vec![Network {
+                    cidr: "bogus".to_string(),
+                    except: None,
+                }]),
+                unauthenticated: true,
+                mesh_tls: None,
+            },
+        },
+    })
+    .await;
+}