Skip to content

Commit 6e0c42b

Browse files
committed
Add revoke action and YAML/JSON permission parsing.
Support permission updates and revocations from both JSON and YAML files, reuse shared parsing/configmap helpers, and extend tests to cover revoke CLI validation and multi-format permission file parsing. Made-with: Cursor
1 parent 6f1f127 commit 6e0c42b

File tree

2 files changed

+177
-51
lines changed

2 files changed

+177
-51
lines changed

provider-kubeconfig.py

Lines changed: 107 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,74 @@ def run_command(cmd):
4242

4343

4444
class KubeconfigGenerator(object):
45+
def _load_permission_data(self, permissionfile):
46+
"""Load permissions definition from JSON or YAML file."""
47+
with open(permissionfile, "r", encoding="utf-8") as fp:
48+
contents = fp.read()
49+
try:
50+
perms_data = json.loads(contents)
51+
except json.JSONDecodeError:
52+
perms_data = yaml.safe_load(contents)
53+
if not isinstance(perms_data, dict) or "perms" not in perms_data:
54+
raise ValueError("Permission file must define a top-level 'perms' object.")
55+
if not isinstance(perms_data["perms"], dict):
56+
raise ValueError("'perms' must be a mapping of apiGroup to permission rules.")
57+
return perms_data["perms"]
58+
59+
def _parse_permission_rules(self, perms):
60+
"""Convert permission mapping to k8s rule list and tracked resources list."""
61+
rule_list = []
62+
resources = []
63+
for api_group, res_actions in perms.items():
64+
for res in res_actions:
65+
for resource, verbs in res.items():
66+
if resource not in resources:
67+
resources.append(resource.strip())
68+
rule_group = {}
69+
if api_group == "non-apigroup":
70+
if "nonResourceURL" in resource:
71+
parts = resource.split("nonResourceURL::")
72+
non_res = parts[1].strip() if len(parts) > 1 else parts[0].strip()
73+
rule_group["nonResourceURLs"] = [non_res]
74+
rule_group["verbs"] = verbs
75+
else:
76+
rule_group["apiGroups"] = [api_group]
77+
rule_group["verbs"] = verbs
78+
if "resourceName" in resource:
79+
parts = resource.split("/resourceName::")
80+
rule_group["resources"] = [parts[0].strip()]
81+
rule_group["resourceNames"] = [parts[1].strip()]
82+
else:
83+
rule_group["resources"] = [resource]
84+
if rule_group:
85+
rule_list.append(rule_group)
86+
return rule_list, resources
87+
88+
def _read_perm_configmap_resources(self, sa, namespace, kubeconfig):
89+
cfg_map_name = sa + "-perms"
90+
cfg_map_filename = sa + "-perms.txt"
91+
out, _ = run_command("kubectl get configmap " + cfg_map_name + " -o json -n " + namespace + kubeconfig)
92+
kubeplus_perms = []
93+
if out:
94+
json_op = json.loads(out)
95+
perms_str = json_op.get("data", {}).get(cfg_map_filename, "")
96+
for p in perms_str.replace("'", "").replace("[", "").replace("]", "").split(","):
97+
p = p.strip()
98+
if p:
99+
kubeplus_perms.append(p)
100+
return kubeplus_perms
101+
102+
def _write_perm_configmap_resources(self, sa, namespace, kubeconfig, resources):
103+
cfg_map_name = sa + "-perms"
104+
cfg_map_filename = sa + "-perms.txt"
105+
run_command("kubectl delete configmap " + cfg_map_name + " -n " + namespace + kubeconfig)
106+
with open(cfg_map_filename, "w", encoding="utf-8") as fp:
107+
fp.write(str(sorted(set(resources))))
108+
run_command(
109+
"kubectl create configmap " + cfg_map_name + " -n " + namespace
110+
+ " --from-file=" + cfg_map_filename + kubeconfig
111+
)
112+
45113

46114

47115
def _create_kubecfg_file(self, sa, namespace, filename, token, ca, server, kubeconfig, cluster_name=None):
@@ -581,35 +649,9 @@ def _apply_provider_rbac(self, sa, namespace, kubeconfig):
581649
)
582650

583651
def _update_rbac(self, permissionfile, sa, namespace, kubeconfig):
584-
"""Add permissions from JSON file to provider (update command)."""
585-
with open(permissionfile, "r", encoding="utf-8") as fp:
586-
perms_data = json.load(fp)
587-
perms = perms_data["perms"]
588-
rule_list = []
589-
new_resources = []
590-
591-
for api_group, res_actions in perms.items():
592-
for res in res_actions:
593-
for resource, verbs in res.items():
594-
if resource not in new_resources:
595-
new_resources.append(resource.strip())
596-
rule_group = {}
597-
if api_group == "non-apigroup":
598-
if "nonResourceURL" in resource:
599-
parts = resource.split("nonResourceURL::")
600-
non_res = parts[1].strip() if len(parts) > 1 else parts[0].strip()
601-
rule_group["nonResourceURLs"] = [non_res]
602-
rule_group["verbs"] = verbs
603-
else:
604-
rule_group["apiGroups"] = [api_group]
605-
rule_group["verbs"] = verbs
606-
if "resourceName" in resource:
607-
parts = resource.split("/resourceName::")
608-
rule_group["resources"] = [parts[0].strip()]
609-
rule_group["resourceNames"] = [parts[1].strip()]
610-
else:
611-
rule_group["resources"] = [resource]
612-
rule_list.append(rule_group)
652+
"""Add permissions from JSON/YAML file to provider (update command)."""
653+
perms = self._load_permission_data(permissionfile)
654+
rule_list, new_resources = self._parse_permission_rules(perms)
613655

