diff --git a/src/govoplan_core/core/module_package_catalog.py b/src/govoplan_core/core/module_package_catalog.py index 14f92aa..0ca479c 100644 --- a/src/govoplan_core/core/module_package_catalog.py +++ b/src/govoplan_core/core/module_package_catalog.py @@ -658,6 +658,10 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]: "availability": _catalog_availability(value, module_id=module_id), "availability_reason": _optional_str(value, "availability_reason"), "configuration_requirements": _string_list(value.get("configuration_requirements")), + "permissions": _normalize_catalog_permissions( + value.get("permissions"), + module_id=module_id, + ), } if item["availability"] == "withdrawn" and not item["availability_reason"]: raise ValueError( @@ -784,6 +788,109 @@ def _catalog_availability(value: Mapping[str, object], *, module_id: str) -> str return availability +def _normalize_catalog_permissions( + value: object, + *, + module_id: str, +) -> list[dict[str, object]]: + if value is None: + return [] + if not isinstance(value, list): + raise ValueError( + f"Module package catalog permissions for {module_id!r} must be a list." + ) + if len(value) > 1000: + raise ValueError( + f"Module package catalog permissions for {module_id!r} exceed 1000 entries." + ) + normalized: list[dict[str, object]] = [] + seen: set[str] = set() + for raw in value: + if not isinstance(raw, Mapping): + raise ValueError( + f"Module package catalog permission entries for {module_id!r} must be objects." + ) + scope = _bounded_catalog_permission_text( + raw, + "scope", + module_id=module_id, + maximum=200, + ) + if scope in seen: + raise ValueError( + f"Module package catalog entry {module_id!r} declares permission {scope!r} more than once." + ) + seen.add(scope) + level = _bounded_catalog_permission_text( + raw, + "level", + module_id=module_id, + maximum=20, + ) + if level not in {"system", "tenant"}: + raise ValueError( + f"Module package catalog permission {module_id!r}/{scope!r} has unsupported level {level!r}." + ) + deprecated = raw.get("deprecated", False) + if not isinstance(deprecated, bool): + raise ValueError( + f"Module package catalog permission {module_id!r}/{scope!r} deprecated must be true or false." + ) + normalized.append( + { + "scope": scope, + "label": _bounded_catalog_permission_text( + raw, + "label", + module_id=module_id, + maximum=200, + ), + "description": _bounded_catalog_permission_text( + raw, + "description", + module_id=module_id, + maximum=1000, + ), + "category": _bounded_catalog_permission_text( + raw, + "category", + module_id=module_id, + maximum=120, + ), + "level": level, + "resource": _bounded_catalog_permission_text( + raw, + "resource", + module_id=module_id, + maximum=120, + ), + "action": _bounded_catalog_permission_text( + raw, + "action", + module_id=module_id, + maximum=120, + ), + "deprecated": deprecated, + } + ) + return normalized + + +def _bounded_catalog_permission_text( + value: Mapping[str, object], + key: str, + *, + module_id: str, + maximum: int, +) -> str: + text = _required_str(value, key) + if len(text) > maximum: + raise ValueError( + f"Module package catalog permission {key!r} for {module_id!r} exceeds {maximum} characters." + ) + return text + + def _normalize_catalog_source( value: object, *, diff --git a/tests/test_module_system.py b/tests/test_module_system.py index 4cfa2e9..1b1fbff 100644 --- a/tests/test_module_system.py +++ b/tests/test_module_system.py @@ -3504,6 +3504,16 @@ finally: "python_ref": "govoplan-files==0.1.4", "availability": "available", "configuration_requirements": ["Object storage binding"], + "permissions": [{ + "scope": "files:file:read", + "label": "Read files", + "description": "Read managed files.", + "category": "Files", + "level": "tenant", + "resource": "file", + "action": "read", + "deprecated": False, + }], "release_notes_url": "https://git.example.test/modules/files/releases/v0.1.4", "source": { "repository": "govoplan-files", @@ -3596,6 +3606,8 @@ finally: self.assertEqual(123, catalog[0]["artifact_integrity"]["python"]["size"]) self.assertEqual("available", catalog[0]["availability"]) self.assertEqual(["Object storage binding"], catalog[0]["configuration_requirements"]) + self.assertEqual("files:file:read", catalog[0]["permissions"][0]["scope"]) + self.assertEqual("tenant", catalog[0]["permissions"][0]["level"]) self.assertEqual("v0.1.4", catalog[0]["source"]["tag"]) self.assertEqual("a" * 40, catalog[0]["source"]["commit"]) self.assertEqual( @@ -3607,6 +3619,35 @@ finally: self.assertTrue(validation["valid"]) self.assertEqual("files", validation["modules"][0]["module_id"]) + def test_module_package_catalog_rejects_duplicate_permission_scopes(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-permissions-", dir=_TEST_ROOT)) + catalog_path = root / "catalog.json" + permission = { + "scope": "files:file:read", + "label": "Read files", + "description": "Read managed files.", + "category": "Files", + "level": "tenant", + "resource": "file", + "action": "read", + "deprecated": False, + } + catalog_path.write_text( + json.dumps({ + "modules": [{ + "module_id": "files", + "version": "0.1.4", + "permissions": [permission, permission], + }], + }), + encoding="utf-8", + ) + + validation = validate_module_package_catalog(catalog_path) + + self.assertFalse(validation["valid"]) + self.assertIn("more than once", str(validation["error"])) + def test_module_package_catalog_requires_reason_for_withdrawn_release(self) -> None: root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-withdrawn-", dir=_TEST_ROOT)) catalog_path = root / "catalog.json"