Skip to content

Commit 74c4164

Browse files
committed
Round 7 revoke fixes: wildcard in split path, whitespace dedup, stale kubectl annotation
- _parse_permission_rules: dedup by stripped key so 'pods ' and 'pods' don't both end up in the configmap. - _split_rule_after_revoke: apply '*' wildcard to the cell filter inside both URL and resource branches — previously the match phase accepted a revoke with apiGroups:['*'] / resources:['*'] but the per-cell loop did 'ag not in mr_apis' without wildcard handling, so the rule was re-emitted unchanged (silent no-op on existing specific rules). - _revoke_rbac minimal-apply: drop kubectl.kubernetes.io/last-applied- configuration from the preserved annotations — stale snapshot confuses kubectl's 3-way merge on subsequent applies. - Docstring on _atomic_rule_after_revoke: note normalized-rules contract. - Quote '--verb=*' in integration test so the shell doesn't glob-expand against the test's cwd. - 5 new unit tests: whitespace dedup, revoke-side '*' against specific existing cells, atomic no-resources path, URL-gate ordering, URL-path output ordering determinism across input permutations.
1 parent 7c7aa29 commit 74c4164

2 files changed

Lines changed: 72 additions & 7 deletions

File tree

provider-kubeconfig.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,9 @@ def _parse_permission_rules(self, perms):
6969
for api_group, res_actions in perms.items():
7070
for res in res_actions:
7171
for resource, verbs in res.items():
72-
if resource not in resources:
73-
resources.append(resource.strip())
72+
resource_key = resource.strip()
73+
if resource_key not in resources:
74+
resources.append(resource_key)
7475
rule_group = {}
7576
if api_group == "non-apigroup":
7677
if NON_RESOURCE_URL_PREFIX in resource:
@@ -86,7 +87,7 @@ def _parse_permission_rules(self, perms):
8687
rule_group["resources"] = [parts[0].strip()]
8788
rule_group["resourceNames"] = [parts[1].strip()]
8889
else:
89-
rule_group["resources"] = [resource]
90+
rule_group["resources"] = [resource_key]
9091
rule_list.append(rule_group)
9192
return rule_list, resources
9293

@@ -783,7 +784,13 @@ def _revoke_rbac(self, permissionfile, sa, namespace, kubeconfig):
783784
if remaining_rules:
784785
# Apply a minimal object to avoid stale metadata/resourceVersion apply conflicts.
785786
labels = role_obj.get("metadata", {}).get("labels")
786-
annotations = role_obj.get("metadata", {}).get("annotations")
787+
annotations = role_obj.get("metadata", {}).get("annotations") or {}
788+
# Strip kubectl's own last-applied snapshot — it's stale after the rewrite
789+
# and confuses kubectl's 3-way merge on subsequent applies.
790+
annotations = {
791+
k: v for k, v in annotations.items()
792+
if k != "kubectl.kubernetes.io/last-applied-configuration"
793+
}
787794
metadata = {"name": role_name}
788795
if labels:
789796
metadata["labels"] = labels
@@ -827,9 +834,10 @@ def _split_rule_after_revoke(self, rule, matched_revoke_rules, retained_keys):
827834
cell_verbs = {url: set(existing_verbs) for url in existing_urls}
828835
for mr in matched_revoke_rules:
829836
mr_urls = set(mr["nonResourceURLs"])
837+
mr_urls_wild = "*" in mr_urls
830838
mr_verbs = mr["verbs"]
831839
for url in cell_verbs:
832-
if url not in mr_urls:
840+
if not mr_urls_wild and url not in mr_urls:
833841
continue
834842
cell_verbs[url] = self._strip_to_set(cell_verbs[url], mr_verbs)
835843
out_rules = []
@@ -860,13 +868,17 @@ def _split_rule_after_revoke(self, rule, matched_revoke_rules, retained_keys):
860868
for mr in matched_revoke_rules:
861869
mr_apis = set(mr["apiGroups"])
862870
mr_ress = set(mr["resources"])
871+
mr_apis_wild = "*" in mr_apis
872+
mr_ress_wild = "*" in mr_ress
863873
mr_names = set(mr["resourceNames"]) if mr["resourceNames"] else None
864874
mr_verbs = mr["verbs"]
865875
if not mr_apis or not mr_ress:
866876
continue
867877
for cell in cell_verbs:
868878
ag, res, rn = cell
869-
if ag not in mr_apis or res not in mr_ress:
879+
if not mr_apis_wild and ag not in mr_apis:
880+
continue
881+
if not mr_ress_wild and res not in mr_ress:
870882
continue
871883
if mr_names is not None and rn not in mr_names:
872884
continue
@@ -912,6 +924,7 @@ def _atomic_rule_after_revoke(self, rule, matched_revoke_rules, retained_keys):
912924
"""Atomic path: strip the union of matched revoke verbs from the whole rule.
913925
Used when the existing rule has '*' scope or no resource dimension —
914926
RBAC cannot express 'everything except X', so we trim globally or drop.
927+
`matched_revoke_rules` must be normalized (dict entries from _normalize_rule).
915928
"""
916929
combined_verbs = [v for mr in matched_revoke_rules for v in mr["verbs"]]
917930
stripped = self._strip_verbs(rule.get("verbs", []), combined_verbs)

