Skip to content

Commit 7c7aa29

Browse files
committed
Round 6 revoke cleanups: constants, atomic-path helper, deterministic output
- RESOURCE_NAME_SEP / NON_RESOURCE_URL_PREFIX module-level constants — six callsites across provider-kubeconfig.py used bare string literals; a typo in either file silently broke revoke matching. - _rule_matches_revoke: check nonResourceURLs before resourceNames so a malformed revoke rule with both fields isn't rejected on a resourceNames gate that doesn't apply to URL rules. - _atomic_rule_after_revoke helper unifies the wildcard-scope and no-resources branches; both paths strip the union of matched revoke verbs — RBAC can't express 'everything except X'. - _bucket_cells_by_verbs helper shared by URL and resource paths. - Sort bucket iteration by verbs_key so output rule ordering is stable across permutations of semantically equivalent input. - Hoist set(mr['verbs']) once per matched-revoke in both split paths. - Derive has_names from existing_resource_names directly (was cells[0][2]). - 3 new unit tests: resource key containing the substring 'resourceName' but no '/resourceName::' separator; _dim_matches with revoke-side '*'; multi-revoke composition against a wildcard-scope rule.
1 parent 7a57d4a commit 7c7aa29

3 files changed

Lines changed: 81 additions & 45 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ operator-manager/artifacts/deployment/operator-manager
88
operator-manager/operator-manager
99
platform-operator/platform-operator
1010
platform-operator/artifacts/deployment/platform-operator
11+
__pycache__/

provider-kubeconfig.py

Lines changed: 46 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
from logging.config import dictConfig
1010

