Repository: yokecd/yoke (go)

Issue:
Support Cluster-Access Outside of release
Users may want to read cluster state that isn't defined by the release.

This has been disabled and should remain off by default.

Currently to have access to the cluster we use `--cluster-access` but this doesn't allow us to lookup resources beyond our release.

We could allow lookup with a new flag: `--external-cluster-access=*`. We could allow the flag to define what resources it is allowed to access:

```
{kind} | {namespace}/{kind} | {namespace}/{kind}:{name}
```

 Where `*` matches anything.

Pull request:
external resource access
Closes #141 

This PR allows flights to access resources outside of the target release by opting in via resource access matchers.

These matchers are defined as strings: `namespace/kind.group:name` where namespace and name are optional and default to `*` which matches anything. `kind.group` can also be set to `*`.

This allows user to control which resources flight are allowed to lookup, allowing flight to depend on shared cluster state in an opt-in fashion.

Patch:
diff --git a/cmd/atc/dockerwatcher.go b/cmd/atc/dockerwatcher.go
index acaa896..9f91057 100644
--- a/cmd/atc/dockerwatcher.go
+++ b/cmd/atc/dockerwatcher.go
@@ -58,7 +58,7 @@ func WatchDockerConfig(ctx context.Context, params WatchDockerConfigParams) erro
 		params.Logger.Info("init: successfully setup docker credentials from secret", "secretName", params.SecretName)
 	}
 