tests/test_provider_kubeconfig.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,58 @@ def test_split_rule_wildcard_scope_multi_revoke_composition(self):
376376
self.assertEqual(len(result), 1)
377377
self.assertEqual(sorted(result[0]["verbs"]), ["list"])
378378

379+
def test_parse_permission_rules_whitespace_dedup(self):
380+
"""Trailing whitespace in a resource key must not create a duplicate."""
381+
perms = {"": [{"pods": ["get"]}, {"pods ": ["delete"]}]}
382+
_, resources = self.generator._parse_permission_rules(perms)
383+
self.assertEqual(resources, ["pods"])
384+
385+
def test_split_rule_wildcard_revoke_matches_specific_existing(self):
386+
"""Revoke-side '*' in apiGroups/resources must trim specific existing cells."""
387+
rule = {"apiGroups": [""], "resources": ["pods"], "verbs": ["get", "delete"]}
388+
matched = [self.generator._normalize_rule(
389+
{"apiGroups": ["*"], "resources": ["*"], "verbs": ["delete"]}
390+
)]
391+
result = self.generator._split_rule_after_revoke(rule, matched, set())
392+
self.assertEqual(len(result), 1)
393+
self.assertEqual(sorted(result[0]["verbs"]), ["get"])
394+
395+
def test_atomic_rule_no_resources_no_retained_keys(self):
396+
"""Atomic path on a rule with no resources/URLs contributes no retained keys."""
397+
rule = {"apiGroups": [""], "verbs": ["get", "delete"]}
398+
matched = [self.generator._normalize_rule(
399+
{"apiGroups": [""], "resources": [], "verbs": ["delete"]}
400+
)]
401+
retained = set()
402+
result = self.generator._split_rule_after_revoke(rule, matched, retained)
403+
self.assertEqual(retained, set())
404+
# _atomic_rule_after_revoke trims via _strip_verbs even without matching scope.
405+
self.assertEqual(len(result), 1)
406+
407+
def test_rule_matches_revoke_url_gate_runs_before_resource_names(self):
408+
"""A URL revoke against a URL rule matches even if revoke lacks resourceNames."""
409+
existing = self.generator._normalize_rule(
410+
{"nonResourceURLs": ["/metrics"], "verbs": ["get"]}
411+
)
412+
url_revoke = self.generator._normalize_rule(
413+
{"nonResourceURLs": ["/metrics"], "verbs": ["get"]}
414+
)
415+
self.assertTrue(self.generator._rule_matches_revoke(existing, url_revoke))
416+
417+
def test_split_rule_url_path_output_ordering_deterministic(self):
418+
"""URL-path output order is stable across input permutations."""
419+
rule_a = {"nonResourceURLs": ["/a", "/b", "/c"], "verbs": ["get", "list"]}
420+
rule_b = {"nonResourceURLs": ["/c", "/b", "/a"], "verbs": ["get", "list"]}
421+
matched = [self.generator._normalize_rule(
422+
{"nonResourceURLs": ["/b"], "verbs": ["list"]}
423+
)]
424+
out_a = self.generator._split_rule_after_revoke(rule_a, matched, set())
425+
out_b = self.generator._split_rule_after_revoke(rule_b, matched, set())
426+
self.assertEqual(
427+
[(sorted(r["verbs"]), sorted(r["nonResourceURLs"])) for r in out_a],
428+
[(sorted(r["verbs"]), sorted(r["nonResourceURLs"])) for r in out_b],
429+
)
430+
379431
def test_permission_fixture_files_parse_json_and_yaml(self):
380432
"""Fixture files in tests/permission_files should load and parse via script helpers."""
381433
fixture_dir = os.path.join(ROOT, "tests", "permission_files")
@@ -945,7 +997,7 @@ def test_revoke_on_wildcard_existing_verbs_drops_matching_scope_rule(self):
945997
_run_command("kubectl create sa " + sa + " -n " + ns + self.kubeconfig_flag)
946998
_run_command(
947999
"kubectl create clusterrole " + sa
948-
+ " --verb=* --resource=pods" + self.kubeconfig_flag
1000+
+ " '--verb=*' --resource=pods" + self.kubeconfig_flag
9491001
)
9501002
_run_command(
9511003
"kubectl create clusterrolebinding " + sa

0 commit comments

Comments
 (0)