Repository: linkerd/linkerd2
Pull request #11882 (2024-01-04 11:00:21)

policy: allow spiffe ids in `MeshTLSAuthentication`
Problem
At the moment the MeshTLSAuthentication CRD contains regex validation on the identities. This validation allows only IDs that follow the standard Linkerd TLS ID scheme (DNS like names). In order to allow for SPIFFE Ids, we need to relax this validation. 

Solution
The validation has been moved to the policy controller admission webhook. Apart from a more clear expression of the constraints that a SPIFFE ID needs to meet, this approach allows for richer error messages. Note that the DNS validation is still based on a regex (this can be changed in the future).

Validation

You can install this branch and test the validation by trying to create `MeshTLSAuthentication` resources with different invalid IDs. For example: 

```yaml
apiVersion: policy.linkerd.io/v1alpha1
kind: MeshTLSAuthentication
metadata:
  name: authors-get-authn
spec:
  identities:
    - "books.booksapp.serviceaccount.identity.linkerd.cluster.local"
    - "spiffe://linkerd.cluster.local/my-workload/instance-1"
    - "spiffe://linkerd.cluster.local//instance-1"

```
In addition to that, there are module tests added. 

Signed-off-by: Zahari Dichev <zaharidichev@gmail.com>


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index d3a442f4a..57c1b1bf0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1181,6 +1181,7 @@ dependencies = [
  "linkerd-policy-controller-k8s-status",
  "openssl",
  "parking_lot",
+ "regex",
  "serde",
  "serde_json",
  "thiserror",
@@ -1456,9 +1457,9 @@ checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
 
 [[package]]
 name = "openssl"
-version = "0.10.61"
+version = "0.10.62"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6b8419dc8cc6d866deb801274bba2e6f8f6108c1bb7fcc10ee5ab864931dbb45"
+checksum = "8cde4d2d9200ad5909f8dac647e29482e07c3a35de8a13fce7c9c7747ad9f671"
 dependencies = [
  "bitflags 2.4.0",
  "cfg-if",
@@ -1497,9 +1498,9 @@ dependencies = [
 
 [[package]]
 name = "openssl-sys"
-version = "0.9.97"
+version = "0.9.98"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3eaad34cdd97d81de97964fc7f29e2d104f483840d906ef56daa1912338460b"
+checksum = "c1665caf8ab2dc9aef43d1c0023bd904633a6a05cb30b0ad59bec2ae986e57a7"
 dependencies = [
  "cc",
  "libc",
@@ -1979,9 +1980,9 @@ dependencies = [
 
 [[package]]
 name = "security-framework"
-version = "2.8.2"
+version = "2.9.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a332be01508d814fed64bf28f798a146d73792121129962fdf335bb3c49a4254"
+checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de"
 dependencies = [
  "bitflags 1.3.2",
  "core-foundation",
@@ -2338,9 +2339,9 @@ dependencies = [
 
 [[package]]
 name = "tokio-util"
-version = "0.7.7"
+version = "0.7.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5427d89453009325de0d8f342c9490009f76e999cb7672d77e46267448f7e6b2"
+checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15"
 dependencies = [
  "bytes",
  "futures-core",
@@ -2763,7 +2764,7 @@ dependencies = [
  "windows_aarch64_gnullvm 0.48.2",
  "windows_aarch64_msvc 0.48.2",
  "windows_i686_gnu 0.48.2",
- "windows_i686_msvc 0.48.2",
+ "windows_i686_msvc 0.48.5",
  "windows_x86_64_gnu 0.48.2",
  "windows_x86_64_gnullvm 0.48.2",
  "windows_x86_64_msvc 0.48.2",
@@ -2822,9 +2823,9 @@ checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313"
 
 [[package]]
 name = "windows_i686_msvc"
-version = "0.48.2"
+version = "0.48.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "600956e2d840c194eedfc5d18f8242bc2e17c7775b6684488af3a9fff6fe3287"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
 
 [[package]]
 name = "windows_i686_msvc"
diff --git a/charts/linkerd-crds/templates/policy/meshtls-authentication.yaml b/charts/linkerd-crds/templates/policy/meshtls-authentication.yaml
index 6840d5ee6..58ee815f5 100644
--- a/charts/linkerd-crds/templates/policy/meshtls-authentication.yaml
+++ b/charts/linkerd-crds/templates/policy/meshtls-authentication.yaml
@@ -49,7 +49,6 @@ spec:
                   minItems: 1
                   items:
                     type: string
-                    pattern: '^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?)(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$'
                 identityRefs:
                   type: array
                   minItems: 1
diff --git a/charts/linkerd-crds/templates/workload/external-workload.yaml b/charts/linkerd-crds/templates/workload/external-workload.yaml
new file mode 100644
index 000000000..056d6a275
--- /dev/null
+++ b/charts/linkerd-crds/templates/workload/external-workload.yaml
@@ -0,0 +1,157 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  name: externalworkloads.workload.linkerd.io
+  annotations:
+    {{ include "partials.annotations.created-by" . }}
+  labels:
+    helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
+    linkerd.io/control-plane-ns: {{.Release.Namespace}}
+spec:
+  group: workload.linkerd.io
+  names:
+    categories:
+    - external
+    kind: ExternalWorkload
+    listKind: ExternalWorkloadList
+    plural: externalworkloads
+    singular: externalworkload
+    shortNames: []
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .spec.meshTls.identity
+      name: Identity
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    name: v1alpha1
+    served: true
+    storage: true
+    schema:
+      openAPIV3Schema:
+        description: >-
+          An ExternalWorkload describes a single workload (i.e. a deployable unit) external
+          to the cluster that should be enrolled in the mesh.
+        type: object
+        required: [spec]
+        properties:
+          apiVerson:
+            type: string
+          kind:
+            type: string
+          metadata:
+            type: object
+          spec:
+            properties:
+              meshTls:
+                description: meshTls describes TLS settings associated with an
+                  external workload.
+                properties:
+                  identity:
+                    type: string
+                    description: identity of the workload. Corresponds to the
+                      identity used in the workload's certificate. It is used
+                      by peers to perform verification in the mTLS handshake.
+                    minLength: 1
+                    maxLength: 253
+                  serverName:
+                    type: string
+                    description: serverName is the name of the workload in DNS
+                      format. It is used by the workload to terminate TLS using
+                      SNI.
+                    minLength: 1
+                    maxLength: 253
+                type: object
+              ports:
+                type: array
+                description: ports describes a list of ports exposed by the
+                  workload
+                items:
+                  type: object
+                  properties:
+                    name:
+                      type: string
+                      description: name must be an IANA_SVC_NAME and unique
+                        within the ports set. Each named port can be referred
+                        to by services.
+                    port:
+                      format: int32
+                      maximum: 65535
+                      minimum: 1
+                      type: integer
+                    protocol:
+                      description: protocol exposed by the port. Must be UDP or
+                        TCP. Defaults to TCP.
+                      type: string
+                      default: "TCP"
+              workloadIPs:
+                type: array
+                description: workloadIPs contains a list of IP addresses that
+                  can be used to send traffic to the workload.
+                items:
+                  type: object
+                  properties:
+                    ip:
+                      type: string
+            type: object
+            required:
+            - ports
+            - workloadIPs
+          status:
+            type: object
+            properties:
+              conditions:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    lastProbeTime:
+                      description: lastProbeTime is the last time the
+                        healthcheck endpoint was probed.
+                      format: date-time
+                      type: string
+                    lastTransitionTime:
+                      description: lastTransitionTime is the last time the
+                        condition transitioned from one status to another.
+                      format: date-time
+                      type: string
+                    status:
+                      description: status of the condition (one of True, False, Unknown)
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of the condition in CamelCase or in
+                        foo.example.com/CamelCase.
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                    reason:
+                      description: reason contains a programmatic identifier
+                        indicating the reason for the condition's last
+                        transition. Producers of specific condition types may
+                        define expected values and meanings for this field, and
+                        whether the values are considered a guaranteed API. The
+                        value should be a CamelCase string. This field may not
+                        be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    message:
+                      description: message is a human readable message
+                        indicating details about the transition. This may be an
+                        empty string.
+                      maxLength: 32768
+                      type: string
+                required:
+                - lastTransitionTime
+                - status
+                - type
+                - reason
+                - message
diff --git a/cli/cmd/install.go b/cli/cmd/install.go
index 0c0e4a881..90e46b636 100644
--- a/cli/cmd/install.go
+++ b/cli/cmd/install.go
@@ -55,6 +55,7 @@ var (
 		"templates/policy/server.yaml",
 		"templates/serviceprofile.yaml",
 		"templates/gateway.networking.k8s.io_httproutes.yaml",
+		"templates/workload/external-workload.yaml",
 	}
 
 	templatesControlPlane = []string{
diff --git a/policy-controller/Cargo.toml b/policy-controller/Cargo.toml
index 5bf353d43..188f3a8bf 100644
--- a/policy-controller/Cargo.toml
+++ b/policy-controller/Cargo.toml
@@ -24,7 +24,7 @@ ipnet = { version = "2", default-features = false }
 linkerd-policy-controller-core = { path = "./core" }
 linkerd-policy-controller-grpc = { path = "./grpc" }
 linkerd-policy-controller-k8s-api = { path = "./k8s/api" }
-openssl = { version = "0.10.61", optional = true }
+openssl = { version = "0.10.62", optional = true }
 linkerd-policy-controller-k8s-index = { path = "./k8s/index" }
 linkerd-policy-controller-k8s-status = { path = "./k8s/status" }
 parking_lot = "0.12"
@@ -33,6 +33,7 @@ serde_json = "1"
 thiserror = "1"
 tokio-stream = { version = "0.1", features = ["sync"] }
 tracing = "0.1"
+regex = "1"
 
 [dependencies.clap]
 version = "4"
diff --git a/policy-controller/src/admission.rs b/policy-controller/src/admission.rs
index 09515126b..63eb41cf2 100644
--- a/policy-controller/src/admission.rs
+++ b/policy-controller/src/admission.rs
@@ -1,3 +1,4 @@
+use super::validation;
 use crate::k8s::{
     labels,
     policy::{
@@ -296,7 +297,12 @@ fn validate_identity_ref(id: &NamespacedTargetRef) -> Result<()> {
 #[async_trait::async_trait]
 impl Validate<MeshTLSAuthenticationSpec> for Admission {
     async fn validate(self, _ns: &str, _name: &str, spec: MeshTLSAuthenticationSpec) -> Result<()> {
-        // The CRD validates identity strings, but does not validate identity references.
+        for id in spec.identities.iter().flatten() {
+            if let Err(err) = validation::validate_identity(id) {
+                bail!("id {} is invalid: {}", id, err);
+            }
+        }
+
         for id in spec.identity_refs.iter().flatten() {
             validate_identity_ref(id)?;
         }
diff --git a/policy-controller/src/lib.rs b/policy-controller/src/lib.rs
index 4fbff7360..8efbb1cb9 100644
--- a/policy-controller/src/lib.rs
+++ b/policy-controller/src/lib.rs
@@ -2,6 +2,7 @@
 #![forbid(unsafe_code)]
 mod admission;
 pub mod index_list;
+mod validation;
 pub use self::admission::Admission;
 use anyhow::Result;
 use linkerd_policy_controller_core::inbound::{
diff --git a/policy-controller/src/validation.rs b/policy-controller/src/validation.rs
new file mode 100644
index 000000000..c84840268
--- /dev/null
+++ b/policy-controller/src/validation.rs
@@ -0,0 +1,245 @@
+use anyhow::Result;
+use thiserror::Error;
+
+use regex::Regex;
+
+const SCHEME_PREFIX: &str = "spiffe://";
+const VALID_TRUST_DOMAIN_CHARS: &str = "abcdefghijklmnopqrstuvwxyz0123456789-._";
+const VALID_PATH_SEGMENT_CHARS: &str =
+    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._";
+
+const DNS_LIKE_IDENTITY_REGEX: &str =
+    r"^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?)(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$";
+
+#[derive(Debug, Error, PartialEq, Clone)]
+pub enum IdError {
+    /// The trust domain name of SPIFFE ID cannot be empty.
+    #[error("SPIFFE trust domain is missing")]
+    MissingTrustDomain,
+
+    /// A trust domain name can only contain chars in a limited char set.
+    #[error(
+        "SPIFFE trust domain characters are limited to lowercase letters, numbers, dots, dashes, and \
+         underscores"
+    )]
+    BadTrustDomainChar,
+
+    /// A path segment can only contain chars in a limited char set.
+    #[error(
+        "SPIFFE path segment characters are limited to letters, numbers, dots, dashes, and underscores"
+    )]
+    BadPathSegmentChar,
+
+    /// Path cannot contain empty segments, e.g '//'
+    #[error("SPIFFE path cannot contain empty segments")]
+    EmptySegment,
+
+    /// Path cannot contain dot segments, e.g '/.', '/..'
+    #[error("SPIFFE path cannot contain dot segments")]
+    DotSegment,
+
+    /// Path cannot have a trailing slash.
+    #[error("SPIFFE path cannot have a trailing slash")]
+    TrailingSlash,
+
+    #[error(
+        "identity must be a valid SPIFFE id or a DNS SAN, matching the regex: {}",
+        DNS_LIKE_IDENTITY_REGEX
+    )]
+    Invalid,
+}
+
+// Validates that an ID is either in DNS or SPIFFE form. SPIFFE
+// validation is based on https://github.com/spiffe/spiffe/blob/27b59b81ba8c56885ac5d4be73b35b9b3305fd7a/standards/SPIFFE-ID.md.
+// Implementation is based on: https://github.com/maxlambrecht/rust-spiffe/blob/3d3614f70d0d7a4b9190ab9650e224f2ac362368/spiffe/src/spiffe_id/mod.rs
+pub(crate) fn validate_identity(id: &str) -> Result<(), IdError> {
+    if let Some(rest) = id.strip_prefix(SCHEME_PREFIX) {
+        let i = rest.find('/').unwrap_or(rest.len());
+
+        if i == 0 {
+            return Err(IdError::MissingTrustDomain);
+        }
+
+        let td = &rest[..i];
+        if td.chars().any(|c| !VALID_TRUST_DOMAIN_CHARS.contains(c)) {
+            return Err(IdError::BadTrustDomainChar);
+        }
+
+        let path = &rest[i..];
+
+        if !path.is_empty() {
+            validate_path(path)?;
+        }
+
+        Ok(())
+    } else {
+        let regex = Regex::new(DNS_LIKE_IDENTITY_REGEX).expect("should_compile");
+        if !regex.is_match(id) {
+            return Err(IdError::Invalid);
+        }
+        Ok(())
+    }
+}
+
+/// Validates that a path string is a conformant path for a SPIFFE ID.
+/// See https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md#22-path
+fn validate_path(path: &str) -> Result<(), IdError> {
+    let chars = path.char_indices().peekable();
+    let mut segment_start = 0;
+
+    for (idx, c) in chars {
+        if c == '/' {
+            match &path[segment_start..idx] {
+                "/" => return Err(IdError::EmptySegment),
+                "/." | "/.." => return Err(IdError::DotSegment),
+                _ => {}
+            }
+            segment_start = idx;
+            continue;
+        }
+
+        if !VALID_PATH_SEGMENT_CHARS.contains(c) {
+            return Err(IdError::BadPathSegmentChar);
+        }
+    }
+
+    match &path[segment_start..] {
+        "/" => return Err(IdError::TrailingSlash),
+        "/." | "/.." => return Err(IdError::DotSegment),
+        _ => {}
+    }
+
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn valid_dns() {
+        assert!(validate_identity("system.local").is_ok())
+    }
+
+    #[test]
+    fn valid_dns_all() {
+        assert!(validate_identity("*").is_ok())
+    }
+
+    #[test]
+    fn valid_dns_prefix() {
+        assert!(validate_identity("*.system.local").is_ok())
+    }
+
+    #[test]
+    fn invalid_dns_suffix() {
+        let err = validate_identity("system.local.*").unwrap_err();
+        assert_eq!(err, IdError::Invalid);
+    }
+
+    #[test]
+    fn invalid_dns_trailing_dot() {
+        let err = validate_identity("system.local.").unwrap_err();
+        assert_eq!(err, IdError::Invalid);
+    }
+
+    #[test]
+    fn invalid_dns_leading_dot() {
+        let err = validate_identity(".system.local").unwrap_err();
+        assert_eq!(err, IdError::Invalid);
+    }
+
+    #[test]
+    fn invalid_dns_double_dots() {
+        let err = validate_identity("system..local").unwrap_err();
+        assert_eq!(err, IdError::Invalid);
+    }
+
+    #[test]
+    fn valid_spiffe_no_path() {
+        assert!(validate_identity("spiffe://trustdomain").is_ok())
+    }
+
+    #[test]
+    fn valid_spiffe_with_path() {
+        assert!(validate_identity("spiffe://trustdomain/path/element").is_ok())
+    }
+
+    #[test]
+    fn invalid_spiffe_scheme() {
+        let err = validate_identity("http://domain.test/path/element").unwrap_err();
+        assert_eq!(err, IdError::Invalid);
+    }
+
+    #[test]
+    fn invalid_spiffe_wrong_scheme() {
+        let err = validate_identity("spiffe:/path/element").unwrap_err();
+        assert_eq!(err, IdError::Invalid);
+    }
+
+    #[test]
+    fn invalid_spiffe_empty_trust_domain() {
+        let err = validate_identity("spiffe:///path/element").unwrap_err();
+        assert_eq!(err, IdError::MissingTrustDomain);
+    }
+
+    #[test]
+    fn invalid_spiffe_no_slashes_in_scheme() {
+        let err = validate_identity("spiffe:path/element").unwrap_err();
+        assert_eq!(err, IdError::Invalid);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_with_query() {
+        let err = validate_identity("spiffe://domain.test/path/element?query=").unwrap_err();
+        assert_eq!(err, IdError::BadPathSegmentChar);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_with_fragment() {
+        let err = validate_identity("spiffe://domain.test/path/element#fragment-1").unwrap_err();
+        assert_eq!(err, IdError::BadPathSegmentChar);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_with_str_port() {
+        let err = validate_identity("spiffe://domain.test:8080/path/element").unwrap_err();
+        assert_eq!(err, IdError::BadTrustDomainChar);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_with_user_info() {
+        let err = validate_identity("spiffe://user:password@test.org/path/element").unwrap_err();
+        assert_eq!(err, IdError::BadTrustDomainChar);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_with_trailing_slash() {
+        let err = validate_identity("spiffe://test.org/").unwrap_err();
+        assert_eq!(err, IdError::TrailingSlash);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_with_empty_segment() {
+        let err = validate_identity("spiffe://test.org//").unwrap_err();
+        assert_eq!(err, IdError::EmptySegment);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_str_with_path_with_trailing_slash() {
+        let err = validate_identity("spiffe://test.org/path/other/").unwrap_err();
+        assert_eq!(err, IdError::TrailingSlash);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_str_with_dot_segment() {
+        let err = validate_identity("spiffe://test.org/./other").unwrap_err();
+        assert_eq!(err, IdError::DotSegment);
+    }
+
+    #[test]
+    fn invalid_spiffe_uri_str_with_double_dot_segment() {
+        let err = validate_identity("spiffe://test.org/../other").unwrap_err();
+        assert_eq!(err, IdError::DotSegment);
+    }
+}
diff --git a/viz/tap/api/handlers.go b/viz/tap/api/handlers.go
index d740dca27..5869eb7bb 100644
--- a/viz/tap/api/handlers.go
+++ b/viz/tap/api/handlers.go
@@ -55,19 +55,18 @@ var (
 
 	resources = []struct {
 		name       string
-		shortname  string
 		namespaced bool
 	}{
-		{"namespaces", "ns", false},
-		{"pods", "po", true},
-		{"replicationcontrollers", "rc", true},
-		{"services", "svc", true},
-		{"daemonsets", "ds", true},
-		{"deployments", "deploy", true},
-		{"replicasets", "rs", true},
-		{"statefulsets", "sts", true},
-		{"jobs", "", true},
-		{"cronjobs", "cj", true},
+		{"namespaces", false},
+		{"pods", true},
+		{"replicationcontrollers", true},
+		{"services", true},
+		{"daemonsets", true},
+		{"deployments", true},
+		{"replicasets", true},
+		{"statefulsets", true},
+		{"jobs", true},
+		{"cronjobs", true},
 	}
 )
 
@@ -257,7 +256,6 @@ func handleAPIResourceList(w http.ResponseWriter, _ *http.Request, _ httprouter.
 		resList.APIResources = append(resList.APIResources,
 			metav1.APIResource{
 				Name:       res.name,
-				ShortNames: []string{res.shortname},
 				Namespaced: res.namespaced,
 				Kind:       gvk.Kind,
 				Verbs:      metav1.Verbs{"watch"},


Test patch:
diff --git a/cli/cmd/testdata/install_crds.golden b/cli/cmd/testdata/install_crds.golden
index 62baf3bbd..9632fe2c7 100644
--- a/cli/cmd/testdata/install_crds.golden
+++ b/cli/cmd/testdata/install_crds.golden
@@ -5364,7 +5364,6 @@ spec:
                   minItems: 1
                   items:
                     type: string
-                    pattern: '^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?)(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$'
                 identityRefs:
                   type: array
                   minItems: 1
@@ -10137,3 +10136,160 @@ status:
     plural: ""
   conditions: null
   storedVersions: null
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  name: externalworkloads.workload.linkerd.io
+  annotations:
+    linkerd.io/created-by: linkerd/cli dev-undefined
+  labels:
+    helm.sh/chart: linkerd-crds-1.9.1-edge
+    linkerd.io/control-plane-ns: linkerd
+spec:
+  group: workload.linkerd.io
+  names:
+    categories:
+    - external
+    kind: ExternalWorkload
+    listKind: ExternalWorkloadList
+    plural: externalworkloads
+    singular: externalworkload
+    shortNames: []
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .spec.meshTls.identity
+      name: Identity
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    name: v1alpha1
+    served: true
+    storage: true
+    schema:
+      openAPIV3Schema:
+        description: >-
+          An ExternalWorkload describes a single workload (i.e. a deployable unit) external
+          to the cluster that should be enrolled in the mesh.
+        type: object
+        required: [spec]
+        properties:
+          apiVerson:
+            type: string
+          kind:
+            type: string
+          metadata:
+            type: object
+          spec:
+            properties:
+              meshTls:
+                description: meshTls describes TLS settings associated with an
+                  external workload.
+                properties:
+                  identity:
+                    type: string
+                    description: identity of the workload. Corresponds to the
+                      identity used in the workload's certificate. It is used
+                      by peers to perform verification in the mTLS handshake.
+                    minLength: 1
+                    maxLength: 253
+                  serverName:
+                    type: string
+                    description: serverName is the name of the workload in DNS
+                      format. It is used by the workload to terminate TLS using
+                      SNI.
+                    minLength: 1
+                    maxLength: 253
+                type: object
+              ports:
+                type: array
+                description: ports describes a list of ports exposed by the
+                  workload
+                items:
+                  type: object
+                  properties:
+                    name:
+                      type: string
+                      description: name must be an IANA_SVC_NAME and unique
+                        within the ports set. Each named port can be referred
+                        to by services.
+                    port:
+                      format: int32
+                      maximum: 65535
+                      minimum: 1
+                      type: integer
+                    protocol:
+                      description: protocol exposed by the port. Must be UDP or
+                        TCP. Defaults to TCP.
+                      type: string
+                      default: "TCP"
+              workloadIPs:
+                type: array
+                description: workloadIPs contains a list of IP addresses that
+                  can be used to send traffic to the workload.
+                items:
+                  type: object
+                  properties:
+                    ip:
+                      type: string
+            type: object
+            required:
+            - ports
+            - workloadIPs
+          status:
+            type: object
+            properties:
+              conditions:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    lastProbeTime:
+                      description: lastProbeTime is the last time the
+                        healthcheck endpoint was probed.
+                      format: date-time
+                      type: string
+                    lastTransitionTime:
+                      description: lastTransitionTime is the last time the
+                        condition transitioned from one status to another.
+                      format: date-time
+                      type: string
+                    status:
+                      description: status of the condition (one of True, False, Unknown)
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of the condition in CamelCase or in
+                        foo.example.com/CamelCase.
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                    reason:
+                      description: reason contains a programmatic identifier
+                        indicating the reason for the condition's last
+                        transition. Producers of specific condition types may
+                        define expected values and meanings for this field, and
+                        whether the values are considered a guaranteed API. The
+                        value should be a CamelCase string. This field may not
+                        be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    message:
+                      description: message is a human readable message
+                        indicating details about the transition. This may be an
+                        empty string.
+                      maxLength: 32768
+                      type: string
+                required:
+                - lastTransitionTime
+                - status
+                - type
+                - reason
+                - message
diff --git a/cli/cmd/testdata/install_helm_crds_output.golden b/cli/cmd/testdata/install_helm_crds_output.golden
index a106d8844..ee66ee8f1 100644
--- a/cli/cmd/testdata/install_helm_crds_output.golden
+++ b/cli/cmd/testdata/install_helm_crds_output.golden
@@ -5370,7 +5370,6 @@ spec:
                   minItems: 1
                   items:
                     type: string
-                    pattern: '^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?)(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$'
                 identityRefs:
                   type: array
                   minItems: 1
@@ -10153,3 +10152,162 @@ status:
     plural: ""
   conditions: null
   storedVersions: null
+---
+# Source: linkerd-crds/templates/workload/external-workload.yaml
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  name: externalworkloads.workload.linkerd.io
+  annotations:
+    linkerd.io/created-by: linkerd/helm linkerd-version
+  labels:
+    helm.sh/chart: linkerd-crds-
+    linkerd.io/control-plane-ns: linkerd-dev
+spec:
+  group: workload.linkerd.io
+  names:
+    categories:
+    - external
+    kind: ExternalWorkload
+    listKind: ExternalWorkloadList
+    plural: externalworkloads
+    singular: externalworkload
+    shortNames: []
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .spec.meshTls.identity
+      name: Identity
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    name: v1alpha1
+    served: true
+    storage: true
+    schema:
+      openAPIV3Schema:
+        description: >-
+          An ExternalWorkload describes a single workload (i.e. a deployable unit) external
+          to the cluster that should be enrolled in the mesh.
+        type: object
+        required: [spec]
+        properties:
+          apiVerson:
+            type: string
+          kind:
+            type: string
+          metadata:
+            type: object
+          spec:
+            properties:
+              meshTls:
+                description: meshTls describes TLS settings associated with an
+                  external workload.
+                properties:
+                  identity:
+                    type: string
+                    description: identity of the workload. Corresponds to the
+                      identity used in the workload's certificate. It is used
+                      by peers to perform verification in the mTLS handshake.
+                    minLength: 1
+                    maxLength: 253
+                  serverName:
+                    type: string
+                    description: serverName is the name of the workload in DNS
+                      format. It is used by the workload to terminate TLS using
+                      SNI.
+                    minLength: 1
+                    maxLength: 253
+                type: object
+              ports:
+                type: array
+                description: ports describes a list of ports exposed by the
+                  workload
+                items:
+                  type: object
+                  properties:
+                    name:
+                      type: string
+                      description: name must be an IANA_SVC_NAME and unique
+                        within the ports set. Each named port can be referred
+                        to by services.
+                    port:
+                      format: int32
+                      maximum: 65535
+                      minimum: 1
+                      type: integer
+                    protocol:
+                      description: protocol exposed by the port. Must be UDP or
+                        TCP. Defaults to TCP.
+                      type: string
+                      default: "TCP"
+              workloadIPs:
+                type: array
+                description: workloadIPs contains a list of IP addresses that
+                  can be used to send traffic to the workload.
+                items:
+                  type: object
+                  properties:
+                    ip:
+                      type: string
+            type: object
+            required:
+            - ports
+            - workloadIPs
+          status:
+            type: object
+            properties:
+              conditions:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    lastProbeTime:
+                      description: lastProbeTime is the last time the
+                        healthcheck endpoint was probed.
+                      format: date-time
+                      type: string
+                    lastTransitionTime:
+                      description: lastTransitionTime is the last time the
+                        condition transitioned from one status to another.
+                      format: date-time
+                      type: string
+                    status:
+                      description: status of the condition (one of True, False, Unknown)
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of the condition in CamelCase or in
+                        foo.example.com/CamelCase.
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                    reason:
+                      description: reason contains a programmatic identifier
+                        indicating the reason for the condition's last
+                        transition. Producers of specific condition types may
+                        define expected values and meanings for this field, and
+                        whether the values are considered a guaranteed API. The
+                        value should be a CamelCase string. This field may not
+                        be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    message:
+                      description: message is a human readable message
+                        indicating details about the transition. This may be an
+                        empty string.
+                      maxLength: 32768
+                      type: string
+                required:
+                - lastTransitionTime
+                - status
+                - type
+                - reason
+                - message
diff --git a/cli/cmd/testdata/install_helm_crds_output_ha.golden b/cli/cmd/testdata/install_helm_crds_output_ha.golden
index a106d8844..ee66ee8f1 100644
--- a/cli/cmd/testdata/install_helm_crds_output_ha.golden
+++ b/cli/cmd/testdata/install_helm_crds_output_ha.golden
@@ -5370,7 +5370,6 @@ spec:
                   minItems: 1
                   items:
                     type: string
-                    pattern: '^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?)(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$'
                 identityRefs:
                   type: array
                   minItems: 1
@@ -10153,3 +10152,162 @@ status:
     plural: ""
   conditions: null
   storedVersions: null
+---
+# Source: linkerd-crds/templates/workload/external-workload.yaml
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  name: externalworkloads.workload.linkerd.io
+  annotations:
+    linkerd.io/created-by: linkerd/helm linkerd-version
+  labels:
+    helm.sh/chart: linkerd-crds-
+    linkerd.io/control-plane-ns: linkerd-dev
+spec:
+  group: workload.linkerd.io
+  names:
+    categories:
+    - external
+    kind: ExternalWorkload
+    listKind: ExternalWorkloadList
+    plural: externalworkloads
+    singular: externalworkload
+    shortNames: []
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .spec.meshTls.identity
+      name: Identity
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    name: v1alpha1
+    served: true
+    storage: true
+    schema:
+      openAPIV3Schema:
+        description: >-
+          An ExternalWorkload describes a single workload (i.e. a deployable unit) external
+          to the cluster that should be enrolled in the mesh.
+        type: object
+        required: [spec]
+        properties:
+          apiVerson:
+            type: string
+          kind:
+            type: string
+          metadata:
+            type: object
+          spec:
+            properties:
+              meshTls:
+                description: meshTls describes TLS settings associated with an
+                  external workload.
+                properties:
+                  identity:
+                    type: string
+                    description: identity of the workload. Corresponds to the
+                      identity used in the workload's certificate. It is used
+                      by peers to perform verification in the mTLS handshake.
+                    minLength: 1
+                    maxLength: 253
+                  serverName:
+                    type: string
+                    description: serverName is the name of the workload in DNS
+                      format. It is used by the workload to terminate TLS using
+                      SNI.
+                    minLength: 1
+                    maxLength: 253
+                type: object
+              ports:
+                type: array
+                description: ports describes a list of ports exposed by the
+                  workload
+                items:
+                  type: object
+                  properties:
+                    name:
+                      type: string
+                      description: name must be an IANA_SVC_NAME and unique
+                        within the ports set. Each named port can be referred
+                        to by services.
+                    port:
+                      format: int32
+                      maximum: 65535
+                      minimum: 1
+                      type: integer
+                    protocol:
+                      description: protocol exposed by the port. Must be UDP or
+                        TCP. Defaults to TCP.
+                      type: string
+                      default: "TCP"
+              workloadIPs:
+                type: array
+                description: workloadIPs contains a list of IP addresses that
+                  can be used to send traffic to the workload.
+                items:
+                  type: object
+                  properties:
+                    ip:
+                      type: string
+            type: object
+            required:
+            - ports
+            - workloadIPs
+          status:
+            type: object
+            properties:
+              conditions:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    lastProbeTime:
+                      description: lastProbeTime is the last time the
+                        healthcheck endpoint was probed.
+                      format: date-time
+                      type: string
+                    lastTransitionTime:
+                      description: lastTransitionTime is the last time the
+                        condition transitioned from one status to another.
+                      format: date-time
+                      type: string
+                    status:
+                      description: status of the condition (one of True, False, Unknown)
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of the condition in CamelCase or in
+                        foo.example.com/CamelCase.
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                    reason:
+                      description: reason contains a programmatic identifier
+                        indicating the reason for the condition's last
+                        transition. Producers of specific condition types may
+                        define expected values and meanings for this field, and
+                        whether the values are considered a guaranteed API. The
+                        value should be a CamelCase string. This field may not
+                        be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    message:
+                      description: message is a human readable message
+                        indicating details about the transition. This may be an
+                        empty string.
+                      maxLength: 32768
+                      type: string
+                required:
+                - lastTransitionTime
+                - status
+                - type
+                - reason
+                - message