Skip to content

Commit 55976b9

Browse files
committed
ingressClass per org
1 parent 7cb7721 commit 55976b9

2 files changed

Lines changed: 60 additions & 100 deletions

File tree

containers/tailscale-operator-manager/examples/rbac.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,12 @@ rules:
4646
verbs: [get, list, watch, create, update, patch, delete, deletecollection]
4747

4848
# Manage ClusterRoleBinding subjects (add/remove operator SAs).
49+
# Scoped to only the tailscale-operator binding — prevents escalation
50+
# to other ClusterRoleBindings (e.g. cluster-admin).
4951
- apiGroups: [rbac.authorization.k8s.io]
5052
resources: [clusterrolebindings]
51-
verbs: [get, list, update, patch]
53+
resourceNames: ["tailscale-operator"]
54+
verbs: [get, update, patch]
5255

5356
# Delegated permissions — the manager must hold every permission it grants
5457
# via the tailscale-operator ClusterRole (K8s RBAC escalation prevention).

containers/tailscale-operator-manager/operator.py

Lines changed: 56 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,44 @@ def _delete_if_exists(fn, *args, logger=None):
207207
logger.warning(f"Failed to delete {args}: {exc}")
208208

209209

210+
def _ingress_class_name(crd_name):
211+
return f"tailscale-{crd_name}"
212+
213+
214+
def _ensure_ingress_class(networking_api, crd_name, labels, logger):
215+
"""Create per-org IngressClass so the operator only watches its own ingresses."""
216+
ic_name = _ingress_class_name(crd_name)
217+
body = kubernetes.client.V1IngressClass(
218+
metadata=kubernetes.client.V1ObjectMeta(
219+
name=ic_name,
220+
labels=labels,
221+
),
222+
spec=kubernetes.client.V1IngressClassSpec(
223+
controller="tailscale.com/ts-ingress",
224+
),
225+
)
226+
try:
227+
networking_api.read_ingress_class(ic_name)
228+
logger.info(f"IngressClass {ic_name} already exists")
229+
except ApiException as exc:
230+
if exc.status == 404:
231+
networking_api.create_ingress_class(body)
232+
logger.info(f"Created IngressClass {ic_name}")
233+
else:
234+
raise
235+
236+
237+
def _delete_ingress_class(networking_api, crd_name, logger):
238+
"""Delete per-org IngressClass."""
239+
ic_name = _ingress_class_name(crd_name)
240+
try:
241+
networking_api.delete_ingress_class(ic_name)
242+
logger.info(f"Deleted IngressClass {ic_name}")
243+
except ApiException as exc:
244+
if exc.status != 404:
245+
logger.warning(f"Failed to delete IngressClass {ic_name}: {exc}")
246+
247+
210248
def _ensure_proxy_group(custom_api, crd_name, labels, default_tags, logger):
211249
"""Create the ProxyGroup so ingresses appear as Tailscale Services."""
212250
pg_name = _proxy_group_name(crd_name)
@@ -260,91 +298,10 @@ def _delete_proxy_group(custom_api, crd_name, logger):
260298

261299

262300
# ---------------------------------------------------------------------------
263-
# Operator role rules (what the Tailscale operator needs within its namespace)
301+
# RBAC rules for proxy pods (namespace-scoped)
264302
# ---------------------------------------------------------------------------
265303

266304

