Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f09632f
feat: add authz schema compilation
rodmgwgu Sep 23, 2026
8d81f69
squash!: Fix rebase issues
rodmgwgu Oct 2, 2026
4daff34
squash!: refactor conflict resolution methods
rodmgwgu Oct 2, 2026
e6b4c5c
squash!: Refactor var names
rodmgwgu Oct 2, 2026
2340101
squash!: Refactor _Tracked to be immutable
rodmgwgu Oct 2, 2026
df2e76a
squash!: RoleMetadataField
rodmgwgu Oct 2, 2026
51aa22a
squash!: Make sure RoleMetadataField is a subset of RoleExtension fields
rodmgwgu Oct 2, 2026
5b308c4
squash!: refactor _seed_base_provenance
rodmgwgu Oct 2, 2026
d79a1bd
squash!: Fix lint issues
rodmgwgu Oct 2, 2026
7fc9da0
squash!: Refactor _gather_extension_changes
rodmgwgu Oct 2, 2026
0f1659e
squash!: Refactor _resolve_permissions
rodmgwgu Oct 5, 2026
126c05e
squash!: Refactor DefinitionKind
rodmgwgu Oct 5, 2026
f0b0475
feat: add authz schema validation
rodmgwgu Sep 23, 2026
e257a7a
squash!: Fix rebase issues
rodmgwgu Oct 2, 2026
939ba3f
squash!: Attend PR comments
rodmgwgu Oct 5, 2026
20ceb15
squash!: Refactor shared validations
rodmgwgu Oct 5, 2026
c0f666e
feat: add authz schema definition models
rodmgwgu Sep 23, 2026
18141fc
squash!: Attend PR comments
rodmgwgu Oct 5, 2026
6e784a9
squash!: Refactor models to extend an abstract model for timestamps
rodmgwgu Oct 5, 2026
c04342d
squash!: Document model naming convention
rodmgwgu Oct 5, 2026
76bb22a
feat: add authz schema policy renderer
rodmgwgu Sep 23, 2026
bdd5ea3
squash!: Refactor constants
rodmgwgu Oct 5, 2026
ad23634
squash!: Attend PR comments
rodmgwgu Oct 5, 2026
1885f05
squash!: Fix lint issues
rodmgwgu Oct 5, 2026
737f674
feat: add authz schema applier
rodmgwgu Sep 23, 2026
ad8db0b
squash!: Refactor imports
rodmgwgu Oct 5, 2026
9cdeeae
squash!: Improve redability
rodmgwgu Oct 5, 2026
7a03353
feat: add authz schema pipeline and load_authz_schema command
rodmgwgu Sep 23, 2026
768b626
feat: Validate paragon icons on authz schemas
rodmgwgu Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,66 @@ Change Log
Unreleased
**********

1.27.0 - 2026-09-30
*******************

Added
=====

* Added validation of Paragon ``icon`` names in authorization schema definitions
(ADR 0017 §4). Category, permission, role, and role-extension icons are now checked
against the set of names exported by ``@openedx/paragon/icons``, vendored in
``openedx_authz/engine/schema/paragon_icons.py`` and regenerated with
``make paragon_icons`` (ADR 0026).

1.26.0 - 2026-09-30
*******************

Added
=====

* Added the static authorization schema, a versioned YAML format for declaring permissions,
permission categories, roles, and role extensions (ADR 0017). The permissions and roles that
``authz.policy`` defines are now also expressed as schema files under
``openedx_authz/authz/schema/``.
* Added the schema loading pipeline in ``openedx_authz/engine/schema/``, covering the discover,
load, validate and compile phases of the lifecycle (ADR 0018), plus render and apply in
``openedx_authz/engine/renderer.py``.
* Added schema discovery through the ``authz.schema`` entry-point group and the
``OPENEDX_AUTHZ_SCHEMA_DIRECTORIES`` setting, so applications can ship authorization definitions
with their code and operators can contribute them through deployment configuration (ADR 0019).
* Added the ``load_authz_schema`` management command, the single non-interactive deployment entry
point, with ``--dry-run`` to print the change report without writing, ``--force`` to allow
removing roles that still have assignments, and repeatable ``--dir`` for CI and local runs.
* Added ``role_extensions`` support: an application or deployment can add or remove permissions and
replace the display metadata or ``hidden`` flag of an existing static role without copying its
definition. ``priority`` resolves conflicts; an unresolvable equal-priority conflict stops the run
before any database change (ADR 0023).
* Added first-class tables for compiled definitions and their provenance, in migration
``0011_authz_schema_definitions``: permission categories, permission definitions, role
definitions, role-permission grants, schema sources, and one source-link table per definition kind
recording whether a contribution was a base definition or an extension (ADR 0025).
* Added source attribution at the role-permission grain, so contributions from different
applications to the same role remain distinguishable and queryable through
``origins_for_role``, ``origins_for_permission``, ``origins_for_category`` and
``origin_for_role_permission``.
* Added a change report before any write: the command lists the policy rows and the definitions that
would be added, updated or removed, including metadata-only edits that change no policy row
(ADR 0018 §6).

