A namespace scopes many Kubernetes objects within one cluster. Two teams can each create a ConfigMap named settings because those objects have different namespace-qualified identities. This organizational boundary is useful, but it does not automatically provide network isolation, a dedicated kernel, reserved node capacity, or complete tenant separation. Identify which additional policies enforce the boundaries your workload actually requires.

Names are scoped by resource type

Pods, Services, ConfigMaps, and many workload controllers are namespaced. Nodes, namespaces themselves, and other cluster-level resources are not divided into separate copies merely because a client specifies a namespace. Check the resource’s API scope rather than assuming every object lives inside a room. Namespaces also do not form a nested hierarchy of independent child clusters.

Within a Pod, a short Service name normally resolves in the Pod’s namespace. A qualified name can target a Service elsewhere, such as api.other.svc.cluster.local in a cluster using the default domain. This naming convention locates a destination; it does not authorize or prohibit the connection. The cluster domain can be configured differently, so do not mistake the example suffix for a universal constant.

Namespace choice must remain explicit in automation. A correct object name combined with the wrong active namespace still addresses the wrong resource. Use deliberate kubeconfig, context, and namespace inputs, especially for mutation. Namespace scoping makes identity clearer, but an administrator with broad permissions can still modify objects across those scopes.

When it breaks

A namespace without an applicable NetworkPolicy remains connected according to the cluster network’s behavior. Merely creating a namespace or qualifying a DNS name does not deny other Pods. Network isolation requires suitable policies and a network implementation that enforces them. Application authentication remains another layer; successful name resolution says nothing about who should be allowed to read a response.

RBAC controls API operations separately. A RoleBinding can grant an account access to ConfigMaps in one namespace without granting the same access elsewhere. Permissions are additive, so another broad binding can still grant additional access. Test actual allowed and denied operations for the intended identity rather than assuming that adding a narrow Role cancels wider grants.

ResourceQuota primarily enforces admission limits, including object counts and aggregate resource requests or limits. It does not itself throttle a running process or reserve physical capacity. LimitRange can supply defaults and bounds for admitted workloads, while runtime resource enforcement has other mechanisms. These policies must be configured; the namespace alone does not make a noisy neighbor harmless.

Check it quickly

Use a disposable Kubernetes cluster with the default cluster.local DNS domain, kubectl administrator access for impersonation, and python:3.11-slim-bookworm available on its nodes. Save this as namespaces.py and run python3 namespaces.py KUBECONFIG_PATH CONTEXT with explicit test configuration. The recorded cluster ran Kubernetes 1.34.11 with kind networking. The script creates two unique namespaces and removes both afterward.

import json
import subprocess
import sys
import time
from uuid import uuid4

assert len(sys.argv) == 3, 'usage: python3 example.py DISPOSABLE_KUBECONFIG CONTEXT'
base = ['kubectl', '--kubeconfig', sys.argv[1], '--context', sys.argv[2], '--request-timeout=30s']
image = 'python:3.11-slim-bookworm'


def k(*args, payload=None, expected=0):
    result = subprocess.run(base + list(args), input=json.dumps(payload) if payload is not None else None,
                            capture_output=True, text=True, timeout=150)
    if expected is not None:
        assert result.returncode == expected, result.stderr
    return result


def obj(namespace, kind, name=None, selector=None):
    args = ['get', kind, '-n', namespace, '-o', 'json']
    if name:
        args.append(name)
    if selector:
        args += ['-l', selector]
    return json.loads(k(*args).stdout)


def apply(namespace, data):
    return k('apply', '-n', namespace, '-f', '-', payload=data)


def wait_for(read, ready, seconds=120):
    until = time.monotonic() + seconds
    while time.monotonic() < until:
        value = read()
        if ready(value):
            return value
        time.sleep(0.5)
    raise AssertionError('expected Kubernetes condition did not arrive')


def pod_ready(pod):
    return any(c['type'] == 'Ready' and c['status'] == 'True'
               for c in pod.get('status', {}).get('conditions', []))


def spec(script='import time; time.sleep(600)'):
    return {'automountServiceAccountToken': False, 'terminationGracePeriodSeconds': 1,
            'containers': [{'name': 'app', 'image': image, 'imagePullPolicy': 'IfNotPresent',
                            'command': ['python3', '-c', script],
                            'resources': {'requests': {'cpu': '10m', 'memory': '24Mi'},
                                          'limits': {'cpu': '200m', 'memory': '96Mi'}}}]}