-	watcher, err := retryWatcher.NewRetryWatcher(cmp.Or(secrets.ResourceVersion, "1"), &kcache.ListWatch{
+	watcher, err := retryWatcher.NewRetryWatcherWithContext(ctx, cmp.Or(secrets.ResourceVersion, "1"), &kcache.ListWatch{
 		WatchFunc: func(opts metav1.ListOptions) (watch.Interface, error) {
 			opts.FieldSelector = fieldSelector
 			return secretIntf.Watch(ctx, opts)
diff --git a/cmd/atc/handler.go b/cmd/atc/handler.go
index b63a8bd..6e3a1e6 100644
--- a/cmd/atc/handler.go
+++ b/cmd/atc/handler.go
@@ -188,9 +188,10 @@ func Handler(client *k8s.Client, cache *wasm.ModuleCache, controllers *atc.Contr
 		}
 
 		params := yoke.TakeoffParams{
-			Release:        atc.ReleaseName(&cr),
-			CrossNamespace: airway.Spec.CrossNamespace,
-			ClusterAccess:  airway.Spec.ClusterAccess,
+			Release:               atc.ReleaseName(&cr),
+			CrossNamespace:        airway.Spec.CrossNamespace,
+			ClusterAccess:         airway.Spec.ClusterAccess,
+			ClusterResourceAccess: airway.Spec.ResourceAccessMatchers,
 			Flight: yoke.FlightParams{
 				Input:     bytes.NewReader(data),
 				Namespace: cr.GetNamespace(),
diff --git a/cmd/yoke/cmd_takeoff.go b/cmd/yoke/cmd_takeoff.go
index 5b87b81..29aaa4e 100644
--- a/cmd/yoke/cmd_takeoff.go
+++ b/cmd/yoke/cmd_takeoff.go
@@ -75,6 +75,15 @@ func GetTakeoffParams(settings GlobalSettings, source io.Reader, args []string)
 
 	flagset.StringVar(&params.Flight.CompilationCacheDir, "compilation-cache", "", "location to cache wasm compilations")
 
+	flagset.Func(
+		"resource-access",
+		"allows flights with cluster-access to read resources outside of the release that match pattern. This flag can be set many times and matchers can be comma separated.",
+		func(s string) error {
+			params.ClusterResourceAccess = append(params.ClusterResourceAccess, strings.Split(s, ",")...)
+			return nil
+		},
+	)
+
 	args, params.Flight.Args = internal.CutArgs(args)
 
 	flagset.Parse(args)
@@ -99,7 +108,7 @@ func TakeOff(ctx context.Context, params TakeoffParams) error {
 	}
 
 	// We want the CLI to stream stderr back to the user instead of buffering.
-	params.TakeoffParams.Flight.Stderr = internal.Stderr(ctx)
+	params.Flight.Stderr = internal.Stderr(ctx)
 
 	return commander.Takeoff(ctx, params.TakeoffParams)
 }
diff --git a/cmd/yokecd/main.go b/cmd/yokecd/main.go
index ee464ea..f4b927e 100644
--- a/cmd/yokecd/main.go
+++ b/cmd/yokecd/main.go
@@ -99,7 +99,7 @@ func run(ctx context.Context, cfg Config) (err error) {
 			return nil, fmt.Errorf("failed to get wasm path: %w", err)
 		}
 
-		data, _, err := yoke.EvalFlight(ctx, client, cfg.Application.Name, yoke.FlightParams{
+		data, _, err := yoke.EvalFlight(ctx, client, cfg.Application.Name, nil, yoke.FlightParams{
 			Path:      wasmPath,
 			Input:     strings.NewReader(cfg.Flight.Input),
 			Args:      cfg.Flight.Args,
diff --git a/examples/unsafelookup/main.go b/examples/unsafelookup/main.go
new file mode 100644
index 0000000..5667ddd
--- /dev/null
+++ b/examples/unsafelookup/main.go
@@ -0,0 +1,58 @@
+// This lookup is unsafe as the flight can try and read arbitrary secrets and write them to a configmap.
+// This example exists to test resource-access matchers. By default flights with cluster access can only read
+// the resources that are owned by the release. Hence this flight with cluster-access can only read the configmap it outputs.
+// It cannot read any sercrets from the cluster. However we can use the `-resource-access` flag to allow it access to sensitive data.
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/yaml"
+
+	"github.com/yokecd/yoke/pkg/flight"
+	"github.com/yokecd/yoke/pkg/flight/wasi/k8s"
+)
+
+func main() {
+	if err := run(); err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
+}
+
+func run() error {
+	identifier := k8s.ResourceIdentifier{
+		Kind:       "Secret",
+		ApiVersion: "v1",
+	}
+	if err := yaml.NewYAMLToJSONDecoder(os.Stdin).Decode(&identifier); err != nil {
+		return fmt.Errorf("failed to decode input: %w", err)
+	}
+
+	secret, err := k8s.Lookup[corev1.Secret](identifier)
+	if err != nil && !k8s.IsErrNotFound(err) {
+		return err
+	}
+
+	data, err := json.Marshal(secret)
+	if err != nil {
+		return err
+	}
+
+	return json.NewEncoder(os.Stdout).Encode(corev1.ConfigMap{
+		TypeMeta: metav1.TypeMeta{
+			Kind:       "ConfigMap",
+			APIVersion: "v1",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name: flight.Release(),
+		},
+		Data: map[string]string{
+			"data": string(data),
+		},
+	})
+}
diff --git a/internal/atc/atc.go b/internal/atc/atc.go
index db88230..6ecb81e 100644
--- a/internal/atc/atc.go
+++ b/internal/atc/atc.go
@@ -262,7 +262,7 @@ func (atc atc) Reconcile(ctx context.Context, event ctrl.Event) (result ctrl.Res
 			}
 			mod, err := wasi.Compile(ctx, wasi.CompileParams{
 				Wasm:           data,
-				LookupResource: wasi.HostLookupResource(ctrl.Client(ctx), nil),
+				LookupResource: wasi.HostLookupResource(ctrl.Client(ctx), typedAirway.Spec.ResourceAccessMatchers),
 			})
 			if err != nil {
 				return fmt.Errorf("failed to compile wasm: %w", err)
@@ -651,9 +651,10 @@ func (atc atc) FlightReconciler(params FlightReconcilerParams) ctrl.HandleFunc {
 				Input:     bytes.NewReader(data),
 				Namespace: event.Namespace,
 			},
-			HistoryCapSize: cmp.Or(params.Airway.Spec.HistoryCapSize, 2),
-			ClusterAccess:  params.Airway.Spec.ClusterAccess,
-			CrossNamespace: params.Airway.Spec.CrossNamespace,
+			HistoryCapSize:        cmp.Or(params.Airway.Spec.HistoryCapSize, 2),
+			ClusterAccess:         params.Airway.Spec.ClusterAccess,
+			ClusterResourceAccess: params.Airway.Spec.ResourceAccessMatchers,
+			CrossNamespace:        params.Airway.Spec.CrossNamespace,
 			OwnerReferences: []metav1.OwnerReference{
 				{
 					APIVersion: resource.GetAPIVersion(),
diff --git a/internal/matcher.go b/internal/matcher.go
index 06a17f1..16e524e 100644
--- a/internal/matcher.go
+++ b/internal/matcher.go
@@ -18,22 +18,25 @@ func MatchResource(resource *unstructured.Unstructured, matcher string) bool {
 		ns, gkn, ok := strings.Cut(matcher, "/")
 		if !ok {
 			gkn = ns
-			ns = ""
+			ns = "*"
+		}
+		gk, name, ok := strings.Cut(gkn, ":")
+		if !ok {
+			name = "*"
 		}
-		gk, name, _ := strings.Cut(gkn, ":")
 
 		return ns, gk, name
 	}()
 
-	if namespace != "" && resource.GetNamespace() != namespace {
+	if namespace != "*" && resource.GetNamespace() != namespace {
 		return false
 	}
 
-	if resource.GroupVersionKind().GroupKind().String() != gk {
+	if gk != "*" && resource.GroupVersionKind().GroupKind().String() != gk {
 		return false
 	}
 
-	if name != "" && resource.GetName() != name {
+	if name != "*" && resource.GetName() != name {
 		return false
 	}
 
diff --git a/pkg/apis/airway/v1alpha1/airway.go b/pkg/apis/airway/v1alpha1/airway.go
index 1f325b9..045299e 100644
--- a/pkg/apis/airway/v1alpha1/airway.go
+++ b/pkg/apis/airway/v1alpha1/airway.go
@@ -68,6 +68,21 @@ type AirwaySpec struct {
 	// ClusterAccess allows the flight to lookup resources in the cluster. Resources are limited to those owned by the calling release.
 	ClusterAccess bool `json:"clusterAccess,omitempty"`
 
+	// ResourceAccessMatchers combined with ClusterAccess allow you to lookup any resource in your cluster. By default without any matchers
+	// the only resources that you can lookup are resources that are directly owned by the release. If you wish to access resources external
+	// to the release you can provide a set of matcher patterns. If any pattern matches, the resource is allowed to by accessed.
+	//
+	// The pattern goes like this: $namespace/$Kind.Group:$name
+	// Where namespace and name are optional. If they are omitted it is the same as setting them to '*'.
+	//
+	// Examples Matchers:
+	// 	- Deployment.apps 							# matches all deployments in your cluster
+	// 	- foo/Deployment.apps 					# matches all deployments in namespace foo
+	// 	- foo/Deployment.apps:example 	# matches a deployment named example in namespace foo.
+	// 	- * 														# matches all resources in the cluster.
+	// 	- foo/* 												# matches all resources in namespace foo.
+	ResourceAccessMatchers []string `json:"resourceAccessMatchers,omitempty"`
+
 	// CrossNamespace allows for resources to be created in other namespaces other than the releases target namespace.
 	CrossNamespace bool `json:"crossNamespace,omitempty"`
 
diff --git a/pkg/openapi/flight.golden.json b/pkg/openapi/flight.golden.json
index 743d5d9..59b73c1 100644
--- a/pkg/openapi/flight.golden.json
+++ b/pkg/openapi/flight.golden.json
@@ -40,6 +40,12 @@
             "type": "string"
           }
         },
+        "resourceAccessMatchers": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
         "skipAdmissionWebhook": {
           "type": "boolean"
         },
diff --git a/pkg/yoke/wasm.go b/pkg/yoke/wasm.go
index 537d749..0559379 100644
--- a/pkg/yoke/wasm.go
+++ b/pkg/yoke/wasm.go
@@ -101,7 +101,7 @@ func gzipReader(r io.Reader) io.Reader {
 	return pr
 }
 
-func EvalFlight(ctx context.Context, client *k8s.Client, release string, flight FlightParams) ([]byte, []byte, error) {
+func EvalFlight(ctx context.Context, client *k8s.Client, release string, matchers []string, flight FlightParams) ([]byte, []byte, error) {
 	if flight.Input != nil && flight.Path == "" && flight.Module.Instance == nil {
 		output, err := io.ReadAll(flight.Input)
 		return output, nil, err
@@ -130,7 +130,7 @@ func EvalFlight(ctx context.Context, client *k8s.Client, release string, flight
 			"NAMESPACE":      flight.Namespace,
 		},
 		CacheDir:       flight.CompilationCacheDir,
-		LookupResource: wasi.HostLookupResource(client, nil),
+		LookupResource: wasi.HostLookupResource(client, matchers),
 	})
 	if err != nil {
 		return nil, nil, fmt.Errorf("failed to execute wasm: %w", err)
diff --git a/pkg/yoke/yoke_takeoff.go b/pkg/yoke/yoke_takeoff.go
index c74ab1d..303a0e6 100644
--- a/pkg/yoke/yoke_takeoff.go
+++ b/pkg/yoke/yoke_takeoff.go
@@ -106,6 +106,9 @@ type TakeoffParams struct {
 	// ClusterAccess grants the flight access to the kubernetes cluster. Users will be able to use the host k8s_lookup function.
 	ClusterAccess bool
 
+	// ClusterResourceAccess
+	ClusterResourceAccess []string
+
 	// HistoryCapSize limits the number of revisions kept in the release's history by the size. If Cap is less than 1 history is uncapped.
 	HistoryCapSize int
 }
@@ -122,6 +125,7 @@ func (commander Commander) Takeoff(ctx context.Context, params TakeoffParams) er
 			return commander.k8s
 		}(),
 		params.Release,
+		params.ClusterResourceAccess,
 		params.Flight,
 	)
 	if err != nil {


Test patch:
diff --git a/cmd/atc/internal/testing/Dockerfile.wasmcache b/cmd/atc/internal/testing/Dockerfile.wasmcache
index 02ccf15..d70aa54 100644
--- a/cmd/atc/internal/testing/Dockerfile.wasmcache
+++ b/cmd/atc/internal/testing/Dockerfile.wasmcache
@@ -16,6 +16,7 @@ RUN \
   GOOS=wasip1 GOARCH=wasm go build -o ./cmd/atc/internal/testing/wasmcache/wasm/converter.wasm ./cmd/atc/internal/testing/apis/backend/converter && \
   GOOS=wasip1 GOARCH=wasm go build -o ./cmd/atc/internal/testing/wasmcache/wasm/crossnamespace.wasm ./cmd/atc/internal/testing/flights/crossnamespace && \
   GOOS=wasip1 GOARCH=wasm go build -o ./cmd/atc/internal/testing/wasmcache/wasm/longrunning.wasm ./cmd/atc/internal/testing/flights/longrunning && \
+  GOOS=wasip1 GOARCH=wasm go build -o ./cmd/atc/internal/testing/wasmcache/wasm/resourceaccessmatchers.wasm ./cmd/atc/internal/testing/flights/resourceaccessmatchers && \
   go build -o ./bin/server ./cmd/atc/internal/testing/wasmcache
 
 FROM alpine
diff --git a/cmd/atc/internal/testing/flights/resourceaccessmatchers/main.go b/cmd/atc/internal/testing/flights/resourceaccessmatchers/main.go
new file mode 100644
index 0000000..e9781d8
--- /dev/null
+++ b/cmd/atc/internal/testing/flights/resourceaccessmatchers/main.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/yokecd/yoke/pkg/flight/wasi/k8s"
+)
+
+func main() {
+	if err := run(); err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
+}
+
+// This implementation exists to test accessResourceMatchers.
+// This release owns a single configmap but loads two secrets from different namespaces.
+// The test will need to show that with appropriate matchers this works, and without matcher or with inappropriate matchers
+// we get appropriate errors on admission.
+func run() error {
+	secretOne, err := k8s.Lookup[corev1.Secret](k8s.ResourceIdentifier{
+		Name:       "one",
+		Namespace:  "default",
+		Kind:       "Secret",
+		ApiVersion: "v1",
+	})
+	if err != nil {
+		return fmt.Errorf("failed to lookup secret one: %w", err)
+	}
+
+	secretTwo, err := k8s.Lookup[corev1.Secret](k8s.ResourceIdentifier{
+		Name:       "two",
+		Namespace:  "custom",
+		Kind:       "Secret",
+		ApiVersion: "v1",
+	})
+	if err != nil {
+		return fmt.Errorf("failed to lookup secret two: %w", err)
+	}
+
+	dataOne, err := json.Marshal(secretOne.Data)
+	if err != nil {
+		return err
+	}
+
+	dataTwo, err := json.Marshal(secretTwo.Data)
+	if err != nil {
+		return err
+	}
+
+	return json.NewEncoder(os.Stdout).Encode(
+		&corev1.ConfigMap{
+			TypeMeta: metav1.TypeMeta{
+				APIVersion: "v1",
+				Kind:       "ConfigMap",
+			},
+			ObjectMeta: metav1.ObjectMeta{
+				Name: "cm",
+			},
+			Data: map[string]string{
+				"one": string(dataOne),
+				"two": string(dataTwo),
+			},
+		},
+	)
+}
diff --git a/cmd/atc/main_test.go b/cmd/atc/main_test.go
index 4c5b7d6..5e63d89 100644
--- a/cmd/atc/main_test.go
+++ b/cmd/atc/main_test.go
@@ -14,6 +14,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	appsv1 "k8s.io/api/apps/v1"
+	corev1 "k8s.io/api/core/v1"
 	apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	kerrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -37,6 +38,11 @@ import (
 	"github.com/yokecd/yoke/pkg/yoke"
 )
 
+type EmptyCRD struct {
+	metav1.TypeMeta
+	metav1.ObjectMeta `json:"metadata"`
+}
+
 func TestMain(m *testing.M) {
 	must := func(err error) {
 		if err != nil {
@@ -744,11 +750,6 @@ func TestCrossNamespace(t *testing.T) {
 
 	commander := yoke.FromK8Client(client)
 
-	type EmptyCRD struct {
-		metav1.TypeMeta
-		metav1.ObjectMeta `json:"metadata"`
-	}
-
 	airwayWithCrossNamespace := func(crossNamespace bool) v1alpha1.Airway {
 		return v1alpha1.Airway{
 			ObjectMeta: metav1.ObjectMeta{
@@ -1276,11 +1277,6 @@ func TestStatusReadiness(t *testing.T) {
 
 	commander := yoke.FromK8Client(client)
 
-	type EmptyCRD struct {
-		metav1.TypeMeta
-		metav1.ObjectMeta `json:"metadata"`
-	}
-
 	testutils.EventuallyNoErrorf(
 		t,
 		func() error {
@@ -1379,6 +1375,219 @@ func TestStatusReadiness(t *testing.T) {
 	require.EqualValues(t, []string{"InProgress", "Ready"}, statuses)
 }
 
+func TestResourceAccessMatchers(t *testing.T) {
+	client, err := k8s.NewClientFromKubeConfig(home.Kubeconfig)
+	require.NoError(t, err)
+
+	ctx := internal.WithDebugFlag(context.Background(), ptr.To(true))
+
+	commander := yoke.FromK8Client(client)
+
+	_, err = client.Clientset.CoreV1().Secrets("default").Create(
+		ctx,
+		&corev1.Secret{
+			TypeMeta: metav1.TypeMeta{
+				Kind:       "Secret",
+				APIVersion: "v1",
+			},
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "one",
+				Namespace: "default",
+			},
+			StringData: map[string]string{"key": "secret one"},
+		},
+		metav1.CreateOptions{},
+	)
+	require.NoError(t, err)
+	defer func() {
+		require.NoError(t, client.Clientset.CoreV1().Secrets("default").Delete(ctx, "one", metav1.DeleteOptions{}))
+	}()
+
+	require.NoError(t, client.EnsureNamespace(ctx, "custom"))
+	defer func() {
+		require.NoError(t, client.Clientset.CoreV1().Namespaces().Delete(ctx, "custom", metav1.DeleteOptions{}))
+	}()
+
+	_, err = client.Clientset.CoreV1().Secrets("custom").Create(
+		ctx,
+		&corev1.Secret{
+			TypeMeta: metav1.TypeMeta{
+				Kind:       "Secret",
+				APIVersion: "v1",
+			},
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "two",
+				Namespace: "custom",
+			},
+			StringData: map[string]string{"key": "secret two"},
+		},
+		metav1.CreateOptions{},
+	)
+	require.NoError(t, err)
+	defer func() {
+		require.NoError(t, client.Clientset.CoreV1().Secrets("custom").Delete(ctx, "two", metav1.DeleteOptions{}))
+	}()
+
+	cases := []struct {
+		Name     string
+		Matchers []string
+		Err      string
+	}{
+		{
+			Name:     "no matchers",
+			Matchers: nil,
+			Err:      "failed to lookup secret one: forbidden: cannot access resource outside of target release ownership",
+		},
+		{
+			Name:     "matchers that do not match",
+			Matchers: []string{"bar/Secret:example"},
+			Err:      "failed to lookup secret one: forbidden: cannot access resource outside of target release ownership",
+		},
+		{
+			Name:     "match only first secret",
+			Matchers: []string{"default/*"},
+			Err:      "failed to lookup secret two: forbidden: cannot access resource outside of target release ownership",
+		},
+		// {
+		// 	Name:     "match both separately",
+		// 	Matchers: []string{"default/*", "custom/Secret"},
+		// },
+		{
+			Name:     "match all secrets",
+			Matchers: []string{"Secret"},
+		},
+		{
+			Name:     "match all resources",
+			Matchers: []string{"*"},
+		},
+	}
+
+	params := func(matchers []string) yoke.TakeoffParams {
+		return yoke.TakeoffParams{
+			Release: "resourceaccessmatchers-airway",
+			Flight: yoke.FlightParams{
+				Input: testutils.JsonReader(v1alpha1.Airway{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "backends.examples.com",
+					},
+					Spec: v1alpha1.AirwaySpec{
+						WasmURLs: v1alpha1.WasmURLs{
+							Flight: "http://wasmcache/resourceaccessmatchers.wasm",
+						},
+						ClusterAccess:          true,
+						ResourceAccessMatchers: matchers,
+						Template: apiextv1.CustomResourceDefinitionSpec{
+							Group: "examples.com",
+							Names: apiextv1.CustomResourceDefinitionNames{
+								Plural:   "backends",
+								Singular: "backend",
+								Kind:     "Backend",
+							},
+							Scope: apiextv1.NamespaceScoped,
+							Versions: []apiextv1.CustomResourceDefinitionVersion{
+								{
+									Name:    "v1",
+									Served:  true,
+									Storage: true,
+									Schema: &apiextv1.CustomResourceValidation{
+										OpenAPIV3Schema: openapi.SchemaFrom(reflect.TypeFor[EmptyCRD]()),
+									},
+								},
+							},
+						},
+					},
+				}),
+			},
+			Wait: 30 * time.Second,
+			Poll: time.Second,
+		}
+	}
+	defer func() {
+		require.NoError(t, commander.Mayday(ctx, "resourceaccessmatchers-airway", ""))
+
+		airwayIntf := client.Dynamic.Resource(schema.GroupVersionResource{
+			Group:    "yoke.cd",
+			Version:  "v1alpha1",
+			Resource: "airways",
+		})
+
+		testutils.EventuallyNoErrorf(
+			t,
+			func() error {
+				list, err := airwayIntf.List(ctx, metav1.ListOptions{})
+				if err != nil {
+					return err
+				}
+				if count := len(list.Items); count != 0 {
+					return fmt.Errorf("expected no error but got %d", count)
+				}
+				return nil
+			},
+			time.Second,
+			30*time.Second,
+			"failed to remove airway",
+		)
+	}()
+
+	for _, tc := range cases {
+		t.Run(tc.Name, func(t *testing.T) {
+			require.NoError(t, commander.Takeoff(ctx, params(tc.Matchers)))
+
+			// TODO: remove when we can fix the readiness of Airways? Likely to do with ObservedGeneration being
+			// missing from the status impementation of Airways. If this isn't here we likely call the webhook before
+			// the airway has fully had time to switch over to the new matchers.
+			time.Sleep(3 * time.Second)
+
+			backendIntf := client.Dynamic.
+				Resource(schema.GroupVersionResource{
+					Group:    "examples.com",
+					Version:  "v1",
+					Resource: "backends",
+				}).
+				Namespace("default")
+
+			be := &unstructured.Unstructured{
+				Object: map[string]any{
+					"apiVersion": "examples.com/v1",
+					"kind":       "Backend",
+					"metadata": map[string]any{
+						"name":      "test",
+						"namespace": "default",
+					},
+				},
+			}
+
+			_, err = backendIntf.Create(ctx, be, metav1.CreateOptions{})
+			if tc.Err != "" {
+				require.ErrorContains(t, err, tc.Err)
+				return
+			}
+			require.NoError(t, err)
+
+			require.NoError(t, client.WaitForReady(ctx, be, k8s.WaitOptions{Timeout: 30 * time.Second}))
+
+			require.NoError(t, backendIntf.Delete(ctx, be.GetName(), metav1.DeleteOptions{}))
+
+			testutils.EventuallyNoErrorf(
+				t,
+				func() error {
+					list, err := backendIntf.List(ctx, metav1.ListOptions{})
+					if err != nil {
+						return err
+					}
+					if count := len(list.Items); count > 0 {
+						return fmt.Errorf("listed %d backends instead of none", count)
+					}
+					return nil
+				},
+				time.Second,
+				30*time.Second,
+				"backend count did not go to zero",
+			)
+		})
+	}
+}
+
 func TestAirwayModes(t *testing.T) {
 	client, err := k8s.NewClientFromKubeConfig(home.Kubeconfig)
 	require.NoError(t, err)
diff --git a/cmd/yoke/main_test.go b/cmd/yoke/main_test.go
index be70745..07e1cf8 100644
--- a/cmd/yoke/main_test.go
+++ b/cmd/yoke/main_test.go
@@ -1030,6 +1030,50 @@ func TestLookupResource(t *testing.T) {
 	)
 
 	require.Contains(t, stderr.String(), "cannot access resource outside of target release ownership")
+
+	require.ErrorContains(
+		t,
+		TakeOff(ctx, TakeoffParams{
+			GlobalSettings: settings,
+			TakeoffParams: yoke.TakeoffParams{
+				Release:               "foo",
+				CreateNamespace:       true,
+				ClusterAccess:         true,
+				ClusterResourceAccess: []string{"default/Configmap"},
+				Flight: yoke.FlightParams{
+					Path:      "./test_output/flight.wasm",
+					Namespace: "foo",
+					Input:     strings.NewReader(`{"Namespace": "default"}`),
+				},
+				Wait: 10 * time.Second,
+				Poll: time.Second,
+			},
+		}),
+		"exit_code(1)",
+	)
+
+	require.Contains(t, stderr.String(), "cannot access resource outside of target release ownership")
+
+	require.NoError(
+		t,
+		TakeOff(ctx, TakeoffParams{
+			GlobalSettings: settings,
+			TakeoffParams: yoke.TakeoffParams{
+				Release:               "foo",
+				CreateNamespace:       true,
+				ClusterAccess:         true,
+				ClusterResourceAccess: []string{"default/*", "foo/*"},
+				Flight: yoke.FlightParams{
+					Path:      "./test_output/flight.wasm",
+					Namespace: "foo",
+					Input:     strings.NewReader(`{"Namespace": "default"}`),
+				},
+				Wait: 10 * time.Second,
+				Poll: time.Second,
+			},
+		}),
+		"exit_code(1)",
+	)
 }
 
 func TestOciFlight(t *testing.T) {
diff --git a/internal/matcher_test.go b/internal/matcher_test.go
index 87d5dd0..7dda2b8 100644
--- a/internal/matcher_test.go
+++ b/internal/matcher_test.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"strconv"
 	"testing"
 
 	"github.com/stretchr/testify/require"
@@ -20,51 +21,35 @@ func TestMatchResource(t *testing.T) {
 		},
 	}
 
-	cases := []struct {
-		Matcher  string
-		Expected bool
-	}{
-		{
-			Matcher:  "*",
-			Expected: true,
-		},
-		{
-			Matcher:  "",
-			Expected: false,
-		},
-		{
-			Matcher:  "foo/Deployment.apps:test",
-			Expected: true,
-		},
-		{
-			Matcher:  "Deployment.apps:test",
-			Expected: true,
-		},
-		{
-			Matcher:  "Deployment.apps",
-			Expected: true,
-		},
-		{
-			Matcher:  "Deployment",
-			Expected: false,
-		},
-		{
-			Matcher:  "bar/Deployment.apps",
-			Expected: false,
-		},
-		{
-			Matcher:  "Deployment.custom:test",
-			Expected: false,
-		},
-		{
-			Matcher:  "Deployment.apps:other",
-			Expected: false,
+	cases := map[bool][]string{
+		true: {
+			"*",
+			"foo/Deployment.apps:test",
+			"Deployment.apps:test",
+			"Deployment.apps",
+			"*/*",
+			"*/Deployment.apps",
+			"foo/Deployment.apps:*",
+			"*/Deployment.apps:*",
+		},
+		false: {
+			"",
+			"/",
+			"Deployment",
+			"bar/Deployment.apps",
+			"Deployment.custom:test",
+			"Deployment.apps:other",
+			"bar/*",
 		},
 	}
 
-	for _, tc := range cases {
-		t.Run(tc.Matcher, func(t *testing.T) {
-			require.Equal(t, tc.Expected, MatchResource(&resource, tc.Matcher))
+	for ok, matchers := range cases {
+		t.Run(strconv.FormatBool(ok), func(t *testing.T) {
+			for _, matcher := range matchers {
+				t.Run(matcher, func(t *testing.T) {
+					require.Equal(t, ok, MatchResource(&resource, matcher))
+				})
+			}
 		})
 	}
 }