diff --git a/pyproject.toml b/pyproject.toml index 7d7f97de..3786ab6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,12 @@ openedx_authz = "openedx_authz.apps:OpenedxAuthzConfig" [project.entry-points."cms.djangoapp"] openedx_authz = "openedx_authz.apps:OpenedxAuthzConfig" +# Static authorization schema resources contributed by this package (ADR 0019). +# openedx-authz is a schema provider like any other distribution; the callable +# returns resource paths relative to the openedx_authz.authz module. +[project.entry-points."authz.schema"] +openedx_authz = "openedx_authz.authz:get_schema_resources" + [tool.setuptools_scm] version_scheme = "only-version" local_scheme = "no-local-version" diff --git a/src/openedx_authz/engine/schema/__init__.py b/src/openedx_authz/engine/schema/__init__.py new file mode 100644 index 00000000..7a789345 --- /dev/null +++ b/src/openedx_authz/engine/schema/__init__.py @@ -0,0 +1,16 @@ +"""Authorization schema pipeline. + +Turns on-disk ``.yaml`` schema resources into a validated, compiled set of +static definitions, following the lifecycle defined in the authz ADRs: + + discover -> load -> validate -> compile (this package, Casbin-free) + render -> apply (openedx_authz.engine.renderer) + consume (existing enforcer + APIs) + +References: + * ADR 0017 - static authorization schema (format) + * ADR 0018 - authorization schema lifecycle (vocabulary + semantics) + * ADR 0019 - authorization schema discovery (entry points + resources) + * ADR 0023 - extend static roles (role_extensions merge rules) + * docs/references/authorization-schema.rst - field-level reference +""" diff --git a/src/openedx_authz/engine/schema/discovery.py b/src/openedx_authz/engine/schema/discovery.py new file mode 100644 index 00000000..830bd817 --- /dev/null +++ b/src/openedx_authz/engine/schema/discovery.py @@ -0,0 +1,224 @@ +"""Discover static authz schema resources (the ``discover`` step, ADR 0019). + +Providers register **directories** (not individual files); the loader reads +every ``.yaml`` file inside them. Two contribution sources are merged: + +1. The ``authz.schema`` entry-point group. Each registered callable returns + directory paths in the package-anchored format below (e.g. + openedx-authz's ``["openedx_authz/authz/schema"]``). +2. The ``OPENEDX_AUTHZ_SCHEMA_DIRECTORIES`` Django setting, a list of directory + path strings in the same format. This lets operators and CI contribute + directories without shipping a package entry point. + +Directory format: these are **not** filesystem paths (neither relative nor +absolute). Each is a package-anchored ``importlib.resources`` path: the first +segment is an importable top-level package (the *anchor*) and the remaining +forward-slash segments name a resource container within it. For example +``"openedx_authz/authz/schema"`` anchors on the ``openedx_authz`` package and +addresses its ``authz/schema`` subdirectory. Resolving through +``importlib.resources`` (rather than the filesystem) means discovery does not +depend on virtualenv or container layout, and works even when the package is +imported from a zip. If any provider raises, discovery +stops and reports the failing application (ADR 0019): deployment must not +proceed with an incomplete set of static definitions. + +Timing: call only after Django settings are configured (from the management +command or ``AppConfig.ready()``). ``django.conf.settings`` is a lazy proxy, so +importing it is inert; the setting is only *read* at call time, inside +``_discover_settings_directories``. Reading before Django is configured raises +``ImproperlyConfigured``, which is caught and treated as "no contribution" so a +standalone CI schema check can run outside a Django process. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import StrEnum +from importlib import metadata, resources + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +ENTRY_POINT_GROUP = "authz.schema" +SETTINGS_DIRECTORIES_NAME = "OPENEDX_AUTHZ_SCHEMA_DIRECTORIES" +SCHEMA_FILE_SUFFIXES = (".yaml", ".yml") + +logger = logging.getLogger(__name__) + + +class Origin(StrEnum): + """Diagnostic labels for where a discovered directory was contributed from. + + Members: + ENTRY_POINT: Contributed via the ``authz.schema`` entry-point group. + SETTINGS: Contributed via the ``OPENEDX_AUTHZ_SCHEMA_DIRECTORIES`` setting. + PASSED_IN: Passed directly into ``SchemaDiscovery`` (CI/local mode). + """ + + ENTRY_POINT = "entry_point" + SETTINGS = "settings" + PASSED_IN = "passed_in" + + +@dataclass(frozen=True) +class DiscoveredResource: + """A single located schema file discovered inside a contributed directory. + + Attributes: + package: Importable top-level package used as the ``importlib.resources`` + anchor (e.g. ``openedx_authz``). + resource_path: Path to the file within that anchor + (e.g. ``authz/schema/course_roles.yaml``). + module: Dotted path of the owning directory, used as the source-record + module and provenance identity (e.g. ``openedx_authz.authz.schema``). + origin: Which contribution route produced this resource, as an + ``Origin`` member; diagnostics only. + """ + + package: str + resource_path: str + module: str + origin: Origin + + def read_bytes(self) -> bytes: + """Read this resource's bytes via ``importlib.resources``. + + Encapsulates the anchoring convention shared with directory discovery: + a resource is a ``(package, resource_path)`` pair resolved as + ``resources.files(package).joinpath(resource_path)``. Callers (the + ``load`` step, ADR 0018) decide *when* to read; this keeps the *how* + beside where the resource is produced. + + Raises: + SchemaDiscoveryError: If the resource cannot be located or read. + """ + try: + return resources.files(self.package).joinpath(self.resource_path).read_bytes() + except (ModuleNotFoundError, OSError) as exc: + raise SchemaDiscoveryError( + f"Could not read schema resource {self.resource_path!r} from package {self.package!r}: {exc}" + ) from exc + + +class SchemaDiscoveryError(Exception): + """Raised when a provider fails or a declared directory cannot be read.""" + + +class SchemaDiscovery: + """Enumerates registered schema directories into discovered files.""" + + def __init__(self, *, passed_in_directories: list[str] | None = None): + """Initialize discovery. + + Args: + passed_in_directories: Optional directory path strings supplied + directly (the ADR 0019 CI/local mode where directories are + passed to the command). Discovered in addition to entry points + and settings. + """ + self._passed_in_directories = passed_in_directories or [] + + def discover(self) -> list[DiscoveredResource]: + """Return every discovered schema file in a deterministic order. + + Expands entry-point directories, settings directories, and passed-in + directories into individual ``.yaml`` files, then de-duplicates and + sorts. Order is normalized here because discovery order may vary across + environments (ADR 0019); priority — not discovery order — drives + conflict resolution later. + + Sorting default: results are ordered by ``(package, resource_path)`` + ascending, so the same set of directories always yields the same list + regardless of the order sources were discovered in. + + Raises: + SchemaDiscoveryError: If a provider callable raises or a declared + directory cannot be located/read. + """ + found: list[DiscoveredResource] = [] + found.extend(self._discover_entry_points()) + found.extend(self._discover_settings_directories()) + for directory in self._passed_in_directories: + found.extend(self._iter_directory(directory, origin=Origin.PASSED_IN)) + + seen: dict[tuple[str, str], DiscoveredResource] = {} + for resource in found: + seen.setdefault((resource.package, resource.resource_path), resource) + + return sorted(seen.values(), key=lambda r: (r.package, r.resource_path)) + + def _discover_entry_points(self) -> list[DiscoveredResource]: + """Load the ``authz.schema`` group; each provider returns directories.""" + discovered: list[DiscoveredResource] = [] + for entry_point in metadata.entry_points(group=ENTRY_POINT_GROUP): + try: + provider = entry_point.load() + directories = provider() + except Exception as exc: # noqa: BLE001 - re-raised with context below + raise SchemaDiscoveryError( + f"authz.schema provider {entry_point.name!r} ({entry_point.value}) failed during discovery: {exc}" + ) from exc + for directory in directories: + discovered.extend(self._iter_directory(directory, origin=Origin.ENTRY_POINT)) + return discovered + + def _discover_settings_directories(self) -> list[DiscoveredResource]: + """Read ``OPENEDX_AUTHZ_SCHEMA_DIRECTORIES`` from Django settings. + + This is the operator/Tutor contribution route (ADR 0019 §1, ADR 0023 §4): + each item is a directory path string. Absent, empty, or unconfigured + settings yield nothing. + + The setting is read here (not at import), so if Django is installed but + not configured the read raises ``ImproperlyConfigured``; that degrades + to no contribution so a standalone CI schema check can run outside a + Django process rather than failing with an unrelated Django error. + """ + try: + directories = getattr(settings, SETTINGS_DIRECTORIES_NAME, None) or [] + except ImproperlyConfigured: + return [] + discovered: list[DiscoveredResource] = [] + for directory in directories: + discovered.extend(self._iter_directory(directory, origin=Origin.SETTINGS)) + return discovered + + def _iter_directory(self, directory: str, *, origin: Origin) -> list[DiscoveredResource]: + """Resolve a directory path and return one resource per ``.yaml`` file. + + The path's first segment is an importable top-level package used as the + anchor; the remainder is a subdirectory within it. For example + ``"openedx_authz/authz/schema"`` anchors on ``openedx_authz`` and reads + the ``authz/schema`` subdirectory. + """ + parts = [segment for segment in directory.strip("/").split("/") if segment] + if not parts: + raise SchemaDiscoveryError(f"Empty schema directory path: {directory!r}.") + + anchor = parts[0] + subpath = "/".join(parts[1:]) + module = ".".join(parts) + + try: + base = resources.files(anchor) + target = base.joinpath(subpath) if subpath else base + # Materialize inside the try: iterdir() is lazy, so a missing/invalid + # directory raises OSError only when the iterator is consumed. Listing + # here keeps that failure inside this handler (wrapped as + # SchemaDiscoveryError) instead of escaping from the loop below. + entries = list(target.iterdir()) + except (ModuleNotFoundError, OSError) as exc: + raise SchemaDiscoveryError(f"Could not read schema directory {directory!r}: {exc}") from exc + + discovered: list[DiscoveredResource] = [] + for entry in entries: + if not entry.name.endswith(SCHEMA_FILE_SUFFIXES): + continue + if not entry.is_file(): + continue + resource_path = f"{subpath}/{entry.name}" if subpath else entry.name + discovered.append( + DiscoveredResource(package=anchor, resource_path=resource_path, module=module, origin=origin) + ) + return discovered diff --git a/src/openedx_authz/tests/schema/__init__.py b/src/openedx_authz/tests/schema/__init__.py new file mode 100644 index 00000000..82f0bba0 --- /dev/null +++ b/src/openedx_authz/tests/schema/__init__.py @@ -0,0 +1 @@ +"""Tests for the authz schema pipeline (openedx_authz.engine.schema).""" diff --git a/src/openedx_authz/tests/schema/test_discovery.py b/src/openedx_authz/tests/schema/test_discovery.py new file mode 100644 index 00000000..9cafdb60 --- /dev/null +++ b/src/openedx_authz/tests/schema/test_discovery.py @@ -0,0 +1,335 @@ +"""Tests for directory-based schema discovery (ADR 0019). + +The suite is grouped by the behaviour under test so each group documents one +contract of :class:`SchemaDiscovery`: + +* ``TestDirectoryExpansion`` — a contributed directory expands to its YAML files. +* ``TestSettingsDirectories`` — the operator/Tutor settings contribution route. +* ``TestDiscoverySourcePrecedence`` — how sources merge and de-duplicate. +* ``TestDiscoveryErrors`` — every failure surfaces as ``SchemaDiscoveryError``. +* ``TestSchemaFileSelection`` — only YAML *files* are picked up. + +""" + +import pytest +from django.test import override_settings + +from openedx_authz.engine.schema.discovery import ( + DiscoveredResource, + Origin, + SchemaDiscovery, + SchemaDiscoveryError, +) + +SCHEMA_DIR = "openedx_authz/authz/schema" +EXPECTED_FILES = { + "course_permissions.yaml", + "course_roles.yaml", + "library_permissions.yaml", + "library_roles.yaml", +} + + +class TestDirectoryExpansion: + """A contributed directory expands to the individual YAML files it holds.""" + + def test_directory_is_expanded_to_yaml_files(self): + """Passing a directory yields exactly its YAML files, by name.""" + resources = SchemaDiscovery(passed_in_directories=[SCHEMA_DIR]).discover() + names = {r.resource_path.rsplit("/", 1)[-1] for r in resources} + assert names == EXPECTED_FILES + + def test_discovered_resource_anchors_and_module_are_set(self): + """Each resource records its import anchor, resource path, and module.""" + resources = SchemaDiscovery(passed_in_directories=[SCHEMA_DIR]).discover() + sample = resources[0] + assert sample.package == "openedx_authz" # importable anchor + assert sample.resource_path.startswith("authz/schema/") + assert sample.module == "openedx_authz.authz.schema" # source identity + + def test_contents_are_readable(self): + """A discovered resource can be read back as non-empty bytes.""" + resources = SchemaDiscovery(passed_in_directories=[SCHEMA_DIR]).discover() + assert resources[0].read_bytes() # non-empty bytes + + def test_discovery_is_deterministic(self): + """Repeated discovery over the same input returns an identical list.""" + a = SchemaDiscovery(passed_in_directories=[SCHEMA_DIR]).discover() + b = SchemaDiscovery(passed_in_directories=[SCHEMA_DIR]).discover() + assert a == b + + def test_unknown_directory_raises(self): + """A directory that does not exist is a hard error, not a silent skip.""" + with pytest.raises(SchemaDiscoveryError): + SchemaDiscovery(passed_in_directories=["openedx_authz/authz/does_not_exist"]).discover() + + def test_own_entry_point_directory_is_discovered(self): + """The installed ``authz.schema`` entry point yields this package's files.""" + resources = SchemaDiscovery().discover() + names = {r.resource_path.rsplit("/", 1)[-1] for r in resources} + assert EXPECTED_FILES.issubset(names) + + def test_duplicate_directories_are_deduplicated(self): + """The same directory passed twice yields each file only once.""" + resources = SchemaDiscovery(passed_in_directories=[SCHEMA_DIR, SCHEMA_DIR]).discover() + paths = [r.resource_path for r in resources] + assert len(paths) == len(set(paths)) == len(EXPECTED_FILES) + + +class TestSettingsDirectories: + """The operator/Tutor contribution route (ADR 0019 §1, ADR 0023 §4). + + Tutor patches ``OPENEDX_AUTHZ_SCHEMA_DIRECTORIES`` and then runs the + deployment command, so this is the only path a site operator has for + contributing a schema without shipping a Python package. + """ + + @override_settings(OPENEDX_AUTHZ_SCHEMA_DIRECTORIES=["operator_authz_pkg/schema"]) + def test_settings_directories_are_discovered(self, tmp_path, monkeypatch): + """A file reachable *only* via the setting is discovered and tagged. + + The directory points at a throwaway package that no entry point + contributes, so the discovered file can only have come from the + setting. Using this package's own ``SCHEMA_DIR`` here would pass even if + the setting were ignored, because the installed entry point already + yields those files (and first-wins de-dup would re-tag them). + """ + package = tmp_path / "operator_authz_pkg" + (package / "schema").mkdir(parents=True) + (package / "schema" / "operator_roles.yaml").write_text("schema_version: '1.0'\n", encoding="utf-8") + monkeypatch.syspath_prepend(str(tmp_path)) + + resources = SchemaDiscovery().discover() + + [only] = [r for r in resources if r.origin == Origin.SETTINGS] + assert only.resource_path == "schema/operator_roles.yaml" + + def test_absent_setting_contributes_nothing(self): + """The default state: no deployment has declared the setting at all. + + ``OPENEDX_AUTHZ_SCHEMA_DIRECTORIES`` is deliberately not defined in the + packaged settings, so it is read with a ``getattr`` default. + """ + # pylint: disable=protected-access + assert not SchemaDiscovery()._discover_settings_directories() + + @override_settings(OPENEDX_AUTHZ_SCHEMA_DIRECTORIES=[]) + def test_empty_setting_contributes_nothing(self): + """An empty list is a no-op, not an error.""" + # pylint: disable=protected-access + assert not SchemaDiscovery()._discover_settings_directories() + + @override_settings(OPENEDX_AUTHZ_SCHEMA_DIRECTORIES=None) + def test_none_setting_contributes_nothing(self): + """An explicit ``None`` degrades to no contribution.""" + # pylint: disable=protected-access + assert not SchemaDiscovery()._discover_settings_directories() + + @override_settings(OPENEDX_AUTHZ_SCHEMA_DIRECTORIES=["openedx_authz/authz/does_not_exist"]) + def test_bad_settings_directory_raises(self): + """A non-existent directory in the setting fails discovery loudly.""" + with pytest.raises(SchemaDiscoveryError): + SchemaDiscovery().discover() + + @override_settings(OPENEDX_AUTHZ_SCHEMA_DIRECTORIES=[SCHEMA_DIR]) + def test_settings_resource_is_marked_with_its_origin(self): + """``origin`` is diagnostic only, but it must identify the real route.""" + # pylint: disable=protected-access + resources = SchemaDiscovery()._discover_settings_directories() + + assert {r.origin for r in resources} == {Origin.SETTINGS} + + def test_unconfigured_django_contributes_nothing(self, monkeypatch): + """The pipeline must stay usable outside a Django process. + + Reading a setting with no ``DJANGO_SETTINGS_MODULE`` raises + ``ImproperlyConfigured``, which would otherwise surface as an unrelated + Django failure during a CI schema check. + """ + # pylint: disable=import-outside-toplevel + from django.conf import LazySettings + from django.core.exceptions import ImproperlyConfigured + + def _unconfigured(_self, name): + raise ImproperlyConfigured(f"Requested setting {name}, but settings are not configured") + + monkeypatch.setattr(LazySettings, "__getattr__", _unconfigured) + + # pylint: disable=protected-access + assert not SchemaDiscovery()._discover_settings_directories() + + +class TestDiscoverySourcePrecedence: + """Discovery merges sources in a fixed order and de-duplicates first-wins.""" + + def test_entry_point_origin_wins_over_passed_in_duplicate(self): + """The same file from two routes is kept once, tagged with the first route.""" + resources = SchemaDiscovery(passed_in_directories=[SCHEMA_DIR]).discover() + + assert {r.origin for r in resources} == {Origin.ENTRY_POINT} + assert len(resources) == len(EXPECTED_FILES) + + def test_passed_in_only_directory_is_marked_passed_in(self): + """A directory reached only via the constructor keeps the passed-in origin.""" + resources = SchemaDiscovery(passed_in_directories=[SCHEMA_DIR])._iter_directory( # pylint: disable=protected-access + SCHEMA_DIR, origin=Origin.PASSED_IN + ) + + assert {r.origin for r in resources} == {Origin.PASSED_IN} + + +class TestDiscoveryErrors: + """Every failure surfaces as SchemaDiscoveryError naming what went wrong.""" + + def test_failing_provider_names_the_entry_point(self, monkeypatch): + """ADR 0019 §1: discovery stops and reports which application failed. + + Continuing would apply an incomplete set of static definitions. + """ + from importlib import metadata # pylint: disable=import-outside-toplevel + + class _BrokenEntryPoint: + """An ``authz.schema`` entry point whose import fails.""" + + name = "broken_app" + value = "broken_app.authz:get_schema_resources" + + @staticmethod + def load(): + """Fail the way a broken module import would.""" + raise RuntimeError("provider exploded") + + monkeypatch.setattr(metadata, "entry_points", lambda **_kwargs: [_BrokenEntryPoint()]) + + with pytest.raises(SchemaDiscoveryError) as exc_info: + SchemaDiscovery().discover() + + assert "broken_app" in str(exc_info.value) + assert "provider exploded" in str(exc_info.value) + + def test_provider_raising_when_called_is_also_reported(self, monkeypatch): + """A provider that imports cleanly but raises on call is reported too.""" + from importlib import metadata # pylint: disable=import-outside-toplevel + + class _BrokenProvider: + """An entry point that imports fine but fails when called.""" + + name = "late_app" + value = "late_app.authz:get_schema_resources" + + @staticmethod + def load(): + """Return a provider that raises on invocation.""" + + def _provider(): + raise ValueError("no schema here") + + return _provider + + monkeypatch.setattr(metadata, "entry_points", lambda **_kwargs: [_BrokenProvider()]) + + with pytest.raises(SchemaDiscoveryError, match="late_app"): + SchemaDiscovery().discover() + + def test_provider_returning_malformed_directory_raises(self, monkeypatch): + """A provider that returns a bogus directory path fails discovery. + + Providers hand back directory strings; a malformed or non-existent path + must surface as ``SchemaDiscoveryError`` naming the offending directory, + not as an unrelated ``importlib.resources`` traceback. + """ + from importlib import metadata # pylint: disable=import-outside-toplevel + + class _MalformedProvider: + """An entry point returning a directory that cannot be resolved.""" + + name = "malformed_app" + value = "malformed_app.authz:get_schema_resources" + + @staticmethod + def load(): + """Return a provider yielding a non-existent directory path.""" + + def _provider(): + return ["no_such_top_level_pkg/authz/schema"] + + return _provider + + monkeypatch.setattr(metadata, "entry_points", lambda **_kwargs: [_MalformedProvider()]) + + with pytest.raises(SchemaDiscoveryError, match="no_such_top_level_pkg/authz/schema"): + SchemaDiscovery().discover() + + def test_empty_directory_path_raises(self): + """A path that reduces to no segments is rejected explicitly.""" + with pytest.raises(SchemaDiscoveryError, match="Empty schema directory path"): + SchemaDiscovery(passed_in_directories=["/"]).discover() + + def test_unreadable_resource_raises(self): + """Reading a resource that no longer exists is a named error.""" + discovery = SchemaDiscovery(passed_in_directories=[SCHEMA_DIR]) + resource = discovery.discover()[0] + missing = DiscoveredResource( + package=resource.package, + resource_path="authz/schema/not_a_real_file.yaml", + module=resource.module, + origin=Origin.PASSED_IN, + ) + + with pytest.raises(SchemaDiscoveryError, match="Could not read schema resource"): + missing.read_bytes() + + +class TestSchemaFileSelection: + """Only YAML *files* are picked up; other entries are ignored. + + These drive ``_iter_directory`` directly so the assertions cover just the + directory being scanned, not the entry points ``discover`` also merges in. + """ + + @staticmethod + def _iter(directory): + # pylint: disable=protected-access + return SchemaDiscovery()._iter_directory(directory, origin=Origin.PASSED_IN) + + def test_yml_suffix_is_accepted(self, tmp_path, monkeypatch): + """A ``.yml`` file is collected and a ``.txt`` sibling is ignored.""" + package = tmp_path / "fake_authz_pkg" + (package / "schema").mkdir(parents=True) + (package / "schema" / "roles.yml").write_text("schema_version: '1.0'\n", encoding="utf-8") + (package / "schema" / "notes.txt").write_text("ignored\n", encoding="utf-8") + monkeypatch.syspath_prepend(str(tmp_path)) + + resources = self._iter("fake_authz_pkg/schema") + + assert [r.resource_path for r in resources] == ["schema/roles.yml"] + + def test_directory_named_like_a_schema_file_is_skipped(self, tmp_path, monkeypatch): + """A directory whose name ends in ``.yaml`` is not mistaken for a file.""" + package = tmp_path / "other_authz_pkg" + # A directory named '*.yaml' passes the suffix check but is not a file. + (package / "schema" / "subdir.yaml").mkdir(parents=True) + (package / "schema" / "readme.md").write_text("ignored\n", encoding="utf-8") + monkeypatch.syspath_prepend(str(tmp_path)) + + assert not self._iter("other_authz_pkg/schema") + + def test_both_yaml_and_yml_are_collected(self, tmp_path, monkeypatch): + """Mixed ``.yaml``/``.yml`` files are both collected. + + ``_iter_directory`` does not promise an order; ordering is normalized in + ``discover()``, so this asserts the set of collected files rather than + their order. + """ + package = tmp_path / "mixed_authz_pkg" + (package / "schema").mkdir(parents=True) + for name in ("b_roles.yml", "a_permissions.yaml"): + (package / "schema" / name).write_text("schema_version: '1.0'\n", encoding="utf-8") + monkeypatch.syspath_prepend(str(tmp_path)) + + resources = self._iter("mixed_authz_pkg/schema") + + assert {r.resource_path for r in resources} == { + "schema/a_permissions.yaml", + "schema/b_roles.yml", + }