614656
role = {
615657
"apiVersion": "rbac.authorization.k8s.io/v1",
@@ -628,27 +670,37 @@ def _update_rbac(self, permissionfile, sa, namespace, kubeconfig):
628670
}
629671
create_role_rolebinding(role_binding, sa + "-update-rolebinding.yaml", kubeconfig)
630672

631-
cfg_map_name = sa + "-perms"
632-
cfg_map_filename = sa + "-perms.txt"
633-
out, _ = run_command("kubectl get configmap " + cfg_map_name + " -o json -n " + namespace + kubeconfig)
634-
kubeplus_perms = []
635-
if out:
636-
json_op = json.loads(out)
637-
perms_str = json_op.get("data", {}).get(cfg_map_filename, "")
638-
for p in perms_str.replace("'", "").replace("[", "").replace("]", "").split(","):
639-
p = p.strip()
640-
if p:
641-
kubeplus_perms.append(p)
673+
kubeplus_perms = self._read_perm_configmap_resources(sa, namespace, kubeconfig)
642674
new_resources.extend(kubeplus_perms)
675+
self._write_perm_configmap_resources(sa, namespace, kubeconfig, new_resources)
643676

644-
run_command("kubectl delete configmap " + cfg_map_name + " -n " + namespace + kubeconfig)
645-
new_resources = sorted(set(new_resources))
646-
with open(cfg_map_filename, "w", encoding="utf-8") as fp:
647-
fp.write(str(new_resources))
648-
run_command(
649-
"kubectl create configmap " + cfg_map_name + " -n " + namespace
650-
+ " --from-file=" + cfg_map_filename + kubeconfig
651-
)
677+
def _revoke_rbac(self, permissionfile, sa, namespace, kubeconfig):
678+
"""Revoke permissions from JSON/YAML file for provider/consumer update role."""
679+
perms = self._load_permission_data(permissionfile)
680+
revoke_rule_list, revoke_resources = self._parse_permission_rules(perms)
681+
revoke_norm = set(tuple(sorted(r.items())) for r in self._normalize_rule_list(revoke_rule_list))
682+
683+
role_name = sa + "-update"
684+
out, _ = run_command("kubectl get clusterrole " + role_name + " -o json" + kubeconfig)
685+
if out:
686+
role_obj = json.loads(out)
687+
existing_rules = role_obj.get("rules", [])
688+
remaining_rules = []
689+
for rule in existing_rules:
690+
norm = self._normalize_rule(rule)
691+
norm_key = tuple(sorted(norm.items()))
692+
if norm_key not in revoke_norm:
693+
remaining_rules.append(rule)
694+
if remaining_rules:
695+
role_obj["rules"] = remaining_rules
696+
create_role_rolebinding(role_obj, sa + "-update-role.yaml", kubeconfig)
697+
else:
698+
run_command("kubectl delete clusterrole " + role_name + kubeconfig)
699+
run_command("kubectl delete clusterrolebinding " + role_name + kubeconfig)
700+
701+
current_resources = self._read_perm_configmap_resources(sa, namespace, kubeconfig)
702+
remaining_resources = [res for res in current_resources if res not in set(revoke_resources)]
703+
self._write_perm_configmap_resources(sa, namespace, kubeconfig, remaining_resources)
652704

653705

