feat(modules): validate catalog permission declarations
This commit is contained in:
@@ -658,6 +658,10 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
|||||||
"availability": _catalog_availability(value, module_id=module_id),
|
"availability": _catalog_availability(value, module_id=module_id),
|
||||||
"availability_reason": _optional_str(value, "availability_reason"),
|
"availability_reason": _optional_str(value, "availability_reason"),
|
||||||
"configuration_requirements": _string_list(value.get("configuration_requirements")),
|
"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"]:
|
if item["availability"] == "withdrawn" and not item["availability_reason"]:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -784,6 +788,109 @@ def _catalog_availability(value: Mapping[str, object], *, module_id: str) -> str
|
|||||||
return availability
|
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(
|
def _normalize_catalog_source(
|
||||||
value: object,
|
value: object,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -3504,6 +3504,16 @@ finally:
|
|||||||
"python_ref": "govoplan-files==0.1.4",
|
"python_ref": "govoplan-files==0.1.4",
|
||||||
"availability": "available",
|
"availability": "available",
|
||||||
"configuration_requirements": ["Object storage binding"],
|
"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",
|
"release_notes_url": "https://git.example.test/modules/files/releases/v0.1.4",
|
||||||
"source": {
|
"source": {
|
||||||
"repository": "govoplan-files",
|
"repository": "govoplan-files",
|
||||||
@@ -3596,6 +3606,8 @@ finally:
|
|||||||
self.assertEqual(123, catalog[0]["artifact_integrity"]["python"]["size"])
|
self.assertEqual(123, catalog[0]["artifact_integrity"]["python"]["size"])
|
||||||
self.assertEqual("available", catalog[0]["availability"])
|
self.assertEqual("available", catalog[0]["availability"])
|
||||||
self.assertEqual(["Object storage binding"], catalog[0]["configuration_requirements"])
|
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("v0.1.4", catalog[0]["source"]["tag"])
|
||||||
self.assertEqual("a" * 40, catalog[0]["source"]["commit"])
|
self.assertEqual("a" * 40, catalog[0]["source"]["commit"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -3607,6 +3619,35 @@ finally:
|
|||||||
self.assertTrue(validation["valid"])
|
self.assertTrue(validation["valid"])
|
||||||
self.assertEqual("files", validation["modules"][0]["module_id"])
|
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:
|
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))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-withdrawn-", dir=_TEST_ROOT))
|
||||||
catalog_path = root / "catalog.json"
|
catalog_path = root / "catalog.json"
|
||||||
|
|||||||
Reference in New Issue
Block a user