Notes
=====

* No authorization behavior changes in this release. Loading ``authz.policy`` works as before, and
the new pipeline runs only when ``load_authz_schema`` is invoked.
* Applying a schema is idempotent and preserves data the loader does not own: user assignments,
dynamic roles, legacy ``g2`` action-inheritance rows, and pre-existing policy rows that no schema
declares. Rows that already exist are adopted, gaining definition and source records rather than
being rewritten (ADR 0025 §6).
* Removing a static role that still has user assignments stops the deployment and reports the
assignments. ``--force`` removes the role together with its assignments and writes a
``RoleAssignmentAudit`` record for each one.

1.24.0 - 2026-09-14
*******************

Expand Down
11 changes: 9 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.PHONY: clean clean_tox compile_translations coverage diff_cover docs dummy_translations \
extract_translations fake_translations help pii_check pull_translations \
quality requirements selfcheck test test-all upgrade validate install_transifex_client
extract_translations fake_translations help pii_check pull_translations paragon_icons \
paragon_icons_check quality requirements selfcheck test test-all upgrade validate \
install_transifex_client

.DEFAULT_GOAL := help

Expand Down Expand Up @@ -36,6 +37,12 @@ upgrade: ## update the uv.lock file with the latest packages satisfying pyprojec
uv run --with edx-lint edx_lint write_uv_constraints pyproject.toml
uv lock --upgrade

paragon_icons: ## regenerate the vendored @openedx/paragon icon-name allow-list (see ADR 0026)
python scripts/generate_paragon_icons.py

paragon_icons_check: ## fail if the vendored Paragon icon list is out of date (CI)
python scripts/generate_paragon_icons.py --check

quality: ## check coding style with pycodestyle and pylint
tox -e quality

Expand Down
88 changes: 88 additions & 0 deletions docs/decisions/0026-paragon-icon-list-maintenance.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
0026: Maintaining the Vendored Paragon Icon Allow-List
######################################################

Status
******

**Draft**

Context
*******

`ADR 0017`_ §4 requires schema validation to reject ``icon`` values that are not
valid names exported by ``@openedx/paragon/icons``. Categories, permissions,
roles, and role extensions each carry an optional Paragon icon name (for example
``BookOpen`` or ``RemoveRedEye``), and those names are used by frontends to
render the schema.

``openedx-authz`` is a Python backend with no Node or Paragon dependency, so the
JavaScript icon package cannot be imported at validation time. There is no
Python-side source of truth for the set of valid icon names, and the set changes
whenever Paragon adds, renames, or removes an icon.

Paragon publishes its icons as component exports from ``@openedx/paragon/icons``.
The built export barrel (``icons/es5/index.js`` in a published release) lists
every icon as ``export { default as <IconName> } from "./<file>";``, which is a
stable, machine-readable source for the name set.

Decision
********

We vendor the icon-name allow-list as a generated Python module,
``openedx_authz/engine/schema/paragon_icons.py``, exposing
``PARAGON_ICON_NAMES: frozenset[str]``. The schema validator imports this
frozenset and rejects any ``icon`` value that is not a member (ADR 0017 §4).

The list is generated by ``scripts/generate_paragon_icons.py``, run via
``make paragon_icons``. The script fetches the icon export barrel for a pinned
Paragon version from the public npm CDN, parses the exported names, and writes
the vendored module. The generated file is committed to the repository so that
validation is deterministic and needs no network access at runtime.

**Pinned version.** The generator pins the Paragon version to match the
``@openedx/paragon`` major that ``frontend-app-admin-console`` declares on its
``master`` branch (currently ``^23``, generated from ``23.21.3``). That MFE is
the primary consumer that renders these icons, so aligning the allow-list with
the version it ships keeps validation honest: an icon that validates here is one
the console can actually render.

