diff --git a/MANIFEST.in b/MANIFEST.in index 25eda8e0..d998b09e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,4 +3,4 @@ include LICENSE.txt include README.rst include requirements/base.in include requirements/constraints.txt -recursive-include openedx_authz *.html *.png *.gif *.js *.css *.jpg *.jpeg *.svg *.conf *.policy +recursive-include openedx_authz *.html *.png *.gif *.js *.css *.jpg *.jpeg *.svg *.conf *.policy *.yaml *.yml diff --git a/docs/decisions/0024-authorization-schema-source-tracking.rst b/docs/decisions/0024-authorization-schema-source-tracking.rst new file mode 100644 index 00000000..434e0141 --- /dev/null +++ b/docs/decisions/0024-authorization-schema-source-tracking.rst @@ -0,0 +1,146 @@ +0024: Track the Source of Compiled Authorization Definitions +############################################################ + +Status +****** + +**Draft** + +Context +******* + +`ADR 0019`_ discovers static schema resources from multiple applications and `ADR 0018`_ +compiles them into policy rows during deployment. Several requirements need to know *where* +each compiled definition came from: + +* Multiple applications may contribute to the same role. When a module adds a permission to a + built-in role (for example ``courses.export_grades`` on ``course_admin``), the system must be + able to tell the core-provided grants apart from the module-provided grant even though both + live in the same role. +* Operators and developers benefit from seeing which application contributed a role or + permission, for debugging and auditing. +* A future capability to remove an application should be able to drop only the definitions that + application provided, while shared definitions remain. + +Today none of this is stored. Compiled definitions exist only as Casbin ``p`` rows, which encode +``role, action, scope, effect`` and carry no display metadata and no origin. + +Two representations are possible for the origin: an extra field on the Casbin policy row, or a +value held only in the in-memory registry. Both were rejected. A policy-row field cannot hold +more than one contributing source, risks interfering with the enforcement matcher, and cannot +attribute display metadata or categories, which are not policy rows. An in-memory value does not +survive restarts, cannot be shared across processes, and cannot support idempotent re-deploys or +future removal. + +Decision +******** + +1. Store compiled definitions and their sources in dedicated tables +==================================================================== + +The schema loader persists compiled definitions in first-class tables owned by ``openedx-authz``. +Casbin ``p`` rows remain the enforcement representation and are rendered from these tables in the +same transaction; the definition tables are the authoritative record that the API reads and that +deployment diffs. + +The definition tables are: + +* a permission-category table (stable id and display fields); +* a permission-definition table (namespace and name forming the complete permission id, plus + display fields, category, and supported scopes); +* a role-definition table (stable role id, display fields, supported scopes, and the ``hidden`` + flag from `ADR 0023`_); and +* a role-permission table holding one row per ``(role, permission, scope)`` relationship. + +The role-permission relationship is the atomic unit of attribution, because it corresponds one +to one with a rendered ``p`` row and is where role extensions take effect. + +2. Attribute sources through an explicit many-to-many link +=========================================================== + +A source table records each distinct contribution. Its identity is ``(distribution, module)`` — +the installed distribution and the Python module that owns the resource. The file path is stored +as a non-identifying attribute only, so moving a definition between files within the same module +does not change its source and produces no add/remove churn. A content digest may be recorded for +diagnostics, but change detection relies on diffing compiled definitions rather than on the +digest, so the digest is advisory. + +Every definition and every role-permission relationship links to one or more sources through +explicit link tables. Each link records whether the contribution was a base definition or an +extension, and the contributing file's priority so the winning metadata source is derivable. The +common case is a single link; the many-to-many exists to represent shared ownership and to make +removal precise. + +All sources are treated equally. ``openedx-authz`` is a schema provider like any other +distribution, so there is no core-versus-module flag; callers that care about a particular origin +compare the distribution name directly. + +3. Extending a built-in role keeps both origins distinct +======================================================== + +Because attribution lives at the role-permission grain, a core grant and a module-added grant on +the same role remain individually attributed. For ``course_admin``: + +* the role definition links to the distribution that defines it, as a base contribution; +* each core permission links to that same distribution as a base contribution; and +* ``courses.export_grades`` links to the contributing module as an extension. + +The two coexist in one role, yet each relationship row carries its own origin. If two applications +add the same permission to the same role, the single relationship row gains two source links. + +4. Attribution is queryable and may be exposed by the API +========================================================= + +Given any role or permission, its origin can be queried from the definition and link tables — for +a role as a whole, for a single permission, or for a specific role-permission grant. The +authorization definition API (`ADR 0021`_) may expose these sources so a client such as the +Administrative Console can show which application contributed a role or permission. Exposing the +sources is an additive, optional API change and is not required by this decision. + +5. Metadata changes update definitions in place +================================================ + +When a source file changes a display name, description, icon, or similar field, the next +deployment recompiles and updates the existing definition row, keyed by its stable identifier. No +new definition row is created and relationships and assignments are unaffected. + +6. Adopt pre-existing policy rows; leave unmanaged rows untouched +================================================================= + +On the first deployment after this feature ships, existing Casbin ``p`` rows are adopted rather +than duplicated: for each rendered ``(role, permission, scope)`` that already exists as a policy +row without a definition record, the loader creates the definition and relationship rows and links +them to the contributing source. A pre-existing policy row that no schema declares is left in place +and enforceable, but is not attributed and does not appear in the definition tables. Pruning such +unmanaged rows is out of scope. + +Consequences +************ + +* Compiled definitions, including display metadata that previously had no home, are persisted and + readable through the API. +* The origin of any role, permission, or individual role-permission grant is queryable, and core + and module contributions to the same role remain distinguishable. +* Moving a definition between files in the same module does not change its recorded source. +* Removing an application becomes tractable: relationships and definitions whose only source is the + removed application can be pruned, while shared ones remain. Removal itself remains out of scope + and follows the assignment-safety rules of `ADR 0018`_. +* The existing ``ExtendedCasbinRule`` model is not reused; it continues to describe role + assignments (``g`` rows), while the new tables own definitions and provenance. +* Compilation must track provenance at the role-permission grain, and the apply step writes the + definition, relationship, and source tables in the same transaction as the policy rows. + +References +********** + +* `ADR 0017`_ +* `ADR 0018`_ +* `ADR 0019`_ +* `ADR 0021`_ +* `ADR 0023`_ + +.. _ADR 0017: 0017-static-authorization-schema.rst +.. _ADR 0018: 0018-authorization-schema-lifecycle.rst +.. _ADR 0019: 0019-authorization-schema-discovery.rst +.. _ADR 0021: 0021-authorization-definition-api.rst +.. _ADR 0023: 0023-extend-static-roles.rst diff --git a/openedx_authz/authz/__init__.py b/openedx_authz/authz/__init__.py new file mode 100644 index 00000000..06fb286f --- /dev/null +++ b/openedx_authz/authz/__init__.py @@ -0,0 +1,35 @@ +"""openedx-authz's own static authorization schema resources. + +This package ships the platform-default ``.authz.yaml`` files and exposes them +through the ``authz.schema`` entry point (ADR 0019). openedx-authz is a schema +provider like any other distribution; its files are discovered the same way a +third-party application's would be. + +Register in setup.py / pyproject.toml:: + + entry_points = { + "authz.schema": [ + "openedx_authz = openedx_authz.authz:get_schema_resources", + ], + } +""" + +from __future__ import annotations + +# Resource paths are relative to this module (``openedx_authz.authz``), which +# keeps discovery independent of virtualenv/container layout (ADR 0019). +SCHEMA_RESOURCES: tuple[str, ...] = ( + "library_permissions.authz.yaml", + "library_roles.authz.yaml", + "course_permissions.authz.yaml", + "course_roles.authz.yaml", +) + + +def get_schema_resources() -> list[str]: + """Return this package's schema resource paths (relative to this module). + + The ``authz.schema`` entry point points at this callable; the discovery + step resolves the returned paths via ``importlib.resources``. + """ + return list(SCHEMA_RESOURCES) diff --git a/openedx_authz/authz/course_permissions.authz.yaml b/openedx_authz/authz/course_permissions.authz.yaml new file mode 100644 index 00000000..06456929 --- /dev/null +++ b/openedx_authz/authz/course_permissions.authz.yaml @@ -0,0 +1,337 @@ +schema_version: "1.0" +priority: 100 + +# Course permission and category definitions for the platform-default +# (openedx-authz) authorization schema. +# +# Display data (display_name, description, icon, category) is sourced from +# frontend-app-admin-console: +# src/authz-module/roles-permissions/course/constants.ts +# Permission identifiers are reconciled against engine/config/authz.policy so the +# schema covers every action referenced by a role definition there. +# +# Icons are Paragon icon names (@openedx/paragon/icons). + +permission_categories: + - id: course_access_content + display_name: Course access & content + description: Permissions related to accessing the course and managing core course content, including creating, editing, and publishing materials. + icon: BookOpen + - id: course_library_updates + display_name: Library updates + description: Permissions for reviewing and managing updates made to content libraries connected to the course. + icon: LibraryBooks + - id: course_updates_handouts + display_name: Course updates & handouts + description: Permissions for viewing and managing course updates and handouts that are visible to learners. + icon: Sync + - id: course_pages_resources + display_name: Pages & resources + description: Permissions for viewing and managing course pages and additional learning resources. + icon: Article + - id: course_files + display_name: Files + description: Permissions for viewing and managing files and assets uploaded to the course. + icon: Folder + - id: course_schedule_details + display_name: Schedule & details + description: Permissions for viewing and editing the course schedule and course information. + icon: Calendar + - id: course_grading + display_name: Grading + description: Permissions related to viewing and managing grading configuration and grading policies. + icon: Award + - id: course_team_group + display_name: Course team & groups + description: Permissions for viewing and managing the course team, learner groups, and group configurations. + icon: Group + - id: course_tags_taxonomies + display_name: Tags + description: Permissions for managing tags used to organize course content. + icon: LocalOffer + - id: course_advanced_certificates + display_name: Advanced & certificates + description: Permissions for managing advanced course settings and course certificates. + icon: CheckCircle + - id: course_import_export + display_name: Import / export + description: Permissions for importing and exporting course content and related data. + icon: Download + - id: course_other + display_name: Other + description: Additional permissions not included in other categories, such as viewing checklists. + icon: DrawShapes + # Backend-only category for legacy compatibility actions that have no frontend + # display metadata. These are referenced by legacy roles in authz.policy. + - id: course_legacy + display_name: Legacy compatibility + description: Internal compatibility permissions that map legacy course roles to the authorization system. Not shown as individually assignable permissions. + icon: DrawShapes + +permissions: + - namespace: courses + 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 + scopes: [course-v1] + icon: RemoveRedEye + - namespace: courses + name: edit_course_content + display_name: Edit course content + description: Edit the course outline, units, and components. + category: course_access_content + scopes: [course-v1] + icon: EditOutline + - namespace: courses + name: publish_course_content + display_name: Publish course content + description: Make course content visible to learners. + category: course_access_content + scopes: [course-v1] + icon: DownloadDone + - namespace: courses + name: view_library_updates + display_name: View library updates + description: View pending updates from content libraries linked to this course. + category: course_library_updates + scopes: [course-v1] + icon: RemoveRedEye + - namespace: courses + 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 + scopes: [course-v1] + icon: Checklist + - namespace: courses + name: view_course_updates + display_name: View course updates + description: See course announcements and handouts visible to learners. + category: course_updates_handouts + scopes: [course-v1] + icon: RemoveRedEye + - namespace: courses + name: manage_course_updates + display_name: Manage course updates + description: Create, edit, and delete course announcements and handouts. + category: course_updates_handouts + scopes: [course-v1] + icon: Settings + - namespace: courses + name: view_pages_and_resources + display_name: View pages & resources + description: See the Pages & Resources section in Studio. + category: course_pages_resources + scopes: [course-v1] + icon: RemoveRedEye + - namespace: courses + 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 + scopes: [course-v1] + icon: Settings + - namespace: courses + name: view_files + display_name: View files + description: See the list of files and assets uploaded to the course. + category: course_files + scopes: [course-v1] + icon: RemoveRedEye + - namespace: courses + name: create_files + display_name: Create files + description: Upload new files and assets to the course. + category: course_files + scopes: [course-v1] + icon: Plus + - namespace: courses + name: edit_files + display_name: Edit files + description: Perform non-destructive actions on files, such as locking or unlocking them. + category: course_files + scopes: [course-v1] + icon: EditOutline + - namespace: courses + name: delete_files + display_name: Delete files + description: Permanently remove files and assets from the course. + category: course_files + scopes: [course-v1] + icon: Delete + - namespace: courses + 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 + scopes: [course-v1] + icon: RemoveRedEye + - namespace: courses + name: edit_schedule + display_name: Edit schedule + description: Update course start and end dates, enrollment dates, and pacing settings. + category: course_schedule_details + scopes: [course-v1] + icon: EditOutline + - namespace: courses + name: edit_details + display_name: Edit course details + description: Update course information including the course summary, pacing, and prerequisites. + category: course_schedule_details + scopes: [course-v1] + icon: EditOutline + - namespace: courses + 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 + scopes: [course-v1] + icon: RemoveRedEye + - namespace: courses + 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 + scopes: [course-v1] + icon: EditOutline + - namespace: courses + 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 + scopes: [course-v1] + icon: RemoveRedEye + - namespace: courses + 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 + scopes: [course-v1] + icon: Settings + - namespace: courses + 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 + scopes: [course-v1] + icon: RemoveRedEye + - namespace: courses + 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 + scopes: [course-v1] + icon: Settings + - namespace: courses + name: manage_tags + display_name: Manage tags + description: Create, edit, and delete tags on this course. + category: course_tags_taxonomies + scopes: [course-v1] + icon: Settings + - namespace: courses + 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 + scopes: [course-v1] + icon: RemoveRedEye + - namespace: courses + 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 + scopes: [course-v1] + icon: Settings + - namespace: courses + name: view_certificates + display_name: View certificates + description: See the course certificate settings. + category: course_advanced_certificates + scopes: [course-v1] + icon: RemoveRedEye + - namespace: courses + name: manage_certificates + display_name: Manage certificates + description: Create and edit course certificates, including certificate design and eligibility settings. + category: course_advanced_certificates + scopes: [course-v1] + icon: Settings + - namespace: courses + 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 + scopes: [course-v1] + icon: Download + - namespace: courses + 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 + scopes: [course-v1] + icon: Upload + - namespace: courses + name: export_tags + display_name: Export tags + description: Download the tag data associated with this course. + category: course_import_export + scopes: [course-v1] + icon: Upload + - namespace: courses + name: view_checklists + display_name: View checklists + description: See the course launch checklist in Studio. + category: course_other + scopes: [course-v1] + icon: RemoveRedEye + + # ------------------------------------------------------------ + # Backend-only course permissions. + # These appear in engine/config/authz.policy but have NO display + # metadata in the frontend constants. Included so the schema fully + # covers every action referenced by a role definition. + # ------------------------------------------------------------ + - namespace: courses + 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 + scopes: [course-v1] + icon: LocalOffer + - namespace: courses + 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 + scopes: [course-v1] + icon: DrawShapes + - namespace: courses + 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 + scopes: [course-v1] + icon: DrawShapes + - namespace: courses + 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 + scopes: [course-v1] + icon: DrawShapes + - namespace: courses + 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 + scopes: [course-v1] + icon: DrawShapes + - namespace: courses + 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 + scopes: [course-v1] + icon: DrawShapes diff --git a/openedx_authz/authz/course_roles.authz.yaml b/openedx_authz/authz/course_roles.authz.yaml new file mode 100644 index 00000000..a2d271b5 --- /dev/null +++ b/openedx_authz/authz/course_roles.authz.yaml @@ -0,0 +1,172 @@ +schema_version: "1.0" +priority: 100 + +# Course role definitions for the platform-default (openedx-authz) +# authorization schema. +# +# Display data (display_name, description) is sourced from +# frontend-app-admin-console: +# src/authz-module/roles-permissions/course/constants.ts +# Each role's permission list is taken from its `p` rows in +# engine/config/authz.policy (the source of truth) and referenced by complete +# permission identifier (namespace.name). +# +# `hidden: true` mirrors the `disabled: true` flag the admin console sets on +# course_editor and course_auditor: the role stays valid for existing +# assignments and permission checks but does not appear in normal role +# discovery / selection interfaces (ADR 0023). + +roles: + - id: course_admin + display_name: Course Admin + description: Can manage the course team and all course settings. + scopes: [course-v1] + permissions: + - courses.legacy_instructor_role_permissions + - courses.view_course + - courses.view_course_updates + - courses.view_pages_and_resources + - courses.view_files + - courses.view_grading_settings + - courses.view_checklists + - courses.view_course_team + - courses.view_schedule_and_details + - courses.view_advanced_settings + - courses.view_certificates + - courses.view_group_configurations + - courses.view_library_updates + - courses.edit_course_content + - courses.manage_library_updates + - courses.manage_course_updates + - courses.manage_pages_and_resources + - courses.create_files + - courses.edit_files + - courses.edit_grading_settings + - courses.manage_group_configurations + - courses.edit_details + - courses.manage_tags + - courses.publish_course_content + - courses.delete_files + - courses.edit_schedule + - courses.manage_advanced_settings + - courses.manage_certificates + - courses.import_course + - courses.export_course + - courses.export_tags + - courses.manage_course_team + - courses.manage_taxonomies + + - id: course_staff + display_name: Course Staff + description: Can publish content and manage the course lifecycle in Studio. + scopes: [course-v1] + permissions: + - courses.legacy_staff_role_permissions + - courses.view_course + - courses.view_course_updates + - courses.view_pages_and_resources + - courses.view_files + - courses.view_grading_settings + - courses.view_checklists + - courses.view_course_team + - courses.view_schedule_and_details + - courses.view_advanced_settings + - courses.view_certificates + - courses.view_group_configurations + - courses.view_library_updates + - courses.edit_course_content + - courses.manage_library_updates + - courses.manage_course_updates + - courses.manage_pages_and_resources + - courses.create_files + - courses.edit_files + - courses.edit_grading_settings + - courses.manage_group_configurations + - courses.edit_details + - courses.manage_tags + - courses.publish_course_content + - courses.delete_files + - courses.edit_schedule + - courses.manage_advanced_settings + - courses.manage_certificates + - courses.import_course + - courses.export_course + - courses.export_tags + + - id: course_editor + display_name: Course Editor + description: Can create and edit course content, but cannot publish or change critical course settings. + scopes: [course-v1] + hidden: true + permissions: + - courses.view_course + - courses.view_course_updates + - courses.view_pages_and_resources + - courses.view_files + - courses.view_grading_settings + - courses.view_checklists + - courses.view_course_team + - courses.view_schedule_and_details + - courses.view_advanced_settings + - courses.view_certificates + - courses.view_group_configurations + - courses.view_library_updates + - courses.edit_course_content + - courses.manage_library_updates + - courses.manage_course_updates + - courses.manage_pages_and_resources + - courses.create_files + - courses.edit_files + - courses.edit_grading_settings + - courses.manage_group_configurations + - courses.edit_details + - courses.manage_tags + + - id: course_auditor + display_name: Course Auditor + description: Can view course content and settings, but cannot make changes. + scopes: [course-v1] + hidden: true + permissions: + - courses.view_course + - courses.view_course_updates + - courses.view_pages_and_resources + - courses.view_files + - courses.view_grading_settings + - courses.view_checklists + - courses.view_course_team + - courses.view_schedule_and_details + - courses.view_advanced_settings + - courses.view_certificates + - courses.view_group_configurations + - courses.view_library_updates + + # ------------------------------------------------------------ + # Legacy course roles. + # These exist in engine/config/authz.policy but have NO frontend + # metadata. Each grants a single legacy compatibility permission. + # Display text is generated; hidden from normal discovery. + # ------------------------------------------------------------ + - id: course_limited_staff + display_name: Course Limited Staff (legacy) + description: Legacy course role retained for backward compatibility. Grants the legacy limited staff compatibility permission. + scopes: [course-v1] + hidden: true + permissions: + - courses.legacy_limited_staff_role_permissions + + - id: course_data_researcher + display_name: Course Data Researcher (legacy) + description: Legacy course role retained for backward compatibility. Grants the legacy data researcher compatibility permission. + scopes: [course-v1] + hidden: true + permissions: + - courses.legacy_data_researcher_permissions + + - id: course_beta_tester + display_name: Course Beta Tester (legacy) + description: Legacy course role retained for backward compatibility. Grants the legacy beta tester compatibility permission. + scopes: [course-v1] + hidden: true + permissions: + - courses.legacy_beta_tester_permissions diff --git a/openedx_authz/authz/library_permissions.authz.yaml b/openedx_authz/authz/library_permissions.authz.yaml new file mode 100644 index 00000000..e7bc903d --- /dev/null +++ b/openedx_authz/authz/library_permissions.authz.yaml @@ -0,0 +1,110 @@ +schema_version: "1.0" +priority: 100 + +# Content library permission and category definitions for the platform-default +# (openedx-authz) authorization schema. +# +# Display data (display_name, description, icon, category) is sourced from +# frontend-app-admin-console: +# src/authz-module/roles-permissions/library/constants.ts +# Permission identifiers are reconciled against engine/config/authz.policy so the +# schema covers every action referenced by a role definition there. +# +# Icons are Paragon icon names (@openedx/paragon/icons). + +permission_categories: + - id: library + display_name: Library + description: Permissions related to viewing, managing, and publishing the library structure and metadata. + icon: CollectionsBookmark + - id: library_content + display_name: Content + description: Permissions for editing, publishing, and reusing content within the library. + icon: Notes + - id: library_team + display_name: Team + description: Permissions for viewing and managing users who have access to the library. + icon: Group + - id: library_collection + display_name: Collection + description: Permissions for creating and managing content collections within the library. + icon: AutoAwesomeMosaic + +permissions: + - namespace: content_libraries + name: view_library + display_name: View + description: See the library in Studio and access its content in read-only mode. + category: library + scopes: [lib] + icon: RemoveRedEye + - namespace: content_libraries + name: manage_library_tags + display_name: Manage tags + description: Create, edit, and delete tags on this library. + category: library + scopes: [lib] + icon: Settings + - namespace: content_libraries + name: delete_library + display_name: Delete + description: Allows users to delete the entire content library. + category: library + scopes: [lib] + icon: Delete + - namespace: content_libraries + name: edit_library_content + display_name: Edit + description: Create, edit, and delete content items in the library. + category: library_content + scopes: [lib] + icon: EditOutline + - namespace: content_libraries + name: publish_library_content + display_name: Publish + description: Publish individual content items to make them available for reuse in courses. + category: library_content + scopes: [lib] + icon: DownloadDone + - namespace: content_libraries + name: reuse_library_content + display_name: Reuse + description: Add published content from this library to a course. + category: library_content + scopes: [lib] + icon: SpinnerIcon + - namespace: content_libraries + name: view_library_team + display_name: View + description: See the list of users with a role assigned to this library. + category: library_team + scopes: [lib] + icon: RemoveRedEye + - namespace: content_libraries + 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 + scopes: [lib] + icon: Settings + - namespace: content_libraries + name: create_library_collection + display_name: Create + description: Create new collections to organize content within the library. + category: library_collection + scopes: [lib] + icon: Plus + - namespace: content_libraries + name: edit_library_collection + display_name: Edit + description: Update the name and contents of existing collections. + category: library_collection + scopes: [lib] + icon: EditOutline + - namespace: content_libraries + name: delete_library_collection + display_name: Delete + description: Permanently remove collections from the library. + category: library_collection + scopes: [lib] + icon: Delete diff --git a/openedx_authz/authz/library_roles.authz.yaml b/openedx_authz/authz/library_roles.authz.yaml new file mode 100644 index 00000000..ab209026 --- /dev/null +++ b/openedx_authz/authz/library_roles.authz.yaml @@ -0,0 +1,77 @@ +schema_version: "1.0" +priority: 100 + +# Content library role definitions for the platform-default (openedx-authz) +# authorization schema. +# +# Display data (display_name, description) is sourced from +# frontend-app-admin-console: +# src/authz-module/roles-permissions/library/constants.ts +# Each role's permission list is taken from its `p` rows in +# engine/config/authz.policy (the source of truth) and referenced by complete +# permission identifier (namespace.name). + +roles: + - id: library_admin + display_name: Library Admin + description: >- + The Library Admin has full control over the library, including managing + users, modifying content, and handling publishing workflows. They ensure + content is properly maintained and accessible as needed. + scopes: [lib] + permissions: + - content_libraries.view_library + - content_libraries.manage_library_tags + - content_libraries.delete_library + - content_libraries.edit_library_content + - content_libraries.publish_library_content + - content_libraries.reuse_library_content + - content_libraries.view_library_team + - content_libraries.manage_library_team + - content_libraries.create_library_collection + - content_libraries.edit_library_collection + - content_libraries.delete_library_collection + + - id: library_author + display_name: Library Author + description: >- + The Library Author is responsible for creating, editing, and publishing + content within a library. They can manage tags and collections but cannot + delete libraries or manage users. + scopes: [lib] + permissions: + - content_libraries.view_library + - content_libraries.manage_library_tags + - content_libraries.edit_library_content + - content_libraries.publish_library_content + - content_libraries.reuse_library_content + - content_libraries.view_library_team + - content_libraries.create_library_collection + - content_libraries.edit_library_collection + - content_libraries.delete_library_collection + + - id: library_contributor + display_name: Library Contributor + description: >- + The Library Contributor can create and edit content within a library but + cannot publish it. They support the authoring process while leaving final + publishing to Authors or Admins. + scopes: [lib] + permissions: + - content_libraries.view_library + - content_libraries.manage_library_tags + - content_libraries.edit_library_content + - content_libraries.reuse_library_content + - content_libraries.view_library_team + - content_libraries.create_library_collection + - content_libraries.edit_library_collection + - content_libraries.delete_library_collection + + - id: library_user + display_name: Library User + description: The Library User can view and reuse content but cannot edit or delete any resource. + scopes: [lib] + permissions: + - content_libraries.view_library + - content_libraries.reuse_library_content + - content_libraries.view_library_team diff --git a/openedx_authz/engine/renderer.py b/openedx_authz/engine/renderer.py new file mode 100644 index 00000000..b4f16dd9 --- /dev/null +++ b/openedx_authz/engine/renderer.py @@ -0,0 +1,388 @@ +"""Render compiled definitions to Casbin rows and apply them (ADR 0018 §1, §5). + +This is the only Casbin/Django-aware part of the schema pipeline. It implements +the ``render`` and ``apply`` lifecycle steps: + +* ``render`` builds the Casbin ``p`` rows for a :class:`CompiledSchema` in + memory, without touching the database. +* ``apply`` persists the rows in a single transaction, while preserving data + owned by other services (ADR 0018 §3): dynamic roles, user assignments, and + the legacy ``g2`` action-inheritance rows that still live in ``authz.policy``. + +Key semantics: + * Idempotent (ADR 0018 §2): re-applying identical definitions changes + nothing and creates no duplicates. + * Change report before write (ADR 0018 §6): :meth:`SchemaApplier.plan` + reports the ``p`` rows that will be added or removed by comparing rendered + output against the currently stored policy. + * Removal is force-gated (ADR 0018 §6): removing a role that still has + assignments requires an explicit force option. + +Note on the current transition (see module TODOs): persistent storage of +compiled definitions and their :class:`SourceRecord`s (the definition/source +model of ADR 0018 §3) is not implemented yet, and precise "schema-owned" row +ownership depends on it. Until then, :meth:`SchemaApplier.apply` performs an +additive, idempotent write of rendered ``p`` rows and does not prune stale +rows; :meth:`SchemaApplier.plan` still reports would-be removals for review. + +``render`` is pure and imports nothing from Casbin/Django. ``plan``/``apply`` +import the enforcer lazily so this module stays importable without a configured +Django environment. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from openedx_authz.data import AUTHZ_POLICY_ATTRIBUTES_SEPARATOR as SEP +from openedx_authz.engine.schema.exceptions import SchemaApplyError +from openedx_authz.engine.schema.types import CompiledSchema, RoleDefinition + +logger = logging.getLogger(__name__) + +# Namespace prefixes for the internal Casbin form (schema objects never carry them). +ROLE_PREFIX = "role" +ACTION_PREFIX = "act" +SCOPE_WILDCARD = "*" +ALLOW = "allow" +POLICY_PTYPE = "p" + + +@dataclass(frozen=True) +class PolicyRow: + """A single Casbin ``p`` row rendered from a role-permission pair. + + Fields follow the ``p`` shape: subject (role), action (permission), scope + pattern, effect. Namespacing to the internal Casbin form (``role^``, + ``act^``, ``^*``) happens here, at the boundary — schema objects + never carry those prefixes. + """ + + ptype: str # always "p" for rendered definition rows + subject: str + action: str + scope: str + effect: str + + def as_policy(self) -> list[str]: + """Return the enforcer arg form: ``[subject, action, scope, effect]``.""" + return [self.subject, self.action, self.scope, self.effect] + + @classmethod + def from_policy(cls, values: list[str]) -> "PolicyRow": + """Build from a stored ``p`` row (``[subject, action, scope, effect]``).""" + subject, action, scope, effect = (list(values) + ["", "", "", ""])[:4] + return cls(POLICY_PTYPE, subject, action, scope, effect) + + +@dataclass +class RenderedPolicy: + """The full set of ``p`` rows for a compiled schema (no DB access).""" + + rows: list[PolicyRow] = field(default_factory=list) + + +@dataclass +class ChangePlan: + """Diff between rendered definitions and what is currently stored. + + Presented to the operator before any write (ADR 0018 §6). + """ + + added_rows: list[PolicyRow] = field(default_factory=list) + removed_rows: list[PolicyRow] = field(default_factory=list) + unchanged: bool = False + # (role_subject, assignment_subject) pairs: roles being removed that still + # have user assignments; block removal unless force is set. + blocking_assignments: list[tuple[str, str]] = field(default_factory=list) + + +@dataclass +class ApplyResult: + """Outcome of an apply operation, for reporting.""" + + added: int = 0 + removed: int = 0 + unchanged: bool = False + + +class PolicyRenderer: + """Turns a :class:`CompiledSchema` into Casbin ``p`` rows in memory.""" + + def render(self, schema: CompiledSchema) -> RenderedPolicy: + """Produce one ``p`` row per (role, permission, supported scope). + + Emits definition (``p``) rows only — never ``g`` (assignments) or ``g2`` + (action inheritance). Applies the internal Casbin namespacing here. + Performs no database access. Output order is deterministic. + """ + rows: list[PolicyRow] = [] + for role_id in sorted(schema.roles): + role: RoleDefinition = schema.roles[role_id].definition + subject = f"{ROLE_PREFIX}{SEP}{role.id}" + for scope in sorted(role.scopes): + scope_pattern = f"{scope}{SEP}{SCOPE_WILDCARD}" + for permission in sorted(role.permissions): + rows.append( + PolicyRow( + ptype=POLICY_PTYPE, + subject=subject, + action=f"{ACTION_PREFIX}{SEP}{permission}", + scope=scope_pattern, + effect=ALLOW, + ) + ) + return RenderedPolicy(rows=rows) + + +class SchemaApplier: + """Compares, then transactionally applies rendered policy to the database.""" + + def __init__(self, enforcer=None): + """Args: + enforcer: Casbin enforcer; defaults to ``AuthzEnforcer.get_enforcer()``. + + The default is resolved lazily inside methods (not at import) to respect + the plugin/settings timing constraint. + """ + self._enforcer = enforcer + + def plan(self, rendered: RenderedPolicy) -> ChangePlan: + """Compute the change report without writing (ADR 0018 §6). + + Compares ``rendered`` against the currently stored ``p`` rows. Flags + roles that would be removed (their subject no longer appears in the + rendered set) that still have user assignments as blocking. + """ + enforcer = self._resolve_enforcer() + + rendered_set = set(rendered.rows) + stored_set = {PolicyRow.from_policy(row) for row in enforcer.get_policy()} + + added = sorted(rendered_set - stored_set, key=self._row_sort_key) + removed = sorted(stored_set - rendered_set, key=self._row_sort_key) + + rendered_subjects = {row.subject for row in rendered_set} + removed_subjects = {row.subject for row in removed} - rendered_subjects + + blocking = self._find_blocking_assignments(enforcer, removed_subjects) + + return ChangePlan( + added_rows=added, + removed_rows=removed, + unchanged=not added and not removed, + blocking_assignments=blocking, + ) + + def apply( + self, + rendered: RenderedPolicy, + schema: CompiledSchema, + *, + force: bool = False, + ) -> ApplyResult: + """Persist rendered ``p`` rows in one transaction (additive, idempotent). + + Adds rendered rows not already present and invalidates the policy cache + so the enforcer reloads. Preserves dynamic roles, assignments, and + ``g2`` rows. + + Pruning of stale rows is deferred pending the definition/source storage + model (ADR 0018 §3); would-be removals are reported by :meth:`plan` and + logged here rather than applied. When a removal would drop a role that + still has assignments, ``force`` must be set to acknowledge it. + + Raises: + SchemaApplyError: If the plan has blocking assignments and ``force`` + is False. + """ + from django.db import transaction # pylint: disable=import-outside-toplevel + + from openedx_authz.engine.enforcer import AuthzEnforcer # pylint: disable=import-outside-toplevel + + plan = self.plan(rendered) + + if plan.blocking_assignments and not force: + details = ", ".join(f"{role} (assigned to {subject})" for role, subject in plan.blocking_assignments) + raise SchemaApplyError( + "Refusing to proceed: static roles with existing assignments would be removed: " + f"{details}. Re-run with force to remove them together with their assignments." + ) + + enforcer = self._resolve_enforcer() + + # Persist definition/source rows and add any missing p rows atomically. + # Definitions are synced even when p rows are unchanged so metadata-only + # edits land and pre-existing p rows get adopted on first run. + with transaction.atomic(): + for row in plan.added_rows: + enforcer.add_policy(*row.as_policy()) + self._store_sources(schema) + + if plan.removed_rows: + logger.warning( + "Authz schema apply: %d stale p row(s) detected but NOT removed " + "(row pruning is deferred pending the definition/source storage model). " + "Rows: %s", + len(plan.removed_rows), + [row.as_policy() for row in plan.removed_rows], + ) + + if plan.added_rows: + AuthzEnforcer.invalidate_policy_cache() + logger.info("Authz schema apply: added %d p row(s).", len(plan.added_rows)) + else: + logger.info("Authz schema apply: policy rows unchanged; definitions synced.") + + return ApplyResult(added=len(plan.added_rows), removed=0, unchanged=plan.unchanged) + + # ---- helpers ---------------------------------------------------------- + + def _resolve_enforcer(self): + """Lazily resolve the enforcer to honor plugin/settings timing.""" + if self._enforcer is None: + from openedx_authz.engine.enforcer import AuthzEnforcer # pylint: disable=import-outside-toplevel + + self._enforcer = AuthzEnforcer.get_enforcer() + return self._enforcer + + @staticmethod + def _find_blocking_assignments(enforcer, removed_subjects: set[str]) -> list[tuple[str, str]]: + """Return (role_subject, assignment_subject) for removed roles still assigned. + + Grouping (``g``) rows have the shape ``[subject, role, scope]``; a role + being removed is blocking if any ``g`` row references it at index 1. + """ + if not removed_subjects: + return [] + blocking: list[tuple[str, str]] = [] + for grouping in enforcer.get_grouping_policy(): + if len(grouping) >= 2 and grouping[1] in removed_subjects: + blocking.append((grouping[1], grouping[0])) + return sorted(set(blocking)) + + @staticmethod + def _row_sort_key(row: PolicyRow) -> tuple[str, str, str, str]: + return (row.subject, row.action, row.scope, row.effect) + + def _store_sources(self, schema: CompiledSchema) -> None: + """Persist compiled definitions and their sources (ADR 0024). + + Upserts categories, permissions, roles, and each ``(role, permission, + scope)`` grant, linking every definition and grant to its contributing + sources. Idempotent: re-applying identical schema is a no-op. Pre-existing + ``p`` rows are adopted because grants are upserted for every rendered + triple regardless of prior ``p``-row existence. + + Stale-definition pruning is deferred (consistent with ``p``-row pruning); + this method only upserts. + + Called inside the ``apply`` transaction. + """ + from openedx_authz.models import schema as m # pylint: disable=import-outside-toplevel + + source_cache: dict[tuple[str, str], object] = {} + + def source_obj(record): + key = (record.distribution, record.module) + cached = source_cache.get(key) + if cached is not None: + return cached + obj, _ = m.AuthzSchemaSource.objects.update_or_create( + distribution=record.distribution, + module=record.module, + defaults={ + "distribution_version": record.distribution_version, + "resource_path": record.resource_path, + "content_digest": record.content_digest, + "schema_version": record.schema_version, + }, + ) + source_cache[key] = obj + return obj + + # Categories. + category_objs: dict[str, object] = {} + for cid, compiled in schema.categories.items(): + definition = compiled.definition + obj, _ = m.AuthzPermissionCategory.objects.update_or_create( + category_id=definition.id, + defaults={ + "display_name": definition.display_name, + "description": definition.description or "", + "icon": definition.icon, + }, + ) + category_objs[cid] = obj + for record in compiled.sources: + m.AuthzCategorySource.objects.update_or_create( + category=obj, source=source_obj(record), defaults={"origin_kind": m.OriginKind.BASE} + ) + + # Permissions. + permission_objs: dict[str, object] = {} + for pid, compiled in schema.permissions.items(): + definition = compiled.definition + obj, _ = m.AuthzPermissionDefinition.objects.update_or_create( + namespace=definition.namespace, + name=definition.name, + defaults={ + "display_name": definition.display_name, + "description": definition.description or "", + "category": category_objs.get(definition.category), + "scopes": list(definition.scopes), + "icon": definition.icon, + }, + ) + permission_objs[pid] = obj + for record in compiled.sources: + m.AuthzPermissionSource.objects.update_or_create( + permission=obj, source=source_obj(record), defaults={"origin_kind": m.OriginKind.BASE} + ) + + # Roles. + role_objs: dict[str, object] = {} + for rid, compiled in schema.roles.items(): + definition = compiled.definition + obj, _ = m.AuthzRoleDefinition.objects.update_or_create( + role_id=definition.id, + defaults={ + "display_name": definition.display_name, + "description": definition.description or "", + "scopes": list(definition.scopes), + "icon": definition.icon, + "hidden": definition.hidden, + }, + ) + role_objs[rid] = obj + for record in compiled.sources: + m.AuthzRoleSource.objects.update_or_create( + role=obj, source=source_obj(record), defaults={"origin_kind": m.OriginKind.BASE} + ) + + # Role-permission grants (one per rendered role/permission/scope triple). + for rid, compiled in schema.roles.items(): + role_obj = role_objs[rid] + definition = compiled.definition + for scope in definition.scopes: + for perm_id in definition.permissions: + permission_obj = permission_objs.get(perm_id) + if permission_obj is None: + continue # validated away in practice; skip defensively + grant, _ = m.AuthzRolePermission.objects.update_or_create( + role=role_obj, permission=permission_obj, scope=scope + ) + for rel in schema.role_permission_sources.get((rid, perm_id), []): + m.AuthzRolePermissionSource.objects.update_or_create( + role_permission=grant, + source=source_obj(rel.source), + defaults={"origin_kind": rel.origin_kind, "priority": rel.priority}, + ) + + logger.info( + "Authz schema apply: persisted %d role(s), %d permission(s), %d category(ies).", + len(schema.roles), + len(schema.permissions), + len(schema.categories), + ) diff --git a/openedx_authz/engine/schema/__init__.py b/openedx_authz/engine/schema/__init__.py new file mode 100644 index 00000000..1b24472f --- /dev/null +++ b/openedx_authz/engine/schema/__init__.py @@ -0,0 +1,16 @@ +"""Authorization schema pipeline. + +Turns on-disk ``.authz.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/openedx_authz/engine/schema/compilation.py b/openedx_authz/engine/schema/compilation.py new file mode 100644 index 00000000..61c1fa65 --- /dev/null +++ b/openedx_authz/engine/schema/compilation.py @@ -0,0 +1,270 @@ +"""Resolve documents into one set of static definitions (the ``compile`` step). + +Compilation (ADR 0018 §1) merges base definitions across all documents and +applies ``role_extensions`` per ADR 0023: + + * Extensions resolve only after every role and permission is loaded. + * An extension changes only the fields it includes; absent fields keep + their current value; it cannot change a role ID. + * Different fields from different contributions combine. + * ``priority`` resolves conflicts on the same metadata field or the same + permission (higher wins). Equal priority with disagreeing values raises + :class:`SchemaCompileError` so deployment stops before the database + changes. + * Adding a permission the role already has, or removing one it lacks, is a + no-op logged as a warning. + +Every resulting :class:`CompiledDefinition` retains all contributing +:class:`SourceRecord` values, and each role-permission grant is attributed at +the (role, permission) grain with its origin (base vs extension) for ADR 0024 +source tracking. Output is deterministic regardless of discovery order. No +Casbin/Django imports. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field, replace + +from openedx_authz.engine.schema.exceptions import SchemaCompileError +from openedx_authz.engine.schema.types import ( + ORIGIN_BASE, + ORIGIN_EXTENSION, + CompiledDefinition, + CompiledSchema, + RelationshipSource, + RoleDefinition, + SchemaDocument, + SourceRecord, +) + +logger = logging.getLogger(__name__) + +# Metadata fields an extension may replace on a role. +_METADATA_FIELDS = ("display_name", "description", "icon", "hidden") + + +@dataclass +class _Tracked: + """A base definition plus the sources and priority that produced it.""" + + definition: object + sources: list[SourceRecord] = field(default_factory=list) + priority: int = 0 + + +class SchemaCompiler: + """Merges validated documents into a :class:`CompiledSchema`.""" + + def compile(self, documents: list[SchemaDocument]) -> CompiledSchema: + """Resolve categories, permissions, roles, and extensions. + + Assumes ``documents`` already passed validation. + + Raises: + SchemaCompileError: On an unresolvable equal-priority conflict. + """ + categories = self._collect(documents, "categories", key=lambda c: c.id) + permissions = self._collect(documents, "permissions", key=lambda p: p.identifier) + roles = self._collect(documents, "roles", key=lambda r: r.id) + + role_permission_sources = self._resolve_roles_and_provenance(roles, documents) + + return CompiledSchema( + categories=self._finalize(categories, "category"), + permissions=self._finalize(permissions, "permission"), + roles=self._finalize(roles, "role"), + role_permission_sources=role_permission_sources, + ) + + # ---- base collection -------------------------------------------------- + + def _collect(self, documents: list[SchemaDocument], attr: str, key) -> dict[str, _Tracked]: + """Gather base definitions keyed by identifier, resolving by priority. + + Higher priority wins on conflict; equal priority with differing content + raises; identical duplicates merge their sources. + """ + tracked: dict[str, _Tracked] = {} + for document in documents: + for definition in getattr(document, attr): + identifier = key(definition) + existing = tracked.get(identifier) + if existing is None: + tracked[identifier] = _Tracked( + definition=definition, + sources=[document.source], + priority=document.priority, + ) + continue + + if existing.definition == definition: + existing.sources.append(document.source) + elif document.priority > existing.priority: + tracked[identifier] = _Tracked( + definition=definition, + sources=[document.source], + priority=document.priority, + ) + elif document.priority == existing.priority: + raise SchemaCompileError( + f"Conflicting {attr[:-1]} definition for {identifier!r} at equal priority " + f"{document.priority} ({existing.sources[0].source_id} vs {document.source.source_id})." + ) + # else: lower priority, keep existing. + return tracked + + # ---- roles + provenance ---------------------------------------------- + + def _resolve_roles_and_provenance( + self, roles: dict[str, _Tracked], documents: list[SchemaDocument] + ) -> dict[tuple[str, str], list[RelationshipSource]]: + """Apply extensions and build per-(role, permission) provenance. + + Seeds base provenance from each role's own definition, then folds in + ``role_extensions`` (metadata replacement + permission add/remove), + honoring priority. Returns the relationship provenance map. + """ + metadata_changes, perm_changes = self._gather_extension_changes(roles, documents) + rp_sources: dict[tuple[str, str], list[RelationshipSource]] = {} + + for role_id, tracked in roles.items(): + role: RoleDefinition = tracked.definition + base_sources = list(tracked.sources) + base_priority = tracked.priority + + # Seed base provenance for every permission the role declares. + provenance: dict[str, list[RelationshipSource]] = { + perm: [RelationshipSource(src, ORIGIN_BASE, base_priority) for src in base_sources] + for perm in role.permissions + } + + md = metadata_changes.get(role_id, {}) + if md: + new_values, contributing_sources = self._resolve_metadata(role_id, md) + tracked.definition = replace(role, **new_values) + role = tracked.definition + for src in contributing_sources: + if src not in tracked.sources: + tracked.sources.append(src) + + pc = perm_changes.get(role_id) + if pc and (pc["add"] or pc["remove"]): + final_perms, provenance = self._resolve_permissions( + role_id, role.permissions, base_sources, base_priority, pc + ) + tracked.definition = replace(tracked.definition, permissions=final_perms) + + for perm, sources in provenance.items(): + rp_sources[(role_id, perm)] = sources + + return rp_sources + + def _gather_extension_changes(self, roles: dict[str, _Tracked], documents: list[SchemaDocument]): + """Collect per-role metadata and permission changes from all extensions. + + Entries carry the full :class:`SourceRecord` and priority so provenance + and conflict resolution have everything they need. + """ + metadata_changes: dict[str, dict[str, list[tuple[object, int, SourceRecord]]]] = {} + perm_changes: dict[str, dict[str, list[tuple[str, int, SourceRecord]]]] = {} + + for document in documents: + for extension in document.role_extensions: + role_id = extension.role + if role_id not in roles: + # Validation already errors on this; skip defensively. + continue + md = metadata_changes.setdefault(role_id, {}) + for field_name in _METADATA_FIELDS: + value = getattr(extension, field_name) + if value is not None: + md.setdefault(field_name, []).append((value, document.priority, document.source)) + pc = perm_changes.setdefault(role_id, {"add": [], "remove": []}) + for perm in extension.add_permissions: + pc["add"].append((perm, document.priority, document.source)) + for perm in extension.remove_permissions: + pc["remove"].append((perm, document.priority, document.source)) + return metadata_changes, perm_changes + + def _resolve_metadata(self, role_id: str, md: dict[str, list[tuple[object, int, SourceRecord]]]): + """Pick winning metadata values by priority; error on equal-priority ties.""" + new_values: dict[str, object] = {} + contributing: set[SourceRecord] = set() + for field_name, entries in md.items(): + max_priority = max(priority for _, priority, _ in entries) + top_values = {value for value, priority, _ in entries if priority == max_priority} + if len(top_values) > 1: + raise SchemaCompileError( + f"Conflicting {field_name!r} for role {role_id!r} at equal priority " + f"{max_priority}: {sorted(map(str, top_values))}." + ) + new_values[field_name] = next(iter(top_values)) + contributing.update(src for _, priority, src in entries if priority == max_priority) + return new_values, contributing + + def _resolve_permissions( + self, + role_id: str, + base: tuple[str, ...], + base_sources: list[SourceRecord], + base_priority: int, + pc: dict[str, list[tuple[str, int, SourceRecord]]], + ): + """Apply add/remove per permission, returning (final_perms, provenance). + + Add-vs-remove conflicts resolve by priority; equal priority raises. + Provenance keeps base attribution and appends extension attribution for + added permissions. + """ + current = set(base) + provenance: dict[str, list[RelationshipSource]] = { + perm: [RelationshipSource(src, ORIGIN_BASE, base_priority) for src in base_sources] for perm in base + } + + actions: dict[str, list[tuple[str, int, SourceRecord]]] = {} + for perm, priority, src in pc["add"]: + actions.setdefault(perm, []).append(("add", priority, src)) + for perm, priority, src in pc["remove"]: + actions.setdefault(perm, []).append(("remove", priority, src)) + + for perm, entries in actions.items(): + max_priority = max(priority for _, priority, _ in entries) + top = {action for action, priority, _ in entries if priority == max_priority} + if len(top) > 1: + raise SchemaCompileError( + f"Conflicting add/remove for permission {perm!r} on role {role_id!r} " + f"at equal priority {max_priority}." + ) + action = next(iter(top)) + winning_sources = [src for act, priority, src in entries if priority == max_priority and act == action] + + if action == "add": + if perm in current: + logger.warning("role_extension adds %r already on role %r; no-op.", perm, role_id) + current.add(perm) + provenance.setdefault(perm, []) + provenance[perm].extend( + RelationshipSource(src, ORIGIN_EXTENSION, max_priority) for src in winning_sources + ) + else: # remove + if perm not in current: + logger.warning("role_extension removes %r not on role %r; no-op.", perm, role_id) + current.discard(perm) + provenance.pop(perm, None) + + return tuple(sorted(current)), provenance + + # ---- finalize --------------------------------------------------------- + + def _finalize(self, tracked: dict[str, _Tracked], kind: str) -> dict[str, CompiledDefinition]: + """Turn tracked definitions into CompiledDefinition entries.""" + return { + identifier: CompiledDefinition( + kind=kind, + key=identifier, + definition=entry.definition, + sources=tuple(entry.sources), + ) + for identifier, entry in tracked.items() + } diff --git a/openedx_authz/engine/schema/discovery.py b/openedx_authz/engine/schema/discovery.py new file mode 100644 index 00000000..fab7e986 --- /dev/null +++ b/openedx_authz/engine/schema/discovery.py @@ -0,0 +1,172 @@ +"""Discover static authz schema resources (the ``discover`` step, ADR 0019). + +Two contribution sources are merged, both expressed as +``(package_name, resource_path)`` pairs: + +1. The ``authz.schema`` entry-point group. Each registered callable returns + resource paths relative to its own module (e.g. openedx-authz's + ``get_schema_resources``). +2. The ``OPENEDX_AUTHZ_SCHEMA_RESOURCES`` Django setting, a list of + ``(package_name, resource_path)`` tuples. This is how the Tutor + ``openedx-authz-schema`` patch and other operators contribute schema + without shipping a package entry point. + +Resource paths are resolved with ``importlib.resources`` so discovery does not +depend on virtualenv or container filesystem layout. 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 available (from the management +command or ``AppConfig.ready()``), never at module import. Django is imported +lazily so this module stays importable (and unit-testable) without a configured +Django environment. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from importlib import metadata, resources + +ENTRY_POINT_GROUP = "authz.schema" +SETTINGS_RESOURCES_NAME = "OPENEDX_AUTHZ_SCHEMA_RESOURCES" + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class DiscoveredResource: + """A single located schema resource with enough info to build a source. + + Attributes: + package: Importable package/module the resource lives in (the anchor + passed to ``importlib.resources``). + resource_path: Path to the resource within that package. + origin: Where this contribution came from, ``"entry_point"``, + ``"settings"``, or ``"explicit"`` (used for diagnostics). + """ + + package: str + resource_path: str + origin: str + + +class SchemaDiscoveryError(Exception): + """Raised when a provider fails; names the failing contribution.""" + + +class SchemaDiscovery: + """Enumerates registered schema contributions into discovered resources.""" + + def __init__(self, *, explicit_resources: list[tuple[str, str]] | None = None): + """Initialize discovery. + + Args: + explicit_resources: Optional ``(package, resource_path)`` pairs + supplied directly (the ADR 0019 CI/local mode where explicit + resources are passed to the command). Discovered in addition to + entry points and settings. + """ + self._explicit_resources = explicit_resources or [] + + def discover(self) -> list[DiscoveredResource]: + """Return every discovered resource in a deterministic order. + + Merges entry-point providers, the settings list, and any explicit + resources, 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. + + Raises: + SchemaDiscoveryError: If a provider callable raises or a declared + resource cannot be located. + """ + resources_found: list[DiscoveredResource] = [] + resources_found.extend(self._discover_entry_points()) + resources_found.extend(self._discover_settings_resources()) + resources_found.extend( + DiscoveredResource(package=pkg, resource_path=path, origin="explicit") + for pkg, path in self._explicit_resources + ) + + # De-duplicate on (package, resource_path) while keeping the first origin + # seen, then sort for deterministic downstream processing. + seen: dict[tuple[str, str], DiscoveredResource] = {} + for resource in resources_found: + key = (resource.package, resource.resource_path) + seen.setdefault(key, 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 and call each provider. + + Uses ``importlib.metadata.entry_points`` to find providers and invokes + each callable to get its resource paths, anchoring them to the module + that owns the callable. Any provider exception is wrapped in + :class:`SchemaDiscoveryError` identifying the entry-point name. + """ + discovered: list[DiscoveredResource] = [] + for entry_point in metadata.entry_points(group=ENTRY_POINT_GROUP): + try: + provider = entry_point.load() + paths = provider() + except Exception as exc: # noqa: BLE001 - re-raised with context below + raise SchemaDiscoveryError( + f"authz.schema provider {entry_point.name!r} " + f"({entry_point.value}) failed during discovery: {exc}" + ) from exc + + # The module that owns the callable is the resource anchor; the + # provider returns paths relative to it. + package = entry_point.module + for path in paths: + discovered.append( + DiscoveredResource(package=package, resource_path=path, origin="entry_point") + ) + return discovered + + def _discover_settings_resources(self) -> list[DiscoveredResource]: + """Read ``OPENEDX_AUTHZ_SCHEMA_RESOURCES`` from Django settings. + + Each item is a ``(package, resource_path)`` tuple. An absent, empty, or + unconfigured setting yields no resources. Django is imported lazily so + this module does not require a configured environment to import. + """ + try: + from django.conf import settings # pylint: disable=import-outside-toplevel + except ImportError: + return [] + + raw = getattr(settings, SETTINGS_RESOURCES_NAME, None) or [] + discovered: list[DiscoveredResource] = [] + for item in raw: + try: + package, resource_path = item + except (ValueError, TypeError) as exc: + raise SchemaDiscoveryError( + f"{SETTINGS_RESOURCES_NAME} entries must be (package, resource_path) " + f"tuples; got {item!r}." + ) from exc + discovered.append( + DiscoveredResource(package=package, resource_path=resource_path, origin="settings") + ) + return discovered + + def resolve_contents(self, resource: DiscoveredResource) -> bytes: + """Read a discovered resource's bytes via ``importlib.resources``. + + Kept separate from :meth:`discover` so the loader controls when files + are read and so the content digest is computed from the exact bytes + used. + + Raises: + SchemaDiscoveryError: If the resource cannot be located or read. + """ + try: + return resources.files(resource.package).joinpath(resource.resource_path).read_bytes() + except (FileNotFoundError, ModuleNotFoundError, OSError) as exc: + raise SchemaDiscoveryError( + f"Could not read schema resource {resource.resource_path!r} " + f"from package {resource.package!r}: {exc}" + ) from exc diff --git a/openedx_authz/engine/schema/exceptions.py b/openedx_authz/engine/schema/exceptions.py new file mode 100644 index 00000000..2a87fd6e --- /dev/null +++ b/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/openedx_authz/engine/schema/loading.py b/openedx_authz/engine/schema/loading.py new file mode 100644 index 00000000..57d2410d --- /dev/null +++ b/openedx_authz/engine/schema/loading.py @@ -0,0 +1,191 @@ +"""Read discovered resources into schema documents (the ``load`` step, ADR 0018). + +Parses each ``.authz.yaml`` 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 +from importlib import metadata + +import yaml + +from openedx_authz.engine.schema.discovery import DiscoveredResource, SchemaDiscovery +from openedx_authz.engine.schema.exceptions import SchemaLoadError +from openedx_authz.engine.schema.types import ( + PermissionCategory, + PermissionDefinition, + RoleDefinition, + RoleExtension, + SchemaDocument, + SourceRecord, +) + +UNKNOWN = "unknown" + + +class SchemaLoader: + """Turns discovered resources into typed schema documents.""" + + def __init__(self, discovery: SchemaDiscovery | None = None): + """Args: + discovery: Discovery instance used to read resource bytes. Injected + for testability; defaults to a standard :class:`SchemaDiscovery`. + """ + self._discovery = discovery or SchemaDiscovery() + + 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 = self._discovery.resolve_contents(resource) + 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.package) + content_digest = hashlib.sha256(contents).hexdigest() + return SourceRecord( + distribution=distribution, + distribution_version=version, + module=resource.package, + resource_path=resource.resource_path, + schema_version=schema_version, + content_digest=content_digest, + ) + + @staticmethod + def _resolve_distribution(package: str) -> tuple[str, str]: + """Map an import package to its providing distribution name and version.""" + top_level = package.split(".", 1)[0] + try: + mapping = metadata.packages_distributions() + except Exception: # noqa: BLE001 - defensive; metadata quirks across envs + mapping = {} + candidates = mapping.get(top_level) or [] + if candidates: + distribution = candidates[0] + try: + return distribution, metadata.version(distribution) + except metadata.PackageNotFoundError: + return distribution, UNKNOWN + return top_level, UNKNOWN + + def _build_document(self, raw: dict, source: SourceRecord) -> SchemaDocument: + """Map the parsed mapping's blocks into a typed SchemaDocument.""" + 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: + 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: + 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=item.get("category", ""), + scopes=self._as_tuple(item.get("scopes")), + icon=item.get("icon"), + ) + + def _build_role(self, item: dict, source: SourceRecord) -> RoleDefinition: + 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: + self._require_mapping(item, "role_extensions", source) + return RoleExtension( + role=item.get("role", ""), + 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: + 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/openedx_authz/engine/schema/pipeline.py b/openedx_authz/engine/schema/pipeline.py new file mode 100644 index 00000000..7af9865e --- /dev/null +++ b/openedx_authz/engine/schema/pipeline.py @@ -0,0 +1,96 @@ +"""End-to-end orchestration of the authz schema lifecycle (ADR 0018). + +:class:`SchemaPipeline` wires the steps together: + + discover -> load -> validate -> compile -> render -> (plan) -> apply + +The Casbin-free steps (discover..compile) live in :mod:`openedx_authz.engine.schema`; +render/apply live in :mod:`openedx_authz.engine.renderer`. This orchestrator is +the single entry point used by the deployment management command and by tests. + +Deployment runs discover-through-apply before the application serves traffic +(ADR 0018 §2). CI/local runs may stop after ``plan`` for a dry run, or pass +explicit resources. +""" + +from __future__ import annotations + +import logging + +from openedx_authz.engine.renderer import ( + ApplyResult, + ChangePlan, + PolicyRenderer, + SchemaApplier, +) +from openedx_authz.engine.schema.compilation import SchemaCompiler +from openedx_authz.engine.schema.discovery import SchemaDiscovery +from openedx_authz.engine.schema.exceptions import SchemaValidationError +from openedx_authz.engine.schema.loading import SchemaLoader +from openedx_authz.engine.schema.types import CompiledSchema +from openedx_authz.engine.schema.validation import SchemaValidator + +logger = logging.getLogger(__name__) + + +class SchemaPipeline: + """Runs the schema lifecycle from discovery through apply. + + Components are injected for testability; each defaults to its standard + implementation. + """ + + def __init__( + self, + *, + discovery: SchemaDiscovery | None = None, + loader: SchemaLoader | None = None, + validator: SchemaValidator | None = None, + compiler: SchemaCompiler | None = None, + renderer: PolicyRenderer | None = None, + applier: SchemaApplier | None = None, + ): + self._discovery = discovery or SchemaDiscovery() + self._loader = loader or SchemaLoader(self._discovery) + self._validator = validator or SchemaValidator() + self._compiler = compiler or SchemaCompiler() + self._renderer = renderer or PolicyRenderer() + self._applier = applier or SchemaApplier() + + def compile(self) -> CompiledSchema: + """Run discover -> load -> validate -> compile and return the result. + + Raises: + SchemaValidationError: If validation finds error-level issues. + SchemaCompileError: On an unresolvable conflict. + """ + resources = self._discovery.discover() + documents = self._loader.load(resources) + + issues = self._validator.validate(documents) + for issue in issues: + log = logger.error if issue.is_error else logger.warning + log("authz schema %s: %s [%s]", issue.level, issue.message, issue.source_id or "-") + if self._validator.has_errors(issues): + raise SchemaValidationError([i for i in issues if i.is_error]) + + return self._compiler.compile(documents) + + def plan(self) -> ChangePlan: + """Run through render and produce the change report without writing. + + Used for dry-run / CI review (ADR 0018 §6). + """ + schema = self.compile() + rendered = self._renderer.render(schema) + return self._applier.plan(rendered) + + def apply(self, *, force: bool = False) -> ApplyResult: + """Run the full lifecycle and persist the result transactionally. + + Args: + force: Allow removal of roles that still have assignments (ADR 0018). + """ + schema = self.compile() + rendered = self._renderer.render(schema) + return self._applier.apply(rendered, schema, force=force) diff --git a/openedx_authz/engine/schema/types.py b/openedx_authz/engine/schema/types.py new file mode 100644 index 00000000..62be6559 --- /dev/null +++ b/openedx_authz/engine/schema/types.py @@ -0,0 +1,226 @@ +"""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`. + +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. + +This module is intentionally free of any Casbin or Django imports so it can be +unit-tested in isolation. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +# --------------------------------------------------------------------------- +# Provenance +# --------------------------------------------------------------------------- + + +@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 path and resource path, e.g. + ``"openedx-authz:openedx_authz/authz/course_roles.authz.yaml"``. + """ + module_path = self.module.replace(".", "/") + return f"{self.distribution}:{module_path}/{self.resource_path}" + + +# --------------------------------------------------------------------------- +# Definition objects (ADR 0017 / reference) +# --------------------------------------------------------------------------- + + +@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: str + display_name: str + description: str + category: 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: 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 + + +# --------------------------------------------------------------------------- +# Loading output +# --------------------------------------------------------------------------- + + +@dataclass +class SchemaDocument: + """One loaded ``.authz.yaml`` 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) + + +# --------------------------------------------------------------------------- +# Compilation output +# --------------------------------------------------------------------------- + + +# Origin of a contribution to a role or a role-permission grant (ADR 0023/0024). +ORIGIN_BASE = "base" +ORIGIN_EXTENSION = "extension" + + +@dataclass(frozen=True) +class RelationshipSource: + """Provenance of a single role-permission grant (ADR 0024). + + Attributes: + source: The contributing source record. + origin_kind: ``ORIGIN_BASE`` (from the role's own definition) or + ``ORIGIN_EXTENSION`` (added by a ``role_extensions`` entry). + priority: The contributing file's priority. + """ + + source: SourceRecord + origin_kind: str + priority: int + + +@dataclass(frozen=True) +class CompiledDefinition: + """A resolved definition plus every source that contributed to it. + + Attributes: + kind: ``"category"`` | ``"permission"`` | ``"role"``. + 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. + """ + + kind: str + key: str + definition: object + 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 0024). + 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/openedx_authz/engine/schema/validation.py b/openedx_authz/engine/schema/validation.py new file mode 100644 index 00000000..6cb145f3 --- /dev/null +++ b/openedx_authz/engine/schema/validation.py @@ -0,0 +1,280 @@ +"""Validate schema documents individually and as a whole (the ``validate`` step). + +Rules come from ADR 0017 §4 and the field reference: + +Per-document checks: + * ``schema_version`` is a supported, quoted ``major.minor`` value. + * ``namespace``, ``name``, category ``id``, role ``id`` match + :data:`IDENTIFIER_RE` (lowercase snake_case, begins with a letter). + * Casbin forms (``act^...``, ``role^...``) are rejected as identifiers. + * Required fields are present. + * ``scopes`` are non-empty and look like scope namespaces (hyphens allowed, + e.g. ``course-v1``); they are exempt from the identifier regex. + +Whole-set checks (after all documents load): + * Every permission ``category`` references an existing category. + * Every role/extension permission references an existing permission. + * A role's ``scopes`` are supported by each of its permissions. + * ``role_extensions`` target an existing role (ADR 0023). + * Conflicting duplicate base definitions fail; identical duplicates warn. + +Validation collects issues rather than raising on the first problem, so the +deployment report can list every error and warning at once. No Casbin/Django +imports. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from openedx_authz.engine.schema.types import SchemaDocument + +IDENTIFIER_RE = re.compile(r"^[a-z][a-z0-9_]*$") +# Scope namespaces follow their registered spelling and may contain hyphens. +SCOPE_RE = re.compile(r"^[a-z][a-z0-9_-]*$") + +# Casbin-internal prefixes that must never appear in a schema identifier. +CASBIN_INTERNAL_PREFIXES = ("act^", "role^", "sub^", "scope^", "g^", "p^") + +ERROR = "error" +WARNING = "warning" + + +@dataclass(frozen=True) +class ValidationIssue: + """A single validation finding. + + Attributes: + level: ``"error"`` (blocks deployment) or ``"warning"`` (reported only). + message: Human-readable description. + source_id: The contributing source, when the issue is file-specific. + """ + + level: str + message: str + source_id: str | None = None + + @property + def is_error(self) -> bool: + return self.level == ERROR + + +class SchemaValidator: + """Runs per-document and whole-set validation.""" + + SUPPORTED_SCHEMA_VERSIONS = frozenset({"1.0"}) + + # ---- entry points ----------------------------------------------------- + + def validate(self, documents: list[SchemaDocument]) -> list[ValidationIssue]: + """Run per-document then whole-set validation, returning all issues.""" + issues: list[ValidationIssue] = [] + for document in documents: + issues.extend(self.validate_document(document)) + issues.extend(self.validate_set(documents)) + return issues + + @staticmethod + def has_errors(issues: list[ValidationIssue]) -> bool: + """True if any issue is error-level.""" + return any(issue.is_error for issue in issues) + + # ---- per-document ----------------------------------------------------- + + def validate_document(self, document: SchemaDocument) -> list[ValidationIssue]: + """Per-file checks that need no cross-file context.""" + issues: list[ValidationIssue] = [] + sid = document.source.source_id + + if document.source.schema_version not in self.SUPPORTED_SCHEMA_VERSIONS: + issues.append( + ValidationIssue( + ERROR, + f"Unsupported schema_version {document.source.schema_version!r}; " + f"supported: {sorted(self.SUPPORTED_SCHEMA_VERSIONS)}.", + sid, + ) + ) + + for category in document.categories: + issues.extend(self._check_identifier(category.id, "category id", sid)) + issues.extend(self._require(category.id, "category id", sid)) + + for permission in document.permissions: + issues.extend(self._check_identifier(permission.namespace, "permission namespace", sid)) + issues.extend(self._check_identifier(permission.name, "permission name", sid)) + issues.extend(self._require(permission.category, f"category for {permission.identifier}", sid)) + issues.extend(self._check_scopes(permission.scopes, f"permission {permission.identifier}", sid)) + + for role in document.roles: + issues.extend(self._check_identifier(role.id, "role id", sid)) + issues.extend(self._check_scopes(role.scopes, f"role {role.id}", sid)) + for perm_id in role.permissions: + issues.extend(self._check_permission_id(perm_id, f"role {role.id}", sid)) + + for extension in document.role_extensions: + issues.extend(self._check_identifier(extension.role, "role_extension target", sid)) + for perm_id in (*extension.add_permissions, *extension.remove_permissions): + issues.extend(self._check_permission_id(perm_id, f"role_extension {extension.role}", sid)) + + return issues + + # ---- whole-set -------------------------------------------------------- + + def validate_set(self, documents: list[SchemaDocument]) -> list[ValidationIssue]: + """Whole-set checks across all loaded documents.""" + issues: list[ValidationIssue] = [] + + category_ids: set[str] = set() + permission_index: dict[str, tuple[str, ...]] = {} # id -> scopes + role_ids: set[str] = set() + + issues.extend(self._collect_and_check_duplicates(documents, category_ids, permission_index, role_ids)) + + # Reference integrity: permission categories exist. + for document in documents: + sid = document.source.source_id + for permission in document.permissions: + if permission.category and permission.category not in category_ids: + issues.append( + ValidationIssue( + ERROR, + f"Permission {permission.identifier} references unknown category " + f"{permission.category!r}.", + sid, + ) + ) + + # Role permissions exist, and role scopes are supported by each permission. + for role in document.roles: + for perm_id in role.permissions: + if perm_id not in permission_index: + issues.append( + ValidationIssue( + ERROR, + f"Role {role.id} references unknown permission {perm_id!r}.", + sid, + ) + ) + continue + unsupported = set(role.scopes) - set(permission_index[perm_id]) + if unsupported: + issues.append( + ValidationIssue( + ERROR, + f"Role {role.id} is defined for scope(s) {sorted(unsupported)} " + f"that permission {perm_id!r} does not support.", + sid, + ) + ) + + # Extensions target existing roles and reference existing permissions. + for extension in document.role_extensions: + if extension.role not in role_ids: + issues.append( + ValidationIssue( + ERROR, + f"role_extension targets unknown role {extension.role!r}.", + sid, + ) + ) + for perm_id in (*extension.add_permissions, *extension.remove_permissions): + if perm_id not in permission_index: + issues.append( + ValidationIssue( + ERROR, + f"role_extension {extension.role} references unknown permission {perm_id!r}.", + sid, + ) + ) + + return issues + + def _collect_and_check_duplicates( + self, + documents: list[SchemaDocument], + category_ids: set[str], + permission_index: dict[str, tuple[str, ...]], + role_ids: set[str], + ) -> list[ValidationIssue]: + """Populate the id indexes and flag conflicting/identical duplicates.""" + issues: list[ValidationIssue] = [] + categories: dict[str, object] = {} + permissions: dict[str, object] = {} + roles: dict[str, object] = {} + + for document in documents: + sid = document.source.source_id + for category in document.categories: + issues.extend(self._register(categories, category.id, category, "category", sid)) + category_ids.add(category.id) + for permission in document.permissions: + issues.extend(self._register(permissions, permission.identifier, permission, "permission", sid)) + permission_index[permission.identifier] = permission.scopes + for role in document.roles: + issues.extend(self._register(roles, role.id, role, "role", sid)) + role_ids.add(role.id) + return issues + + @staticmethod + def _register(index: dict, key: str, value, kind: str, sid: str) -> list[ValidationIssue]: + """Record a base definition, flagging duplicates. + + Identical duplicate → warning; conflicting duplicate → error. + """ + if key not in index: + index[key] = value + return [] + if index[key] == value: + return [ValidationIssue(WARNING, f"Duplicate identical {kind} {key!r}.", sid)] + return [ValidationIssue(ERROR, f"Conflicting {kind} definition for {key!r}.", sid)] + + # ---- helpers ---------------------------------------------------------- + + def _check_identifier(self, value: str, label: str, sid: str) -> list[ValidationIssue]: + if not value: + return [] # emptiness handled by _require where relevant + if any(value.startswith(prefix) for prefix in CASBIN_INTERNAL_PREFIXES): + return [ValidationIssue(ERROR, f"{label} {value!r} uses an internal Casbin form.", sid)] + if not IDENTIFIER_RE.match(value): + return [ + ValidationIssue( + ERROR, + f"{label} {value!r} must match {IDENTIFIER_RE.pattern} (lowercase snake_case).", + sid, + ) + ] + return [] + + def _check_permission_id(self, value: str, context: str, sid: str) -> list[ValidationIssue]: + """A complete permission id is ``namespace.name`` with both parts valid.""" + if value.count(".") != 1: + return [ + ValidationIssue( + ERROR, + f"{context}: permission id {value!r} must be 'namespace.name'.", + sid, + ) + ] + namespace, name = value.split(".", 1) + issues = self._check_identifier(namespace, f"{context} permission namespace", sid) + issues += self._check_identifier(name, f"{context} permission name", sid) + return issues + + def _check_scopes(self, scopes: tuple[str, ...], context: str, sid: str) -> list[ValidationIssue]: + if not scopes: + return [ValidationIssue(ERROR, f"{context} must declare at least one scope.", sid)] + issues: list[ValidationIssue] = [] + for scope in scopes: + if not SCOPE_RE.match(scope): + issues.append( + ValidationIssue(ERROR, f"{context}: invalid scope namespace {scope!r}.", sid) + ) + return issues + + @staticmethod + def _require(value: str, label: str, sid: str) -> list[ValidationIssue]: + if not value: + return [ValidationIssue(ERROR, f"Missing required field: {label}.", sid)] + return [] diff --git a/openedx_authz/management/commands/load_authz_schema.py b/openedx_authz/management/commands/load_authz_schema.py new file mode 100644 index 00000000..696778e5 --- /dev/null +++ b/openedx_authz/management/commands/load_authz_schema.py @@ -0,0 +1,125 @@ +"""Discover, validate, compile, report, and apply the static authz schema. + +This is the single non-interactive deployment command described in ADR 0019 §3. +Tutor (via a plugin init task) and other deployment systems invoke it before the +application serves traffic; all integrations share this one compiler/pipeline. + +Usage:: + + python manage.py load_authz_schema # full apply + python manage.py load_authz_schema --dry-run # report only, no writes + python manage.py load_authz_schema --force # allow role removals + python manage.py load_authz_schema \\ + --resource openedx_authz.authz:course_roles.authz.yaml # explicit (CI/local) + +The command must run at a point where all contributing packages are installed +and Django settings/DB are available (ADR 0018 / plugin timing constraint). +""" + +from __future__ import annotations + +from django.core.management.base import BaseCommand, CommandError + +from openedx_authz.engine.schema.discovery import SchemaDiscovery, SchemaDiscoveryError +from openedx_authz.engine.schema.exceptions import SchemaError +from openedx_authz.engine.schema.pipeline import SchemaPipeline + + +class Command(BaseCommand): + """Management command wrapper around :class:`SchemaPipeline`.""" + + help = "Discover, validate, compile, and apply the static authorization schema." + + def add_arguments(self, parser) -> None: + """Register command-line options.""" + parser.add_argument( + "--dry-run", + action="store_true", + help="Run discover through render and print the change report without writing to the database.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Allow removing static roles that still have user assignments (ADR 0018).", + ) + parser.add_argument( + "--resource", + action="append", + default=None, + metavar="PACKAGE:RESOURCE_PATH", + help=( + "Explicitly include a schema resource (repeatable), in addition to discovered " + "entry points and settings. Intended for CI and local development." + ), + ) + + def handle(self, *args, **options) -> None: + """Build the pipeline and run the requested operation. + + Validation/compile/apply errors surface as CommandError so deployment + stops before (or without partially applying) any database change. + """ + explicit = self._parse_resource_overrides(options.get("resource")) + discovery = SchemaDiscovery(explicit_resources=explicit) if explicit else SchemaDiscovery() + pipeline = SchemaPipeline(discovery=discovery) + + try: + if options.get("dry_run"): + plan = pipeline.plan() + self._report_plan(plan) + return + + result = pipeline.apply(force=options.get("force", False)) + except (SchemaError, SchemaDiscoveryError) as exc: + raise CommandError(str(exc)) from exc + + if result.unchanged: + self.stdout.write(self.style.SUCCESS("Authz schema unchanged; no rows written.")) + else: + self.stdout.write( + self.style.SUCCESS( + f"Authz schema applied: {result.added} row(s) added, {result.removed} removed." + ) + ) + + def _parse_resource_overrides(self, raw: list[str] | None) -> list[tuple[str, str]]: + """Parse ``PACKAGE:RESOURCE_PATH`` strings into tuples.""" + if not raw: + return [] + parsed: list[tuple[str, str]] = [] + for item in raw: + if ":" not in item: + raise CommandError( + f"--resource must be 'PACKAGE:RESOURCE_PATH', got {item!r}." + ) + package, resource_path = item.split(":", 1) + if not package or not resource_path: + raise CommandError( + f"--resource must be 'PACKAGE:RESOURCE_PATH', got {item!r}." + ) + parsed.append((package, resource_path)) + return parsed + + def _report_plan(self, plan) -> None: + """Print the change report (added/removed rows, blocking assignments).""" + if plan.unchanged: + self.stdout.write(self.style.SUCCESS("Authz schema unchanged; no rows would be written.")) + return + + self.stdout.write(f"Rows to add ({len(plan.added_rows)}):") + for row in plan.added_rows: + self.stdout.write(f" + {row.as_policy()}") + + self.stdout.write(f"Stale rows detected ({len(plan.removed_rows)}) — pruning deferred:") + for row in plan.removed_rows: + self.stdout.write(f" - {row.as_policy()}") + + if plan.blocking_assignments: + self.stdout.write( + self.style.WARNING( + f"{len(plan.blocking_assignments)} role(s) with existing assignments would be " + "removed; apply requires --force:" + ) + ) + for role, subject in plan.blocking_assignments: + self.stdout.write(f" ! {role} assigned to {subject}") diff --git a/openedx_authz/migrations/0011_authz_schema_definitions.py b/openedx_authz/migrations/0011_authz_schema_definitions.py new file mode 100644 index 00000000..4ece18c0 --- /dev/null +++ b/openedx_authz/migrations/0011_authz_schema_definitions.py @@ -0,0 +1,336 @@ +"""Compiled authorization definitions and source-tracking tables (ADR 0024).""" + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("openedx_authz", "0010_scope_external_key"), + ] + + operations = [ + migrations.CreateModel( + name="AuthzSchemaSource", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "distribution", + models.CharField( + help_text="Installed distribution that shipped the contribution (e.g. 'openedx-authz').", + max_length=255, + ), + ), + ( + "module", + models.CharField( + help_text="Python module that owns the schema resource (e.g. 'openedx_authz.authz').", + max_length=255, + ), + ), + ("distribution_version", models.CharField(blank=True, default="", max_length=64)), + ( + "resource_path", + models.CharField( + blank=True, + default="", + help_text="Latest-seen resource path within the module. Non-identifying.", + max_length=255, + ), + ), + ("content_digest", models.CharField(blank=True, default="", max_length=64)), + ("schema_version", models.CharField(blank=True, default="", max_length=16)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Authz Schema Source", + "verbose_name_plural": "Authz Schema Sources", + }, + ), + migrations.CreateModel( + name="AuthzPermissionCategory", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("category_id", models.CharField(max_length=255, unique=True)), + ("display_name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("icon", models.CharField(blank=True, max_length=128, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Authz Permission Category", + "verbose_name_plural": "Authz Permission Categories", + }, + ), + migrations.CreateModel( + name="AuthzPermissionDefinition", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("namespace", models.CharField(max_length=255)), + ("name", models.CharField(max_length=255)), + ("display_name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("scopes", models.JSONField(default=list)), + ("icon", models.CharField(blank=True, max_length=128, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "category", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="permissions", + to="openedx_authz.authzpermissioncategory", + ), + ), + ], + options={ + "verbose_name": "Authz Permission Definition", + "verbose_name_plural": "Authz Permission Definitions", + }, + ), + migrations.CreateModel( + name="AuthzRoleDefinition", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("role_id", models.CharField(max_length=255, unique=True)), + ("display_name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("scopes", models.JSONField(default=list)), + ("icon", models.CharField(blank=True, max_length=128, null=True)), + ("hidden", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Authz Role Definition", + "verbose_name_plural": "Authz Role Definitions", + }, + ), + migrations.CreateModel( + name="AuthzRolePermission", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "scope", + models.CharField( + help_text="Scope namespace where the grant applies (e.g. 'course-v1', 'lib').", + max_length=255, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "permission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="role_permissions", + to="openedx_authz.authzpermissiondefinition", + ), + ), + ( + "role", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="role_permissions", + to="openedx_authz.authzroledefinition", + ), + ), + ], + options={ + "verbose_name": "Authz Role Permission", + "verbose_name_plural": "Authz Role Permissions", + }, + ), + migrations.CreateModel( + name="AuthzCategorySource", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "origin_kind", + models.CharField( + choices=[("base", "Base"), ("extension", "Extension")], default="base", max_length=16 + ), + ), + ("priority", models.IntegerField(default=0)), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzpermissioncategory" + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzschemasource" + ), + ), + ], + options={ + "verbose_name": "Authz Category Source", + "verbose_name_plural": "Authz Category Sources", + }, + ), + migrations.CreateModel( + name="AuthzPermissionSource", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "origin_kind", + models.CharField( + choices=[("base", "Base"), ("extension", "Extension")], default="base", max_length=16 + ), + ), + ("priority", models.IntegerField(default=0)), + ( + "permission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzpermissiondefinition" + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzschemasource" + ), + ), + ], + options={ + "verbose_name": "Authz Permission Source", + "verbose_name_plural": "Authz Permission Sources", + }, + ), + migrations.CreateModel( + name="AuthzRoleSource", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "origin_kind", + models.CharField( + choices=[("base", "Base"), ("extension", "Extension")], default="base", max_length=16 + ), + ), + ("priority", models.IntegerField(default=0)), + ( + "role", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzroledefinition" + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzschemasource" + ), + ), + ], + options={ + "verbose_name": "Authz Role Source", + "verbose_name_plural": "Authz Role Sources", + }, + ), + migrations.CreateModel( + name="AuthzRolePermissionSource", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "origin_kind", + models.CharField( + choices=[("base", "Base"), ("extension", "Extension")], default="base", max_length=16 + ), + ), + ("priority", models.IntegerField(default=0)), + ( + "role_permission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzrolepermission" + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="openedx_authz.authzschemasource" + ), + ), + ], + options={ + "verbose_name": "Authz Role Permission Source", + "verbose_name_plural": "Authz Role Permission Sources", + }, + ), + migrations.AddField( + model_name="authzpermissioncategory", + name="sources", + field=models.ManyToManyField( + related_name="categories", + through="openedx_authz.AuthzCategorySource", + to="openedx_authz.authzschemasource", + ), + ), + migrations.AddField( + model_name="authzpermissiondefinition", + name="sources", + field=models.ManyToManyField( + related_name="permissions", + through="openedx_authz.AuthzPermissionSource", + to="openedx_authz.authzschemasource", + ), + ), + migrations.AddField( + model_name="authzroledefinition", + name="sources", + field=models.ManyToManyField( + related_name="roles", + through="openedx_authz.AuthzRoleSource", + to="openedx_authz.authzschemasource", + ), + ), + migrations.AddField( + model_name="authzrolepermission", + name="sources", + field=models.ManyToManyField( + related_name="role_permissions", + through="openedx_authz.AuthzRolePermissionSource", + to="openedx_authz.authzschemasource", + ), + ), + migrations.AddConstraint( + model_name="authzschemasource", + constraint=models.UniqueConstraint( + fields=["distribution", "module"], name="authz_source_dist_module_uniq" + ), + ), + migrations.AddConstraint( + model_name="authzpermissiondefinition", + constraint=models.UniqueConstraint(fields=["namespace", "name"], name="authz_permission_ns_name_uniq"), + ), + migrations.AddConstraint( + model_name="authzrolepermission", + constraint=models.UniqueConstraint( + fields=["role", "permission", "scope"], name="authz_role_permission_uniq" + ), + ), + migrations.AddConstraint( + model_name="authzcategorysource", + constraint=models.UniqueConstraint(fields=["category", "source"], name="authz_category_source_uniq"), + ), + migrations.AddConstraint( + model_name="authzpermissionsource", + constraint=models.UniqueConstraint( + fields=["permission", "source"], name="authz_permission_source_uniq" + ), + ), + migrations.AddConstraint( + model_name="authzrolesource", + constraint=models.UniqueConstraint(fields=["role", "source"], name="authz_role_source_uniq"), + ), + migrations.AddConstraint( + model_name="authzrolepermissionsource", + constraint=models.UniqueConstraint( + fields=["role_permission", "source"], name="authz_role_permission_source_uniq" + ), + ), + ] diff --git a/openedx_authz/models/__init__.py b/openedx_authz/models/__init__.py index 06b5d003..6f3a3b22 100644 --- a/openedx_authz/models/__init__.py +++ b/openedx_authz/models/__init__.py @@ -17,5 +17,6 @@ from openedx_authz.models.authz_migration import * from openedx_authz.models.core import * +from openedx_authz.models.schema import * from openedx_authz.models.scopes import * from openedx_authz.models.subjects import * diff --git a/openedx_authz/models/schema.py b/openedx_authz/models/schema.py new file mode 100644 index 00000000..f5bbcf92 --- /dev/null +++ b/openedx_authz/models/schema.py @@ -0,0 +1,362 @@ +"""Models for compiled authorization definitions and their sources (ADR 0024). + +These tables are the authoritative store of the compiled static schema: +permission categories, permission definitions, role definitions, and the +role-permission grants rendered into Casbin ``p`` rows. Each definition and each +role-permission grant is attributed to one or more contributing sources so the +origin of any role or permission can be queried, so a built-in role and a +module-added grant on that role stay distinguishable, and so a future +application removal can prune only what that application uniquely provided. + +Casbin ``p`` rows remain the enforcement representation; these tables are the +definition/provenance record written alongside them in the same transaction. +""" + +from __future__ import annotations + +from django.db import models + +__all__ = [ + "OriginKind", + "AuthzSchemaSource", + "AuthzPermissionCategory", + "AuthzPermissionDefinition", + "AuthzRoleDefinition", + "AuthzRolePermission", + "AuthzCategorySource", + "AuthzPermissionSource", + "AuthzRoleSource", + "AuthzRolePermissionSource", + "origins_for_role", + "origins_for_permission", + "origins_for_category", + "origin_for_role_permission", +] + + +class OriginKind(models.TextChoices): + """Whether a contribution is a base definition or an extension (ADR 0023/0024).""" + + BASE = "base", "Base" + EXTENSION = "extension", "Extension" + + +class AuthzSchemaSource(models.Model): + """A distinct schema contribution, identified by distribution and module. + + .. no_pii: + + Identity is ``(distribution, module)`` — moving a definition between files + within the same module does not change its source. ``resource_path`` and + ``content_digest`` are non-identifying and advisory (kept latest-seen for + diagnostics); change detection relies on diffing compiled definitions. + """ + + distribution = models.CharField( + max_length=255, + help_text="Installed distribution that shipped the contribution (e.g. 'openedx-authz').", + ) + module = models.CharField( + max_length=255, + help_text="Python module that owns the schema resource (e.g. 'openedx_authz.authz').", + ) + distribution_version = models.CharField(max_length=64, blank=True, default="") + resource_path = models.CharField( + max_length=255, + blank=True, + default="", + help_text="Latest-seen resource path within the module. Non-identifying.", + ) + content_digest = models.CharField(max_length=64, blank=True, default="") + schema_version = models.CharField(max_length=16, blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Authz Schema Source" + verbose_name_plural = "Authz Schema Sources" + constraints = [ + models.UniqueConstraint(fields=["distribution", "module"], name="authz_source_dist_module_uniq"), + ] + + @property + def source_id(self) -> str: + """Stable identifier, e.g. ``'openedx-authz:openedx_authz/authz'``.""" + return f"{self.distribution}:{self.module.replace('.', '/')}" + + def __str__(self): + return self.source_id + + +class AuthzPermissionCategory(models.Model): + """A display/grouping category for permissions (grants no access). + + .. no_pii: + """ + + category_id = models.CharField(max_length=255, unique=True) + display_name = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + icon = models.CharField(max_length=128, blank=True, null=True) + sources = models.ManyToManyField( + AuthzSchemaSource, through="AuthzCategorySource", related_name="categories" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Authz Permission Category" + verbose_name_plural = "Authz Permission Categories" + + def __str__(self): + return self.category_id + + +class AuthzPermissionDefinition(models.Model): + """A compiled permission definition. + + .. no_pii: + + The complete permission id is ``namespace.name`` (see :attr:`identifier`). + """ + + namespace = models.CharField(max_length=255) + name = models.CharField(max_length=255) + display_name = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + category = models.ForeignKey( + AuthzPermissionCategory, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="permissions", + ) + scopes = models.JSONField(default=list) + icon = models.CharField(max_length=128, blank=True, null=True) + sources = models.ManyToManyField( + AuthzSchemaSource, through="AuthzPermissionSource", related_name="permissions" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Authz Permission Definition" + verbose_name_plural = "Authz Permission Definitions" + constraints = [ + models.UniqueConstraint(fields=["namespace", "name"], name="authz_permission_ns_name_uniq"), + ] + + @property + def identifier(self) -> str: + """Complete permission id, e.g. ``'courses.view_course'``.""" + return f"{self.namespace}.{self.name}" + + def __str__(self): + return self.identifier + + +class AuthzRoleDefinition(models.Model): + """A compiled role definition. + + .. no_pii: + + ``hidden`` mirrors ADR 0023: a hidden role is excluded from normal role + discovery/selection but keeps its assignments, permission checks, and + reserved id. + """ + + role_id = models.CharField(max_length=255, unique=True) + display_name = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + scopes = models.JSONField(default=list) + icon = models.CharField(max_length=128, blank=True, null=True) + hidden = models.BooleanField(default=False) + sources = models.ManyToManyField( + AuthzSchemaSource, through="AuthzRoleSource", related_name="roles" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Authz Role Definition" + verbose_name_plural = "Authz Role Definitions" + + def __str__(self): + return self.role_id + + +class AuthzRolePermission(models.Model): + """A single role-permission-scope grant (one per rendered Casbin ``p`` row). + + .. no_pii: + + This is the atomic unit of attribution: a base grant and a module-added + grant on the same role are distinct rows with distinct sources. + """ + + role = models.ForeignKey( + AuthzRoleDefinition, on_delete=models.CASCADE, related_name="role_permissions" + ) + permission = models.ForeignKey( + AuthzPermissionDefinition, on_delete=models.CASCADE, related_name="role_permissions" + ) + scope = models.CharField( + max_length=255, + help_text="Scope namespace where the grant applies (e.g. 'course-v1', 'lib').", + ) + sources = models.ManyToManyField( + AuthzSchemaSource, through="AuthzRolePermissionSource", related_name="role_permissions" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Authz Role Permission" + verbose_name_plural = "Authz Role Permissions" + constraints = [ + models.UniqueConstraint( + fields=["role", "permission", "scope"], name="authz_role_permission_uniq" + ), + ] + + def __str__(self): + return f"{self.role_id} -> {self.permission_id} @ {self.scope}" + + +# --------------------------------------------------------------------------- +# Source link (through) models. Each carries origin and priority so the winning +# metadata source is derivable and shared ownership is representable. +# --------------------------------------------------------------------------- + + +class _BaseSourceLink(models.Model): + """Common fields for source links. + + .. no_pii: + """ + + source = models.ForeignKey(AuthzSchemaSource, on_delete=models.CASCADE) + origin_kind = models.CharField(max_length=16, choices=OriginKind.choices, default=OriginKind.BASE) + priority = models.IntegerField(default=0) + + class Meta: + abstract = True + + +class AuthzCategorySource(_BaseSourceLink): + """Links a category to a contributing source. + + .. no_pii: + """ + + category = models.ForeignKey(AuthzPermissionCategory, on_delete=models.CASCADE) + + class Meta: + verbose_name = "Authz Category Source" + verbose_name_plural = "Authz Category Sources" + constraints = [ + models.UniqueConstraint(fields=["category", "source"], name="authz_category_source_uniq"), + ] + + +class AuthzPermissionSource(_BaseSourceLink): + """Links a permission definition to a contributing source. + + .. no_pii: + """ + + permission = models.ForeignKey(AuthzPermissionDefinition, on_delete=models.CASCADE) + + class Meta: + verbose_name = "Authz Permission Source" + verbose_name_plural = "Authz Permission Sources" + constraints = [ + models.UniqueConstraint(fields=["permission", "source"], name="authz_permission_source_uniq"), + ] + + +class AuthzRoleSource(_BaseSourceLink): + """Links a role definition to a contributing source. + + .. no_pii: + """ + + role = models.ForeignKey(AuthzRoleDefinition, on_delete=models.CASCADE) + + class Meta: + verbose_name = "Authz Role Source" + verbose_name_plural = "Authz Role Sources" + constraints = [ + models.UniqueConstraint(fields=["role", "source"], name="authz_role_source_uniq"), + ] + + +class AuthzRolePermissionSource(_BaseSourceLink): + """Links a role-permission grant to a contributing source. + + .. no_pii: + + This is where the extension case is recorded: a core grant links to the + core source (``origin_kind=base``) and a module-added grant links to that + module's source (``origin_kind=extension``). + """ + + role_permission = models.ForeignKey(AuthzRolePermission, on_delete=models.CASCADE) + + class Meta: + verbose_name = "Authz Role Permission Source" + verbose_name_plural = "Authz Role Permission Sources" + constraints = [ + models.UniqueConstraint( + fields=["role_permission", "source"], name="authz_role_permission_source_uniq" + ), + ] + + +# --------------------------------------------------------------------------- +# Query helpers: given any role or permission, get its origin(s). +# --------------------------------------------------------------------------- + + +def origins_for_role(role_id: str) -> list[str]: + """Return the distributions that contribute to a role (base + extensions).""" + return sorted( + AuthzSchemaSource.objects.filter(roles__role_id=role_id).values_list("distribution", flat=True).distinct() + ) + + +def origins_for_permission(identifier: str) -> list[str]: + """Return the distributions that define a permission, by complete id.""" + namespace, _, name = identifier.partition(".") + return sorted( + AuthzSchemaSource.objects.filter(permissions__namespace=namespace, permissions__name=name) + .values_list("distribution", flat=True) + .distinct() + ) + + +def origins_for_category(category_id: str) -> list[str]: + """Return the distributions that define a category.""" + return sorted( + AuthzSchemaSource.objects.filter(categories__category_id=category_id) + .values_list("distribution", flat=True) + .distinct() + ) + + +def origin_for_role_permission(role_id: str, permission_identifier: str) -> list[str]: + """Return the distributions that contribute a specific role-permission grant. + + This distinguishes, for one role, the core-provided grants from a grant a + module added, even though both live in the same role. + """ + namespace, _, name = permission_identifier.partition(".") + return sorted( + AuthzSchemaSource.objects.filter( + role_permissions__role__role_id=role_id, + role_permissions__permission__namespace=namespace, + role_permissions__permission__name=name, + ) + .values_list("distribution", flat=True) + .distinct() + ) diff --git a/openedx_authz/tests/schema/__init__.py b/openedx_authz/tests/schema/__init__.py new file mode 100644 index 00000000..82f0bba0 --- /dev/null +++ b/openedx_authz/tests/schema/__init__.py @@ -0,0 +1 @@ +"""Tests for the authz schema pipeline (openedx_authz.engine.schema).""" diff --git a/openedx_authz/tests/schema/factories.py b/openedx_authz/tests/schema/factories.py new file mode 100644 index 00000000..9982df04 --- /dev/null +++ b/openedx_authz/tests/schema/factories.py @@ -0,0 +1,111 @@ +"""Small builders and a stub discovery for schema pipeline tests.""" + +from __future__ import annotations + +from openedx_authz.engine.schema.discovery import DiscoveredResource +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=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=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 StubDiscovery: + """A discovery double whose ``resolve_contents`` returns preset bytes. + + Maps ``(package, resource_path)`` to raw bytes; ``discover`` returns the + corresponding :class:`DiscoveredResource` list. + """ + + def __init__(self, contents: dict[tuple[str, str], bytes]): + self._contents = contents + + def discover(self): + return [ + DiscoveredResource(package=pkg, resource_path=path, origin="explicit") + for (pkg, path) in self._contents + ] + + def resolve_contents(self, resource: DiscoveredResource) -> bytes: + return self._contents[(resource.package, resource.resource_path)] diff --git a/openedx_authz/tests/schema/test_compilation.py b/openedx_authz/tests/schema/test_compilation.py new file mode 100644 index 00000000..97b1fbaf --- /dev/null +++ b/openedx_authz/tests/schema/test_compilation.py @@ -0,0 +1,127 @@ +"""Unit tests for the schema compilation step (merge + extensions + priority).""" + +import pytest + +from openedx_authz.engine.schema.compilation import SchemaCompiler +from openedx_authz.engine.schema.exceptions import SchemaCompileError +from openedx_authz.engine.schema.types import ORIGIN_BASE, ORIGIN_EXTENSION + +from .factories import category, extension, make_document, permission, role + +PERMS = [ + permission(name="view_course", cat="cat"), + permission(name="export_course", cat="cat"), + permission(name="manage_tags", cat="cat"), +] + + +def _base(**role_kwargs): + return make_document( + "base", + priority=100, + categories=[category("cat")], + permissions=PERMS, + roles=[role(rid="course_editor", permissions=("courses.view_course", "courses.manage_tags"), **role_kwargs)], + ) + + +def test_base_definitions_compile(): + schema = SchemaCompiler().compile([_base()]) + assert set(schema.roles) == {"course_editor"} + assert len(schema.permissions) == 3 + # Base definitions keep their declared order; rendering sorts later. + assert schema.roles["course_editor"].definition.permissions == ( + "courses.view_course", + "courses.manage_tags", + ) + + +def test_extension_adds_and_removes_permissions_and_metadata(): + ext = make_document( + "ext", + priority=200, + role_extensions=[ + extension( + "course_editor", + add_permissions=("courses.export_course",), + remove_permissions=("courses.manage_tags",), + display_name="Author", + hidden=True, + ) + ], + ) + definition = SchemaCompiler().compile([_base(), ext]).roles["course_editor"].definition + assert "courses.export_course" in definition.permissions + assert "courses.manage_tags" not in definition.permissions + assert definition.display_name == "Author" + assert definition.hidden is True + + +def test_extension_sources_are_retained(): + ext = make_document("ext", priority=200, role_extensions=[extension("course_editor", display_name="X")]) + compiled = SchemaCompiler().compile([_base(), ext]) + assert len(compiled.roles["course_editor"].sources) == 2 + + +def test_equal_priority_metadata_conflict_raises(): + a = make_document("a", priority=200, role_extensions=[extension("course_editor", display_name="A")]) + b = make_document("b", priority=200, role_extensions=[extension("course_editor", display_name="B")]) + with pytest.raises(SchemaCompileError): + SchemaCompiler().compile([_base(), a, b]) + + +def test_higher_priority_metadata_wins(): + lo = make_document("lo", priority=150, role_extensions=[extension("course_editor", display_name="Lo")]) + hi = make_document("hi", priority=300, role_extensions=[extension("course_editor", display_name="Hi")]) + definition = SchemaCompiler().compile([_base(), lo, hi]).roles["course_editor"].definition + assert definition.display_name == "Hi" + + +def test_equal_priority_add_remove_conflict_raises(): + add = make_document("add", priority=200, role_extensions=[extension("course_editor", add_permissions=("courses.export_course",))]) + rem = make_document("rem", priority=200, role_extensions=[extension("course_editor", remove_permissions=("courses.export_course",))]) + with pytest.raises(SchemaCompileError): + SchemaCompiler().compile([_base(), add, rem]) + + +def test_conflicting_base_definition_equal_priority_raises(): + a = make_document("a", priority=100, roles=[role(rid="dup", display_name="A", permissions=())]) + b = make_document("b", priority=100, roles=[role(rid="dup", display_name="B", permissions=())]) + with pytest.raises(SchemaCompileError): + SchemaCompiler().compile([a, b]) + + +def test_higher_priority_base_definition_wins(): + lo = make_document("lo", priority=100, roles=[role(rid="dup", display_name="Lo", permissions=())]) + hi = make_document("hi", priority=200, roles=[role(rid="dup", display_name="Hi", permissions=())]) + compiled = SchemaCompiler().compile([lo, hi]) + assert compiled.roles["dup"].definition.display_name == "Hi" + + +def test_base_permissions_get_base_provenance(): + schema = SchemaCompiler().compile([_base()]) + for perm in ("courses.view_course", "courses.manage_tags"): + prov = schema.role_permission_sources[("course_editor", perm)] + assert [(rs.source.distribution, rs.origin_kind) for rs in prov] == [("test-dist", ORIGIN_BASE)] + + +def test_extension_grant_is_attributed_to_the_module_not_core(): + ext = make_document( + "modx", priority=200, role_extensions=[extension("course_editor", add_permissions=("courses.export_course",))] + ) + schema = SchemaCompiler().compile([_base(), ext]) + + core = schema.role_permission_sources[("course_editor", "courses.view_course")] + added = schema.role_permission_sources[("course_editor", "courses.export_course")] + + # Both permissions coexist on the role, but their origins remain distinct. + assert [rs.origin_kind for rs in core] == [ORIGIN_BASE] + assert [rs.origin_kind for rs in added] == [ORIGIN_EXTENSION] + + +def test_removed_permission_has_no_provenance(): + ext = make_document( + "modx", priority=200, role_extensions=[extension("course_editor", remove_permissions=("courses.manage_tags",))] + ) + schema = SchemaCompiler().compile([_base(), ext]) + assert ("course_editor", "courses.manage_tags") not in schema.role_permission_sources diff --git a/openedx_authz/tests/schema/test_loading.py b/openedx_authz/tests/schema/test_loading.py new file mode 100644 index 00000000..5b36a6da --- /dev/null +++ b/openedx_authz/tests/schema/test_loading.py @@ -0,0 +1,83 @@ +"""Unit tests for the schema loading step.""" + +import pytest + +from openedx_authz.engine.schema.exceptions import SchemaLoadError +from openedx_authz.engine.schema.loading import SchemaLoader + +from .factories import StubDiscovery + +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: 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: course_editor + add_permissions: [courses.export_course] +""" + + +def _load(contents: bytes): + key = ("pkg.mod", "file.authz.yaml") + loader = SchemaLoader(discovery=StubDiscovery({key: contents})) + return loader.load(loader._discovery.discover()) + + +def test_loads_all_blocks_into_typed_objects(): + 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 == "course_editor" + assert doc.role_extensions[0].add_permissions == ("courses.export_course",) + + +def test_empty_document_yields_empty_blocks(): + docs = _load(b"schema_version: '1.0'\npriority: 1\n") + assert docs[0].categories == [] + assert docs[0].roles == [] + + +def test_invalid_yaml_raises_load_error(): + with pytest.raises(SchemaLoadError): + _load(b"schema_version: '1.0'\n bad: [unclosed\n") + + +def test_non_mapping_top_level_raises_load_error(): + with pytest.raises(SchemaLoadError): + _load(b"- just\n- a\n- list\n") + + +def test_non_integer_priority_raises_load_error(): + with pytest.raises(SchemaLoadError): + _load(b"schema_version: '1.0'\npriority: high\n") diff --git a/openedx_authz/tests/schema/test_renderer.py b/openedx_authz/tests/schema/test_renderer.py new file mode 100644 index 00000000..e27a2b09 --- /dev/null +++ b/openedx_authz/tests/schema/test_renderer.py @@ -0,0 +1,54 @@ +"""Unit tests for the (pure) render step.""" + +from openedx_authz.engine.renderer import PolicyRenderer +from openedx_authz.engine.schema.compilation import SchemaCompiler + +from .factories import category, make_document, permission, role + + +def _schema(): + doc = make_document( + categories=[category("cat")], + permissions=[ + permission(name="view_course", cat="cat", scopes=("course-v1",)), + permission(name="edit_course_content", cat="cat", scopes=("course-v1",)), + ], + roles=[ + role( + rid="course_editor", + scopes=("course-v1",), + permissions=("courses.view_course", "courses.edit_course_content"), + ) + ], + ) + return SchemaCompiler().compile([doc]) + + +def test_render_emits_one_p_row_per_role_permission_scope(): + rendered = PolicyRenderer().render(_schema()) + assert len(rendered.rows) == 2 + assert all(row.ptype == "p" and row.effect == "allow" for row in rendered.rows) + + +def test_render_applies_casbin_namespacing(): + rendered = PolicyRenderer().render(_schema()) + row = next(r for r in rendered.rows if r.action == "act^courses.view_course") + assert row.subject == "role^course_editor" + assert row.scope == "course-v1^*" + assert row.as_policy() == ["role^course_editor", "act^courses.view_course", "course-v1^*", "allow"] + + +def test_render_is_deterministic(): + schema = _schema() + assert PolicyRenderer().render(schema).rows == PolicyRenderer().render(schema).rows + + +def test_multiple_scopes_multiply_rows(): + doc = make_document( + categories=[category("cat")], + permissions=[permission(name="view_course", cat="cat", scopes=("course-v1", "ccx-v1"))], + roles=[role(rid="r", scopes=("course-v1", "ccx-v1"), permissions=("courses.view_course",))], + ) + rendered = PolicyRenderer().render(SchemaCompiler().compile([doc])) + scopes = {row.scope for row in rendered.rows} + assert scopes == {"course-v1^*", "ccx-v1^*"} diff --git a/openedx_authz/tests/schema/test_source_storage.py b/openedx_authz/tests/schema/test_source_storage.py new file mode 100644 index 00000000..dfb81804 --- /dev/null +++ b/openedx_authz/tests/schema/test_source_storage.py @@ -0,0 +1,152 @@ +"""Tests for persisting compiled definitions and their sources (ADR 0024). + +These exercise ``SchemaApplier._store_sources`` directly (it performs only ORM +upserts, no enforcer access) plus the origin query helpers. The full ``apply`` +path (enforcer + p rows) is covered by the engine tests. +""" + +from django.test import TestCase + +from openedx_authz.engine.renderer import SchemaApplier +from openedx_authz.engine.schema.compilation import SchemaCompiler +from openedx_authz.models.schema import ( + AuthzPermissionDefinition, + AuthzRoleDefinition, + AuthzRolePermission, + AuthzRolePermissionSource, + AuthzSchemaSource, + OriginKind, + origin_for_role_permission, + origins_for_permission, + origins_for_role, +) + +from .factories import category, extension, make_document, permission, role + +CORE_PERMS = [ + permission(name="view_course", cat="cat"), + permission(name="manage_tags", cat="cat"), + permission(name="export_course", cat="cat"), +] + + +def _core_doc(): + return make_document( + "core", + priority=100, + categories=[category("cat")], + permissions=CORE_PERMS, + roles=[role(rid="course_admin", permissions=("courses.view_course", "courses.manage_tags"))], + ) + + +def _module_extension_doc(): + return make_document( + "modx", + priority=200, + role_extensions=[extension("course_admin", add_permissions=("courses.export_course",))], + ) + + +def _store(*documents): + schema = SchemaCompiler().compile(list(documents)) + SchemaApplier()._store_sources(schema) # pylint: disable=protected-access + return schema + + +class StoreSourcesTests(TestCase): + """Persistence of compiled definitions and their provenance.""" + + def test_definitions_are_persisted(self): + _store(_core_doc()) + self.assertEqual(AuthzRoleDefinition.objects.count(), 1) + self.assertEqual(AuthzPermissionDefinition.objects.count(), 3) + role_obj = AuthzRoleDefinition.objects.get(role_id="course_admin") + # course_admin has 2 permissions x 1 scope = 2 grants. + self.assertEqual(role_obj.role_permissions.count(), 2) + + def test_source_identity_is_distribution_and_module(self): + _store(_core_doc()) + source = AuthzSchemaSource.objects.get() + self.assertEqual(source.distribution, "test-dist") + self.assertEqual(source.module, "pkg.core") + + def test_extension_grant_attributed_to_module_not_core(self): + _store(_core_doc(), _module_extension_doc()) + + # Both grants live on course_admin, with distinct origins. + self.assertEqual(origin_for_role_permission("course_admin", "courses.view_course"), ["test-dist"]) + self.assertEqual(origin_for_role_permission("course_admin", "courses.export_course"), ["test-dist"]) + + export_grant = AuthzRolePermission.objects.get( + role__role_id="course_admin", permission__namespace="courses", permission__name="export_course" + ) + link = AuthzRolePermissionSource.objects.get(role_permission=export_grant) + self.assertEqual(link.origin_kind, OriginKind.EXTENSION) + self.assertEqual(link.priority, 200) + + view_grant = AuthzRolePermission.objects.get( + role__role_id="course_admin", permission__name="view_course" + ) + view_link = AuthzRolePermissionSource.objects.get(role_permission=view_grant) + self.assertEqual(view_link.origin_kind, OriginKind.BASE) + + def test_origin_query_helpers(self): + _store(_core_doc(), _module_extension_doc()) + self.assertEqual(origins_for_role("course_admin"), ["test-dist"]) + self.assertEqual(origins_for_permission("courses.export_course"), ["test-dist"]) + + def test_store_is_idempotent(self): + _store(_core_doc(), _module_extension_doc()) + counts = ( + AuthzRoleDefinition.objects.count(), + AuthzPermissionDefinition.objects.count(), + AuthzRolePermission.objects.count(), + AuthzRolePermissionSource.objects.count(), + AuthzSchemaSource.objects.count(), + ) + _store(_core_doc(), _module_extension_doc()) + counts_again = ( + AuthzRoleDefinition.objects.count(), + AuthzPermissionDefinition.objects.count(), + AuthzRolePermission.objects.count(), + AuthzRolePermissionSource.objects.count(), + AuthzSchemaSource.objects.count(), + ) + self.assertEqual(counts, counts_again) + + def test_metadata_change_updates_in_place(self): + _store(_core_doc()) + changed = make_document( + "core", + priority=100, + categories=[category("cat")], + permissions=CORE_PERMS, + roles=[ + role( + rid="course_admin", + display_name="Course Administrator", + permissions=("courses.view_course", "courses.manage_tags"), + ) + ], + ) + _store(changed) + self.assertEqual(AuthzRoleDefinition.objects.count(), 1) + self.assertEqual( + AuthzRoleDefinition.objects.get(role_id="course_admin").display_name, "Course Administrator" + ) + + def test_moving_definition_between_files_keeps_single_source(self): + # Same module, different resource_path -> identity unchanged. + doc_a = _core_doc() + doc_b = make_document( + "core", # same module name -> same (distribution, module) + priority=100, + categories=[category("cat")], + permissions=CORE_PERMS, + roles=[role(rid="course_admin", permissions=("courses.view_course", "courses.manage_tags"))], + ) + doc_b.source = doc_b.source.__class__(**{**doc_b.source.__dict__, "resource_path": "moved.authz.yaml"}) + _store(doc_a) + _store(doc_b) + self.assertEqual(AuthzSchemaSource.objects.count(), 1) diff --git a/openedx_authz/tests/schema/test_types.py b/openedx_authz/tests/schema/test_types.py new file mode 100644 index 00000000..1ee4c2ea --- /dev/null +++ b/openedx_authz/tests/schema/test_types.py @@ -0,0 +1,50 @@ +"""Unit tests for schema type helpers.""" + +from openedx_authz.engine.schema.types import ( + CompiledDefinition, + CompiledSchema, + PermissionDefinition, + RoleDefinition, + SourceRecord, +) + + +def test_source_id_combines_distribution_and_module_path(): + 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(): + perm = PermissionDefinition( + namespace="courses", + name="view_course", + display_name="View", + description="d", + category="cat", + scopes=("course-v1",), + ) + assert perm.identifier == "courses.view_course" + + +def test_role_permission_pairs_are_sorted_and_flattened(): + 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("role", "course_admin", role, ())} + ) + assert schema.role_permission_pairs() == [ + ("course_admin", "courses.edit_course_content"), + ("course_admin", "courses.view_course"), + ] diff --git a/openedx_authz/tests/schema/test_validation.py b/openedx_authz/tests/schema/test_validation.py new file mode 100644 index 00000000..a794bead --- /dev/null +++ b/openedx_authz/tests/schema/test_validation.py @@ -0,0 +1,84 @@ +"""Unit tests for the schema validation step.""" + +from openedx_authz.engine.schema.validation import SchemaValidator + +from .factories import category, extension, make_document, permission, role + + +def _errors(issues): + return [i for i in issues if i.is_error] + + +def test_valid_document_has_no_errors(): + doc = make_document( + categories=[category("cat")], + permissions=[permission(cat="cat")], + roles=[role(permissions=("courses.view_course",))], + ) + assert not _errors(SchemaValidator().validate([doc])) + + +def test_unsupported_schema_version_is_error(): + doc = make_document(schema_version="9.9", categories=[category()]) + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + assert any("Unsupported schema_version" in m for m in messages) + + +def test_non_snakecase_identifier_is_error(): + doc = make_document(permissions=[permission(namespace="Courses")]) + assert _errors(SchemaValidator().validate([doc])) + + +def test_casbin_internal_form_rejected(): + doc = make_document(categories=[category("act^foo")]) + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + assert any("internal Casbin form" in m for m in messages) + + +def test_unknown_category_reference_is_error(): + doc = make_document(permissions=[permission(cat="missing")]) + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + assert any("unknown category" in m for m in messages) + + +def test_unknown_permission_in_role_is_error(): + doc = make_document(roles=[role(permissions=("courses.nope",))]) + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + assert any("unknown permission" in m for m in messages) + + +def test_role_scope_not_supported_by_permission_is_error(): + doc = make_document( + categories=[category("cat")], + permissions=[permission(cat="cat", scopes=("course-v1",))], + roles=[role(rid="r", scopes=("lib",), permissions=("courses.view_course",))], + ) + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + assert any("does not support" in m for m in messages) + + +def test_extension_targeting_unknown_role_is_error(): + doc = make_document(role_extensions=[extension("ghost", add_permissions=("courses.view_course",))]) + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + assert any("unknown role" in m for m in messages) + + +def test_missing_scope_is_error(): + doc = make_document( + categories=[category("cat")], + permissions=[permission(cat="cat", scopes=())], + ) + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + assert any("at least one scope" in m for m in messages) + + +def test_conflicting_duplicate_definition_is_error(): + doc = make_document( + categories=[category("cat")], + permissions=[ + permission(cat="cat", display_name="One"), + permission(cat="cat", display_name="Two"), # same id, different content + ], + ) + messages = [i.message for i in _errors(SchemaValidator().validate([doc]))] + assert any("Conflicting permission" in m for m in messages) diff --git a/setup.py b/setup.py index b43cec91..87cdc1bb 100755 --- a/setup.py +++ b/setup.py @@ -165,5 +165,12 @@ def is_requirement(line): "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. + "authz.schema": [ + "openedx_authz = openedx_authz.authz:get_schema_resources", + ], }, )