diff --git a/MANIFEST.in b/MANIFEST.in index 25eda8e0..7dac2737 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 *.json diff --git a/docs/conf.py b/docs/conf.py index 75e02d1a..d71479a6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -71,6 +71,7 @@ def get_version(*file_paths): "sphinx.ext.intersphinx", "sphinx.ext.ifconfig", "sphinx.ext.napoleon", + "sphinx-jsonschema", ] # A list of warning types to suppress arbitrary warning messages. @@ -570,6 +571,7 @@ def on_init(app): # pylint: disable=unused-argument docs_path, os.path.join(root_path, "openedx_authz"), os.path.join(root_path, "openedx_authz/migrations"), + os.path.join(root_path, "openedx_authz/schema"), os.path.join(root_path, "openedx_authz/tests"), ], env=env diff --git a/docs/decisions/0017-static-authorization-schema.rst b/docs/decisions/0017-static-authorization-schema.rst index c2e77fef..8f95b10a 100644 --- a/docs/decisions/0017-static-authorization-schema.rst +++ b/docs/decisions/0017-static-authorization-schema.rst @@ -19,10 +19,12 @@ Decision 1. Schema format and boundary ============================= -The authz schema is a versioned YAML format for static permissions, permission categories, roles, and changes to existing roles. Every file declares ``schema_version`` and ``priority``, which apply to all definitions in that file. Priority is used when role extensions conflict. Open edX publishes a YAML Schema for this format so that editors, CI, and the compiler all apply the same field and validation rules. +The authz schema is a versioned YAML format for static permissions, permission categories, roles, and changes to existing roles. Every file declares ``schema_version`` and ``priority``, which apply to all definitions in that file. Priority is used when role extensions conflict. Open edX publishes a :download:`machine-readable JSON Schema <../../openedx_authz/schema/authz-schema-v1.json>` for this format so that editors, CI, and the compiler all apply the same field and validation rules. The existing static role and permission definitions in Python modules and ``authz.policy`` will move into the schema. Once this migration is complete, the schema becomes the source for static definitions, so developers add a new role or permission there without duplicating it in Python constants or policy files. +The :ref:`Authorization Schema Reference` describes every field, the naming conventions, and complete configuration examples. + 2. Permissions and categories ============================= @@ -159,11 +161,11 @@ References * `ADR 0016`_ * `ADR 0023`_ * `Casbin adapters`_ -* `ASDF YAML Schema`_ +* `JSON Schema`_ * `Paragon icons`_ .. _ADR 0016: 0016-static-and-dynamic-roles.rst .. _ADR 0023: https://github.com/openedx/openedx-authz/pull/430 .. _Casbin adapters: https://v3.casbin.org/docs/adapters -.. _ASDF YAML Schema: https://www.asdf-format.org/projects/asdf-standard/en/1.0.2/schemas/yaml_schema.html +.. _JSON Schema: https://json-schema.org/draft/2020-12/json-schema-core .. _Paragon icons: https://paragon-openedx.netlify.app/components/icon/ diff --git a/docs/decisions/0018-authorization-schema-lifecycle.rst b/docs/decisions/0018-authorization-schema-lifecycle.rst index e83e3ac8..82f82148 100644 --- a/docs/decisions/0018-authorization-schema-lifecycle.rst +++ b/docs/decisions/0018-authorization-schema-lifecycle.rst @@ -62,7 +62,7 @@ For example, the loader may update the static row that links ``courses.view_cour Static and dynamic roles share the same set of names, so neither kind can reuse a name that already exists. The dynamic role API rejects a name used by a static role, and deployment stops when a new static role conflicts with an existing dynamic role. -Because this ADR covers the lifecycle of static definitions, it establishes that static role IDs follow the authz schema's naming conventions. Naming conventions for dynamic roles are outside its scope. +Because this ADR covers the lifecycle of static definitions, it establishes that static role IDs follow the conventions in the :ref:`Authorization Schema Reference`. Naming conventions for dynamic roles are outside its scope. For example, an administrator cannot create a dynamic ``course_observer`` role when an application already defines a static role with that name. If the dynamic role existed first, a deployment that introduces the static role stops and reports both the contributing package and the conflicting database record, leaving both definitions unchanged. diff --git a/docs/decisions/0023-extend-static-roles.rst b/docs/decisions/0023-extend-static-roles.rst index 8482ae65..c62bf9f9 100644 --- a/docs/decisions/0023-extend-static-roles.rst +++ b/docs/decisions/0023-extend-static-roles.rst @@ -19,7 +19,7 @@ Decision 1. Role extension fields ======================== -A ``role_extensions`` entry identifies an existing static role with ``role`` and changes only the fields included in the entry. It may use: +A ``role_extensions`` entry identifies an existing static role with ``role`` and changes only the fields included in the entry. The :ref:`Authorization Schema Reference` describes these fields and includes complete examples for applications and Tutor configuration. An entry may use: * ``add_permissions`` to add complete permission IDs; * ``remove_permissions`` to remove complete permission IDs; @@ -131,6 +131,7 @@ References * `ADR 0017`_ * `ADR 0018`_ * `ADR 0019`_ +* :ref:`Authorization Schema Reference` * `Tutor plugin development`_ .. _ADR 0017: 0017-static-authorization-schema.rst diff --git a/docs/references/authorization-schema.rst b/docs/references/authorization-schema.rst new file mode 100644 index 00000000..3e343414 --- /dev/null +++ b/docs/references/authorization-schema.rst @@ -0,0 +1,361 @@ +.. _Authorization Schema Reference: + +Authorization Schema Reference +############################## + +The Open edX Authorization schema, or authz schema, is a YAML configuration format for static permissions, permission categories, roles, and changes to existing roles. Applications ship schema files with their code, while site operators can contribute the same format through their deployment configuration. Deployment validates and compiles all contributions into the policy used by ``openedx-authz``. + +Use this reference when creating or reviewing an authz schema file. The examples omit fields only when the surrounding section does not need them. + +.. contents:: Contents + :depth: 2 + :local: + +Machine-readable schema +*********************** + +The :download:`authz JSON Schema <../../openedx_authz/schema/authz-schema-v1.json>` describes the fields, types, required values, and identifier formats for schema version ``1.0``. Editors and validation tools can use it to check YAML files because JSON Schema applies to the data represented by both JSON and YAML. + +The JSON Schema checks the structure of one file. The compiler also checks references across the combined schema, registered scope namespaces, available Paragon icons, and conflicts between contributions. + +.. jsonschema:: ../../openedx_authz/schema/authz-schema-v1.json + :auto_target: + :auto_reference: + +Complete example +**************** + +The following file defines one category, two permissions, one role, and an extension to a role defined elsewhere: + +.. code-block:: yaml + + schema_version: "1.0" + priority: 100 + + permission_categories: + - id: course_content + display_name: Course content + description: Permissions for viewing and editing course content. + icon: Article + + permissions: + - namespace: courses + name: view_course + display_name: View course + description: View course configuration and content. + category: course_content + scopes: + - course-v1 + icon: Visibility + + - namespace: courses + name: view_course_updates + display_name: View course updates + description: View course update posts. + category: course_content + scopes: + - course-v1 + icon: Visibility + + roles: + - id: course_observer + display_name: Course observer + description: Reviews a course without changing it. + scopes: + - course-v1 + permissions: + - courses.view_course + - courses.view_course_updates + + role_extensions: + - role: course_editor + add_permissions: + - courses.export_course + +Top-level fields +**************** + +``schema_version`` +================== + +The version of the YAML format used by the file. Write it as a quoted ``major.minor`` value, such as ``"1.0"``. A deployment stops before changing the database when it encounters a version it cannot read. + +``priority`` +============ + +An integer used when several files extend the same role and change the same field or permission. A higher number takes precedence. Contributions with the same priority may be combined when they agree or affect different fields, but conflicting values at the same priority fail validation. + +Priority does not control the order shown in a user interface. Clients may sort roles, permissions, and categories for their own presentation. + +``permission_categories`` +========================= + +A list of category definitions used to group permissions for display and discovery. Categories do not grant access. + +``permissions`` +=============== + +A list of permission definitions. Application checks use the stable permission ID formed from each permission's ``namespace`` and ``name``. + +``roles`` +========= + +A list of static role definitions. A role lists every permission assigned to it. + +``role_extensions`` +=================== + +A list of changes to static roles defined in this file or another schema contribution. An extension changes only the fields it includes and does not copy or replace the complete role. + +Permission categories +********************* + +A category contains these fields: + +``id`` + The stable category identifier. It is required and uses lowercase snake case, such as ``course_content`` or ``library_management``. Category IDs are global and do not include a permission namespace. Applications that use the same ID contribute permissions to the same category. + +``display_name`` + The source-language name shown to users. It uses sentence case and is translated through the authz schema translation process. + +``description`` + A complete source-language sentence describing the group of permissions. + +``icon`` + An optional icon name exported by ``@openedx/paragon/icons``. The value is case-sensitive, such as ``Article``. + +For example: + +.. code-block:: yaml + + permission_categories: + - id: library_management + display_name: Library management + description: Permissions for managing content libraries. + icon: Article + +Permissions +*********** + +A permission contains these fields: + +``namespace`` + The stable product domain that owns the permission. It uses lowercase snake case, such as ``courses`` or ``content_libraries``. The namespace does not need to match the Python package, Django app, IDA, or Tutor plugin that contributes the file. Code may move between applications without changing the permission ID. + +``name`` + The operation within the product domain. It uses lowercase snake case and normally begins with a verb, such as ``view_course``, ``export_course``, or ``manage_library_tags``. + +``display_name`` + The source-language name shown to users. Changing it does not change the permission ID used by application checks. + +``description`` + A complete source-language sentence describing the access controlled by the permission. + +``category`` + The complete ID of a category defined in the combined schema. + +``scopes`` + The scope namespaces where the permission can apply. These values come from registered ``ScopeData`` types, such as ``course-v1``, ``ccx-v1``, or ``lib``. + +``icon`` + An optional, case-sensitive icon name exported by ``@openedx/paragon/icons``. + +The complete permission ID joins ``namespace`` and ``name`` with a period. For example: + +.. code-block:: yaml + + permissions: + - namespace: content_libraries + name: manage_library_tags + display_name: Manage library tags + description: Add, edit, and remove tags in a content library. + category: library_management + scopes: + - lib + +The complete ID is ``content_libraries.manage_library_tags``. Role definitions, role extensions, application checks, and API responses use this value. + +The Casbin form ``act^content_libraries.manage_library_tags`` is an internal value and is not valid in a schema file. + +Roles +***** + +A role contains these fields: + +``id`` + The stable role identifier. It uses lowercase snake case, such as ``course_admin``, ``course_editor``, or ``library_author``. Role IDs do not include a product namespace because authorization uses the role within its supported scopes. + +``display_name`` + The source-language name shown to users. + +``description`` + A complete source-language sentence describing what the role can do. + +``scopes`` + The scope namespaces where the role can be assigned. Every permission listed by the role must support those scopes. + +``permissions`` + A list of complete permission IDs. The compiler does not infer one permission from another, so the role lists every permission it needs. + +``icon`` + An optional, case-sensitive icon name exported by ``@openedx/paragon/icons``. + +``hidden`` + An optional boolean that defaults to ``false``. A hidden role does not appear in normal role discovery and selection interfaces. Hiding does not delete the role, remove existing assignments, or change permission checks. + +For example: + +.. code-block:: yaml + + roles: + - id: library_reviewer + display_name: Library reviewer + description: Reviews library content without publishing it. + scopes: + - lib + permissions: + - content_libraries.view_library + - content_libraries.view_library_team + icon: Visibility + +The Casbin form ``role^library_reviewer`` is an internal value and is not valid as ``roles.id`` or in a ``role_extensions.role`` reference. + +Role extensions +*************** + +A role extension contains ``role`` and at least one field to change: + +``role`` + The complete ID of an existing static role. + +``add_permissions`` + Complete permission IDs to add to the role. + +``remove_permissions`` + Complete permission IDs to remove from the role. + +``display_name``, ``description``, and ``icon`` + Display metadata to replace. Metadata fields left out of the extension keep their current values. + +``hidden`` + Whether the role appears in normal role discovery and selection interfaces. + +For example, a deployment can allow course editors to export courses, remove their access to tag management, change the displayed role name, and hide the course auditor role: + +.. code-block:: yaml + + schema_version: "1.0" + priority: 200 + + role_extensions: + - role: course_editor + add_permissions: + - courses.export_course + remove_permissions: + - courses.manage_tags + display_name: Course author + description: Creates and exports course content. + + - role: course_auditor + hidden: true + +An extension fails validation when its target role or a referenced permission does not exist. Adding a permission already assigned to the role or removing one the role does not have produces a warning and leaves the result unchanged. + +Identifier rules +**************** + +Permission namespaces, permission names, category IDs, and role IDs use lowercase letters, numbers, and underscores, begin with a letter, and match ``[a-z][a-z0-9_]*``. The period in a complete permission ID separates its namespace from its name and does not appear inside either part. + +Valid identifiers include: + +.. code-block:: text + + courses + view_course + courses.view_course + course_content + course_editor + +The following values are invalid: + +.. code-block:: text + + Courses.view_course # uppercase letter + courses:view_course # wrong separator + act^courses.view_course # internal Casbin namespace + course content # space + role^course_editor # internal Casbin namespace + +Scope namespaces follow the spelling registered by their ``ScopeData`` type and may contain a hyphen. Do not apply the snake-case identifier rule to values such as ``course-v1`` or ``ccx-v1``. + +Schema files in applications +**************************** + +Applications can keep schema resources under an ``authz/schema`` package directory. Filenames are chosen by the application, but separating permission and role definitions makes the resources easier to maintain: + +.. code-block:: text + + course_authoring/ + └── authz/ + └── schema/ + ├── permissions.yaml + └── roles.yaml + +The application exposes the ``authz/schema`` directory through the ``openedx-authz`` schema entry point described in :doc:`ADR 0019 <../decisions/0019-authorization-schema-discovery>`. The directory path is relative to the Python module, which keeps discovery independent of virtual-environment and container paths. The compiler loads every ``.yaml`` file in that directory. + +Tutor configuration for site operators +************************************** + +A site operator can provide an authz schema through a Python Tutor plugin that uses the ``openedx-authz-schema`` patch. Run ``tutor plugins printroot`` to find the local plugin directory, then create ``openedx_authz_overrides.py`` there: + +.. code-block:: python + + from tutor import hooks + + hooks.Filters.ENV_PATCHES.add_item(( + "openedx-authz-schema", + """ + schema_version: "1.0" + priority: 200 + + role_extensions: + - role: course_editor + add_permissions: + - courses.export_course + remove_permissions: + - courses.manage_tags + display_name: Course author + description: Creates and exports course content. + + - role: course_auditor + hidden: true + """, + )) + +Enable the plugin and save the rendered Tutor configuration: + +.. code-block:: console + + tutor plugins enable openedx_authz_overrides + tutor config save + +The next deployment validates and compiles the patch with the schema files provided by applications. + +Checking the resulting permissions +********************************** + +After deployment, use the existing ``enforcement`` management command to check the policy stored in the database: + +.. code-block:: console + + tutor local run lms ./manage.py lms enforcement + +The command expects a subject, complete permission ID, and scope. Assuming ``alice`` has ``course_editor`` in ``course-v1:OpenedX+DemoX+DemoCourse``, the extension above produces these results: + +.. code-block:: text + + alice courses.export_course course-v1:OpenedX+DemoX+DemoCourse + ✓ ALLOWED: alice courses.export_course course-v1:OpenedX+DemoX+DemoCourse + + alice courses.manage_tags course-v1:OpenedX+DemoX+DemoCourse + ✗ DENIED: alice courses.manage_tags course-v1:OpenedX+DemoX+DemoCourse diff --git a/docs/references/index.rst b/docs/references/index.rst index ba5ea57c..204ae634 100644 --- a/docs/references/index.rst +++ b/docs/references/index.rst @@ -1,2 +1,7 @@ References ########## + +.. toctree:: + :maxdepth: 1 + + authorization-schema diff --git a/openedx_authz/schema/__init__.py b/openedx_authz/schema/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_authz/schema/authz-schema-v1.json b/openedx_authz/schema/authz-schema-v1.json new file mode 100644 index 00000000..f7ca0727 --- /dev/null +++ b/openedx_authz/schema/authz-schema-v1.json @@ -0,0 +1,157 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Open edX Authorization schema", + "description": "Static authorization definitions contributed by applications and deployment configuration.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "priority"], + "properties": { + "schema_version": { + "type": "string", + "const": "1.0" + }, + "priority": { + "type": "integer" + }, + "permission_categories": { + "type": "array", + "items": { + "$ref": "#/$defs/permission_category" + }, + "minItems": 1 + }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/$defs/permission" + }, + "minItems": 1 + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/$defs/role" + }, + "minItems": 1 + }, + "role_extensions": { + "type": "array", + "items": { + "$ref": "#/$defs/role_extension" + }, + "minItems": 1 + } + }, + "anyOf": [ + {"required": ["permission_categories"]}, + {"required": ["permissions"]}, + {"required": ["roles"]}, + {"required": ["role_extensions"]} + ], + "$defs": { + "identifier": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$" + }, + "permission_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*\\.[a-z][a-z0-9_]*$" + }, + "scope_namespace": { + "type": "string", + "minLength": 1 + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string", + "minLength": 1 + }, + "icon": { + "type": "string", + "minLength": 1 + }, + "permission_list": { + "type": "array", + "items": { + "$ref": "#/$defs/permission_id" + }, + "minItems": 1, + "uniqueItems": true + }, + "scope_list": { + "type": "array", + "items": { + "$ref": "#/$defs/scope_namespace" + }, + "minItems": 1, + "uniqueItems": true + }, + "permission_category": { + "type": "object", + "additionalProperties": false, + "required": ["id", "display_name", "description"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "display_name": {"$ref": "#/$defs/display_name"}, + "description": {"$ref": "#/$defs/description"}, + "icon": {"$ref": "#/$defs/icon"} + } + }, + "permission": { + "type": "object", + "additionalProperties": false, + "required": ["namespace", "name", "display_name", "description", "category", "scopes"], + "properties": { + "namespace": {"$ref": "#/$defs/identifier"}, + "name": {"$ref": "#/$defs/identifier"}, + "display_name": {"$ref": "#/$defs/display_name"}, + "description": {"$ref": "#/$defs/description"}, + "category": {"$ref": "#/$defs/identifier"}, + "scopes": {"$ref": "#/$defs/scope_list"}, + "icon": {"$ref": "#/$defs/icon"} + } + }, + "role": { + "type": "object", + "additionalProperties": false, + "required": ["id", "display_name", "description", "scopes", "permissions"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "display_name": {"$ref": "#/$defs/display_name"}, + "description": {"$ref": "#/$defs/description"}, + "scopes": {"$ref": "#/$defs/scope_list"}, + "permissions": {"$ref": "#/$defs/permission_list"}, + "icon": {"$ref": "#/$defs/icon"}, + "hidden": { + "type": "boolean", + "default": false + } + } + }, + "role_extension": { + "type": "object", + "additionalProperties": false, + "required": ["role"], + "properties": { + "role": {"$ref": "#/$defs/identifier"}, + "add_permissions": {"$ref": "#/$defs/permission_list"}, + "remove_permissions": {"$ref": "#/$defs/permission_list"}, + "display_name": {"$ref": "#/$defs/display_name"}, + "description": {"$ref": "#/$defs/description"}, + "icon": {"$ref": "#/$defs/icon"}, + "hidden": {"type": "boolean"} + }, + "anyOf": [ + {"required": ["add_permissions"]}, + {"required": ["remove_permissions"]}, + {"required": ["display_name"]}, + {"required": ["description"]}, + {"required": ["icon"]}, + {"required": ["hidden"]} + ] + } + } +} diff --git a/requirements/doc.in b/requirements/doc.in index a8b02901..5b7647da 100644 --- a/requirements/doc.in +++ b/requirements/doc.in @@ -8,3 +8,4 @@ sphinx-book-theme # Common theme for all Open edX projects twine # Validates README.rst for usage on PyPI build # Needed to build the wheel for twine check Sphinx # Documentation builder +sphinx-jsonschema # Render the authz JSON Schema diff --git a/requirements/doc.txt b/requirements/doc.txt index 4a439597..e614800f 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -183,6 +183,8 @@ jinja2==3.1.6 # -r requirements/test.txt # code-annotations # sphinx +jsonpointer==3.1.1 + # via sphinx-jsonschema keyring==25.7.0 # via twine markdown-it-py==4.2.0 @@ -325,6 +327,8 @@ sphinx==9.1.0 # sphinx-book-theme sphinx-book-theme==1.2.0 # via -r requirements/doc.in +sphinx-jsonschema==1.19.2 + # via -r requirements/doc.in sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0