**Update process.** Regenerate the list whenever
``frontend-app-admin-console`` changes its ``@openedx/paragon`` version:

1. Update ``PARAGON_VERSION`` in ``scripts/generate_paragon_icons.py`` to the
new pinned release.
2. Run ``make paragon_icons`` to rewrite ``paragon_icons.py``.
3. Commit the regenerated file together with the version bump.

``make paragon_icons_check`` regenerates the list into memory and fails if the
committed file is stale; wire it into CI to catch a forgotten refresh after a
version bump.

**Deprecation.** When Paragon renames or removes an icon, the change lands in the
allow-list at the next regeneration. Any schema ``icon`` value that no longer
exists then fails validation as an error, following the same deprecation path as
other consumers of ``@openedx/paragon/icons`` (ADR 0017 §4).

Consequences
************

* Icon validation is deterministic, offline, and requires no Node dependency in
the Python backend.
* The allow-list can drift from the latest Paragon release between refreshes.
This is intentional: it tracks the version the console consumes, not the newest
published icons, and the ``paragon_icons_check`` target surfaces staleness.
* Bumping the console's Paragon version is now a two-repo change: someone must
refresh the vendored list here so newly available icons validate and removed
icons are rejected.
Comment on lines +74 to +76

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How could we be sure this is enforced? Having to update two repos for that particular change I guess it might be prone to human error

A few options / suggestions I can think of we can implement as follow ups to this PR:

  • Integrate in every PR a CI step to validate PARAGON_VERSION against whatever is in admin-console/package.json
  • Create a scheduled job to verify this, raise a PR (via a bot) if there is a mismatch <-- I like this one more
  • Include a comment somewhere in admin-console to also push a PR to openedx-authz if paragon version is ever updated (not sure where to put this, but I think just something in a README file could be easily missed)

Or let me know if you have any other ideas, thanks!

* ``paragon_icons.py`` is a generated artifact; it must not be edited by hand.

References
**********

* `ADR 0017`_
* `Paragon icons`_
* `frontend-app-admin-console`_

.. _ADR 0017: 0017-static-authorization-schema.rst
.. _Paragon icons: https://paragon-openedx.netlify.app/components/icon/
.. _frontend-app-admin-console: https://github.com/openedx/frontend-app-admin-console
157 changes: 157 additions & 0 deletions scripts/generate_paragon_icons.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env python
"""Regenerate the vendored Paragon icon-name allow-list.

ADR 0017 §4 requires schema validation to reject ``icon`` values that are not
valid ``@openedx/paragon/icons`` names. There is no Python-side source of truth
for that name set, so we vendor it: this script fetches the published Paragon
icon export barrel and writes it as a frozenset in
``src/openedx_authz/engine/schema/paragon_icons.py``.

Why a vendored list instead of a runtime lookup: ``openedx-authz`` is a Python
backend with no Node/Paragon dependency, so the JS package is not importable at
validation time. Pinning a version keeps validation deterministic and lets the
list follow Paragon's own deprecation process when an icon is renamed or removed
(ADR 0017 §4).

The Paragon version is pinned to match the ``@openedx/paragon`` major used by
``frontend-app-admin-console`` (``^23``). Bump :data:`PARAGON_VERSION` and rerun
``make paragon_icons`` to refresh.

Usage::

make paragon_icons
python scripts/generate_paragon_icons.py # same thing
python scripts/generate_paragon_icons.py --version 23.21.3 --check

``--check`` regenerates into memory and fails (exit 1) if the committed file is
stale, without writing. Intended for CI.
"""

from __future__ import annotations

import argparse
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path

# Pinned to the @openedx/paragon major used by frontend-app-admin-console master
# (peerDependency "@openedx/paragon": "^23"). This is the concrete 23.x release
# the vendored list is generated from.
PARAGON_VERSION = "23.21.3"

# The published package re-exports every generated icon component from this
# built barrel as ``export { default as <IconName> } from "./<file>";``.
ICONS_BARREL_URL = "https://unpkg.com/@openedx/paragon@{version}/icons/es5/index.js"

# Captures the exported component name in ``export { default as Name } from ...``.
EXPORT_RE = re.compile(r"export\s*\{\s*default\s+as\s+([A-Za-z_$][\w$]*)\s*\}")

# Where the vendored module lives, relative to the repo root.
OUTPUT_PATH = Path("src/openedx_authz/engine/schema/paragon_icons.py")