654706
def _apply_rbac(self, sa, namespace, entity='', kubeconfig=''):
@@ -764,7 +816,7 @@ def _generate_kubeconfig(self, sa, namespace, filename, api_server_ip="", kubeco
764816
"The generated kubeconfig includes the namespace in the context so kubectl defaults to it; "
765817
"consumer RBAC restricts what operations are allowed.",
766818
)
767-
parser.add_argument("action", help="command", choices=['create', 'delete', 'update', 'extract'])
819+
parser.add_argument("action", help="command", choices=['create', 'delete', 'update', 'revoke', 'extract'])
768820
parser.add_argument(
769821
"namespace",
770822
help="Namespace where the ServiceAccount is created. "
@@ -788,7 +840,7 @@ def _generate_kubeconfig(self, sa, namespace, filename, api_server_ip="", kubeco
788840
"-x", "--clustername",
789841
help="Cluster name for context and cluster in the generated kubeconfig file.",
790842
)
791-
permission_help = "Permissions file - use with update command. "
843+
permission_help = "Permissions file - use with update or revoke command. "
792844
permission_help += "JSON structure: {perms:{<apiGroup>:[{resource|resource/resourceName::<name>:[verbs]},...]}}"
793845
parser.add_argument("-p", "--permissionfile", help=permission_help)
794846
parser.add_argument(
@@ -804,7 +856,7 @@ def _generate_kubeconfig(self, sa, namespace, filename, api_server_ip="", kubeco
804856
permission_file = pargs.permissionfile or ""
805857
cluster_name = pargs.clustername or ""
806858

807-
if action == 'update' and permission_file == '':
859+
if action in ['update', 'revoke'] and permission_file == '':
808860
print("Permission file missing. Please provide -p/--permissionfile.")
809861
sys.exit(1)
810862

@@ -849,6 +901,10 @@ def _generate_kubeconfig(self, sa, namespace, filename, api_server_ip="", kubeco
849901
kubeconfigGenerator._update_rbac(permission_file, sa, namespace, kubeconfigString)
850902
print("kubeconfig permissions updated: " + filename)
851903

904+
if action == "revoke":
905+
kubeconfigGenerator._revoke_rbac(permission_file, sa, namespace, kubeconfigString)
906+
print("kubeconfig permissions revoked: " + filename)
907+
852908

853909
if action == "delete":
854910
run_command("kubectl delete sa " + sa + " -n " + namespace + kubeconfigString)

tests/test_provider_kubeconfig.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
that verify non-empty fields, service account creation, and CLI flags in output.
44
"""
55
import json
6+
import importlib.util
67
import os
78
import subprocess
89
import sys
10+
import tempfile
911
import unittest
1012
import uuid
1113

@@ -63,6 +65,74 @@ def test_update_without_permissionfile_exits_with_error(self):
6365
self.assertNotEqual(proc.returncode, 0)
6466
self.assertIn("permission", (proc.stdout or proc.stderr or "").lower())
6567

68+
def test_revoke_without_permissionfile_exits_with_error(self):
69+
"""revoke action without -p exits with code 1."""
70+
proc = subprocess.run(
71+
[sys.executable, os.path.join(ROOT, SCRIPT), "revoke", "default"],
72+
capture_output=True, text=True, cwd=ROOT,
73+
)
74+
self.assertNotEqual(proc.returncode, 0)
75+
self.assertIn("permission", (proc.stdout or proc.stderr or "").lower())
76+
77+
78+
class TestPermissionFileParsing(unittest.TestCase):
79+
"""Unit tests for permission file parsing (JSON/YAML)."""
80+
81+
@classmethod
82+
def setUpClass(cls):
83+
script_path = os.path.join(ROOT, SCRIPT)
84+
spec = importlib.util.spec_from_file_location("provider_kubeconfig_module", script_path)
85+
module = importlib.util.module_from_spec(spec)
86+
spec.loader.exec_module(module)
87+
cls.generator = module.KubeconfigGenerator()
88+
89+
def _write_temp_file(self, suffix, content):
90+
fd, path = tempfile.mkstemp(suffix=suffix, dir=ROOT, text=True)
91+
os.close(fd)
92+
with open(path, "w", encoding="utf-8") as fp:
93+
fp.write(content)
94+
return path
95+
96+
def test_load_permission_data_accepts_json(self):
97+
json_content = json.dumps(
98+
{
99+
"perms": {
100+
"apps": [{"deployments": ["get", "create"]}],
101+
"non-apigroup": [{"nonResourceURL::/metrics": ["get"]}],
102+
}
103+
}
104+
)
105+
path = self._write_temp_file(".json", json_content)
106+
try:
107+
perms = self.generator._load_permission_data(path)
108+
rules, resources = self.generator._parse_permission_rules(perms)
109+
self.assertIn("apps", perms)
110+
self.assertIn("deployments", resources)
111+
self.assertTrue(any("nonResourceURLs" in r for r in rules))
112+
finally:
113+
os.remove(path)
114+
115+
def test_load_permission_data_accepts_yaml(self):
116+
yaml_content = """
117+
perms:
118+
apps:
119+
- deployments:
120+
- get
121+
- update
122+
non-apigroup:
123+
- "nonResourceURL::/healthz":
124+
- get
125+
"""
126+
path = self._write_temp_file(".yaml", yaml_content)
127+
try:
128+
perms = self.generator._load_permission_data(path)
129+
rules, resources = self.generator._parse_permission_rules(perms)
130+
self.assertIn("apps", perms)
131+
self.assertIn("deployments", resources)
132+
self.assertTrue(any("/healthz" in str(r.get("nonResourceURLs", [])) for r in rules))
133+
finally:
134+
os.remove(path)
135+
66136

67137
class TestKubeconfigIntegration(unittest.TestCase):
68138
"""

0 commit comments

Comments
 (0)