Skip to main content
Version: main 🚧

Scope a synced selector field to its tenant

Enterprise
Available in these plansFreeDevProdScale
Custom Resource Syncing

Free, Dev, Prod, and Scale are vCluster Platform license plans. Open source does not need a license or a Platform connection. See Compare open source and free tiers.

Supported Configurations
Running the control plane as a container with:

Some custom resources decide what they act on through a selector held as an opaque string rather than a structured LabelSelector. vClustervClusterAn open-source software product that creates and manages tenant clusters within Kubernetes infrastructure. vCluster provides tenant isolation capabilities while reducing infrastructure costs.Related: Tenant cluster, Control plane cluster can't parse that string, so it syncs the field verbatim. In the default single-namespace sync mode, every tenant namespace maps to the vCluster namespace on the control plane clusterControl plane clusterThe Kubernetes cluster that hosts the virtualized control planes for tenant clusters. The control plane cluster is operated by the platform provider and is completely invisible to tenants. There are no shared control plane nodes, no in-cluster agent pods, and no lateral path between tenant environments. With shared nodes, this cluster also runs tenant workloads alongside the control plane pods — the same node pool is used for both.Related: Tenant cluster, Control plane cluster, Tenant cluster. A copied selector can therefore match workloads the policy was never written for.

This guide walks through scoping such a field with an expression patch, using a Calico NetworkPolicy as the example. The same approach applies to any resource an extension API serverAPI ServerThe core component of Kubernetes that exposes the Kubernetes API. It is the front-end for the Kubernetes control plane and handles all REST operations, validating and configuring data for API objects.Related: Control Plane, rate-limiting serves with a selector-like string field. For the config reference, see Custom resources to the control plane cluster. For lifecycle and troubleshooting guidance, see Manage custom resources.

Why the selector needs a scope term​

vCluster labels every synced object with vcluster.loft.sh/namespace, which records the tenant namespace the object came from. See Sync to the control plane cluster. An extension API server doesn't consult that label on its own. The selector has to name it.

Calico NetworkPolicy has a top-level selector that chooses the protected endpoints. Its ingress and egress rules can also have positive source and destination selectors. Because Calico normally scopes those rule selectors to the policy's namespace, this guide patches all five locations. An empty-path patch scopes the top-level selector, whether the tenant wrote one or omitted it.

Prerequisites​

  • Calico installed on the control planeControl PlaneThe container orchestration layer that exposes the API and interfaces to define, deploy, and manage the lifecycle of containers. In vCluster, each tenant cluster has its own control plane components.Related: API Server, vCluster cluster, with its projectcalico.org/v3 API server enabled.
  • No CustomResourceDefinition for projectcalico.org on the control plane cluster. That absence is what makes this an aggregated API resource. vCluster generates a schemaless CRD in the tenant cluster instead of copying one. See Aggregated API resources.
  • Single-namespace sync mode, with sync.toHost.namespaces.enabled: false, which is the default. With namespace syncing, each tenant namespace maps to a separate namespace on the control plane cluster, so this guide's selector and namespace assumptions don't apply.
  • Exactly one tenant cluster per control plane cluster namespace, which is the default and only supported layout.
This approach doesn't protect a shared namespace

Some deployments running v0.24 or earlier could force more than one tenant cluster into the same control plane cluster namespace. That option was deprecated on introduction and removed in v0.25. If you're still running such a deployment, a namespace-scoped selector doesn't separate those tenants, and no patch on spec.selector makes it safe.

Set up cluster variables​

Set the control plane and tenant cluster contexts and the namespace where vCluster runs.

Modify the following with your specific values to generate a copyable command:
Set up cluster variables
export HOST_CTX="your-host-context"
export VCLUSTER_CTX="vcluster-ctx"
export HOST_NAMESPACE="vcluster-my-vcluster"
tip

You can find your contexts by running kubectl config get-contexts

Configure the tenant cluster​