FILE_TEMPLATE = '''\
"""Vendored allow-list of valid ``@openedx/paragon/icons`` names.

GENERATED FILE -- do not edit by hand. Regenerate with::

make paragon_icons

The names are the component exports of ``@openedx/paragon/icons`` at the pinned
version below, used by :mod:`openedx_authz.engine.schema.validation` to reject
schema ``icon`` values that are not real Paragon icons (ADR 0017 §4).

Source: {source_url}
Paragon version: {version}
Icon count: {count}
"""

from __future__ import annotations

PARAGON_VERSION = "{version}"

PARAGON_ICON_NAMES: frozenset[str] = frozenset(
{{
{entries}
}}
)
'''


class GenerationError(RuntimeError):
"""Raised when the icon list cannot be fetched or parsed."""


def fetch_barrel(version: str) -> str:
"""Return the text of the Paragon icons export barrel for ``version``."""
url = ICONS_BARREL_URL.format(version=version)
try:
with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310 - fixed https host
return response.read().decode("utf-8")
except urllib.error.URLError as exc:
raise GenerationError(f"Could not fetch Paragon icons from {url}: {exc}") from exc


def parse_icon_names(barrel: str) -> list[str]:
"""Extract the sorted, de-duplicated icon names from the export barrel."""
names = sorted(set(EXPORT_RE.findall(barrel)))
if not names:
raise GenerationError("No icon exports found; the barrel format may have changed.")
return names


def render_module(names: list[str], version: str) -> str:
"""Render the vendored Python module source for the given icon names."""
entries = "\n".join(f' "{name}",' for name in names)
return FILE_TEMPLATE.format(
source_url=ICONS_BARREL_URL.format(version=version),
version=version,
count=len(names),
entries=entries,
)


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--version",
default=PARAGON_VERSION,
help=f"Paragon version to generate from (default: {PARAGON_VERSION}).",
)
parser.add_argument(
"--check",
action="store_true",
help="Fail if the committed file is out of date instead of writing it.",
)
args = parser.parse_args(argv)

repo_root = Path(__file__).resolve().parent.parent
output_path = repo_root / OUTPUT_PATH

try:
names = parse_icon_names(fetch_barrel(args.version))
except GenerationError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1

rendered = render_module(names, args.version)

if args.check:
current = output_path.read_text(encoding="utf-8") if output_path.exists() else ""
if current != rendered:
print(
f"error: {OUTPUT_PATH} is out of date; run 'make paragon_icons'.",
file=sys.stderr,
)
return 1
print(f"{OUTPUT_PATH} is up to date ({len(names)} icons).")
return 0

output_path.write_text(rendered, encoding="utf-8")
print(f"Wrote {len(names)} Paragon icon names to {OUTPUT_PATH} (v{args.version}).")
return 0


if __name__ == "__main__":
raise SystemExit(main())
32 changes: 8 additions & 24 deletions src/openedx_authz/api/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@
MANAGE_LIBRARY_TEAM,
VIEW_LIBRARY_TEAM,
)
from openedx_authz.data import AUTHZ_POLICY_ATTRIBUTES_SEPARATOR, ActionData, AuthzBaseClass, AuthZData, PermissionData
from openedx_authz.data import (
AUTHZ_POLICY_ATTRIBUTES_SEPARATOR,
ActionData,
AuthzBaseClass,
AuthZData,
PermissionData,
PolicyIndex,
)
from openedx_authz.models.scopes import get_content_library_model, get_course_overview_model

ContentLibrary = get_content_library_model()
Expand Down Expand Up @@ -76,29 +83,6 @@ class GroupingPolicyIndex(Enum):
# The rest of the fields are optional and can be ignored for now


class PolicyIndex(Enum):
"""Index positions for fields in a Casbin policy (p).

Policies define permissions by linking roles to actions within scopes with an effect.
Format: [role, action, scope, effect, ...]

Attributes:
ROLE: Position 0 - The role identifier (e.g., 'role^instructor').
ACT: Position 1 - The action identifier (e.g., 'act^read').
SCOPE: Position 2 - The scope identifier (e.g., 'lib^lib:DemoX:CSPROB').
EFFECT: Position 3 - The effect, either 'allow' or 'deny'.

Note:
Additional fields beyond position 3 are optional and currently ignored.
"""

ROLE = 0
ACT = 1
SCOPE = 2
EFFECT = 3
# The rest of the fields are optional and can be ignored for now


class ScopeMeta(type):
"""Metaclass for ScopeData to handle dynamic subclass instantiation based on namespace."""

Expand Down
Loading
Loading