11+
RESOURCE_NAME_SEP = "/resourceName::"
12+
NON_RESOURCE_URL_PREFIX = "nonResourceURL::"
13+
1114
dictConfig({
1215
"version": 1,
1316
"formatters": {"default": {"format": "[%(asctime)s] %(levelname)s in %(module)s: %(message)s"}},
@@ -70,16 +73,16 @@ def _parse_permission_rules(self, perms):
7073
resources.append(resource.strip())
7174
rule_group = {}
7275
if api_group == "non-apigroup":
73-
if "nonResourceURL" in resource:
74-
parts = resource.split("nonResourceURL::")
76+
if NON_RESOURCE_URL_PREFIX in resource:
77+
parts = resource.split(NON_RESOURCE_URL_PREFIX)
7578
non_res = parts[1].strip() if len(parts) > 1 else parts[0].strip()
7679
rule_group["nonResourceURLs"] = [non_res]
7780
rule_group["verbs"] = verbs
7881
else:
7982
rule_group["apiGroups"] = [api_group]
8083
rule_group["verbs"] = verbs
81-
if "/resourceName::" in resource:
82-
parts = resource.split("/resourceName::")
84+
if RESOURCE_NAME_SEP in resource:
85+
parts = resource.split(RESOURCE_NAME_SEP)
8386
rule_group["resources"] = [parts[0].strip()]
8487
rule_group["resourceNames"] = [parts[1].strip()]
8588
else:
@@ -208,14 +211,14 @@ def _strip_verbs(self, existing_verbs, revoke_verbs):
208211
def _permission_keys(self, resource, resource_names):
209212
"""Permission-file key form used by _parse_permission_rules / the perms configmap."""
210213
if resource_names:
211-
return [resource + "/resourceName::" + rn for rn in resource_names]
214+
return [resource + RESOURCE_NAME_SEP + rn for rn in resource_names]
212215
return [resource]
213216

214217
def _retained_keys_for_rule(self, rule):
215218
"""All permission-file keys covered by an (unmodified) k8s rule."""
216219
keys = []
217220
for url in rule.get("nonResourceURLs") or []:
218-
keys.append("nonResourceURL::" + url)
221+
keys.append(NON_RESOURCE_URL_PREFIX + url)
219222
resource_names = rule.get("resourceNames") or []
220223
for res in rule.get("resources") or []:
221224
keys.extend(self._permission_keys(res, resource_names))
@@ -236,13 +239,13 @@ def _rule_matches_revoke(self, existing_norm, revoke_norm_rule):
236239
RBAC has no 'deny', so we cannot trim a named cell out of a rule that
237240
grants all names.
238241
"""
242+
if revoke_norm_rule["nonResourceURLs"]:
243+
return bool(set(revoke_norm_rule["nonResourceURLs"]).intersection(existing_norm["nonResourceURLs"]))
239244
if revoke_norm_rule["resourceNames"]:
240245
if not existing_norm["resourceNames"]:
241246
return False
242247
if not set(revoke_norm_rule["resourceNames"]).intersection(existing_norm["resourceNames"]):
243248
return False
244-
if revoke_norm_rule["nonResourceURLs"]:
245-
return bool(set(revoke_norm_rule["nonResourceURLs"]).intersection(existing_norm["nonResourceURLs"]))
246249
if not self._dim_matches(revoke_norm_rule["apiGroups"], existing_norm["apiGroups"]):
247250
return False
248251
if not self._dim_matches(revoke_norm_rule["resources"], existing_norm["resources"]):
@@ -824,51 +827,31 @@ def _split_rule_after_revoke(self, rule, matched_revoke_rules, retained_keys):
824827
cell_verbs = {url: set(existing_verbs) for url in existing_urls}
825828
for mr in matched_revoke_rules:
826829
mr_urls = set(mr["nonResourceURLs"])
830+
mr_verbs = mr["verbs"]
827831
for url in cell_verbs:
828832
if url not in mr_urls:
829833
continue
830-
cell_verbs[url] = self._strip_to_set(cell_verbs[url], mr["verbs"])
831-
by_verbs = {}
832-
for url, cv in cell_verbs.items():
833-
if not cv:
834-
continue
835-
by_verbs.setdefault(tuple(sorted(cv)), []).append(url)
834+
cell_verbs[url] = self._strip_to_set(cell_verbs[url], mr_verbs)
836835
out_rules = []
837-
for verbs_key, urls in by_verbs.items():
836+
for verbs_key, urls in sorted(self._bucket_cells_by_verbs(cell_verbs).items()):
838837
updated = dict(rule)
839838
updated["nonResourceURLs"] = urls
840839
updated["verbs"] = list(verbs_key)
841840
out_rules.append(updated)
842841
for url in urls:
843-
retained_keys.add("nonResourceURL::" + url)
842+
retained_keys.add(NON_RESOURCE_URL_PREFIX + url)
844843
return out_rules
845844

846845
existing_api_groups = rule.get("apiGroups") or []
847846
existing_resources = rule.get("resources") or []
848847
existing_resource_names = rule.get("resourceNames") or []
849848

850-
# Wildcard scope: we cannot enumerate "everything except X". Treat atomic.
851-
if "*" in existing_api_groups or "*" in existing_resources:
852-
combined_verbs = [v for mr in matched_revoke_rules for v in mr["verbs"]]
853-
stripped = self._strip_verbs(existing_verbs, combined_verbs)
854-
if not stripped:
855-
return []
856-
updated = dict(rule)
857-
updated["verbs"] = stripped
858-
for key in self._retained_keys_for_rule(rule):
859-
retained_keys.add(key)
860-
return [updated]
861-
862-
if not existing_resources:
863-
combined_verbs = [v for mr in matched_revoke_rules for v in mr["verbs"]]
864-
stripped = self._strip_verbs(existing_verbs, combined_verbs)
865-
if not stripped:
866-
return []
867-
updated = dict(rule)
868-
updated["verbs"] = stripped
869-
return [updated]
849+
# Wildcard scope or no resources: can't enumerate "all except X" — treat atomic.
850+
if "*" in existing_api_groups or "*" in existing_resources or not existing_resources:
851+
return self._atomic_rule_after_revoke(rule, matched_revoke_rules, retained_keys)
870852

871853
names_axis = existing_resource_names or [None]
854+
has_names = bool(existing_resource_names)
872855
cell_verbs = {}
873856
for ag in existing_api_groups:
874857
for res in existing_resources:
@@ -878,6 +861,7 @@ def _split_rule_after_revoke(self, rule, matched_revoke_rules, retained_keys):
878861
mr_apis = set(mr["apiGroups"])
879862
mr_ress = set(mr["resources"])
880863
mr_names = set(mr["resourceNames"]) if mr["resourceNames"] else None
864+
mr_verbs = mr["verbs"]
881865
if not mr_apis or not mr_ress:
882866
continue
883867
for cell in cell_verbs:
@@ -886,19 +870,12 @@ def _split_rule_after_revoke(self, rule, matched_revoke_rules, retained_keys):
886870
continue
887871
if mr_names is not None and rn not in mr_names:
888872
continue
889-
cell_verbs[cell] = self._strip_to_set(cell_verbs[cell], mr["verbs"])
890-
891-
by_verbs = {}
892-
for cell, cv in cell_verbs.items():
893-
if not cv:
894-
continue
895-
by_verbs.setdefault(tuple(sorted(cv)), []).append(cell)
873+
cell_verbs[cell] = self._strip_to_set(cell_verbs[cell], mr_verbs)
896874

897875
out_rules = []
898-
for verbs_key, cells in by_verbs.items():
876+
for verbs_key, cells in sorted(self._bucket_cells_by_verbs(cell_verbs).items()):
899877
apis = sorted({c[0] for c in cells})
900878
ress = sorted({c[1] for c in cells})
901-
has_names = cells[0][2] is not None
902879
names = sorted({c[2] for c in cells}) if has_names else []
903880
expected = len(apis) * len(ress) * (len(names) if has_names else 1)
904881
if len(cells) == expected:
@@ -930,6 +907,30 @@ def _split_rule_after_revoke(self, rule, matched_revoke_rules, retained_keys):
930907
for key in self._permission_keys(res, cell_names):
931908
retained_keys.add(key)
932909
return out_rules
910+
911+
def _atomic_rule_after_revoke(self, rule, matched_revoke_rules, retained_keys):
912+
"""Atomic path: strip the union of matched revoke verbs from the whole rule.
913+
Used when the existing rule has '*' scope or no resource dimension —
914+
RBAC cannot express 'everything except X', so we trim globally or drop.
915+
"""
916+
combined_verbs = [v for mr in matched_revoke_rules for v in mr["verbs"]]
917+
stripped = self._strip_verbs(rule.get("verbs", []), combined_verbs)
918+
if not stripped:
919+
return []
920+
updated = dict(rule)
921+
updated["verbs"] = stripped
922+
for key in self._retained_keys_for_rule(rule):
923+
retained_keys.add(key)
924+
return [updated]
925+
926+
def _bucket_cells_by_verbs(self, cell_verbs):
927+
"""Group non-empty cells by their sorted-verb tuple for regrouping."""
928+
buckets = {}
929+
for cell, cv in cell_verbs.items():
930+
if not cv:
931+
continue
932+
buckets.setdefault(tuple(sorted(cv)), []).append(cell)
933+
return buckets
933934

934935

935936
def _apply_rbac(self, sa, namespace, entity='', kubeconfig=''):

tests/test_provider_kubeconfig.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,40 @@ def test_split_rule_non_rectangular_emits_per_cell(self):
342342
covered, {("", "services"), ("apps", "pods"), ("apps", "services")}
343343
)
344344

345+
def test_parse_permission_rules_resource_name_substring_without_separator(self):
346+
"""A resource key containing 'resourceName' but no '/resourceName::' separator
347+
must not be split — it's a literal resource name."""
348+
perms = {"": [{"myresourceNames": ["get"]}]}
349+
rule_list, resources = self.generator._parse_permission_rules(perms)
350+
self.assertEqual(len(rule_list), 1)
351+
self.assertEqual(rule_list[0]["resources"], ["myresourceNames"])
352+
self.assertNotIn("resourceNames", rule_list[0])
353+
self.assertEqual(resources, ["myresourceNames"])
354+
355+
def test_dim_matches_wildcard_on_revoke_side(self):
356+
"""A revoke-side '*' matches any specific existing apiGroup/resource."""
357+
self.assertTrue(self.generator._dim_matches(("*",), ("",)))
358+
self.assertTrue(self.generator._dim_matches(("*",), ("apps",)))
359+
self.assertTrue(self.generator._dim_matches(("",), ("*",)))
360+
self.assertFalse(self.generator._dim_matches(("",), ("apps",)))
361+
362+
def test_split_rule_wildcard_scope_multi_revoke_composition(self):
363+
"""When multiple revokes target distinct specific cells of a wildcard rule,
364+
verbs accumulate — we can't express exclusion, so the combined set is stripped."""
365+
rule = {"apiGroups": ["*"], "resources": ["*"], "verbs": ["get", "delete", "list"]}
366+
matched = [
367+
self.generator._normalize_rule(
368+
{"apiGroups": [""], "resources": ["pods"], "verbs": ["delete"]}
369+
),
370+
self.generator._normalize_rule(
371+
{"apiGroups": [""], "resources": ["services"], "verbs": ["get"]}
372+
),
373+
]
374+
retained = set()
375+
result = self.generator._split_rule_after_revoke(rule, matched, retained)
376+
self.assertEqual(len(result), 1)
377+
self.assertEqual(sorted(result[0]["verbs"]), ["list"])
378+
345379
def test_permission_fixture_files_parse_json_and_yaml(self):
346380
"""Fixture files in tests/permission_files should load and parse via script helpers."""
347381
fixture_dir = os.path.join(ROOT, "tests", "permission_files")

0 commit comments

Comments
 (0)