vcluster.yaml
sync:
toHost:
customResources:
networkpolicies.projectcalico.org/v3:
enabled: true
patches:
# Scopes the top-level selector, including one the tenant omitted, and removes the
# composed fields on the way back so they don't reach the tenant. See below.
- path: ""
expression: |
(value => {
const selector = context.virtualObject.spec && context.virtualObject.spec.selector;
const scope = `vcluster.loft.sh/namespace == '${context.virtualObject.metadata.namespace}'`;
if (!value.spec) value.spec = {};
value.spec.selector = selector ? `(${selector}) && ${scope}` : scope;
return value;
})(value)
reverseExpression: |
(value => {
if (value.spec) {
delete value.spec.selector;
delete value.spec.ingress;
delete value.spec.egress;
}
return value;
})(value)
# Scopes the positive rule-level selectors the same way.
- path: spec.ingress[*].source.selector
expression: |
value ? `(${value}) && vcluster.loft.sh/namespace == '${context.virtualObject.metadata.namespace}'` : value
- path: spec.ingress[*].destination.selector
expression: |
value ? `(${value}) && vcluster.loft.sh/namespace == '${context.virtualObject.metadata.namespace}'` : value
- path: spec.egress[*].source.selector
expression: |
value ? `(${value}) && vcluster.loft.sh/namespace == '${context.virtualObject.metadata.namespace}'` : value
- path: spec.egress[*].destination.selector
expression: |
value ? `(${value}) && vcluster.loft.sh/namespace == '${context.virtualObject.metadata.namespace}'` : value
rbac:
role:
extraRules:
# Calico's API server checks tier access before it accepts a policy write.
- apiGroups: ["projectcalico.org"]
resources: ["tier.networkpolicies"]
resourceNames: ["default.*"]
verbs: ["create", "delete", "patch", "update", "get", "list", "watch"]
clusterRole:
extraRules:
- apiGroups: ["projectcalico.org"]
resources: ["tiers"]
resourceNames: ["default"]
verbs: ["get"]

This configuration:

  • Restricts the policy's target endpoints and positive rule endpoint selectors to the tenant namespace where the policy was created. The empty-path patch sets spec.selector for both a selector the tenant wrote and one it omitted. A path-specific patch can't supply a field that isn't there. The four spec.ingress/spec.egress entries scope the rule-level source and destination selectors the same way. The brackets around value keep the tenant's own selector intact, and are required. See Limitations.
  • Removes the composed fields from control plane cluster changes before they reach the tenant. The empty-path patch's reverseExpression deletes spec.selector, spec.ingress, and spec.egress from the change set, so a composed selector never lands back in the tenant object. The four rule-selector entries omit reverseExpression themselves, which is safe here only because the empty-path patch already removes their parent field first.
  • Grants the permissions Calico's API server needs for the default tier. It authorizes a policy write against related tier resources that you never configure for sync. To use another tier, replace both default values with that tier's name and set spec.tier on the policy. See Authorization on the control plane cluster.

