diff --git a/docs/decisions/0017-static-authorization-schema.rst b/docs/decisions/0017-static-authorization-schema.rst index 3ab67a9c..9149b294 100644 --- a/docs/decisions/0017-static-authorization-schema.rst +++ b/docs/decisions/0017-static-authorization-schema.rst @@ -50,7 +50,7 @@ For example: name: view_course display_name: View course description: View course configuration and content. - category: course_content + category_id: course_content scopes: - course-v1 icon: Visibility @@ -58,7 +58,7 @@ For example: name: delete_course display_name: Delete course description: Delete a course. - category: course_content + category_id: course_content scopes: - course-v1 icon: Delete @@ -87,7 +87,7 @@ Version 1 does not define display order for roles, permissions, or categories. C - courses.view_course role_extensions: - - role: course_admin + - role_id: course_admin add_permissions: - courses.delete_course diff --git a/docs/decisions/0019-authorization-schema-discovery.rst b/docs/decisions/0019-authorization-schema-discovery.rst index a8332b77..b74d50a4 100644 --- a/docs/decisions/0019-authorization-schema-discovery.rst +++ b/docs/decisions/0019-authorization-schema-discovery.rst @@ -52,7 +52,7 @@ Site operators can contribute a schema through a Python Tutor plugin that uses t priority: 200 role_extensions: - - role: course_editor + - role_id: course_editor add_permissions: - courses.export_course """, diff --git a/docs/decisions/0023-extend-static-roles.rst b/docs/decisions/0023-extend-static-roles.rst index c62bf9f9..5df9a872 100644 --- a/docs/decisions/0023-extend-static-roles.rst +++ b/docs/decisions/0023-extend-static-roles.rst @@ -19,7 +19,7 @@ Decision 1. Role extension fields ======================== -A ``role_extensions`` entry identifies an existing static role with ``role`` and changes only the fields included in the entry. The :ref:`Authorization Schema Reference` describes these fields and includes complete examples for applications and Tutor configuration. An entry may use: +A ``role_extensions`` entry identifies an existing static role with ``role_id`` and changes only the fields included in the entry. The :ref:`Authorization Schema Reference` describes these fields and includes complete examples for applications and Tutor configuration. An entry may use: * ``add_permissions`` to add complete permission IDs; * ``remove_permissions`` to remove complete permission IDs; @@ -34,14 +34,14 @@ For example: priority: 200 role_extensions: - - role: course_editor + - role_id: course_editor add_permissions: - courses.export_course remove_permissions: - courses.manage_tags display_name: Course author description: Creates and exports course content. - - role: course_auditor + - role_id: course_auditor hidden: true Fields that are not present keep their current value. An extension cannot change the role ID or replace its complete definition. @@ -76,14 +76,14 @@ First, the operator runs ``tutor plugins printroot`` to find the local plugin di priority: 200 role_extensions: - - role: course_editor + - role_id: course_editor add_permissions: - courses.export_course remove_permissions: - courses.manage_tags display_name: Course author description: Creates and exports course content for this site. - - role: course_auditor + - role_id: course_auditor hidden: true """, )) diff --git a/docs/references/authorization-schema-json.rst b/docs/references/authorization-schema-json.rst index e27ff67f..0c0b5d78 100644 --- a/docs/references/authorization-schema-json.rst +++ b/docs/references/authorization-schema-json.rst @@ -60,7 +60,7 @@ Permission fields .. jsonschema:: ../../src/openedx_authz/schema/authz-schema-v1.json#/$defs/permission/properties/description :lift_description: -.. jsonschema:: ../../src/openedx_authz/schema/authz-schema-v1.json#/$defs/permission/properties/category +.. jsonschema:: ../../src/openedx_authz/schema/authz-schema-v1.json#/$defs/permission/properties/category_id :lift_description: .. jsonschema:: ../../src/openedx_authz/schema/authz-schema-v1.json#/$defs/permission/properties/scopes @@ -96,7 +96,7 @@ Role fields Role extension fields ********************* -.. jsonschema:: ../../src/openedx_authz/schema/authz-schema-v1.json#/$defs/role_extension/properties/role +.. jsonschema:: ../../src/openedx_authz/schema/authz-schema-v1.json#/$defs/role_extension/properties/role_id :lift_description: .. jsonschema:: ../../src/openedx_authz/schema/authz-schema-v1.json#/$defs/role_extension/properties/add_permissions diff --git a/docs/references/authorization-schema.rst b/docs/references/authorization-schema.rst index 9ef05e23..1312898c 100644 --- a/docs/references/authorization-schema.rst +++ b/docs/references/authorization-schema.rst @@ -44,7 +44,7 @@ The following file defines one category, two permissions, one role, and an exten name: view_course display_name: View course description: View course configuration and content. - category: course_content + category_id: course_content scopes: - course-v1 icon: Visibility @@ -53,7 +53,7 @@ The following file defines one category, two permissions, one role, and an exten name: view_course_updates display_name: View course updates description: View course update posts. - category: course_content + category_id: course_content scopes: - course-v1 icon: Visibility @@ -69,7 +69,7 @@ The following file defines one category, two permissions, one role, and an exten - courses.view_course_updates role_extensions: - - role: course_editor + - role_id: course_editor add_permissions: - courses.export_course @@ -152,7 +152,7 @@ A permission contains these fields: ``description`` A complete source-language sentence describing the access controlled by the permission. -``category`` +``category_id`` The complete ID of a category defined in the combined schema. ``scopes`` @@ -170,7 +170,7 @@ The complete permission ID joins ``namespace`` and ``name`` with a period. For e name: manage_library_tags display_name: Manage library tags description: Add, edit, and remove tags in a content library. - category: library_management + category_id: library_management scopes: - lib @@ -219,14 +219,14 @@ For example: - content_libraries.view_library_team icon: Visibility -The Casbin form ``role^library_reviewer`` is an internal value and is not valid as ``roles.id`` or in a ``role_extensions.role`` reference. +The Casbin form ``role^library_reviewer`` is an internal value and is not valid as ``roles.id`` or in a ``role_extensions.role_id`` reference. Role extensions *************** -A role extension contains ``role`` and at least one field to change: +A role extension contains ``role_id`` and at least one field to change: -``role`` +``role_id`` The complete ID of an existing static role. ``add_permissions`` @@ -249,7 +249,7 @@ For example, a deployment can allow course editors to export courses, remove the priority: 200 role_extensions: - - role: course_editor + - role_id: course_editor add_permissions: - courses.export_course remove_permissions: @@ -257,7 +257,7 @@ For example, a deployment can allow course editors to export courses, remove the display_name: Course author description: Creates and exports course content. - - role: course_auditor + - role_id: course_auditor hidden: true An extension fails validation when its target role or a referenced permission does not exist. Adding a permission already assigned to the role or removing one the role does not have produces a warning and leaves the result unchanged. @@ -320,7 +320,7 @@ A site operator can provide an authz schema through a Python Tutor plugin that u priority: 200 role_extensions: - - role: course_editor + - role_id: course_editor add_permissions: - courses.export_course remove_permissions: @@ -328,7 +328,7 @@ A site operator can provide an authz schema through a Python Tutor plugin that u display_name: Course author description: Creates and exports course content. - - role: course_auditor + - role_id: course_auditor hidden: true """, )) diff --git a/src/openedx_authz/authz/schema/course_permissions.yaml b/src/openedx_authz/authz/schema/course_permissions.yaml index 01fd254e..bf168b94 100644 --- a/src/openedx_authz/authz/schema/course_permissions.yaml +++ b/src/openedx_authz/authz/schema/course_permissions.yaml @@ -67,7 +67,7 @@ permissions: name: view_course display_name: View course description: See the course in the Studio home and access the course outline in read-only mode. Includes the "View Live" option to preview the course as a learner in the LMS. - category: course_access_content + category_id: course_access_content scopes: - course-v1 icon: RemoveRedEye @@ -75,7 +75,7 @@ permissions: name: edit_course_content display_name: Edit course content description: Edit the course outline, units, and components. - category: course_access_content + category_id: course_access_content scopes: - course-v1 icon: EditOutline @@ -83,7 +83,7 @@ permissions: name: publish_course_content display_name: Publish course content description: Make course content visible to learners. - category: course_access_content + category_id: course_access_content scopes: - course-v1 icon: DownloadDone @@ -91,7 +91,7 @@ permissions: name: view_library_updates display_name: View library updates description: View pending updates from content libraries linked to this course. - category: course_library_updates + category_id: course_library_updates scopes: - course-v1 icon: RemoveRedEye @@ -99,7 +99,7 @@ permissions: name: manage_library_updates display_name: Manage library updates description: Accept or reject pending updates from content libraries linked to this course. - category: course_library_updates + category_id: course_library_updates scopes: - course-v1 icon: Checklist @@ -107,7 +107,7 @@ permissions: name: view_course_updates display_name: View course updates description: See course announcements and handouts visible to learners. - category: course_updates_handouts + category_id: course_updates_handouts scopes: - course-v1 icon: RemoveRedEye @@ -115,7 +115,7 @@ permissions: name: manage_course_updates display_name: Manage course updates description: Create, edit, and delete course announcements and handouts. - category: course_updates_handouts + category_id: course_updates_handouts scopes: - course-v1 icon: Settings @@ -123,7 +123,7 @@ permissions: name: view_pages_and_resources display_name: View pages & resources description: See the Pages & Resources section in Studio. - category: course_pages_resources + category_id: course_pages_resources scopes: - course-v1 icon: RemoveRedEye @@ -131,7 +131,7 @@ permissions: name: manage_pages_and_resources display_name: Manage pages & resources description: Enable or disable course features such as Discussions, the Wiki, Notes, Calculator, and Live. Create and edit Textbooks and Custom pages, and manage their configurations. - category: course_pages_resources + category_id: course_pages_resources scopes: - course-v1 icon: Settings @@ -139,7 +139,7 @@ permissions: name: view_files display_name: View files description: See the list of files and assets uploaded to the course. - category: course_files + category_id: course_files scopes: - course-v1 icon: RemoveRedEye @@ -147,7 +147,7 @@ permissions: name: create_files display_name: Create files description: Upload new files and assets to the course. - category: course_files + category_id: course_files scopes: - course-v1 icon: Plus @@ -155,7 +155,7 @@ permissions: name: edit_files display_name: Edit files description: Perform non-destructive actions on files, such as locking or unlocking them. - category: course_files + category_id: course_files scopes: - course-v1 icon: EditOutline @@ -163,7 +163,7 @@ permissions: name: delete_files display_name: Delete files description: Permanently remove files and assets from the course. - category: course_files + category_id: course_files scopes: - course-v1 icon: Delete @@ -171,7 +171,7 @@ permissions: name: view_schedule_and_details display_name: View schedule & details description: See the course schedule (start and end dates, enrollment dates, and pacing settings) and course details (summary, pacing, and prerequisites). - category: course_schedule_details + category_id: course_schedule_details scopes: - course-v1 icon: RemoveRedEye @@ -179,7 +179,7 @@ permissions: name: edit_schedule display_name: Edit schedule description: Update course start and end dates, enrollment dates, and pacing settings. - category: course_schedule_details + category_id: course_schedule_details scopes: - course-v1 icon: EditOutline @@ -187,7 +187,7 @@ permissions: name: edit_details display_name: Edit course details description: Update course information including the course summary, pacing, and prerequisites. - category: course_schedule_details + category_id: course_schedule_details scopes: - course-v1 icon: EditOutline @@ -195,7 +195,7 @@ permissions: name: view_grading_settings display_name: View grading settings description: See the grading configuration for the course, including assignment types and grading scale. - category: course_grading + category_id: course_grading scopes: - course-v1 icon: RemoveRedEye @@ -203,7 +203,7 @@ permissions: name: edit_grading_settings display_name: Edit grading settings description: Update the grading configuration for the course, including assignment types and grading scale. - category: course_grading + category_id: course_grading scopes: - course-v1 icon: EditOutline @@ -211,7 +211,7 @@ permissions: name: view_course_team display_name: View course team description: See the list of users with a role assigned to this course. - category: course_team_group + category_id: course_team_group scopes: - course-v1 icon: RemoveRedEye @@ -219,7 +219,7 @@ permissions: name: manage_course_team display_name: Manage course team description: Add, change, or remove role assignments for this course from the Roles and Permissions console. - category: course_team_group + category_id: course_team_group scopes: - course-v1 icon: Settings @@ -227,7 +227,7 @@ permissions: name: view_group_configurations display_name: View group configurations description: See the list of content groups and their configurations for this course. - category: course_team_group + category_id: course_team_group scopes: - course-v1 icon: RemoveRedEye @@ -235,7 +235,7 @@ permissions: name: manage_group_configurations display_name: Manage group configurations description: Create and manage content groups used to target course content to specific learners. - category: course_team_group + category_id: course_team_group scopes: - course-v1 icon: Settings @@ -243,7 +243,7 @@ permissions: name: manage_tags display_name: Manage tags description: Create, edit, and delete tags on this course. - category: course_tags_taxonomies + category_id: course_tags_taxonomies scopes: - course-v1 icon: Settings @@ -251,7 +251,7 @@ permissions: name: view_advanced_settings display_name: View advanced settings description: Access the Advanced Settings page in Studio. This covers a wide range of technical course configurations, including proctoring, timed exams, LTI tools, enrollment limits, and custom display options. - category: course_advanced_certificates + category_id: course_advanced_certificates scopes: - course-v1 icon: RemoveRedEye @@ -259,7 +259,7 @@ permissions: name: manage_advanced_settings display_name: Manage advanced settings description: Edit technical course configurations in the Advanced Settings page in Studio. - category: course_advanced_certificates + category_id: course_advanced_certificates scopes: - course-v1 icon: Settings @@ -267,7 +267,7 @@ permissions: name: view_certificates display_name: View certificates description: See the course certificate settings. - category: course_advanced_certificates + category_id: course_advanced_certificates scopes: - course-v1 icon: RemoveRedEye @@ -275,7 +275,7 @@ permissions: name: manage_certificates display_name: Manage certificates description: Create and edit course certificates, including certificate design and eligibility settings. - category: course_advanced_certificates + category_id: course_advanced_certificates scopes: - course-v1 icon: Settings @@ -283,7 +283,7 @@ permissions: name: import_course display_name: Import course description: Import course content from a file. This is a high-privilege action that can overwrite most course content and settings. - category: course_import_export + category_id: course_import_export scopes: - course-v1 icon: Download @@ -291,7 +291,7 @@ permissions: name: export_course display_name: Export course description: Download the course content as a file for backup or reuse in another platform. - category: course_import_export + category_id: course_import_export scopes: - course-v1 icon: Upload @@ -299,7 +299,7 @@ permissions: name: export_tags display_name: Export tags description: Download the tag data associated with this course. - category: course_import_export + category_id: course_import_export scopes: - course-v1 icon: Upload @@ -307,7 +307,7 @@ permissions: name: view_checklists display_name: View checklists description: See the course launch checklist in Studio. - category: course_other + category_id: course_other scopes: - course-v1 icon: RemoveRedEye @@ -315,7 +315,7 @@ permissions: name: manage_taxonomies display_name: Manage taxonomies description: Manage taxonomies associated with this course. (Granted to course_admin; not currently surfaced in the admin console.) - category: course_tags_taxonomies + category_id: course_tags_taxonomies scopes: - course-v1 icon: LocalOffer @@ -330,7 +330,7 @@ permissions: name: legacy_instructor_role_permissions display_name: Legacy instructor permissions description: Compatibility action mapping the legacy course instructor role into the authorization system. - category: course_legacy + category_id: course_legacy scopes: - course-v1 icon: DrawShapes @@ -338,7 +338,7 @@ permissions: name: legacy_staff_role_permissions display_name: Legacy staff permissions description: Compatibility action mapping the legacy course staff role into the authorization system. - category: course_legacy + category_id: course_legacy scopes: - course-v1 icon: DrawShapes @@ -346,7 +346,7 @@ permissions: name: legacy_limited_staff_role_permissions display_name: Legacy limited staff permissions description: Compatibility action mapping the legacy course limited staff role into the authorization system. - category: course_legacy + category_id: course_legacy scopes: - course-v1 icon: DrawShapes @@ -354,7 +354,7 @@ permissions: name: legacy_data_researcher_permissions display_name: Legacy data researcher permissions description: Compatibility action mapping the legacy course data researcher role into the authorization system. - category: course_legacy + category_id: course_legacy scopes: - course-v1 icon: DrawShapes @@ -362,7 +362,7 @@ permissions: name: legacy_beta_tester_permissions display_name: Legacy beta tester permissions description: Compatibility action mapping the legacy course beta tester role into the authorization system. - category: course_legacy + category_id: course_legacy scopes: - course-v1 icon: DrawShapes diff --git a/src/openedx_authz/authz/schema/library_permissions.yaml b/src/openedx_authz/authz/schema/library_permissions.yaml index b1e25be4..a1c80971 100644 --- a/src/openedx_authz/authz/schema/library_permissions.yaml +++ b/src/openedx_authz/authz/schema/library_permissions.yaml @@ -29,7 +29,7 @@ permissions: name: view_library display_name: View description: See the library in Studio and access its content in read-only mode. - category: library + category_id: library scopes: - lib icon: RemoveRedEye @@ -37,7 +37,7 @@ permissions: name: manage_library_tags display_name: Manage tags description: Create, edit, and delete tags on this library. - category: library + category_id: library scopes: - lib icon: Settings @@ -45,7 +45,7 @@ permissions: name: delete_library display_name: Delete description: Allows users to delete the entire content library. - category: library + category_id: library scopes: - lib icon: Delete @@ -53,7 +53,7 @@ permissions: name: edit_library_content display_name: Edit description: Create, edit, and delete content items in the library. - category: library_content + category_id: library_content scopes: - lib icon: EditOutline @@ -61,7 +61,7 @@ permissions: name: publish_library_content display_name: Publish description: Publish individual content items to make them available for reuse in courses. - category: library_content + category_id: library_content scopes: - lib icon: DownloadDone @@ -69,7 +69,7 @@ permissions: name: reuse_library_content display_name: Reuse description: Add published content from this library to a course. - category: library_content + category_id: library_content scopes: - lib icon: SpinnerIcon @@ -77,7 +77,7 @@ permissions: name: view_library_team display_name: View description: See the list of users with a role assigned to this library. - category: library_team + category_id: library_team scopes: - lib icon: RemoveRedEye @@ -85,7 +85,7 @@ permissions: name: manage_library_team display_name: Manage description: Add, change, or remove role assignments for this library from the Roles and Permissions console. - category: library_team + category_id: library_team scopes: - lib icon: Settings @@ -93,7 +93,7 @@ permissions: name: create_library_collection display_name: Create description: Create new collections to organize content within the library. - category: library_collection + category_id: library_collection scopes: - lib icon: Plus @@ -101,7 +101,7 @@ permissions: name: edit_library_collection display_name: Edit description: Update the name and contents of existing collections. - category: library_collection + category_id: library_collection scopes: - lib icon: EditOutline @@ -109,7 +109,7 @@ permissions: name: delete_library_collection display_name: Delete description: Permanently remove collections from the library. - category: library_collection + category_id: library_collection scopes: - lib icon: Delete diff --git a/src/openedx_authz/constants/__init__.py b/src/openedx_authz/constants/__init__.py index a8cf42ad..4928307d 100644 --- a/src/openedx_authz/constants/__init__.py +++ b/src/openedx_authz/constants/__init__.py @@ -1,7 +1,8 @@ """Shared low-level constants for openedx_authz. - Defined here rather than in api.data so that models and other modules at the bottom of the import chain can use them without creating circular imports. """ +from openedx_authz.constants.schema import SchemaOriginKind + AUTHZ_POLICY_ATTRIBUTES_SEPARATOR = "^" diff --git a/src/openedx_authz/constants/schema.py b/src/openedx_authz/constants/schema.py new file mode 100644 index 00000000..494d0f27 --- /dev/null +++ b/src/openedx_authz/constants/schema.py @@ -0,0 +1,26 @@ +"""Shared constants for the static authorization schema (ADR 0023/0025). + +This is the single source of truth for the ``base`` / ``extension`` origin +values. ``SchemaOriginKind`` is a plain ``str`` enum with no Django or Casbin +dependency, so both the engine schema types +(``openedx_authz.engine.schema.types``) and the Django models layer +(``openedx_authz.models.schema.OriginKind``) can share the same values without +either layer depending on the other. +""" + +from __future__ import annotations + +from enum import Enum + + +class SchemaOriginKind(str, Enum): + """Whether a schema contribution is a base definition or an extension. + + ``BASE`` comes from a role's own definition; ``EXTENSION`` is added by a + ``role_extensions`` entry (ADR 0023/0025). Subclassing ``str`` keeps the + members interchangeable with their raw string values, which is why the + persistence layer can store them directly. + """ + + BASE = "base" + EXTENSION = "extension" diff --git a/src/openedx_authz/engine/schema/exceptions.py b/src/openedx_authz/engine/schema/exceptions.py new file mode 100644 index 00000000..2a87fd6e --- /dev/null +++ b/src/openedx_authz/engine/schema/exceptions.py @@ -0,0 +1,39 @@ +"""Exceptions for the authz schema pipeline.""" + +from __future__ import annotations + + +class SchemaError(Exception): + """Base class for schema pipeline errors.""" + + +class SchemaLoadError(SchemaError): + """A resource could not be parsed into a schema document.""" + + +class SchemaValidationError(SchemaError): + """Validation found error-level issues; deployment must stop. + + Carries the collected issues so the caller can report them all at once + rather than failing on the first problem. + """ + + def __init__(self, issues): + self.issues = issues + super().__init__(f"Schema validation failed with {len(issues)} error(s).") + + +class SchemaCompileError(SchemaError): + """Compilation could not resolve the definitions. + + For example, an unresolvable role_extension conflict at equal priority + (ADR 0023). + """ + + +class SchemaApplyError(SchemaError): + """Applying the rendered policy to the database is not safe to proceed. + + For example, a static role slated for removal still has user assignments + and ``force`` was not set (ADR 0018 §6). + """ diff --git a/src/openedx_authz/engine/schema/loading.py b/src/openedx_authz/engine/schema/loading.py new file mode 100644 index 00000000..ed30cde4 --- /dev/null +++ b/src/openedx_authz/engine/schema/loading.py @@ -0,0 +1,271 @@ +"""Read discovered resources into schema documents (the ``load`` step, ADR 0018). + +Parses each ``.yaml`` schema resource into a :class:`SchemaDocument`, attaching +its :class:`SourceRecord` (including a content digest computed from the exact +bytes read). This step performs only parsing and structural shaping; semantic +checks belong to :mod:`.validation` and cross-file resolution to +:mod:`.compilation`. + +No Casbin or Django imports, so it stays unit-testable in isolation. +""" + +from __future__ import annotations + +import hashlib +import logging +from importlib import metadata + +import yaml + +from openedx_authz.engine.schema.discovery import DiscoveredResource +from openedx_authz.engine.schema.exceptions import SchemaLoadError +from openedx_authz.engine.schema.types import ( + PermissionCategory, + PermissionDefinition, + RoleDefinition, + RoleExtension, + SchemaDocument, + SourceRecord, +) + +logger = logging.getLogger(__name__) + + +class SchemaLoader: + """Turns discovered resources into typed schema documents. + + Reading a resource's bytes is the ``load`` step's responsibility (ADR 0018): + each :class:`DiscoveredResource` knows how to read itself, so the loader + controls *when* files are read and computes the content digest from the + exact bytes it parses. + """ + + _UNKNOWN_DISTRIBUTION = "unknown" + + def load(self, resources: list[DiscoveredResource]) -> list[SchemaDocument]: + """Load every discovered resource into a :class:`SchemaDocument`. + + Raises: + SchemaLoadError: On invalid YAML or an unusable document structure. + """ + documents: list[SchemaDocument] = [] + for resource in resources: + contents = resource.read_bytes() + raw = self._parse_yaml(contents, resource) + schema_version = str(raw.get("schema_version", "")) + source = self._build_source_record(resource, contents, schema_version) + documents.append(self._build_document(raw, source)) + return documents + + def _parse_yaml(self, contents: bytes, resource: DiscoveredResource) -> dict: + """Parse YAML bytes into a mapping, raising on malformed input.""" + try: + data = yaml.safe_load(contents) + except yaml.YAMLError as exc: + raise SchemaLoadError(f"Invalid YAML in {resource.package}:{resource.resource_path}: {exc}") from exc + + if data is None: + data = {} + if not isinstance(data, dict): + raise SchemaLoadError( + f"Schema file {resource.package}:{resource.resource_path} must be a mapping " + f"at the top level, got {type(data).__name__}." + ) + return data + + def _build_source_record(self, resource: DiscoveredResource, contents: bytes, schema_version: str) -> SourceRecord: + """Assemble packaging metadata + content digest into a SourceRecord. + + Resolves the installed distribution name/version that owns the resource + package via ``importlib.metadata`` and hashes ``contents`` for the + digest. Falls back to ``"unknown"`` when the package is not tied to an + installed distribution (e.g. operator-supplied settings resources). + """ + distribution, version = self._resolve_distribution(resource) + content_digest = hashlib.sha256(contents).hexdigest() + return SourceRecord( + distribution=distribution, + distribution_version=version, + module=resource.module, + resource_path=resource.resource_path, + schema_version=schema_version, + content_digest=content_digest, + ) + + @classmethod + def _resolve_distribution(cls, resource: DiscoveredResource) -> tuple[str, str]: + """Map a discovered resource to its providing distribution name and version. + + A top-level import package can be provided by more than one installed + distribution (namespace packages, overlapping/legacy installs), so + ``packages_distributions()`` returns a *list* of candidate names. The + same source must resolve to the same distribution under every deployment + layout (ADR 0019 §2), so an arbitrary ``candidates[0]`` is not good + enough: when several distributions claim the top-level package we pick + the one that actually ships this resource's file, and only fall back to + a deterministic (sorted) choice when no single owner can be identified. + """ + top_level = resource.package.split(".", 1)[0] + try: + mapping = metadata.packages_distributions() + # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 - defensive; metadata quirks across envs + mapping = {} + candidates = mapping.get(top_level) or [] + if not candidates: + return top_level, cls._UNKNOWN_DISTRIBUTION + + distribution = cls._select_owning_distribution(candidates, resource) + try: + return distribution, metadata.version(distribution) + except metadata.PackageNotFoundError: + return distribution, cls._UNKNOWN_DISTRIBUTION + + @classmethod + def _select_owning_distribution(cls, candidates: list[str], resource: DiscoveredResource) -> str: + """Choose the distribution that ships ``resource`` from the candidate list. + + With a single candidate there is nothing to disambiguate. With several, + we match each distribution's recorded file list against the resource's + installed path (``package/resource_path``) and return the one that owns + it. If exactly zero or more than one distribution claims the file (or the + file lists are unavailable), we cannot know the true owner, so we return + the first candidate in sorted order — a stable choice across environments + — and log the ambiguity. + """ + if len(candidates) == 1: + return candidates[0] + + installed_path = f"{resource.package}/{resource.resource_path}" + owners = [name for name in candidates if cls._distribution_ships(name, installed_path)] + if len(owners) == 1: + return owners[0] + + fallback = sorted(candidates)[0] + logger.warning( + "Could not uniquely resolve the distribution that ships a schema resource; selecting deterministically.", + extra={ + "top_level": resource.package, + "resource_path": resource.resource_path, + "candidates": sorted(candidates), + "matched_owners": sorted(owners), + "selected": fallback, + }, + ) + return fallback + + @staticmethod + def _distribution_ships(distribution: str, installed_path: str) -> bool: + """Return whether ``distribution`` records a file matching ``installed_path``. + + ``Distribution.files`` lists the files the distribution installed, as + anchor-relative ``PackagePath`` values (e.g. + ``openedx_authz/authz/schema/course_roles.yaml``). A resource belongs to + the distribution when one of those paths ends with the resource's + ``package/resource_path``. Metadata gaps (``files`` is ``None`` or the + distribution is missing) mean "cannot confirm ownership", not an error. + """ + try: + files = metadata.distribution(distribution).files or [] + except metadata.PackageNotFoundError: + return False + return any(str(path).endswith(installed_path) for path in files) + + def _build_document(self, raw: dict, source: SourceRecord) -> SchemaDocument: + """Map the parsed mapping's blocks into a typed SchemaDocument. + + ``priority`` is a *required* field per ``authz-schema-v1.json``; a higher + value wins when contributions conflict (see + ``docs/references/authorization-schema.rst``). Enforcing that it is + present is the ``validate`` step's job, not this one — the loader only + parses and shapes (ADR 0018), and it defers required-field enforcement + to validation exactly as it does for ``schema_version``. So a missing + ``priority`` is read as ``0`` (the lowest precedence) here and rejected + later by validation, whereas a *malformed* (non-integer) ``priority`` is + raised now because it cannot be parsed at all. + """ + try: + priority = int(raw.get("priority", 0)) + except (TypeError, ValueError) as exc: + raise SchemaLoadError( + f"{source.source_id}: 'priority' must be an integer, got {raw.get('priority')!r}." + ) from exc + + return SchemaDocument( + source=source, + priority=priority, + categories=[self._build_category(item, source) for item in raw.get("permission_categories", []) or []], + permissions=[self._build_permission(item, source) for item in raw.get("permissions", []) or []], + roles=[self._build_role(item, source) for item in raw.get("roles", []) or []], + role_extensions=[self._build_extension(item, source) for item in raw.get("role_extensions", []) or []], + ) + + @staticmethod + def _as_tuple(value) -> tuple[str, ...]: + """Coerce a YAML list (or None) into a tuple of strings.""" + if not value: + return () + if isinstance(value, str): + return (value,) + return tuple(str(item) for item in value) + + def _build_category(self, item: dict, source: SourceRecord) -> PermissionCategory: + """Build a ``PermissionCategory`` from one ``permission_categories`` entry.""" + self._require_mapping(item, "permission_categories", source) + return PermissionCategory( + id=item.get("id", ""), + display_name=item.get("display_name", ""), + description=item.get("description", ""), + icon=item.get("icon"), + ) + + def _build_permission(self, item: dict, source: SourceRecord) -> PermissionDefinition: + """Build a ``PermissionDefinition`` from one ``permissions`` entry.""" + self._require_mapping(item, "permissions", source) + return PermissionDefinition( + namespace=item.get("namespace", ""), + name=item.get("name", ""), + display_name=item.get("display_name", ""), + description=item.get("description", ""), + category_id=item.get("category_id", ""), + scopes=self._as_tuple(item.get("scopes")), + icon=item.get("icon"), + ) + + def _build_role(self, item: dict, source: SourceRecord) -> RoleDefinition: + """Build a ``RoleDefinition`` from one ``roles`` entry.""" + self._require_mapping(item, "roles", source) + return RoleDefinition( + id=item.get("id", ""), + display_name=item.get("display_name", ""), + description=item.get("description", ""), + scopes=self._as_tuple(item.get("scopes")), + permissions=self._as_tuple(item.get("permissions")), + icon=item.get("icon"), + hidden=bool(item.get("hidden", False)), + ) + + def _build_extension(self, item: dict, source: SourceRecord) -> RoleExtension: + """Build a ``RoleExtension`` from one ``role_extensions`` entry. + + ``hidden`` is read as tri-state: absent stays ``None`` ("leave + unchanged"), distinct from an explicit ``False`` (ADR 0023 §1). + """ + self._require_mapping(item, "role_extensions", source) + return RoleExtension( + role_id=item.get("role_id", ""), + add_permissions=self._as_tuple(item.get("add_permissions")), + remove_permissions=self._as_tuple(item.get("remove_permissions")), + display_name=item.get("display_name"), + description=item.get("description"), + icon=item.get("icon"), + hidden=item.get("hidden"), # tri-state: None means "leave unchanged" + ) + + @staticmethod + def _require_mapping(item, block: str, source: SourceRecord) -> None: + """Raise ``SchemaLoadError`` (naming the block and source) if ``item`` is not a mapping.""" + if not isinstance(item, dict): + raise SchemaLoadError( + f"{source.source_id}: each entry in '{block}' must be a mapping, got {type(item).__name__}." + ) diff --git a/src/openedx_authz/engine/schema/types/__init__.py b/src/openedx_authz/engine/schema/types/__init__.py new file mode 100644 index 00000000..22c4456e --- /dev/null +++ b/src/openedx_authz/engine/schema/types/__init__.py @@ -0,0 +1,55 @@ +"""Typed schema objects and source records for the authz schema pipeline. + +These dataclasses are the data contract passed between lifecycle steps +(ADR 0018): discovery produces :class:`DiscoveredResource`, loading produces +:class:`SchemaDocument`, and compilation produces :class:`CompiledSchema`. + +The types are split into category modules for readability, but the package +preserves a flat public surface: import everything directly from +``openedx_authz.engine.schema.types``. + + * :mod:`~openedx_authz.engine.schema.types.definitions` - author-facing + definition objects (categories, permissions, roles, role extensions). + * :mod:`~openedx_authz.engine.schema.types.loading` - provenance + (``SourceRecord``) and the per-file ``load`` output (``SchemaDocument``). + * :mod:`~openedx_authz.engine.schema.types.compilation` - the resolved + ``compile`` output (``CompiledSchema`` and friends). + +Definition field shapes follow ``docs/references/authorization-schema.rst`` +(reference PR): identifiers match ``[a-z][a-z0-9_]*``, permission IDs join +``namespace`` and ``name`` with a period, and the internal Casbin forms +(``act^...``, ``role^...``) never appear here. +""" + +from __future__ import annotations + +from openedx_authz.engine.schema.types.compilation import ( + CompiledDefinition, + CompiledSchema, + RelationshipSource, +) +from openedx_authz.engine.schema.types.definitions import ( + PermissionCategory, + PermissionDefinition, + RoleDefinition, + RoleExtension, +) +from openedx_authz.engine.schema.types.loading import ( + SchemaDocument, + SourceRecord, +) + +__all__ = [ + # definitions + "PermissionCategory", + "PermissionDefinition", + "RoleDefinition", + "RoleExtension", + # loading + "SourceRecord", + "SchemaDocument", + # compilation + "RelationshipSource", + "CompiledDefinition", + "CompiledSchema", +] diff --git a/src/openedx_authz/engine/schema/types/compilation.py b/src/openedx_authz/engine/schema/types/compilation.py new file mode 100644 index 00000000..11902ff4 --- /dev/null +++ b/src/openedx_authz/engine/schema/types/compilation.py @@ -0,0 +1,86 @@ +"""Compilation-output types for the authz schema pipeline. + +These dataclasses are the output of the ``compile`` step (ADR 0018): resolved +definitions keyed by stable identifier, each carrying every +:class:`~openedx_authz.engine.schema.types.loading.SourceRecord` that +contributed to it, plus the per-grant provenance the applier persists (ADR +0025). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from openedx_authz.constants import SchemaOriginKind +from openedx_authz.engine.schema.types.definitions import ( + PermissionCategory, + PermissionDefinition, + RoleDefinition, +) +from openedx_authz.engine.schema.types.loading import SourceRecord + + +@dataclass(frozen=True) +class RelationshipSource: + """Provenance of a single role-permission grant (ADR 0025). + + Attributes: + source: The contributing source record. + origin_kind: ``SchemaOriginKind.BASE`` (from the role's own definition) + or ``SchemaOriginKind.EXTENSION`` (added by a ``role_extensions`` + entry). + priority: The contributing file's priority. + """ + + source: SourceRecord + origin_kind: SchemaOriginKind + priority: int + + +@dataclass(frozen=True) +class CompiledDefinition: + """A resolved definition plus every source that contributed to it. + + The kind of definition is not stored: ``definition`` is a typed union and + :class:`CompiledSchema` already keys categories, permissions, and roles into + separate dicts, so the kind is recoverable from context when needed. + + Attributes: + key: The category id, permission identifier, or role id. + definition: The resolved dataclass instance (category/permission/role). + sources: All contributing sources, in priority-then-discovery order. + """ + + key: str + definition: PermissionCategory | PermissionDefinition | RoleDefinition + sources: tuple[SourceRecord, ...] + + +@dataclass +class CompiledSchema: + """The full set of resolved static definitions (output of ``compile``). + + Keyed by stable identifier. This is what the renderer turns into Casbin + ``p`` rows and what the applier persists alongside source records. + """ + + categories: dict[str, CompiledDefinition] = field(default_factory=dict) + permissions: dict[str, CompiledDefinition] = field(default_factory=dict) + roles: dict[str, CompiledDefinition] = field(default_factory=dict) + # Provenance of each role-permission grant, keyed by (role_id, permission_id). + # Populated by the compiler; consumed when persisting sources (ADR 0025). + role_permission_sources: dict[tuple[str, str], list[RelationshipSource]] = field(default_factory=dict) + + def role_permission_pairs(self) -> list[tuple[str, str]]: + """Return ``(role_id, permission_identifier)`` pairs for every role. + + This is the flattened relation the renderer maps to Casbin ``p`` rows. + Pairs are returned in a deterministic order (role id, then permission + id) so downstream rendering and diffing are stable across runs. + """ + pairs: list[tuple[str, str]] = [] + for role_id in sorted(self.roles): + role = self.roles[role_id].definition + for permission in sorted(role.permissions): + pairs.append((role_id, permission)) + return pairs diff --git a/src/openedx_authz/engine/schema/types/definitions.py b/src/openedx_authz/engine/schema/types/definitions.py new file mode 100644 index 00000000..91e61720 --- /dev/null +++ b/src/openedx_authz/engine/schema/types/definitions.py @@ -0,0 +1,86 @@ +"""Static definition objects for the authz schema pipeline. + +These dataclasses describe the author-facing schema vocabulary (ADR 0017 / +``docs/references/authorization-schema.rst``): permission categories, +permissions, static roles, and role extensions. Identifiers match +``[a-z][a-z0-9_]*``, permission IDs join ``namespace`` and ``name`` with a +period, and the internal Casbin forms (``act^...``, ``role^...``) never appear +here. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PermissionCategory: + """A display/grouping category for permissions. Grants no access.""" + + id: str + display_name: str + description: str + icon: str | None = None + + +@dataclass(frozen=True) +class PermissionDefinition: + """A single permission. + + The complete permission ID (used by role definitions, extensions, app + checks, and API responses) is :attr:`identifier`. + """ + + namespace: str + # ``name`` is the stable machine identifier for the operation within the + # namespace (e.g. ``view_course``); it joins ``namespace`` to form the + # complete permission ID and must not change once published. ``display_name`` + # below is the human-facing, translatable label shown in the UI (e.g. "View + # course") and may be re-worded freely without affecting permission checks. + name: str + display_name: str + description: str + category_id: str + scopes: tuple[str, ...] + icon: str | None = None + + @property + def identifier(self) -> str: + """Complete permission ID, e.g. ``"courses.view_course"``.""" + return f"{self.namespace}.{self.name}" + + +@dataclass(frozen=True) +class RoleDefinition: + """A static role listing every permission assigned to it. + + ``hidden`` mirrors ADR 0023: a hidden role is excluded from normal role + discovery/selection but keeps its assignments, permission checks, and + reserved ID. + """ + + id: str + display_name: str + description: str + scopes: tuple[str, ...] + permissions: tuple[str, ...] + icon: str | None = None + hidden: bool = False + + +@dataclass(frozen=True) +class RoleExtension: + """A change to an existing static role (ADR 0023). + + Only the included fields change; ``None``/empty means "leave unchanged". + An extension can never change the role ID or replace the whole definition. + ``hidden`` is tri-state: ``None`` leaves the current value untouched. + """ + + role_id: str + add_permissions: tuple[str, ...] = () + remove_permissions: tuple[str, ...] = () + display_name: str | None = None + description: str | None = None + icon: str | None = None + hidden: bool | None = None diff --git a/src/openedx_authz/engine/schema/types/loading.py b/src/openedx_authz/engine/schema/types/loading.py new file mode 100644 index 00000000..baa7bc24 --- /dev/null +++ b/src/openedx_authz/engine/schema/types/loading.py @@ -0,0 +1,76 @@ +"""Provenance and loading-output types for the authz schema pipeline. + +:class:`SourceRecord` identifies where a schema contribution came from (ADR +0019), and :class:`SchemaDocument` is the per-file output of the ``load`` step +(ADR 0018). A loaded document carries the source that produced it, which is why +both live together here. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from openedx_authz.engine.schema.types.definitions import ( + PermissionCategory, + PermissionDefinition, + RoleDefinition, + RoleExtension, +) + + +@dataclass(frozen=True) +class SourceRecord: + """Identifies a single schema contribution across deployment layouts. + + Per ADR 0019 §2, these packaging-based values (not filesystem paths) must + identify the same source under Tutor, native, and local deployments. A + compiled definition retains every ``SourceRecord`` that contributed to it, + so a role assembled from a base definition plus one or more extensions + keeps all of its sources. + + Attributes: + distribution: Installed distribution name, e.g. ``"openedx-authz"``. + distribution_version: Version of that distribution. + module: Python module that owns the resource. + resource_path: Resource path within that module. + schema_version: The ``schema_version`` declared by the file. + content_digest: Digest of the resource contents (change detection). + """ + + distribution: str + distribution_version: str + module: str + resource_path: str + schema_version: str + content_digest: str + + @property + def source_id(self) -> str: + """Stable, human-readable id. + + Combines the distribution with the module directory path and the file + name, e.g. ``"openedx-authz:openedx_authz/authz/schema/roles.yaml"``. + + ``module`` is the dotted path of the owning directory and ``resource_path`` + is anchor-relative (so it may repeat the directory); only the file name + is appended here to avoid duplicating the directory segments. + """ + module_path = self.module.replace(".", "/") + filename = self.resource_path.rsplit("/", 1)[-1] + return f"{self.distribution}:{module_path}/{filename}" + + +@dataclass +class SchemaDocument: + """One loaded ``.yaml`` schema file plus its provenance and priority. + + Output of the ``load`` step. Still per-file: cross-file references are not + yet resolved (that happens during ``compile``). + """ + + source: SourceRecord + priority: int + categories: list[PermissionCategory] = field(default_factory=list) + permissions: list[PermissionDefinition] = field(default_factory=list) + roles: list[RoleDefinition] = field(default_factory=list) + role_extensions: list[RoleExtension] = field(default_factory=list) diff --git a/src/openedx_authz/schema/authz-schema-v1.json b/src/openedx_authz/schema/authz-schema-v1.json index 0fd57aa7..5f50cfc1 100644 --- a/src/openedx_authz/schema/authz-schema-v1.json +++ b/src/openedx_authz/schema/authz-schema-v1.json @@ -54,7 +54,7 @@ "name": "view_course", "display_name": "View course", "description": "View course configuration and content.", - "category": "course_content", + "category_id": "course_content", "scopes": ["course-v1"], "icon": "Visibility" } @@ -92,7 +92,7 @@ "examples": [ [ { - "role": "course_editor", + "role_id": "course_editor", "add_permissions": ["courses.export_course"] } ] @@ -195,7 +195,7 @@ "description": "A static permission. Its complete identifier is namespace.name.", "type": "object", "additionalProperties": false, - "required": ["namespace", "name", "display_name", "description", "category", "scopes"], + "required": ["namespace", "name", "display_name", "description", "category_id", "scopes"], "properties": { "namespace": { "title": "Permission namespace", @@ -219,8 +219,8 @@ "examples": ["View course configuration and content."], "$ref": "#/$defs/description" }, - "category": { - "title": "Permission category", + "category_id": { + "title": "Permission category_id", "description": "The ID of a permission category in the combined schema.", "examples": ["course_content"], "$ref": "#/$defs/identifier" @@ -289,13 +289,13 @@ }, "role_extension": { "title": "Role extension", - "description": "A partial change to an existing static role. Include at least one field in addition to role.", + "description": "A partial change to an existing static role. Include at least one field in addition to role_id.", "type": "object", "additionalProperties": false, - "required": ["role"], + "required": ["role_id"], "properties": { - "role": { - "title": "Extension role", + "role_id": { + "title": "Extension role_id", "description": "The ID of the static role to change.", "examples": ["course_editor"], "$ref": "#/$defs/identifier" @@ -365,7 +365,7 @@ "name": "view_course", "display_name": "View course", "description": "View course configuration and content.", - "category": "course_content", + "category_id": "course_content", "scopes": ["course-v1"], "icon": "Visibility" }, @@ -374,7 +374,7 @@ "name": "export_course", "display_name": "Export course", "description": "Export course configuration and content.", - "category": "course_content", + "category_id": "course_content", "scopes": ["course-v1"], "icon": "Download" } @@ -390,7 +390,7 @@ ], "role_extensions": [ { - "role": "course_editor", + "role_id": "course_editor", "add_permissions": ["courses.export_course"] } ] diff --git a/src/openedx_authz/tests/schema/factories.py b/src/openedx_authz/tests/schema/factories.py new file mode 100644 index 00000000..b9a6d0b6 --- /dev/null +++ b/src/openedx_authz/tests/schema/factories.py @@ -0,0 +1,112 @@ +"""Small builders and a stub discovery for schema pipeline tests.""" + +from __future__ import annotations + +from openedx_authz.engine.schema.discovery import DiscoveredResource, Origin +from openedx_authz.engine.schema.types import ( + PermissionCategory, + PermissionDefinition, + RoleDefinition, + RoleExtension, + SchemaDocument, + SourceRecord, +) + + +def make_source(name: str = "doc", schema_version: str = "1.0") -> SourceRecord: + """Build a SourceRecord with predictable values for a named document.""" + return SourceRecord( + distribution="test-dist", + distribution_version="1.0", + module=f"pkg.{name}", + resource_path=f"{name}.authz.yaml", + schema_version=schema_version, + content_digest=f"digest-{name}", + ) + + +def make_document( + name: str = "doc", + *, + priority: int = 100, + schema_version: str = "1.0", + categories=None, + permissions=None, + roles=None, + role_extensions=None, +) -> SchemaDocument: + """Build a SchemaDocument with sensible empty defaults.""" + return SchemaDocument( + source=make_source(name, schema_version), + priority=priority, + categories=categories or [], + permissions=permissions or [], + roles=roles or [], + role_extensions=role_extensions or [], + ) + + +def category(cid: str = "cat", **kwargs) -> PermissionCategory: + return PermissionCategory( + id=cid, + display_name=kwargs.get("display_name", "Cat"), + description=kwargs.get("description", "desc"), + icon=kwargs.get("icon"), + ) + + +def permission(namespace="courses", name="view_course", *, cat="cat", scopes=("course-v1",), **kwargs): + return PermissionDefinition( + namespace=namespace, + name=name, + display_name=kwargs.get("display_name", "View"), + description=kwargs.get("description", "desc"), + category_id=cat, + scopes=tuple(scopes), + icon=kwargs.get("icon"), + ) + + +def role(rid="course_editor", *, scopes=("course-v1",), permissions=(), hidden=False, **kwargs): + return RoleDefinition( + id=rid, + display_name=kwargs.get("display_name", "Editor"), + description=kwargs.get("description", "desc"), + scopes=tuple(scopes), + permissions=tuple(permissions), + icon=kwargs.get("icon"), + hidden=hidden, + ) + + +def extension(role_id, **kwargs) -> RoleExtension: + return RoleExtension( + role_id=role_id, + add_permissions=tuple(kwargs.get("add_permissions", ())), + remove_permissions=tuple(kwargs.get("remove_permissions", ())), + display_name=kwargs.get("display_name"), + description=kwargs.get("description"), + icon=kwargs.get("icon"), + hidden=kwargs.get("hidden"), + ) + + +class InMemoryResource(DiscoveredResource): + """A :class:`DiscoveredResource` that reads preset bytes instead of a file. + + Lets loader tests exercise parsing/digest logic without materializing files + on disk. The ``load`` step reads bytes via ``DiscoveredResource.read_bytes``, + so overriding just that method is enough to feed arbitrary content. + """ + + _contents: bytes + + def __init__( + self, contents: bytes, *, package: str = "pkg", module: str = "pkg.mod", resource_path: str = "file.authz.yaml" + ): + super().__init__(package=package, resource_path=resource_path, module=module, origin=Origin.PASSED_IN) + # frozen dataclass: bypass the immutability guard for the test-only field. + object.__setattr__(self, "_contents", contents) + + def read_bytes(self) -> bytes: + return self._contents diff --git a/src/openedx_authz/tests/schema/test_loading.py b/src/openedx_authz/tests/schema/test_loading.py new file mode 100644 index 00000000..40435758 --- /dev/null +++ b/src/openedx_authz/tests/schema/test_loading.py @@ -0,0 +1,340 @@ +"""Unit tests for the schema loading step.""" + +from importlib import metadata + +import pytest + +from openedx_authz.engine.schema.discovery import SchemaDiscovery +from openedx_authz.engine.schema.exceptions import SchemaLoadError +from openedx_authz.engine.schema.loading import SchemaLoader + +from .factories import InMemoryResource + +UNKNOWN_DISTRIBUTION = SchemaLoader._UNKNOWN_DISTRIBUTION # pylint: disable=protected-access + +VALID_YAML = b""" +schema_version: "1.0" +priority: 150 + +permission_categories: + - id: course_content + display_name: Course content + description: Course content permissions. + icon: Article + +permissions: + - namespace: courses + name: view_course + display_name: View course + description: View a course. + category_id: course_content + scopes: [course-v1] + +roles: + - id: course_observer + display_name: Course observer + description: Reviews a course. + scopes: [course-v1] + hidden: true + permissions: + - courses.view_course + +role_extensions: + - role_id: course_editor + add_permissions: [courses.export_course] +""" + + +def _load(contents: bytes): + resource = InMemoryResource(contents, package="pkg", module="pkg.mod") + return SchemaLoader().load([resource]) + + +class TestDocumentLoading: + """Parsing a schema file into a typed :class:`SchemaDocument`. + + Covers the happy path (every block populated), the degenerate empty inputs + the loader must tolerate (validation rejects them later, not loading), and + the structural failures that must stop the load. + """ + + def test_loads_all_blocks_into_typed_objects(self): + """Every top-level block becomes its typed object with fields intact.""" + docs = _load(VALID_YAML) + assert len(docs) == 1 + doc = docs[0] + assert doc.priority == 150 + assert doc.source.schema_version == "1.0" + assert doc.source.content_digest # digest computed + assert doc.categories[0].id == "course_content" + assert doc.permissions[0].identifier == "courses.view_course" + assert doc.permissions[0].scopes == ("course-v1",) + assert doc.roles[0].hidden is True + assert doc.roles[0].permissions == ("courses.view_course",) + assert doc.role_extensions[0].role_id == "course_editor" + assert doc.role_extensions[0].add_permissions == ("courses.export_course",) + + def test_extension_changes_are_loaded(self): + """A ``role_extensions`` entry's edits (adds, removes, metadata) round-trip. + + ``test_loads_all_blocks_into_typed_objects`` only exercises an add; this + pins the remove/rename/hide edits an extension can carry (ADR 0023). + """ + docs = _load( + b"schema_version: '1.0'\n" + b"priority: 200\n" + b"role_extensions:\n" + b" - role_id: course_editor\n" + b" add_permissions: [courses.export_course]\n" + b" remove_permissions: [courses.manage_tags]\n" + b" display_name: Course author\n" + b" description: Creates and exports course content.\n" + b" hidden: true\n" + ) + + extension = docs[0].role_extensions[0] + assert extension.role_id == "course_editor" + assert extension.add_permissions == ("courses.export_course",) + assert extension.remove_permissions == ("courses.manage_tags",) + assert extension.display_name == "Course author" + assert extension.description == "Creates and exports course content." + assert extension.hidden is True + + def test_empty_document_yields_empty_blocks(self): + """A file with only header fields yields empty block lists, not errors.""" + docs = _load(b"schema_version: '1.0'\npriority: 1\n") + assert docs[0].categories == [] + assert docs[0].roles == [] + + def test_completely_empty_file_is_treated_as_an_empty_mapping(self): + """An empty file parses to ``None``; validation rejects it, loading must not.""" + docs = _load(b"") + + assert docs[0].priority == 0 + assert docs[0].source.schema_version == "" + assert docs[0].roles == [] + + def test_invalid_yaml_raises_load_error(self): + """Malformed YAML surfaces as ``SchemaLoadError``.""" + with pytest.raises(SchemaLoadError): + _load(b"schema_version: '1.0'\n bad: [unclosed\n") + + def test_non_mapping_top_level_raises_load_error(self): + """A top-level sequence (not a mapping) is rejected.""" + with pytest.raises(SchemaLoadError): + _load(b"- just\n- a\n- list\n") + + def test_non_integer_priority_raises_load_error(self): + """A non-numeric priority is a mistake, not a default.""" + with pytest.raises(SchemaLoadError): + _load(b"schema_version: '1.0'\npriority: high\n") + + +class TestSourceIdentity: + """``(distribution, module)`` is the identity of a source record (ADR 0025 §2). + + The rest of the suite builds ``SourceRecord`` values through factories, so + these tests are the only ones that exercise the real resolution against + installed package metadata. + """ + + @staticmethod + def _fake_distribution_lookup(files_by_name): + """Build a ``metadata.distribution`` replacement backed by preset file lists. + + ``files_by_name`` maps a distribution name to the anchor-relative paths + it records; the returned callable exposes those via a ``.files`` + attribute, mimicking ``importlib.metadata.Distribution``. Names absent + from the mapping raise ``PackageNotFoundError``, as the real backend + does for an uninstalled distribution. + """ + + class _Distribution: + def __init__(self, files): + self.files = files + + def _distribution(name): + if name not in files_by_name: + raise metadata.PackageNotFoundError(name) + return _Distribution(files_by_name[name]) + + return _distribution + + def test_installed_package_resolves_to_its_distribution(self): + """A module shipped by this package resolves to the real distribution.""" + discovery = SchemaDiscovery(passed_in_directories=["openedx_authz/authz/schema"]) + docs = SchemaLoader().load(discovery.discover()) + + assert {doc.source.distribution for doc in docs} == {"openedx-authz"} + assert all(doc.source.distribution_version != UNKNOWN_DISTRIBUTION for doc in docs) + + def test_module_is_recorded_as_the_dotted_path(self): + """The source module is recorded as the resource's dotted import path.""" + discovery = SchemaDiscovery(passed_in_directories=["openedx_authz/authz/schema"]) + docs = SchemaLoader().load(discovery.discover()) + + assert {doc.source.module for doc in docs} == {"openedx_authz.authz.schema"} + + def test_unknown_package_falls_back_to_its_top_level_name(self): + """An operator-supplied directory need not belong to a distribution.""" + docs = _load(b"schema_version: '1.0'\npriority: 1\n") + + assert docs[0].source.distribution == "pkg" + assert docs[0].source.distribution_version == UNKNOWN_DISTRIBUTION + + def test_missing_distribution_metadata_falls_back_to_unknown_version(self, monkeypatch): + """A distribution with no readable version falls back to ``UNKNOWN``.""" + monkeypatch.setattr(metadata, "packages_distributions", lambda: {"pkg": ["ghost-dist"]}) + + def _missing(_name): + raise metadata.PackageNotFoundError("ghost-dist") + + monkeypatch.setattr(metadata, "version", _missing) + + docs = _load(b"schema_version: '1.0'\npriority: 1\n") + + assert docs[0].source.distribution == "ghost-dist" + assert docs[0].source.distribution_version == UNKNOWN_DISTRIBUTION + + def test_unreadable_package_metadata_is_tolerated(self, monkeypatch): + """Environment quirks must not break the loader.""" + + def _boom(): + raise RuntimeError("metadata backend unavailable") + + monkeypatch.setattr(metadata, "packages_distributions", _boom) + + docs = _load(b"schema_version: '1.0'\npriority: 1\n") + + assert docs[0].source.distribution == "pkg" + + def test_multiple_candidates_resolve_to_the_distribution_that_ships_the_file(self, monkeypatch): + """When several distributions claim the top-level package, pick the file's owner. + + ``packages_distributions`` returns a list because a top-level import + package can be provided by more than one distribution (namespace + packages, overlapping installs). The owner is the one whose recorded + file list actually contains the resource (ADR 0019 §2), not the first + list entry. + """ + monkeypatch.setattr(metadata, "packages_distributions", lambda: {"pkg": ["other-dist", "owning-dist"]}) + monkeypatch.setattr( + metadata, "distribution", self._fake_distribution_lookup({"owning-dist": ["pkg/file.authz.yaml"]}) + ) + monkeypatch.setattr(metadata, "version", lambda name: "9.9" if name == "owning-dist" else "0.0") + + docs = _load(b"schema_version: '1.0'\npriority: 1\n") + + assert docs[0].source.distribution == "owning-dist" + assert docs[0].source.distribution_version == "9.9" + + def test_ambiguous_ownership_falls_back_to_a_deterministic_choice(self, monkeypatch): + """With no single file owner, pick the first candidate in sorted order. + + If zero or more than one candidate claims the file (or file lists are + unavailable), the true owner is unknown. The pick must still be stable + across environments, so it is sorted rather than discovery-ordered. + """ + monkeypatch.setattr(metadata, "packages_distributions", lambda: {"pkg": ["zed-dist", "alpha-dist"]}) + # Neither distribution records the file, so ownership cannot be confirmed. + monkeypatch.setattr(metadata, "distribution", self._fake_distribution_lookup({})) + monkeypatch.setattr(metadata, "version", lambda _name: "1.0") + + docs = _load(b"schema_version: '1.0'\npriority: 1\n") + + assert docs[0].source.distribution == "alpha-dist" + + def test_digest_reflects_the_file_contents(self): + """Different file contents produce different content digests.""" + first = _load(b"schema_version: '1.0'\npriority: 1\n")[0] + second = _load(b"schema_version: '1.0'\npriority: 2\n")[0] + + assert first.source.content_digest != second.source.content_digest + + def test_identical_contents_produce_the_same_digest(self): + """Identical file contents produce the same content digest.""" + first = _load(b"schema_version: '1.0'\npriority: 1\n")[0] + second = _load(b"schema_version: '1.0'\npriority: 1\n")[0] + + assert first.source.content_digest == second.source.content_digest + + +class TestFieldCoercion: + """Loader-level normalization of YAML shapes.""" + + def test_scalar_scope_becomes_a_one_tuple(self): + """``scopes: course-v1`` is accepted as shorthand for a single-item list.""" + docs = _load( + b"schema_version: '1.0'\n" + b"priority: 1\n" + b"roles:\n" + b" - id: course_observer\n" + b" scopes: course-v1\n" + b" permissions: courses.view_course\n" + ) + + assert docs[0].roles[0].scopes == ("course-v1",) + assert docs[0].roles[0].permissions == ("courses.view_course",) + + def test_null_blocks_are_treated_as_empty(self): + """A YAML block whose value is null coerces to an empty list.""" + docs = _load(b"schema_version: '1.0'\npriority: 1\nroles:\npermissions:\n") + + assert docs[0].roles == [] + assert docs[0].permissions == [] + + def test_missing_priority_defaults_to_zero(self): + """``priority`` is required by the schema, but the loader defers that. + + Enforcing required fields is the validate step's job (ADR 0018), so the + loader reads a missing ``priority`` as ``0`` rather than raising; a + later validation pass rejects the omission. + """ + docs = _load(b"schema_version: '1.0'\n") + + assert docs[0].priority == 0 + + def test_missing_schema_version_is_empty_not_absent(self): + """Validation rejects it later; the loader must not crash on it.""" + docs = _load(b"priority: 1\n") + + assert docs[0].source.schema_version == "" + + def test_numeric_string_priority_is_accepted(self): + """A priority written as a numeric string is coerced to an int.""" + docs = _load(b"schema_version: '1.0'\npriority: '150'\n") + + assert docs[0].priority == 150 + + def test_extension_hidden_false_is_preserved_as_a_change(self): + """``hidden`` is tri-state: ``False`` differs from absent (ADR 0023 §1).""" + docs = _load( + b"schema_version: '1.0'\npriority: 1\nrole_extensions:\n - role_id: course_editor\n hidden: false\n" + ) + + assert docs[0].role_extensions[0].hidden is False + + def test_extension_without_hidden_leaves_it_unset(self): + """An extension that omits ``hidden`` leaves it ``None`` (unchanged).""" + docs = _load( + b"schema_version: '1.0'\npriority: 1\nrole_extensions:\n - role_id: course_editor\n icon: Article\n" + ) + + assert docs[0].role_extensions[0].hidden is None + + +class TestMalformedEntries: + """A block entry that is not a mapping stops the load with context.""" + + @pytest.mark.parametrize("block", ["permission_categories", "permissions", "roles", "role_extensions"]) + def test_non_mapping_entry_raises_with_the_block_name(self, block): + """A non-mapping entry in any block raises an error naming that block.""" + contents = f"schema_version: '1.0'\npriority: 1\n{block}:\n - just_a_string\n".encode() + + with pytest.raises(SchemaLoadError, match=block): + _load(contents) + + def test_error_names_the_source(self): + """A malformed entry error names the contributing source.""" + with pytest.raises(SchemaLoadError, match="pkg.mod"): + _load(b"schema_version: '1.0'\npriority: 1\nroles:\n - 5\n") diff --git a/src/openedx_authz/tests/schema/test_types.py b/src/openedx_authz/tests/schema/test_types.py new file mode 100644 index 00000000..90f9748e --- /dev/null +++ b/src/openedx_authz/tests/schema/test_types.py @@ -0,0 +1,57 @@ +"""Unit tests for schema type helpers. + +Covers the derived accessors on the schema dataclasses: source identity, +permission identifiers, and the flattened role-permission pairs a compiled +schema exposes for rendering. +""" + +from openedx_authz.engine.schema.types import ( + CompiledDefinition, + CompiledSchema, + PermissionDefinition, + RoleDefinition, + SourceRecord, +) + + +class TestSchemaTypeHelpers: + """Derived properties and helpers on the schema type dataclasses.""" + + def test_source_id_combines_distribution_and_module_path(self): + """``source_id`` renders as ``distribution:module/path`` with the file name appended.""" + source = SourceRecord( + distribution="openedx-authz", + distribution_version="1.0", + module="openedx_authz.authz", + resource_path="course_roles.authz.yaml", + schema_version="1.0", + content_digest="abc", + ) + assert source.source_id == "openedx-authz:openedx_authz/authz/course_roles.authz.yaml" + + def test_permission_identifier_joins_namespace_and_name(self): + """``identifier`` joins namespace and name with a period.""" + perm = PermissionDefinition( + namespace="courses", + name="view_course", + display_name="View", + description="d", + category_id="cat", + scopes=("course-v1",), + ) + assert perm.identifier == "courses.view_course" + + def test_role_permission_pairs_are_sorted_and_flattened(self): + """``role_permission_pairs`` returns ``(role, permission)`` pairs sorted deterministically.""" + role = RoleDefinition( + id="course_admin", + display_name="Admin", + description="d", + scopes=("course-v1",), + permissions=("courses.view_course", "courses.edit_course_content"), + ) + schema = CompiledSchema(roles={"course_admin": CompiledDefinition("course_admin", role, ())}) + assert schema.role_permission_pairs() == [ + ("course_admin", "courses.edit_course_content"), + ("course_admin", "courses.view_course"), + ]