suffix = uuid4().hex[:8]
first, second = 'notes-a-' + suffix, 'notes-b-' + suffix
created = []
try:
    for namespace, value in [(first, 'A'), (second, 'B')]:
        k('create', 'namespace', namespace)
        created.append(namespace)
        apply(namespace, {'apiVersion': 'v1', 'kind': 'ConfigMap', 'metadata': {'name': 'settings'}, 'data': {'scope': value}})
        script = '''from http.server import BaseHTTPRequestHandler,HTTPServer
class Handler(BaseHTTPRequestHandler):
 def do_GET(self):
  data=VALUE.encode();self.send_response(200);self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data)
 def log_message(self,*args): pass
HTTPServer(('0.0.0.0',8080),Handler).serve_forever()
'''.replace('VALUE', repr(value))
        podspec = spec(script)
        podspec['containers'][0]['readinessProbe'] = {'httpGet': {'path': '/', 'port': 8080}, 'periodSeconds': 1}
        apply(namespace, {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'web', 'labels': {'app': 'web'}}, 'spec': podspec})
        apply(namespace, {'apiVersion': 'v1', 'kind': 'Service', 'metadata': {'name': 'api'},
              'spec': {'selector': {'app': 'web'}, 'ports': [{'port': 80, 'targetPort': 8080}]}})
        wait_for(lambda namespace=namespace: obj(namespace, 'pod', 'web'), pod_ready)
        wait_for(lambda namespace=namespace: obj(namespace, 'endpointslices', selector='kubernetes.io/service-name=api')['items'],
                 lambda slices: any(e.get('conditions', {}).get('ready') for s in slices for e in s.get('endpoints', [])))
    assert obj(first, 'configmap', 'settings')['data']['scope'] == 'A'
    assert obj(second, 'configmap', 'settings')['data']['scope'] == 'B'
    nodes_a = {n['metadata']['uid'] for n in obj(first, 'nodes')['items']}
    nodes_b = {n['metadata']['uid'] for n in obj(second, 'nodes')['items']}
    assert nodes_a == nodes_b and nodes_a
    apply(first, {'apiVersion': 'v1', 'kind': 'ServiceAccount', 'metadata': {'name': 'reader'}, 'automountServiceAccountToken': False})
    apply(first, {'apiVersion': 'rbac.authorization.k8s.io/v1', 'kind': 'Role', 'metadata': {'name': 'read-config'},
                  'rules': [{'apiGroups': [''], 'resources': ['configmaps'], 'verbs': ['get']}]})
    apply(first, {'apiVersion': 'rbac.authorization.k8s.io/v1', 'kind': 'RoleBinding', 'metadata': {'name': 'read-config'},
        'subjects': [{'kind': 'ServiceAccount', 'name': 'reader', 'namespace': first}],
        'roleRef': {'apiGroup': 'rbac.authorization.k8s.io', 'kind': 'Role', 'name': 'read-config'}})
    identity = 'system:serviceaccount:' + first + ':reader'
    assert k('--as', identity, 'get', 'configmap', 'settings', '-n', first).returncode == 0
    denied = k('--as', identity, 'get', 'configmap', 'settings', '-n', second, expected=None)
    assert denied.returncode != 0 and 'Forbidden' in denied.stderr
    apply(first, {'apiVersion': 'v1', 'kind': 'ResourceQuota', 'metadata': {'name': 'one-service'},
                  'spec': {'hard': {'services': '1'}}})
    wait_for(lambda: obj(first, 'resourcequota', 'one-service'), lambda q: q.get('status', {}).get('used', {}).get('services') == '1')
    extra = {'apiVersion': 'v1', 'kind': 'Service', 'metadata': {'name': 'extra', 'namespace': first},
             'spec': {'ports': [{'port': 80}]}}
    quota_denial = k('create', '-f', '-', payload=extra, expected=None)
    assert quota_denial.returncode != 0 and 'exceeded quota' in quota_denial.stderr
    assert not obj(first, 'networkpolicies')['items'] and not obj(second, 'networkpolicies')['items']
    remote = 'api.' + second + '.svc.cluster.local'
    probe = '''import json,socket
from urllib.request import urlopen
print(json.dumps({'short_name':socket.gethostbyname('api'),
                  'qualified_name':socket.gethostbyname(REMOTE),
                  'cross_namespace_body':urlopen('http://'+REMOTE+'/',timeout=3).read().decode()}))'''.replace('REMOTE', repr(remote))
    network = json.loads(k('exec', '-n', first, 'web', '--', 'python3', '-c', probe).stdout)
    assert network['short_name'] == obj(first, 'service', 'api')['spec']['clusterIP']
    assert network['qualified_name'] == obj(second, 'service', 'api')['spec']['clusterIP']
    assert network['cross_namespace_body'] == 'B'
    print(json.dumps({'same_names_different_configmaps': True, 'nodes_are_cluster_scoped': True,
                      'rbac_allows_local_and_denies_other_namespace': True,
                      'quota_rejects_second_service': True, 'short_and_qualified_dns_match_services': True,
                      'cross_namespace_http_succeeds_without_network_policy': True}))
finally:
    for namespace in reversed(created):
        k('delete', 'namespace', namespace, '--wait=true', '--timeout=90s')

Each namespace receives a ConfigMap named settings, a Pod named web, and a Service named api. The ConfigMaps retain different A and B values. Node queries made with either namespace return the same cluster-scoped node identities. This verifies object-name scope without inventing separate node pools.

A test service account is granted ConfigMap reads only in the first namespace. Requests impersonating it succeed there and return Forbidden in the second. A ResourceQuota then permits one Service in the first namespace; creating another is rejected after the existing usage is observed. This exercises a real admission decision rather than only checking that a quota object exists.

Finally, a request from the first Pod resolves api to its local Service, resolves the qualified second Service to that Service’s address, and receives B over HTTP. Neither namespace has a NetworkPolicy. All assertions passed. The experiment verifies naming, API authorization, one quota dimension, and network reachability. It does not demonstrate NetworkPolicy enforcement, CPU fairness, or full hostile-tenant isolation. Review and test each required boundary explicitly instead of making namespace membership stand in for all of them.

Sources