Sync a policy and check its scope​

  1. Tenant Cluster Create two namespaces. Each namespace has a web server and a client with identical labels:

    Create the workloads
    kubectl --context="${VCLUSTER_CTX}" create namespace demo
    kubectl --context="${VCLUSTER_CTX}" create namespace demo2
    kubectl --context="${VCLUSTER_CTX}" -n demo run web --image=nginx --labels=app=web
    kubectl --context="${VCLUSTER_CTX}" -n demo run probe --image=busybox:1.36 \
    --labels=role=client -- sleep 3600
    kubectl --context="${VCLUSTER_CTX}" -n demo2 run web --image=nginx --labels=app=web
    kubectl --context="${VCLUSTER_CTX}" -n demo2 run probe --image=busybox:1.36 \
    --labels=role=client -- sleep 3600
    kubectl --context="${VCLUSTER_CTX}" wait --for=condition=Ready pod --all -n demo --timeout=2m
    kubectl --context="${VCLUSTER_CTX}" wait --for=condition=Ready pod --all -n demo2 --timeout=2m
  2. Apply a policy in one namespace​

  3. Tenant Cluster Apply a policy in demo only:

    deny-web-ingress.yaml
    apiVersion: projectcalico.org/v3
    kind: NetworkPolicy
    metadata:
    name: deny-web-ingress
    namespace: demo
    spec:
    selector: app == 'web'
    types:
    - Ingress
    ingress:
    - action: Allow
    source:
    selector: role == 'client'
    Apply the policy
    kubectl --context="${VCLUSTER_CTX}" create -f deny-web-ingress.yaml
  4. Check the selector on the control plane cluster​

  5. Control Plane Cluster vCluster rewrites object names, so list the policies instead of looking one up by name:

    Read the synced selectors
    kubectl --context="${HOST_CTX}" -n "${HOST_NAMESPACE}" \
    get networkpolicies.projectcalico.org \
    -o custom-columns='NAME:.metadata.name,TARGET:.spec.selector,SOURCE:.spec.ingress[0].source.selector'

    The synced copy carries the scope term:

    Composed selector
    NAME TARGET SOURCE
    v1mn70meafc7je (app == 'web') && vcluster.loft.sh/namespace == 'demo' (role == 'client') && vcluster.loft.sh/namespace == 'demo'
  6. Confirm the policy works within its namespace​

  7. Tenant Cluster The client in demo can reach the selected web server in the same namespace:

    Allow the client in demo
    DEMO_WEB_IP=$(kubectl --context="${VCLUSTER_CTX}" -n demo get pod web -o jsonpath='{.status.podIP}')
    kubectl --context="${VCLUSTER_CTX}" -n demo exec probe -- \
    wget -q -T 5 -O- "http://${DEMO_WEB_IP}:80/"
  8. Confirm the rule selector doesn't cross namespaces​

  9. Tenant Cluster The identically labeled client in demo2 can't reach the protected web server in demo:

    Calico programs policy asynchronously. If this check reports unexpected access immediately after you create the policy, wait a few seconds and run it again.

    Block the client in demo2
    DEMO_WEB_IP=$(kubectl --context="${VCLUSTER_CTX}" -n demo get pod web -o jsonpath='{.status.podIP}')
    if kubectl --context="${VCLUSTER_CTX}" -n demo2 exec probe -- \
    wget -q -T 5 -O- "http://${DEMO_WEB_IP}:80/"; then
    echo "unexpectedly reached the protected workload"
    else
    echo "blocked as expected"
    fi
  10. Confirm the other namespace is unaffected​

  11. Tenant Cluster The web server in demo2 keeps serving traffic because its namespace has no policy:

    Reach the unprotected workload in demo2
    DEMO2_WEB_IP=$(kubectl --context="${VCLUSTER_CTX}" -n demo2 get pod web -o jsonpath='{.status.podIP}')
    kubectl --context="${VCLUSTER_CTX}" -n demo2 exec probe -- \
    wget -q -T 5 -O- "http://${DEMO2_WEB_IP}:80/"
  12. Confirm the tenant object is unchanged​

  13. Tenant Cluster The tenant object still reads the selectors it was created with, not the composed values from the control plane cluster:

    Read the tenant selectors
    kubectl --context="${VCLUSTER_CTX}" -n demo get networkpolicies.projectcalico.org \
    deny-web-ingress \
    -o jsonpath='{.spec.selector}{"\n"}{.spec.ingress[0].source.selector}{"\n"}'
    Tenant selectors
    app == 'web'
    role == 'client'

Limitations​

  • Keep the brackets around value. Calico gives || the lowest precedence, so app == 'web' || all() composed without brackets becomes app == 'web' || (all() && <scope term>), and the first half escapes the scope term. Selector grammar belongs to the extension API server, so check its precedence rules.
  • Don't patch notSelector by appending the scope term. Calico negates the whole notSelector, which turns the result into "not X, or outside this namespace" and admits other namespaces' workloads as peers. This guide doesn't translate notSelector semantics.
  • This configuration doesn't translate namespaceSelector. It selects over namespace labels, but tenant namespace objects don't exist separately on the control plane cluster in single-namespace mode. Don't use namespaceSelector in policies synced with this configuration.
  • This configuration doesn't translate serviceAccountSelector, rule-level serviceAccounts, or services references. Don't use those fields unless you add resource-specific translations and verify the resulting policy on the control plane cluster.
  • The positive rule-selector patches limit matches to synced workload endpoints that carry vcluster.loft.sh/namespace. They don't preserve selectors intended to match a Calico NetworkSet or HostEndpoint.
  • A rule with no source or destination selector at all isn't scoped either. Calico matches an empty entity rule against every endpoint in scope. For a namespaced policy, that scope is its own namespace, so an unscoped rule admits the same cross-tenant match this guide exists to prevent. A path-specific patch can't add a selector to a rule that doesn't have one, so avoid rules that rely on an implicit match-all peer.
  • Roll out or restart the vCluster control plane after adding these patches. The empty-path patch runs unconditionally, so reconciliation retrofits every top-level spec.selector, including an omitted selector. Existing rule selectors are patched only when that rule changes; update or recreate those policies after the rollout.

Apply this to other resources​

Any aggregated API resource that decides its scope through an opaque string field needs the same treatment, using the terms and grammar of that resource's own selector language. See Patching synced resources for the full expression syntax.