@@ -55,7 +55,10 @@ def _load_permission_data(self, permissionfile):
5555 try :
5656 perms_data = json .loads (contents )
5757 except json .JSONDecodeError :
58- perms_data = yaml .safe_load (contents )
58+ try :
59+ perms_data = yaml .safe_load (contents )
60+ except yaml .YAMLError as exc :
61+ raise ValueError (f"Permission file is neither valid JSON nor YAML: { exc } " ) from exc
5962 if not isinstance (perms_data , dict ) or "perms" not in perms_data :
6063 raise ValueError ("Permission file must define a top-level 'perms' object." )
6164 if not isinstance (perms_data ["perms" ], dict ):
@@ -126,6 +129,7 @@ def _write_perm_configmap_resources(self, sa, namespace, kubeconfig, resources):
126129 "create configmap " + cfg_map_name + " -n " + namespace
127130 + " --from-file=" + cfg_map_filename ,
128131 kubeconfig ,
132+ ignore_not_found = False ,
129133 )
130134
131135
@@ -768,57 +772,48 @@ def _revoke_rbac(self, permissionfile, sa, namespace, kubeconfig):
768772 revoke_rule_list , _ = self ._parse_permission_rules (perms )
769773 revoke_norm = self ._normalize_rule_list (revoke_rule_list )
770774
771- role_name = sa + "-update"
772- rolebinding_name = sa + "-update"
773- out , err , rc = run_command_with_code ("kubectl get clusterrole " + role_name + " -o json" + kubeconfig )
774- if rc != 0 and "(NotFound)" not in err :
775- raise RuntimeError (f"Failed to fetch clusterrole { role_name !r} : { err .strip ()} " )
776- if rc != 0 :
777- # If revoking without updating first, revoke against the base SA role.
778- role_name = sa
779- rolebinding_name = sa
780- out , err , rc = run_command_with_code ("kubectl get clusterrole " + role_name + " -o json" + kubeconfig )
781- if rc != 0 and "(NotFound)" not in err :
782- raise RuntimeError (f"Failed to fetch clusterrole { role_name !r} : { err .strip ()} " )
783-
784- if not out :
775+ # Try both the -update role and the base role. If nothing matches in -update
776+ # but the permission is granted by the base role, fall through and edit base.
777+ update_role = self ._fetch_clusterrole (sa + "-update" , kubeconfig )
778+ base_role = self ._fetch_clusterrole (sa , kubeconfig )
779+ if update_role is None and base_role is None :
785780 print (f"No clusterrole { sa !r} or { sa + '-update' !r} found; nothing to revoke." )
786781 return False
787782
788- retained_keys = set ()
789- # Keys covered by rules the revoke actually touched. Only these may be
790- # dropped from the perms configmap — permissions granted by a separate
791- # role (e.g. the base role when we're editing only -update) must stay.
792- touched_keys = set ()
793- role_obj = json .loads (out )
794- existing_rules = role_obj .get ("rules" ) or []
795- remaining_rules = []
796- for rule in existing_rules :
797- existing_norm = self ._normalize_rule (rule )
798- matched = [r for r in revoke_norm if self ._rule_matches_revoke (existing_norm , r )]
799- if not matched :
800- remaining_rules .append (rule )
801- retained_keys .update (self ._retained_keys_for_rule (rule ))
802- continue
803- touched_keys .update (self ._retained_keys_for_rule (rule ))
804- remaining_rules .extend (self ._split_rule_after_revoke (rule , matched , retained_keys ))
805-
806- # If we're editing <sa>-update, any key the base <sa> role still grants
807- # must stay in the configmap even when we stripped it from -update.
808- if role_name != sa :
809- base_out , base_err , base_rc = run_command_with_code (
810- "kubectl get clusterrole " + sa + " -o json" + kubeconfig
811- )
812- if base_rc != 0 and "(NotFound)" not in base_err :
813- raise RuntimeError (f"Failed to fetch clusterrole { sa !r} : { base_err .strip ()} " )
814- if base_out :
815- for rule in json .loads (base_out ).get ("rules" ) or []:
816- retained_keys .update (self ._retained_keys_for_rule (rule ))
817-
818- if not touched_keys :
819- print (f"Nothing to revoke: no rule in clusterrole { role_name !r} matched the permission file." )
783+ target = None
784+ if update_role is not None :
785+ walk = self ._walk_rules_for_revoke (update_role , revoke_norm )
786+ if walk ["touched_keys" ]:
787+ target = ("update" , sa + "-update" , update_role , walk )
788+ if target is None and base_role is not None :
789+ walk = self ._walk_rules_for_revoke (base_role , revoke_norm )
790+ if walk ["touched_keys" ]:
791+ target = ("base" , sa , base_role , walk )
792+
793+ if target is None :
794+ present = [
795+ name for name , role in (
796+ (sa + "-update" , update_role ),
797+ (sa , base_role ),
798+ ) if role is not None
799+ ]
800+ print (f"Nothing to revoke: no rule in { ' or ' .join (present )} matched the permission file." )
820801 return False
821802
803+ _ , role_name , role_obj , walk = target
804+ rolebinding_name = role_name
805+ retained_keys = walk ["retained_keys" ]
806+ touched_keys = walk ["touched_keys" ]
807+ existing_rules = walk ["existing_rules" ]
808+ remaining_rules = walk ["remaining_rules" ]
809+
810+ # If we edited -update, keys the base role still grants must stay in the configmap.
811+ # (The reverse isn't needed — if we edited base, -update's grants were already
812+ # narrower and we've either dropped or re-applied all it covered.)
813+ if role_name .endswith ("-update" ) and base_role is not None :
814+ for rule in base_role .get ("rules" ) or []:
815+ retained_keys .update (self ._retained_keys_for_rule (rule ))
816+
822817 # Matching a rule's scope doesn't mean we actually stripped any verbs — e.g.,
823818 # revoking 'delete' against an existing rule that only granted 'get' is a no-op.
824819 # Compare normalized rule lists to detect real change.
@@ -872,11 +867,47 @@ def _revoke_rbac(self, permissionfile, sa, namespace, kubeconfig):
872867 self ._write_perm_configmap_resources (sa , namespace , kubeconfig , remaining_resources )
873868 return True
874869
875- def _kubectl_or_raise (self , args , kubeconfig ):
876- """Run a kubectl subcommand and raise if it fails (ignore NotFound)."""
870+ def _kubectl_or_raise (self , args , kubeconfig , * , ignore_not_found = True ):
871+ """Run a kubectl subcommand and raise on failure. Skip NotFound only for
872+ idempotent deletes/reads; create paths should pass ignore_not_found=False
873+ so a missing namespace surfaces as a hard error."""
877874 _ , err , rc = run_command_with_code ("kubectl " + args + kubeconfig )
878- if rc != 0 and "(NotFound)" not in err :
879- raise RuntimeError (f"kubectl { args } failed: { err .strip ()} " )
875+ if rc == 0 :
876+ return
877+ if ignore_not_found and "(NotFound)" in err :
878+ return
879+ raise RuntimeError (f"kubectl { args } failed: { err .strip ()} " )
880+
881+ def _fetch_clusterrole (self , name , kubeconfig ):
882+ """Return the ClusterRole dict, or None if NotFound. Raises on other errors."""
883+ out , err , rc = run_command_with_code ("kubectl get clusterrole " + name + " -o json" + kubeconfig )
884+ if rc == 0 and out :
885+ return json .loads (out )
886+ if "(NotFound)" in err :
887+ return None
888+ raise RuntimeError (f"Failed to fetch clusterrole { name !r} : { err .strip ()} " )
889+
890+ def _walk_rules_for_revoke (self , role_obj , revoke_norm ):
891+ """Apply `revoke_norm` to `role_obj`'s rules; return split result + tracking sets."""
892+ retained_keys = set ()
893+ touched_keys = set ()
894+ existing_rules = role_obj .get ("rules" ) or []
895+ remaining_rules = []
896+ for rule in existing_rules :
897+ existing_norm = self ._normalize_rule (rule )
898+ matched = [r for r in revoke_norm if self ._rule_matches_revoke (existing_norm , r )]
899+ if not matched :
900+ remaining_rules .append (rule )
901+ retained_keys .update (self ._retained_keys_for_rule (rule ))
902+ continue
903+ touched_keys .update (self ._retained_keys_for_rule (rule ))
904+ remaining_rules .extend (self ._split_rule_after_revoke (rule , matched , retained_keys ))
905+ return {
906+ "retained_keys" : retained_keys ,
907+ "touched_keys" : touched_keys ,
908+ "existing_rules" : existing_rules ,
909+ "remaining_rules" : remaining_rules ,
910+ }
880911
881912 def _split_rule_after_revoke (self , rule , matched_revoke_rules , retained_keys ):
882913 """Split an existing rule into the rules that remain after applying matched revokes.
0 commit comments