Repository navigation
feat: validate Paragon icons on authz schemas #472
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
rodmgwgu
wants to merge
29
commits into
rod/authz-schema-loader
from
rod/authz-schema-icons-validation
+8,552
−39
Open
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 8d81f69
squash!: Fix rebase issues
rodmgwgu 4daff34
squash!: refactor conflict resolution methods
rodmgwgu e6b4c5c
squash!: Refactor var names
rodmgwgu 2340101
squash!: Refactor _Tracked to be immutable
rodmgwgu df2e76a
squash!: RoleMetadataField
rodmgwgu 51aa22a
squash!: Make sure RoleMetadataField is a subset of RoleExtension fields
rodmgwgu 5b308c4
squash!: refactor _seed_base_provenance
rodmgwgu d79a1bd
squash!: Fix lint issues
rodmgwgu 7fc9da0
squash!: Refactor _gather_extension_changes
rodmgwgu 0f1659e
squash!: Refactor _resolve_permissions
rodmgwgu 126c05e
squash!: Refactor DefinitionKind
rodmgwgu f0b0475
feat: add authz schema validation
rodmgwgu e257a7a
squash!: Fix rebase issues
rodmgwgu 939ba3f
squash!: Attend PR comments
rodmgwgu 20ceb15
squash!: Refactor shared validations
rodmgwgu c0f666e
feat: add authz schema definition models
rodmgwgu 18141fc
squash!: Attend PR comments
rodmgwgu 6e784a9
squash!: Refactor models to extend an abstract model for timestamps
rodmgwgu c04342d
squash!: Document model naming convention
rodmgwgu 76bb22a
feat: add authz schema policy renderer
rodmgwgu bdd5ea3
squash!: Refactor constants
rodmgwgu ad23634
squash!: Attend PR comments
rodmgwgu 1885f05
squash!: Fix lint issues
rodmgwgu 737f674
feat: add authz schema applier
rodmgwgu ad8db0b
squash!: Refactor imports
rodmgwgu 9cdeeae
squash!: Improve redability
rodmgwgu 7a03353
feat: add authz schema pipeline and load_authz_schema command
rodmgwgu 768b626
feat: Validate paragon icons on authz schemas
rodmgwgu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
| * ``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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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:
PARAGON_VERSIONagainst whatever is inadmin-console/package.jsonadmin-consoleto also push a PR toopenedx-authzif 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!