267-
def _operator_role_rules():
268-
return [
269-
kubernetes.client.V1PolicyRule(
270-
api_groups=[""],
271-
resources=["secrets", "serviceaccounts", "configmaps", "events"],
272-
verbs=[
273-
"get",
274-
"list",
275-
"watch",
276-
"create",
277-
"update",
278-
"patch",
279-
"delete",
280-
"deletecollection",
281-
],
282-
),
283-
kubernetes.client.V1PolicyRule(
284-
api_groups=[""],
285-
resources=["services", "services/status"],
286-
verbs=[
287-
"get",
288-
"list",
289-
"watch",
290-
"create",
291-
"update",
292-
"patch",
293-
"delete",
294-
"deletecollection",
295-
],
296-
),
297-
kubernetes.client.V1PolicyRule(
298-
api_groups=[""],
299-
resources=["pods"],
300-
verbs=["get", "list", "watch"],
301-
),
302-
kubernetes.client.V1PolicyRule(
303-
api_groups=["apps"],
304-
resources=["statefulsets", "deployments"],
305-
verbs=[
306-
"get",
307-
"list",
308-
"watch",
309-
"create",
310-
"update",
311-
"patch",
312-
"delete",
313-
"deletecollection",
314-
],
315-
),
316-
kubernetes.client.V1PolicyRule(
317-
api_groups=["networking.k8s.io"],
318-
resources=["ingresses", "ingresses/status"],
319-
verbs=["get", "list", "watch", "update", "patch"],
320-
),
321-
kubernetes.client.V1PolicyRule(
322-
api_groups=["tailscale.com"],
323-
resources=["*"],
324-
verbs=["get", "list", "watch", "create", "update", "patch", "delete"],
325-
),
326-
kubernetes.client.V1PolicyRule(
327-
api_groups=["coordination.k8s.io"],
328-
resources=["leases"],
329-
verbs=["get", "list", "watch", "create", "update", "patch"],
330-
),
331-
kubernetes.client.V1PolicyRule(
332-
api_groups=["rbac.authorization.k8s.io"],
333-
resources=["roles", "rolebindings"],
334-
verbs=[
335-
"get",
336-
"list",
337-
"watch",
338-
"create",
339-
"update",
340-
"patch",
341-
"delete",
342-
"deletecollection",
343-
],
344-
),
345-
]
346-
347-
348305
def _proxies_role_rules():
349306
return [
350307
kubernetes.client.V1PolicyRule(
@@ -401,26 +358,26 @@ def reconcile_operator(spec, name, namespace, memo, logger, retry, **kwargs):
401358
else:
402359
raise
403360

404-
# 2. ServiceAccounts
361+
# 2. Per-org IngressClass
362+
networking_api = kubernetes.client.NetworkingV1Api()
363+
_ensure_ingress_class(networking_api, name, labels, logger)
364+
365+
# 3. ServiceAccounts
405366
_ensure_service_account(core_api, namespace, operator_sa, labels)
406367
_ensure_service_account(core_api, namespace, "proxies", labels)
407368
logger.info("Ensured ServiceAccounts")
408369

409-
# 3. Operator Role + RoleBinding
410-
_ensure_role(rbac_api, namespace, operator_sa, _operator_role_rules(), labels)
411-
_ensure_role_binding(
412-
rbac_api, namespace, operator_sa, operator_sa, operator_sa, labels
413-
)
414-
logger.info("Ensured operator RBAC")
370+
# 3. ClusterRoleBinding subject (grants operator cluster-wide access
371+
# via the pre-provisioned tailscale-operator ClusterRole — no
372+
# redundant namespace Role needed)
373+
_ensure_cluster_role_binding_subject(rbac_api, namespace, operator_sa, logger)
415374

416-
# 4. Proxies Role + RoleBinding
375+
# 4. Proxies Role + RoleBinding (proxies don't have a ClusterRole,
376+
# they only need namespace-scoped secret access for their state)
417377
_ensure_role(rbac_api, namespace, "proxies", _proxies_role_rules(), labels)
418378
_ensure_role_binding(rbac_api, namespace, "proxies", "proxies", "proxies", labels)
419379
logger.info("Ensured proxies RBAC")
420380

421-
# 5. ClusterRoleBinding subject
422-
_ensure_cluster_role_binding_subject(rbac_api, namespace, operator_sa, logger)
423-
424381
# 6. Operator Deployment (WIF mode — JWT from cabotage OIDC issuer)
425382
deploy_name = operator_sa
426383
env_vars = [
@@ -433,6 +390,10 @@ def reconcile_operator(spec, name, namespace, memo, logger, retry, **kwargs):
433390
),
434391
# CLIENT_ID (not CLIENT_ID_FILE) triggers WIF mode in the operator
435392
kubernetes.client.V1EnvVar(name="CLIENT_ID", value=client_id),
393+
# Per-org IngressClass so this operator only watches its own ingresses
394+
kubernetes.client.V1EnvVar(
395+
name="OPERATOR_INGRESS_CLASS_NAME", value=_ingress_class_name(name)
396+
),
436397
]
437398
if default_tags:
438399
env_vars.append(
@@ -516,16 +477,12 @@ def delete_operator(spec, name, namespace, logger, **kwargs):
516477
custom_api = kubernetes.client.CustomObjectsApi()
517478

518479
# Delete in reverse order
480+
networking_api = kubernetes.client.NetworkingV1Api()
481+
_delete_ingress_class(networking_api, name, logger)
519482
_delete_proxy_group(custom_api, name, logger)
520483
_delete_if_exists(
521484
apps_api.delete_namespaced_deployment, operator_sa, namespace, logger=logger
522485
)
523-
_delete_if_exists(
524-
rbac_api.delete_namespaced_role_binding, operator_sa, namespace, logger=logger
525-
)
526-
_delete_if_exists(
527-
rbac_api.delete_namespaced_role, operator_sa, namespace, logger=logger
528-
)
529486
_delete_if_exists(
530487
rbac_api.delete_namespaced_role_binding, "proxies", namespace, logger=logger
531488
)

0 commit comments

Comments
 (0)